From ff9bdbf296b48e1b3caa38e1f433f2def735f59a Mon Sep 17 00:00:00 2001 From: vutuanlinh2k2 Date: Fri, 20 Feb 2026 16:47:03 +0700 Subject: [PATCH 1/9] feat(tangle-cloud): enhance slashing lifecycle with dispute, cancel, execute, and timeline Add comprehensive slashing management including proposal creation, dispute workflows, batch execution, eligibility checks, and timeline visualization. Improve contract write hook with public client support for wait-for-receipt. Co-Authored-By: Claude Opus 4.6 --- .../tangleCloudTable/TangleCloudTable.tsx | 30 +- .../src/components/tangleCloudTable/type.ts | 1 + .../src/pages/operators/manage/page.tsx | 1430 ++++++++++++++--- .../src/data/graphql/index.ts | 17 + .../src/data/graphql/useSlashing.ts | 1017 +++++++++++- .../src/hooks/useContractWrite.ts | 8 + 6 files changed, 2176 insertions(+), 327 deletions(-) diff --git a/apps/tangle-cloud/src/components/tangleCloudTable/TangleCloudTable.tsx b/apps/tangle-cloud/src/components/tangleCloudTable/TangleCloudTable.tsx index 2377fe5cb..ec2adef90 100644 --- a/apps/tangle-cloud/src/components/tangleCloudTable/TangleCloudTable.tsx +++ b/apps/tangle-cloud/src/components/tangleCloudTable/TangleCloudTable.tsx @@ -10,6 +10,7 @@ const GLASS_CONTAINER_CLASS = export const TangleCloudTable = ({ title, + hideTitle = false, data, isLoading, error, @@ -28,17 +29,18 @@ export const TangleCloudTable = ({ ? `${errorMessage.slice(0, 137)}...` : errorMessage : 'Indexer or network request failed.'; + const hasTitle = !hideTitle; - const titleElement = ( + const titleElement = hasTitle ? ( {title} - ); + ) : undefined; if (isLoading) { return (
- {titleElement} + {hasTitle ? titleElement : null} ({ } icon={loadingTableProps?.icon ?? '๐Ÿ”„'} className={twMerge( - 'w-full !border-0 !rounded-none !p-0 mt-3', + 'w-full !border-0 !rounded-none !p-0', + hasTitle ? 'mt-3' : null, loadingTableProps?.className, )} /> @@ -60,7 +63,7 @@ export const TangleCloudTable = ({ if (error) { return (
- {titleElement} + {hasTitle ? titleElement : null} ({ (hasRetry ? { onClick: () => void onRetry() } : undefined) } className={twMerge( - 'w-full !border-0 !rounded-none !p-0 mt-3', + 'w-full !border-0 !rounded-none !p-0', + hasTitle ? 'mt-3' : null, errorTableProps?.className, )} /> @@ -89,7 +93,7 @@ export const TangleCloudTable = ({ if (isEmpty) { return (
- {titleElement} + {hasTitle ? titleElement : null} ({ description={emptyTableProps?.description ?? 'No data found'} icon={emptyTableProps?.icon ?? '๐Ÿ”'} className={twMerge( - 'w-full !border-0 !rounded-none !p-0 mt-3', + 'w-full !border-0 !rounded-none !p-0', + hasTitle ? 'mt-3' : null, emptyTableProps?.className, )} /> @@ -107,19 +112,22 @@ export const TangleCloudTable = ({ return ( ); }; diff --git a/apps/tangle-cloud/src/components/tangleCloudTable/type.ts b/apps/tangle-cloud/src/components/tangleCloudTable/type.ts index 58e3284f6..1efad71b5 100644 --- a/apps/tangle-cloud/src/components/tangleCloudTable/type.ts +++ b/apps/tangle-cloud/src/components/tangleCloudTable/type.ts @@ -5,6 +5,7 @@ import { RowData, type useReactTable } from '@tanstack/react-table'; export interface TangleCloudTableProps { title: string; + hideTitle?: boolean; data: T[]; isLoading: boolean; error: Error | null; diff --git a/apps/tangle-cloud/src/pages/operators/manage/page.tsx b/apps/tangle-cloud/src/pages/operators/manage/page.tsx index b8f90b37b..edd304318 100644 --- a/apps/tangle-cloud/src/pages/operators/manage/page.tsx +++ b/apps/tangle-cloud/src/pages/operators/manage/page.tsx @@ -1,8 +1,8 @@ /** - * Operator management page - view and manage blueprint registrations and slashing. + * Operator management page - blueprint registration and full slashing lifecycle. */ -import { FC, useState, useMemo, useCallback, useEffect } from 'react'; +import { FC, ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; import { Card, Typography, @@ -18,7 +18,9 @@ import { TooltipTrigger, TooltipBody, CopyWithTooltip, + TabContent, } from '@tangle-network/ui-components'; +import { TableAndChartTabs } from '@tangle-network/ui-components/components/TableAndChartTabs'; import { useQueryClient } from '@tanstack/react-query'; import { useAccount } from 'wagmi'; import { @@ -29,8 +31,17 @@ import { useActiveServiceMemberships, useSlashProposals, useDisputeSlashTx, + useCancelSlashTx, + useProposeSlashTx, + useExecuteSlashTx, + useExecutableSlashes, + useProposableServices, getSlashDisputeEligibility, + getSlashExecutionEligibility, + getSlashActionPermissions, + buildSlashTimeline, formatSlashBps, + toSlashEvidenceBytes32, type OperatorRegistration, type SlashProposerRole, type SlashStatus, @@ -38,6 +49,7 @@ import { } from '@tangle-network/tangle-shared-ui/data/graphql'; import { MembershipModel } from '@tangle-network/tangle-shared-ui/data/services'; import { shortenHex } from '@tangle-network/ui-components/utils/shortenHex'; +import { isAddress, type Address } from 'viem'; import { createColumnHelper, getCoreRowModel, @@ -47,6 +59,12 @@ import { import { TangleCloudTable } from '../../../components/tangleCloudTable/TangleCloudTable'; import TableCellWrapper from '@tangle-network/tangle-shared-ui/components/tables/TableCellWrapper'; import ConnectWalletButton from '@tangle-network/tangle-shared-ui/components/ConnectWalletButton'; +import { + PlayFillIcon, + TimeLineIcon, + CheckboxCircleLine, + GlobalLine, +} from '@tangle-network/icons'; const registrationColumnHelper = createColumnHelper(); const slashColumnHelper = createColumnHelper(); @@ -63,7 +81,30 @@ interface BlueprintServiceBreakdown { totalCount: number; } +interface SlashSyncTarget { + slashId: bigint; + status: SlashStatus; + disputeReason?: string | null; + cancelReason?: string | null; +} + +enum SlashingTab { + PROPOSE = 'Propose', + ACTIVE = 'Active', + EXECUTABLE = 'Executable', + HISTORY = 'History', +} + +const SLASHING_TAB_ICONS: ReactElement[] = [ + , + , + , + , +]; +const SLASHING_TABS = Object.values(SlashingTab); + const MIN_DISPUTE_REASON_LENGTH = 20; +const MIN_CANCEL_REASON_LENGTH = 8; const ECDSA_PUBLIC_KEY_HEX_LENGTH = 132; // 65-byte key => "0x" + 130 hex chars const getRegistrationActionState = ( @@ -126,15 +167,15 @@ const getRegistrationActionState = ( const formatDateTime = (unixSeconds: bigint): string => new Date(Number(unixSeconds) * 1000).toLocaleString(); -const formatTimeRemaining = (secondsUntilDeadline: number): string => { - if (secondsUntilDeadline <= 0) { - return 'Expired'; +const formatTimeRemaining = (seconds: number): string => { + if (seconds <= 0) { + return 'Ready now'; } - const days = Math.floor(secondsUntilDeadline / 86_400); - const hours = Math.floor((secondsUntilDeadline % 86_400) / 3_600); - const minutes = Math.floor((secondsUntilDeadline % 3_600) / 60); - const seconds = secondsUntilDeadline % 60; + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3_600); + const minutes = Math.floor((seconds % 3_600) / 60); + const sec = seconds % 60; if (days > 0) { return `${days}d ${hours}h ${minutes}m`; @@ -143,12 +184,15 @@ const formatTimeRemaining = (secondsUntilDeadline: number): string => { return `${hours}h ${minutes}m`; } if (minutes > 0) { - return `${minutes}m ${seconds}s`; + return `${minutes}m ${sec}s`; } - return `${seconds}s`; + return `${sec}s`; }; +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + const getSlashStatusReason = ( status: SlashStatus, deadlineReason: 'NotPending' | 'DeadlinePassed' | null, @@ -163,6 +207,9 @@ const getSlashStatusReason = ( if (status === 'Cancelled') { return 'Cancelled'; } + if (status === 'Disputed') { + return 'Disputed'; + } return null; }; @@ -256,24 +303,18 @@ const Page: FC = () => { useEffect(() => { const interval = window.setInterval(() => { setNowUnixSeconds(Math.floor(Date.now() / 1000)); - }, 1000); + }, 5_000); return () => window.clearInterval(interval); }, []); - // Data hooks + // Data hooks (registration) const { data: registrations, isLoading: loadingRegistrations, error: registrationsError, refetch: refetchRegistrations, } = useOperatorRegistrations(); - const { - data: slashProposals, - isLoading: loadingSlash, - error: slashError, - refetch: refetchSlashProposals, - } = useSlashProposals(); const { data: activeServices, error: activeServicesError, @@ -283,95 +324,83 @@ const Page: FC = () => { status: 'ACTIVE', }); - // Transaction hooks + // Data hooks (slashing) + const { + data: slashProposals, + isLoading: loadingSlash, + error: slashError, + refetch: refetchSlashProposals, + } = useSlashProposals({ + scope: 'all', + enabled: isConnected, + }); + const { + data: proposableServices, + isLoading: loadingProposableServices, + } = useProposableServices({ + enabled: isConnected, + }); + const { + data: executableSlashIds, + refetch: refetchExecutableSlashes, + } = useExecutableSlashes({ + enabled: isConnected, + proposals: slashProposals, + }); + + // Transaction hooks (registration) const { unregisterOperator, status: unregisterStatus } = useUnregisterOperatorTx(); const { updatePreferences, status: updateStatus } = useUpdateOperatorPreferencesTx(); + + // Transaction hooks (slashing) + const { proposeSlash, status: proposeStatus } = useProposeSlashTx(); const { disputeSlash, status: disputeStatus } = useDisputeSlashTx(); + const { cancelSlash, status: cancelStatus } = useCancelSlashTx(); + const { executeSlash } = useExecuteSlashTx(); - // Modal state + // Registration modal state const [selectedRegistration, setSelectedRegistration] = useState(null); - const [selectedSlash, setSelectedSlash] = useState( - null, - ); const [showUnregisterModal, setShowUnregisterModal] = useState(false); const [showUpdateModal, setShowUpdateModal] = useState(false); - const [showDisputeModal, setShowDisputeModal] = useState(false); - const [showDisputeMessageModal, setShowDisputeMessageModal] = useState(false); - - // Form state const [newRpcAddress, setNewRpcAddress] = useState(''); const [newEcdsaPublicKey, setNewEcdsaPublicKey] = useState(''); + + // Slashing state + const [selectedSlash, setSelectedSlash] = useState(null); + const [selectedSlashingTab, setSelectedSlashingTab] = useState( + SlashingTab.PROPOSE, + ); + const [showDisputeModal, setShowDisputeModal] = useState(false); + const [showDisputeMessageModal, setShowDisputeMessageModal] = useState(false); + const [showCancelModal, setShowCancelModal] = useState(false); + const [showTimelineModal, setShowTimelineModal] = useState(false); + const [showProposeModal, setShowProposeModal] = useState(false); const [disputeReason, setDisputeReason] = useState(''); + const [cancelReason, setCancelReason] = useState(''); + + // Propose form state + const [proposeServiceId, setProposeServiceId] = useState(''); + const [proposeOperator, setProposeOperator] = useState(''); + const [proposeSlashBps, setProposeSlashBps] = useState(''); + const [proposeEvidence, setProposeEvidence] = useState(''); const trimmedDisputeReason = disputeReason.trim(); + const trimmedCancelReason = cancelReason.trim(); const trimmedEcdsaPublicKey = newEcdsaPublicKey.trim(); - const resolvedRpcAddress = useMemo(() => { - if (!selectedRegistration) { - return ''; - } - - return newRpcAddress.trim(); - }, [newRpcAddress, selectedRegistration]); - - const resolvedEcdsaPublicKey = useMemo(() => { - if (!selectedRegistration) { - return null; - } - - const nextKey = - trimmedEcdsaPublicKey || selectedRegistration.preferences.ecdsaPublicKey; - - return nextKey.trim(); - }, [selectedRegistration, trimmedEcdsaPublicKey]); - - const ecdsaPublicKeyError = useMemo(() => { - if (!selectedRegistration || !resolvedEcdsaPublicKey) { - return null; - } - - if (!isValidEcdsaPublicKey(resolvedEcdsaPublicKey)) { - if (!trimmedEcdsaPublicKey) { - return `Current ECDSA public key is invalid. Enter a valid 65-byte key (${ECDSA_PUBLIC_KEY_HEX_LENGTH} hex chars including 0x) to continue.`; - } - - return `ECDSA public key must be 65 bytes (${ECDSA_PUBLIC_KEY_HEX_LENGTH} hex chars including 0x).`; - } - - return null; - }, [resolvedEcdsaPublicKey, selectedRegistration, trimmedEcdsaPublicKey]); - - const rpcAddressError = useMemo(() => { - if (!selectedRegistration) { - return null; - } - - const trimmedRpcAddress = newRpcAddress.trim(); - if (trimmedRpcAddress.length === 0) { - return 'RPC endpoint is required.'; - } - - try { - const url = new URL(trimmedRpcAddress); - const isAllowedProtocol = ['http:', 'https:', 'ws:', 'wss:'].includes( - url.protocol, - ); - - if (!isAllowedProtocol) { - return 'RPC endpoint must use http(s) or ws(s).'; - } - - return null; - } catch { - return 'Please enter a valid RPC endpoint URL.'; + const executableSlashIdSet = useMemo(() => { + const set = new Set(); + for (const id of executableSlashIds ?? []) { + set.add(id.toString()); } - }, [newRpcAddress, selectedRegistration]); + return set; + }, [executableSlashIds]); const activeServiceIds = useMemo( - () => (activeServices ?? []).map((s) => s.serviceId), + () => (activeServices ?? []).map((service) => service.serviceId), [activeServices], ); @@ -412,6 +441,45 @@ const Page: FC = () => { const isActiveServicePrecheckUnavailable = Boolean(activeServicesError); + const pendingSlashProposals = useMemo( + () => (slashProposals ?? []).filter((slash) => slash.status === 'Pending'), + [slashProposals], + ); + + const disputedSlashProposals = useMemo( + () => (slashProposals ?? []).filter((slash) => slash.status === 'Disputed'), + [slashProposals], + ); + + const proposedByMe = useMemo(() => { + if (!address) { + return [] as SlashProposal[]; + } + + return (slashProposals ?? []).filter( + (slash) => slash.proposer.toLowerCase() === address.toLowerCase(), + ); + }, [address, slashProposals]); + + const executableDiscoveryRows = useMemo(() => { + return (slashProposals ?? []).filter( + (slash) => slash.status === 'Pending' || slash.status === 'Disputed', + ); + }, [slashProposals]); + + const activeSlashRows = useMemo( + () => [...pendingSlashProposals, ...disputedSlashProposals], + [pendingSlashProposals, disputedSlashProposals], + ); + + const historyRows = useMemo( + () => + (slashProposals ?? []).filter( + (slash) => slash.status === 'Executed' || slash.status === 'Cancelled', + ), + [slashProposals], + ); + const selectedSlashEligibility = useMemo(() => { if (!selectedSlash) { return null; @@ -419,15 +487,24 @@ const Page: FC = () => { return getSlashDisputeEligibility(selectedSlash, nowUnixSeconds); }, [selectedSlash, nowUnixSeconds]); - const isDisputeReasonValid = - trimmedDisputeReason.length >= MIN_DISPUTE_REASON_LENGTH; - const canSubmitDispute = - !!selectedSlashEligibility?.isEligible && isDisputeReasonValid; + const selectedSlashPermissions = useMemo(() => { + if (!selectedSlash) { + return null; + } - const pendingSlashProposals = useMemo( - () => (slashProposals ?? []).filter((slash) => slash.status === 'Pending'), - [slashProposals], - ); + return getSlashActionPermissions({ + slash: selectedSlash, + connectedAddress: address, + nowUnixSeconds, + }); + }, [address, nowUnixSeconds, selectedSlash]); + + const selectedSlashTimeline = useMemo(() => { + if (!selectedSlash) { + return []; + } + return buildSlashTimeline(selectedSlash, nowUnixSeconds); + }, [selectedSlash, nowUnixSeconds]); const nearestPendingSlash = useMemo(() => { if (!pendingSlashProposals.length) { @@ -447,6 +524,142 @@ const Page: FC = () => { return getSlashDisputeEligibility(nearestPendingSlash, nowUnixSeconds); }, [nearestPendingSlash, nowUnixSeconds]); + const selectedProposableService = useMemo(() => { + if (!proposeServiceId) { + return null; + } + + return (proposableServices ?? []).find( + (service) => service.serviceId.toString() === proposeServiceId, + ) ?? null; + }, [proposableServices, proposeServiceId]); + + useEffect(() => { + if (!selectedProposableService) { + return; + } + + const firstOperator = selectedProposableService.operatorCandidates[0]; + if (firstOperator && proposeOperator.length === 0) { + setProposeOperator(firstOperator); + } + }, [selectedProposableService, proposeOperator.length]); + + const proposableServiceOptions = useMemo(() => { + return (proposableServices ?? []).map((service) => ({ + value: service.serviceId.toString(), + label: `Service #${service.serviceId.toString()} โ€ข ${service.blueprintName}`, + })); + }, [proposableServices]); + + const evidenceNormalization = useMemo( + () => toSlashEvidenceBytes32(proposeEvidence), + [proposeEvidence], + ); + + const proposeValidationError = useMemo(() => { + if (!proposeServiceId) { + return 'Select a service.'; + } + + if (!isAddress(proposeOperator)) { + return 'Operator must be a valid EVM address.'; + } + + const slashBps = Number(proposeSlashBps); + if ( + !Number.isInteger(slashBps) || + slashBps <= 0 || + slashBps > 10_000 + ) { + return 'Slash BPS must be an integer between 1 and 10000.'; + } + + if (evidenceNormalization.error) { + return evidenceNormalization.error; + } + + return null; + }, [ + evidenceNormalization.error, + proposeOperator, + proposeServiceId, + proposeSlashBps, + ]); + + const canSubmitPropose = + proposeValidationError === null && proposeStatus !== 'pending'; + + const isDisputeReasonValid = + trimmedDisputeReason.length >= MIN_DISPUTE_REASON_LENGTH; + const canSubmitDispute = + !!selectedSlashPermissions?.canDispute && isDisputeReasonValid; + + const isCancelReasonValid = trimmedCancelReason.length >= MIN_CANCEL_REASON_LENGTH; + const canSubmitCancel = + !!selectedSlashPermissions?.canCancel && isCancelReasonValid; + + const resolvedRpcAddress = useMemo(() => { + if (!selectedRegistration) { + return ''; + } + + return newRpcAddress.trim(); + }, [newRpcAddress, selectedRegistration]); + + const resolvedEcdsaPublicKey = useMemo(() => { + if (!selectedRegistration) { + return null; + } + + const nextKey = + trimmedEcdsaPublicKey || selectedRegistration.preferences.ecdsaPublicKey; + + return nextKey.trim(); + }, [selectedRegistration, trimmedEcdsaPublicKey]); + + const ecdsaPublicKeyError = useMemo(() => { + if (!selectedRegistration || !resolvedEcdsaPublicKey) { + return null; + } + + if (!isValidEcdsaPublicKey(resolvedEcdsaPublicKey)) { + if (!trimmedEcdsaPublicKey) { + return `Current ECDSA public key is invalid. Enter a valid 65-byte key (${ECDSA_PUBLIC_KEY_HEX_LENGTH} hex chars including 0x) to continue.`; + } + + return `ECDSA public key must be 65 bytes (${ECDSA_PUBLIC_KEY_HEX_LENGTH} hex chars including 0x).`; + } + + return null; + }, [resolvedEcdsaPublicKey, selectedRegistration, trimmedEcdsaPublicKey]); + + const rpcAddressError = useMemo(() => { + if (!selectedRegistration) { + return null; + } + + const trimmedRpcAddress = newRpcAddress.trim(); + if (trimmedRpcAddress.length === 0) { + return 'RPC endpoint is required.'; + } + + try { + const url = new URL(trimmedRpcAddress); + const isAllowedProtocol = ['http:', 'https:', 'ws:', 'wss:'].includes( + url.protocol, + ); + + if (!isAllowedProtocol) { + return 'RPC endpoint must use http(s) or ws(s).'; + } + + return null; + } catch { + return 'Please enter a valid RPC endpoint URL.'; + } + }, [newRpcAddress, selectedRegistration]); + const isNoopUpdate = useMemo(() => { if (!selectedRegistration) { return true; @@ -465,6 +678,156 @@ const Page: FC = () => { return rpcUnchanged && keyUnchanged; }, [resolvedRpcAddress, selectedRegistration, trimmedEcdsaPublicKey]); + const patchSlashInCache = useCallback( + ( + slashId: bigint, + updater: (proposal: SlashProposal) => SlashProposal, + ) => { + queryClient.setQueriesData( + { queryKey: ['slashing', 'proposals'] }, + (existingProposals) => { + if (!existingProposals) { + return existingProposals; + } + + return existingProposals.map((proposal) => + proposal.id === slashId ? updater(proposal) : proposal, + ); + }, + ); + }, + [queryClient], + ); + + const upsertSlashInCache = useCallback( + (slash: SlashProposal) => { + queryClient.setQueriesData( + { queryKey: ['slashing', 'proposals'] }, + (existingProposals) => { + if (!existingProposals) { + return [slash]; + } + + const index = existingProposals.findIndex( + (proposal) => proposal.id === slash.id, + ); + + if (index === -1) { + return [slash, ...existingProposals]; + } + + const next = [...existingProposals]; + next[index] = slash; + return next; + }, + ); + }, + [queryClient], + ); + + const refreshSlashingData = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: ['slashing'] }); + await refetchSlashProposals(); + await refetchExecutableSlashes(); + }, [queryClient, refetchExecutableSlashes, refetchSlashProposals]); + + const applySlashSyncTargetToCache = useCallback( + (target: SlashSyncTarget) => { + patchSlashInCache(target.slashId, (proposal) => ({ + ...proposal, + status: target.status, + disputeReason: + target.disputeReason !== undefined + ? target.disputeReason + : proposal.disputeReason, + cancelReason: + target.cancelReason !== undefined + ? target.cancelReason + : proposal.cancelReason, + })); + }, + [patchSlashInCache], + ); + + const waitForSlashStatusSync = useCallback( + async (targets: SlashSyncTarget[]) => { + if (targets.length === 0) { + return; + } + + targets.forEach((target) => { + applySlashSyncTargetToCache(target); + }); + + const matchesTarget = ( + proposal: SlashProposal | undefined, + target: SlashSyncTarget, + ): boolean => { + if (!proposal || proposal.status !== target.status) { + return false; + } + + if (target.disputeReason !== undefined) { + if (target.disputeReason === null) { + if (proposal.disputeReason !== null) { + return false; + } + } else { + const expectedDispute = target.disputeReason.trim(); + const actualDispute = proposal.disputeReason?.trim() ?? ''; + if (expectedDispute.length > 0 && actualDispute !== expectedDispute) { + return false; + } + } + } + + if (target.cancelReason !== undefined) { + if (target.cancelReason === null) { + if (proposal.cancelReason !== null) { + return false; + } + } else { + const expectedCancel = target.cancelReason.trim(); + const actualCancel = proposal.cancelReason?.trim() ?? ''; + if (expectedCancel.length > 0 && actualCancel !== expectedCancel) { + return false; + } + } + } + + return true; + }; + + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + const refreshed = await refetchSlashProposals(); + const refreshedProposals = refreshed.data ?? []; + const allSynced = targets.every((target) => { + const proposal = refreshedProposals.find( + (item) => item.id === target.slashId, + ); + return matchesTarget(proposal, target); + }); + + if (allSynced) { + break; + } + } catch { + // Keep optimistic cache values while waiting for indexer recovery. + } + + targets.forEach((target) => { + applySlashSyncTargetToCache(target); + }); + + await sleep(1_000); + } + + await refetchExecutableSlashes(); + }, + [applySlashSyncTargetToCache, refetchExecutableSlashes, refetchSlashProposals], + ); + const handleUnregister = useCallback(async () => { if (!selectedRegistration) return; const unregisteredBlueprintId = selectedRegistration.blueprintId; @@ -496,8 +859,9 @@ const Page: FC = () => { rpcAddressError || ecdsaPublicKeyError || !resolvedEcdsaPublicKey - ) + ) { return; + } const updatedBlueprintId = selectedRegistration.blueprintId; const updatedRpcAddress = resolvedRpcAddress; @@ -530,58 +894,172 @@ const Page: FC = () => { }, ); - setShowUpdateModal(false); - setSelectedRegistration(null); - setNewRpcAddress(''); - setNewEcdsaPublicKey(''); + setShowUpdateModal(false); + setSelectedRegistration(null); + setNewRpcAddress(''); + setNewEcdsaPublicKey(''); + } + }, [ + ecdsaPublicKeyError, + queryClient, + resolvedEcdsaPublicKey, + resolvedRpcAddress, + rpcAddressError, + selectedRegistration, + updatePreferences, + ]); + + const handleProposeSlash = useCallback(async () => { + if (!canSubmitPropose || !evidenceNormalization.value) { + return; + } + + const proposalStartedAt = BigInt(Math.floor(Date.now() / 1000)); + const slashBps = Number(proposeSlashBps); + const operatorAddress = proposeOperator as Address; + const targetServiceId = BigInt(proposeServiceId); + const targetEvidence = evidenceNormalization.value.toLowerCase(); + const proposerAddress = address?.toLowerCase() ?? null; + const result = await proposeSlash( + targetServiceId, + operatorAddress, + slashBps, + evidenceNormalization.value, + ); + + if (result) { + if (result.proposal) { + upsertSlashInCache(result.proposal); + } + + setProposeServiceId(''); + setProposeOperator(''); + setProposeSlashBps(''); + setProposeEvidence(''); + setShowProposeModal(false); + setSelectedSlashingTab(SlashingTab.ACTIVE); + await refreshSlashingData(); + + for (let attempt = 0; attempt < 8; attempt += 1) { + const refreshed = await refetchSlashProposals(); + const isVisible = (refreshed.data ?? []).some((proposal) => { + if (result.slashId !== undefined) { + return proposal.id === result.slashId; + } + + return ( + proposerAddress !== null && + proposal.proposer.toLowerCase() === proposerAddress && + proposal.serviceId === targetServiceId && + proposal.operator.toLowerCase() === operatorAddress.toLowerCase() && + proposal.slashBps === BigInt(slashBps) && + proposal.evidence.toLowerCase() === targetEvidence && + proposal.proposedAt >= proposalStartedAt - BigInt(5) + ); + }); + + if (isVisible) { + break; + } + + await sleep(1_000); + } } }, [ - rpcAddressError, - ecdsaPublicKeyError, - queryClient, - resolvedEcdsaPublicKey, - resolvedRpcAddress, - selectedRegistration, - updatePreferences, + address, + canSubmitPropose, + evidenceNormalization.value, + proposeOperator, + proposeServiceId, + proposeSlashBps, + proposeSlash, + refetchSlashProposals, + refreshSlashingData, + upsertSlashInCache, ]); const handleDispute = useCallback(async () => { if (!selectedSlash || !canSubmitDispute) return; const disputedSlashId = selectedSlash.id; - const disputeReason = trimmedDisputeReason; - const result = await disputeSlash(disputedSlashId, disputeReason); + const submittedDisputeReason = trimmedDisputeReason; + const result = await disputeSlash(disputedSlashId, submittedDisputeReason); if (result) { - queryClient.setQueriesData( - { queryKey: ['slashing', 'proposals'] }, - (existingProposals) => { - if (!existingProposals) { - return existingProposals; - } - - return existingProposals.map((proposal) => - proposal.id === disputedSlashId - ? { - ...proposal, - status: 'Disputed', - disputeReason, - } - : proposal, - ); - }, - ); - setShowDisputeModal(false); setSelectedSlash(null); setDisputeReason(''); + await waitForSlashStatusSync([ + { + slashId: disputedSlashId, + status: 'Disputed', + disputeReason: submittedDisputeReason, + }, + ]); } }, [ canSubmitDispute, + disputeSlash, selectedSlash, trimmedDisputeReason, - disputeSlash, - queryClient, + waitForSlashStatusSync, + ]); + + const handleCancel = useCallback(async () => { + if (!selectedSlash || !canSubmitCancel) return; + const cancelledSlashId = selectedSlash.id; + const submittedCancelReason = trimmedCancelReason; + const result = await cancelSlash(cancelledSlashId, submittedCancelReason); + if (result) { + setShowCancelModal(false); + setSelectedSlash(null); + setCancelReason(''); + await waitForSlashStatusSync([ + { + slashId: cancelledSlashId, + status: 'Cancelled', + cancelReason: submittedCancelReason, + }, + ]); + } + }, [ + cancelSlash, + canSubmitCancel, + selectedSlash, + trimmedCancelReason, + waitForSlashStatusSync, ]); + const handleExecuteSingle = useCallback( + async (slash: SlashProposal) => { + const permissions = getSlashActionPermissions({ + slash, + connectedAddress: address, + nowUnixSeconds, + }); + const isExecutable = executableSlashIdSet.has(slash.id.toString()); + + if (!permissions.canExecute || !isExecutable) { + return; + } + + const result = await executeSlash(slash.id); + if (result) { + await waitForSlashStatusSync([ + { + slashId: slash.id, + status: 'Executed', + }, + ]); + } + }, + [ + address, + executableSlashIdSet, + executeSlash, + nowUnixSeconds, + waitForSlashStatusSync, + ], + ); + // Registration columns const registrationColumns = useMemo( () => [ @@ -639,8 +1117,8 @@ const Page: FC = () => { size="sm" isDisabled={!actionState.canUnregister} className="uppercase body4 bg-red-10 dark:bg-red-120 text-red-70 dark:text-red-40 hover:bg-red-20 dark:hover:bg-red-110 border border-red-30 dark:border-red-100 disabled:!opacity-100 disabled:!text-mono-100 disabled:!border-mono-100/30 disabled:!bg-transparent dark:disabled:!text-mono-100 dark:disabled:!border-mono-120 dark:disabled:!bg-mono-160 disabled:cursor-not-allowed" - onClick={(e) => { - e.stopPropagation(); + onClick={(event) => { + event.stopPropagation(); setSelectedRegistration(info.row.original); setShowUnregisterModal(true); }} @@ -658,8 +1136,8 @@ const Page: FC = () => { variant="utility" size="sm" className="uppercase body4 bg-blue-10 dark:bg-blue-120 text-blue-70 dark:text-blue-40 hover:bg-blue-20 dark:hover:bg-blue-110 border border-blue-30 dark:border-blue-100" - onClick={(e) => { - e.stopPropagation(); + onClick={(event) => { + event.stopPropagation(); setSelectedRegistration(info.row.original); setNewRpcAddress( info.row.original.preferences.rpcAddress, @@ -697,7 +1175,33 @@ const Page: FC = () => { ], ); - // Slash columns + const getSlashExecutionReadinessText = useCallback( + (slash: SlashProposal): string => { + if (executableSlashIdSet.has(slash.id.toString())) { + return 'Executable (contract confirmed)'; + } + + const eligibility = getSlashExecutionEligibility(slash, nowUnixSeconds); + if (eligibility.reason === 'DisputeWindowOpen') { + return `Not executable: dispute window open (${formatTimeRemaining( + eligibility.secondsUntilExecutable, + )})`; + } + + if (eligibility.reason === 'Disputed') { + return 'Not executable: disputed slash'; + } + + if (eligibility.reason === 'NotPending') { + return 'Not executable: not pending'; + } + + return 'Not executable yet'; + }, + [executableSlashIdSet, nowUnixSeconds], + ); + + // Slashing columns const slashColumns = useMemo( () => [ slashColumnHelper.accessor('id', { @@ -734,7 +1238,8 @@ const Page: FC = () => { ), }), slashColumnHelper.accessor('effectiveSlashBps', { - header: () => 'Effective Slash %', + header: () => 'Effective %', + minSize: 120, cell: (info) => ( @@ -743,25 +1248,6 @@ const Page: FC = () => { ), }), - slashColumnHelper.accessor('evidence', { - header: () => 'Claim Context', - cell: (info) => { - const slash = info.row.original; - const claimContext = getSlashClaimContext(slash); - - return ( - - - {claimContext} - - - ); - }, - }), slashColumnHelper.accessor('proposer', { header: () => 'Proposer', cell: (info) => { @@ -809,12 +1295,23 @@ const Page: FC = () => { }), slashColumnHelper.accessor('executeAfter', { header: () => 'Execute After', + cell: (info) => ( + + + {formatDateTime(info.getValue())} + + + ), + }), + slashColumnHelper.display({ + id: 'readiness', + header: () => 'Execution Readiness', cell: (info) => { + const slash = info.row.original; + const text = getSlashExecutionReadinessText(slash); return ( - - {formatDateTime(info.getValue())} - + {text} ); }, @@ -824,24 +1321,37 @@ const Page: FC = () => { header: () => '', cell: (info) => { const slash = info.row.original; - const eligibility = getSlashDisputeEligibility(slash, nowUnixSeconds); - const showDisputeButton = slash.status === 'Pending'; - const reasonText = getSlashStatusReason( - slash.status, - eligibility.reason, + const permissions = getSlashActionPermissions({ + slash, + connectedAddress: address, + nowUnixSeconds, + }); + const disputeEligibility = getSlashDisputeEligibility( + slash, + nowUnixSeconds, + ); + const executionEligibility = getSlashExecutionEligibility( + slash, + nowUnixSeconds, ); + const isExecutable = executableSlashIdSet.has(slash.id.toString()); + const canExecute = permissions.canExecute && isExecutable; + const executeDisabledReason = !canExecute + ? permissions.executeReason ?? 'Not executable per on-chain discovery.' + : null; + return ( - {showDisputeButton ? ( - <> +
+ {slash.status === 'Pending' && ( - {!eligibility.isEligible && reasonText && ( - - {reasonText} - - )} - - ) : slash.status === 'Disputed' ? ( -
+ )} + + {(slash.status === 'Pending' || slash.status === 'Disputed') && ( + )} + + {(slash.status === 'Pending' || slash.status === 'Disputed') && ( + + )} + + + + {slash.status === 'Disputed' && ( + -
- ) : ( - - {reasonText ?? '-'} - - )} + )} +
+ +
+ {!permissions.canDispute && slash.status === 'Pending' ? ( + + {permissions.disputeReason} + + ) : null} + + {!permissions.canExecute && + (slash.status === 'Pending' || slash.status === 'Disputed') ? ( + + {executeDisabledReason ?? + (executionEligibility.reason === 'DisputeWindowOpen' + ? `Ready in ${formatTimeRemaining( + executionEligibility.secondsUntilExecutable, + )}` + : null)} + + ) : null} + + {permissions.canExecute && + !isExecutable && + (slash.status === 'Pending' || slash.status === 'Disputed') ? ( + + Not executable per on-chain discovery. + + ) : null} + + {!permissions.canCancel && + (slash.status === 'Pending' || slash.status === 'Disputed') ? ( + + {permissions.cancelReason} + + ) : null} + + {slash.status !== 'Pending' && slash.status !== 'Disputed' ? ( + + {getSlashStatusReason(slash.status, disputeEligibility.reason) ?? + '-'} + + ) : null} +
); }, }), ], - [nowUnixSeconds], + [ + address, + executableSlashIdSet, + getSlashExecutionReadinessText, + handleExecuteSingle, + nowUnixSeconds, + ], ); - // Tables const registrationTable = useReactTable({ data: registrations ?? [], columns: registrationColumns, @@ -891,8 +1484,29 @@ const Page: FC = () => { getPaginationRowModel: getPaginationRowModel(), }); - const slashTable = useReactTable({ - data: slashProposals ?? [], + const proposeQueueTable = useReactTable({ + data: proposedByMe, + columns: slashColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + const activeSlashTable = useReactTable({ + data: activeSlashRows, + columns: slashColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + const executableSlashTable = useReactTable({ + data: executableDiscoveryRows, + columns: slashColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + const historySlashTable = useReactTable({ + data: historyRows, columns: slashColumns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), @@ -906,7 +1520,8 @@ const Page: FC = () => { Connect Your Wallet - Please connect your wallet to view your operator registrations. + Please connect your wallet to manage operator registrations and + slashing lifecycle actions.
@@ -918,42 +1533,49 @@ const Page: FC = () => { return (
- {/* Header */}
Operator Management - Manage your blueprint registrations, update preferences, and handle - slashing disputes. + Manage registrations and the full slash lifecycle: propose, dispute, + execute, batch-execute, and cancel.
- {/* Summary Cards */} -
+
- Active Registrations + Pending Slashes - {registrations?.filter((r) => r.active).length ?? 0} + {pendingSlashProposals.length} + - Registrations (All-Time) + Disputed Slashes - {registrations?.length ?? 0} + {disputedSlashProposals.length} + - Pending Slashes + Executable Now - s.status === 'Pending').length ?? 0) > 0 ? 'text-red-500' : ''}`} - > - {pendingSlashProposals.length} + + {executableSlashIdSet.size} + + + + + + Active Registrations + + + {registrations?.filter((registration) => registration.active).length ?? + 0}
@@ -1002,7 +1624,6 @@ const Page: FC = () => { ) : null} - {/* Registrations Table */} { }} /> - {/* Slashing Table */} - void refetchSlashProposals()} - tableProps={slashTable} - tableConfig={{ - tdClassName: '!p-3 max-w-none whitespace-nowrap', - thClassName: 'whitespace-nowrap', - }} - loadingTableProps={{ - title: 'Loading slash proposals...', - description: 'Please wait while we fetch slashing proposals.', - icon: '๐Ÿ”„', - }} - errorTableProps={{ - title: 'Failed to load slash proposals', - description: - 'Indexer or network request failed while loading slashing proposals.', - icon: 'โš ๏ธ', - }} - emptyTableProps={{ - title: 'No Slash Proposals', - description: 'No slashing proposals against your operator account.', - icon: 'โœ…', - }} - /> + +
+ + Slashing Management + + +
+ + setSelectedSlashingTab(tab as SlashingTab)} + className="space-y-5" + enableAdvancedDivider + > + + void refetchSlashProposals()} + tableProps={proposeQueueTable} + tableConfig={{ + tdClassName: '!p-3 max-w-none whitespace-nowrap', + thClassName: 'whitespace-nowrap', + }} + loadingTableProps={{ + title: 'Loading proposals...', + description: 'Fetching slash proposals you proposed.', + icon: '๐Ÿ”„', + }} + emptyTableProps={{ + title: 'No Proposals Yet', + description: + 'You have not proposed any slash in the current index scope.', + icon: '๐Ÿงพ', + }} + /> + + + + void refetchSlashProposals()} + tableProps={activeSlashTable} + tableConfig={{ + tdClassName: '!p-3 max-w-none whitespace-nowrap', + thClassName: 'whitespace-nowrap', + }} + loadingTableProps={{ + title: 'Loading active proposals...', + description: 'Fetching pending and disputed slash proposals.', + icon: '๐Ÿ”„', + }} + emptyTableProps={{ + title: 'No Active Proposals', + description: + 'There are no pending or disputed slash proposals currently.', + icon: 'โœ…', + }} + /> + + + + void refetchSlashProposals()} + tableProps={executableSlashTable} + tableConfig={{ + tdClassName: '!p-3 max-w-none whitespace-nowrap', + thClassName: 'whitespace-nowrap', + }} + loadingTableProps={{ + title: 'Scanning executable slashes...', + description: + 'Discovering executable slash IDs and reasons for non-executable proposals.', + icon: '๐Ÿ”„', + }} + emptyTableProps={{ + title: 'No Pending/Disputed Proposals', + description: + 'There are no proposals to evaluate for execution at this time.', + icon: '๐Ÿ”', + }} + /> + + + + void refetchSlashProposals()} + tableProps={historySlashTable} + tableConfig={{ + tdClassName: '!p-3 max-w-none whitespace-nowrap', + thClassName: 'whitespace-nowrap', + }} + loadingTableProps={{ + title: 'Loading slash history...', + description: 'Fetching executed and cancelled slash proposals.', + icon: '๐Ÿ”„', + }} + emptyTableProps={{ + title: 'No History Yet', + description: + 'No executed or cancelled slash proposals are available yet.', + icon: '๐Ÿ“š', + }} + /> + + +
{/* Unregister Modal */} @@ -1103,7 +1826,7 @@ const Page: FC = () => { id="rpcAddress" isControlled value={newRpcAddress} - onChange={(v) => setNewRpcAddress(v)} + onChange={(value) => setNewRpcAddress(value)} placeholder="https://your-node.example.com" /> {rpcAddressError ? ( @@ -1123,7 +1846,7 @@ const Page: FC = () => { id="ecdsaPublicKey" isControlled value={newEcdsaPublicKey} - onChange={(v) => setNewEcdsaPublicKey(v)} + onChange={(value) => setNewEcdsaPublicKey(value)} placeholder="0x... (65-byte uncompressed key)" /> @@ -1155,6 +1878,121 @@ const Page: FC = () => { + {/* Propose Slash Modal */} + + + Create Slash Proposal + +
+ {loadingProposableServices ? ( + + Loading eligible services... + + ) : null} + + {!loadingProposableServices && (proposableServices?.length ?? 0) === 0 ? ( + + No active services where your account appears as service or + blueprint owner. + + ) : null} + + {(proposableServices?.length ?? 0) > 0 ? ( +
+
+ + Service + + +
+ +
+ + Operator + + {selectedProposableService && + selectedProposableService.operatorCandidates.length > 0 ? ( + + ) : ( + setProposeOperator(value)} + placeholder="0x..." + /> + )} +
+ +
+ + Slash BPS (1 - 10000) + + setProposeSlashBps(value)} + placeholder="e.g. 500 (5%)" + /> +
+ +
+ + Evidence (text or bytes32 hex) + + setProposeEvidence(value)} + placeholder="ipfs://... or 0x..." + /> +
+
+ ) : null} + + {proposeValidationError ? ( + + {proposeValidationError} + + ) : null} +
+
+ +
+
+ {/* Dispute Reason Modal */} { {selectedSlash?.evidence ?? '-'} - - Evidence Type: - - - On-chain commitment hash -
@@ -1277,19 +2109,17 @@ const Page: FC = () => { className="w-full p-3 rounded-lg border border-mono-40 dark:border-mono-140 bg-mono-0 dark:bg-mono-180 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-purple-500" rows={4} value={disputeReason} - onChange={(e) => setDisputeReason(e.target.value)} + onChange={(event) => setDisputeReason(event.target.value)} placeholder="Explain why this slash proposal is invalid..." /> Minimum {MIN_DISPUTE_REASON_LENGTH} characters ( {trimmedDisputeReason.length}/{MIN_DISPUTE_REASON_LENGTH}). - {!selectedSlashEligibility?.isEligible ? ( + {!selectedSlashPermissions?.canDispute ? ( - {getSlashStatusReason( - selectedSlash?.status ?? 'Pending', - selectedSlashEligibility?.reason ?? null, - ) ?? 'Dispute is not available for this slash.'} + {selectedSlashPermissions?.disputeReason ?? + 'Dispute is not available for this slash.'} ) : null}
@@ -1303,6 +2133,104 @@ const Page: FC = () => { /> + + {/* Cancel Slash Modal */} + + + Cancel Slash Proposal + + + Cancel slash proposal #{selectedSlash?.id.toString()} + + + Admin-only action. Authorization is validated on-chain at + submission time. + +
+ + Reason for cancellation + +