diff --git a/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ServiceRequestDetailModal.tsx b/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ServiceRequestDetailModal.tsx index 5954d48cd8..499e146f67 100644 --- a/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ServiceRequestDetailModal.tsx +++ b/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ServiceRequestDetailModal.tsx @@ -16,6 +16,8 @@ import { type ServiceRequest } from '@tangle-network/tangle-shared-ui/data/graph import { useServiceRequestDetails, useTokenMetadata, + useExpireServiceRequestTx, + isServiceRequestExpired, } from '@tangle-network/tangle-shared-ui/data/services'; import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; import { TxStatus } from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; @@ -75,6 +77,43 @@ const ServiceRequestDetailModal: FC = ({ enabled: selectedRequest !== null, }); + // Permissionless cleanup. Available once `now > createdAt + grace` and the + // request has not already been activated or rejected. The contract + // re-validates these conditions, but we gate the button to avoid wasting a + // user's gas on a guaranteed revert. + const { + execute: expireServiceRequest, + error: expireError, + reset: resetExpire, + isPending: isExpiring, + isSuccess: isExpireSuccess, + } = useExpireServiceRequestTx(); + + const canExpireRequest = useMemo(() => { + if (!contractDetails || !selectedRequest) { + return false; + } + if (contractDetails.rejected) { + return false; + } + return isServiceRequestExpired(contractDetails.createdAt); + }, [contractDetails, selectedRequest]); + + const handleExpireRequest = useCallback(async () => { + if (!selectedRequest || !canExpireRequest) { + return; + } + await expireServiceRequest?.({ requestId: selectedRequest.requestId }); + }, [canExpireRequest, expireServiceRequest, selectedRequest]); + + // After a successful expire the request is gone — close the modal so the + // parent list refetches against an invalidated `serviceRequestDetails` cache. + useEffect(() => { + if (isExpireSuccess) { + onClose(); + } + }, [isExpireSuccess, onClose]); + const { data: tokenMetadata, isLoading: isLoadingToken } = useTokenMetadata( contractDetails?.paymentToken, { @@ -271,27 +310,74 @@ const ServiceRequestDetailModal: FC = ({ /> + {expireError ? ( +
+
+ + {expireError.message || + 'Failed to expire the service request. Please try again.'} + + +
+
+ ) : null} + {!viewOnly && ( -
+
+ {canExpireRequest ? ( + + ) : null} + -
)} {viewOnly && ( -
- + ) : null} +
diff --git a/apps/tangle-cloud/src/pages/operators/manage/components/SlashingParametersCard.tsx b/apps/tangle-cloud/src/pages/operators/manage/components/SlashingParametersCard.tsx new file mode 100644 index 0000000000..67dfdd9364 --- /dev/null +++ b/apps/tangle-cloud/src/pages/operators/manage/components/SlashingParametersCard.tsx @@ -0,0 +1,159 @@ +/** + * Read-only summary of the protocol-level slashing parameters returned by + * `getSlashConfig`. Shipped with tnt-core v0.13.0 — exposes the fields that + * govern dispute lifecycle (disputeBond, disputeResolutionDeadline) and the + * caps that gate proposal flow (maxSlashBps, maxPendingSlashesPerOperator). + * + * Operators and slash proposers both benefit from seeing these up front so + * they don't waste a simulation on a guaranteed revert. + */ + +import { Card } from '@tangle-network/sandbox-ui/primitives'; +import { formatUnits } from 'viem'; +import { Text } from '../../../../components/Text'; + +const SECONDS_PER_HOUR = 60 * 60; +const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24; + +const formatDuration = (seconds: bigint): string => { + const total = Number(seconds); + if (!Number.isFinite(total) || total <= 0) { + return '—'; + } + + if (total >= SECONDS_PER_DAY) { + const days = total / SECONDS_PER_DAY; + const rounded = Math.round(days * 10) / 10; + return `${rounded.toLocaleString(undefined, { + maximumFractionDigits: 1, + })} day${rounded === 1 ? '' : 's'}`; + } + + if (total >= SECONDS_PER_HOUR) { + const hours = total / SECONDS_PER_HOUR; + const rounded = Math.round(hours * 10) / 10; + return `${rounded.toLocaleString(undefined, { + maximumFractionDigits: 1, + })} hour${rounded === 1 ? '' : 's'}`; + } + + return `${total.toLocaleString()} second${total === 1 ? '' : 's'}`; +}; + +// Trim trailing zeros so we don't show "0.000000000000000000 ETH". +const formatEthAmount = (wei: bigint): string => { + const formatted = formatUnits(wei, 18); + if (!formatted.includes('.')) return formatted; + return formatted.replace(/\.?0+$/, ''); +}; + +const formatBps = (bps: number): string => { + const percent = bps / 100; + return `${bps.toLocaleString()} bps (${percent.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + })}%)`; +}; + +export interface SlashingParametersCardProps { + /** + * Active SlashConfig from `getSlashConfig`. Undefined while the read is in + * flight; rendered as a skeleton in that case. + */ + config: + | { + disputeWindow: bigint; + instantSlashEnabled: boolean; + maxSlashBps: number; + disputeResolutionDeadline: bigint; + disputeBond: bigint; + maxPendingSlashesPerOperator: number; + } + | undefined; + isLoading: boolean; +} + +const SlashingParametersCard = ({ + config, + isLoading, +}: SlashingParametersCardProps) => { + if (isLoading || !config) { + return ( + + + Loading slashing parameters... + + + ); + } + + return ( + +
+ + Slashing parameters + + + Protocol-wide settings from the active SlashConfig. Proposals, + disputes, and execution all enforce these. + +
+
+
+ + Maximum slash per proposal + + + {formatBps(config.maxSlashBps)} + +
+
+ + Dispute window + + + {formatDuration(config.disputeWindow)} + +
+
+ + Dispute resolution deadline + + + {formatDuration(config.disputeResolutionDeadline)} + +
+
+ + Required dispute bond + + + {config.disputeBond > BigInt(0) + ? `${formatEthAmount(config.disputeBond)} ETH` + : 'None'} + +
+
+ + Max pending slashes per operator + + + {config.maxPendingSlashesPerOperator > 0 + ? config.maxPendingSlashesPerOperator.toLocaleString() + : 'Unlimited'} + +
+
+ + Instant slash + + + {config.instantSlashEnabled ? 'Enabled' : 'Disabled'} + +
+
+
+ ); +}; + +export default SlashingParametersCard; diff --git a/apps/tangle-cloud/src/pages/operators/manage/components/modals/DisputeSlashModal.tsx b/apps/tangle-cloud/src/pages/operators/manage/components/modals/DisputeSlashModal.tsx index acaf7b66c5..0b83aed1bd 100644 --- a/apps/tangle-cloud/src/pages/operators/manage/components/modals/DisputeSlashModal.tsx +++ b/apps/tangle-cloud/src/pages/operators/manage/components/modals/DisputeSlashModal.tsx @@ -1,4 +1,5 @@ import type { ChangeEvent } from 'react'; +import { formatUnits, zeroAddress } from 'viem'; import { SlashActionPermissions, SlashDisputeEligibility, @@ -24,6 +25,14 @@ import { const shortenHex = (value: string) => value.length <= 12 ? value : `${value.slice(0, 6)}...${value.slice(-4)}`; +// Trim trailing zeros so we don't show "0.000000000000000000 ETH" or +// "1.500000000000000000 ETH" in the bond row. +const formatEthAmount = (wei: bigint): string => { + const formatted = formatUnits(wei, 18); + if (!formatted.includes('.')) return formatted; + return formatted.replace(/\.?0+$/, ''); +}; + interface DisputeSlashModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -39,6 +48,17 @@ interface DisputeSlashModalProps { onConfirm: () => void; errorMessage: string | null; onDismissError: () => void; + /** + * msg.value the contract will require for disputeSlash. Read from the active + * SlashConfig and surfaced here so the user knows what bond they are posting + * before signing. + */ + disputeBond: bigint; + /** + * Seconds remaining until the dispute resolution deadline. Only meaningful + * when the slash is already in `Disputed` status; null otherwise. + */ + disputeResolutionSecondsRemaining: number | null; } const DisputeSlashModal = ({ @@ -56,7 +76,14 @@ const DisputeSlashModal = ({ onConfirm, errorMessage, onDismissError, + disputeBond, + disputeResolutionSecondsRemaining, }: DisputeSlashModalProps) => { + const isAlreadyDisputed = selectedSlash?.status === 'Disputed'; + const hasKnownDisputer = + !!selectedSlash && + selectedSlash.disputer.toLowerCase() !== zeroAddress.toLowerCase(); + return ( @@ -130,6 +157,41 @@ const DisputeSlashModal = ({ {selectedSlash?.evidence ?? '-'} + + Required Dispute Bond: + + + {disputeBond > BigInt(0) + ? `${formatEthAmount(disputeBond)} ETH (claimable via claimDisputeBond if dispute upheld)` + : 'No bond required'} + + {isAlreadyDisputed && hasKnownDisputer ? ( + <> + + Disputer: + + + {selectedSlash ? shortenHex(selectedSlash.disputer) : '-'} + + + ) : null} + {isAlreadyDisputed && + disputeResolutionSecondsRemaining !== null ? ( + <> + + Resolution Deadline: + + + {disputeResolutionSecondsRemaining > 0 + ? formatTimeRemaining(disputeResolutionSecondsRemaining) + : 'Deadline passed'} + + + ) : null}
diff --git a/apps/tangle-cloud/src/pages/operators/manage/components/modals/ProposeSlashModal.tsx b/apps/tangle-cloud/src/pages/operators/manage/components/modals/ProposeSlashModal.tsx index 8b8822172e..6f717a5032 100644 --- a/apps/tangle-cloud/src/pages/operators/manage/components/modals/ProposeSlashModal.tsx +++ b/apps/tangle-cloud/src/pages/operators/manage/components/modals/ProposeSlashModal.tsx @@ -30,6 +30,13 @@ interface ProposeSlashModalProps { canSubmitPropose: boolean; isSubmitting: boolean; onConfirm: () => void; + /** + * Active SlashConfig.maxSlashBps cap (0..10_000). Used both as the upper + * bound in the input placeholder and to short-circuit out-of-range BPS + * before the user pays for a simulation. Undefined while the config is + * loading; in that case we fall back to 10_000. + */ + maxSlashBps: number | undefined; } const ProposeSlashModal = ({ @@ -53,7 +60,18 @@ const ProposeSlashModal = ({ canSubmitPropose, isSubmitting, onConfirm, + maxSlashBps, }: ProposeSlashModalProps) => { + // Hard ceiling defined by the contract; SlashConfig.maxSlashBps is the + // soft (admin-configurable) cap which is always <= 10_000. + const effectiveMaxBps = + maxSlashBps !== undefined && maxSlashBps > 0 ? maxSlashBps : 10_000; + const maxBpsLabel = effectiveMaxBps.toLocaleString(); + const maxPercentLabel = (effectiveMaxBps / 100).toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + return ( @@ -122,15 +140,24 @@ const ProposeSlashModal = ({
- Slash BPS (1 - 10000) + Slash BPS (1 - {maxBpsLabel}) + {maxSlashBps !== undefined ? ( + + Protocol cap: {maxBpsLabel} bps ({maxPercentLabel}%). + Proposals above the cap are rejected on-chain. + + ) : null}
diff --git a/apps/tangle-cloud/src/pages/operators/manage/hooks/useSlashProposalForm.ts b/apps/tangle-cloud/src/pages/operators/manage/hooks/useSlashProposalForm.ts index 35a01cd0f0..8ef4453780 100644 --- a/apps/tangle-cloud/src/pages/operators/manage/hooks/useSlashProposalForm.ts +++ b/apps/tangle-cloud/src/pages/operators/manage/hooks/useSlashProposalForm.ts @@ -25,11 +25,19 @@ export interface UseSlashProposalFormResult { interface UseSlashProposalFormOptions { proposableServices: ProposableService[] | undefined; proposeStatus: 'idle' | 'pending' | 'success' | 'error'; + /** + * Active SlashConfig.maxSlashBps cap. Slash proposals above this value are + * rejected on-chain, so we surface the violation client-side before the + * user pays for a simulation. Defaults to the contract hard ceiling + * (10_000) when the config is still loading. + */ + maxSlashBps?: number; } const useSlashProposalForm = ({ proposableServices, proposeStatus, + maxSlashBps, }: UseSlashProposalFormOptions): UseSlashProposalFormResult => { const [proposeServiceId, setProposeServiceId] = useState(''); const [proposeOperator, setProposeOperator] = useState(''); @@ -76,6 +84,16 @@ const useSlashProposalForm = ({ return 'Slash BPS must be an integer between 1 and 10000.'; } + // Enforce the active SlashConfig.maxSlashBps cap once it has loaded so we + // fail fast in the UI rather than during simulation. + if ( + maxSlashBps !== undefined && + maxSlashBps > 0 && + slashBps > maxSlashBps + ) { + return `Slash BPS exceeds protocol cap of ${maxSlashBps.toLocaleString()} bps.`; + } + if (evidenceNormalization.error) { return evidenceNormalization.error; } @@ -83,6 +101,7 @@ const useSlashProposalForm = ({ return null; }, [ evidenceNormalization.error, + maxSlashBps, proposeOperator, proposeServiceId, proposeSlashBps, diff --git a/apps/tangle-cloud/src/pages/operators/manage/page.tsx b/apps/tangle-cloud/src/pages/operators/manage/page.tsx index 8b51a8b9c1..9e5f36e67a 100644 --- a/apps/tangle-cloud/src/pages/operators/manage/page.tsx +++ b/apps/tangle-cloud/src/pages/operators/manage/page.tsx @@ -34,6 +34,7 @@ import { useServicesByOperator, useActiveServiceMemberships, useSlashProposals, + useSlashConfig, useDisputeSlashTx, useCancelSlashTx, useProposeSlashTx, @@ -75,6 +76,7 @@ import useChainClock from './hooks/useChainClock'; import useSlashProposalForm from './hooks/useSlashProposalForm'; import useSlashActions from './hooks/useSlashActions'; import SlashingSummaryCards from './components/SlashingSummaryCards'; +import SlashingParametersCard from './components/SlashingParametersCard'; import SlashingTabsTable from './components/SlashingTabsTable'; import ProposeSlashModal from './components/modals/ProposeSlashModal'; import DisputeMessageModal from './components/modals/DisputeMessageModal'; @@ -333,6 +335,9 @@ const Page: FC = () => { enabled: isConnected, proposals: slashProposals, }); + const { data: slashConfig, isLoading: loadingSlashConfig } = useSlashConfig({ + enabled: isConnected, + }); // Transaction hooks (registration) const { unregisterOperator, status: unregisterStatus } = @@ -391,6 +396,7 @@ const Page: FC = () => { } = useSlashProposalForm({ proposableServices, proposeStatus, + maxSlashBps: slashConfig?.maxSlashBps, }); const executableSlashIdSet = useMemo(() => { @@ -507,6 +513,22 @@ const Page: FC = () => { return buildSlashTimeline(selectedSlash, nowUnixSeconds); }, [selectedSlash, nowUnixSeconds]); + const disputeBond = slashConfig?.disputeBond ?? BigInt(0); + const slashConfigMaxSlashBps = slashConfig?.maxSlashBps; + + // Only meaningful when the slash is already in `Disputed` state and we have + // an authoritative on-chain disputeDeadline. Returns null otherwise so the + // modal can decide not to render the row. + const selectedSlashDisputeResolutionSecondsRemaining = useMemo(() => { + if (!selectedSlash || selectedSlash.status !== 'Disputed') { + return null; + } + if (selectedSlash.disputeDeadline === BigInt(0)) { + return null; + } + return Number(selectedSlash.disputeDeadline) - nowUnixSeconds; + }, [selectedSlash, nowUnixSeconds]); + const nearestPendingSlash = useMemo(() => { const pendingAgainstMe = againstMe.filter( (slash) => slash.status === 'Pending', @@ -1211,6 +1233,11 @@ const Page: FC = () => { nearestPendingSlashEligibility={nearestPendingSlashEligibility} /> + + {clockError ? ( @@ -1407,6 +1434,7 @@ const Page: FC = () => { canSubmitPropose={canSubmitPropose} isSubmitting={proposeStatus === 'pending'} onConfirm={() => void handleProposeSlash()} + maxSlashBps={slashConfigMaxSlashBps} /> { onConfirm={() => void handleDispute()} errorMessage={actionError.dispute} onDismissError={() => clearActionError('dispute')} + disputeBond={disputeBond} + disputeResolutionSecondsRemaining={ + selectedSlashDisputeResolutionSecondsRemaining + } /> { }; }; -// Hook to get owner's shares +// Hook to get owner's shares. +// tnt-core v0.15.0: legacy `podOwnerShares` was removed during the share-pool +// refactor. `getShares` returns int256 (can be negative if a beacon rebase +// pushed the owner under their previous share count); UI keeps the bigint +// shape and downstream renderers already clamp at zero where needed. export const usePodOwnerShares = (ownerAddress: Address | undefined) => { const chainId = useChainId(); const contractAddress = getValidatorPodManagerAddress(chainId); @@ -95,7 +99,7 @@ export const usePodOwnerShares = (ownerAddress: Address | undefined) => { const { data, isLoading, error, refetch } = useReadContract({ address: contractAddress ?? undefined, abi: VALIDATOR_POD_MANAGER_ABI, - functionName: 'podOwnerShares', + functionName: 'getShares', args: ownerAddress ? [ownerAddress] : undefined, query: { enabled: !!ownerAddress && !!contractAddress, diff --git a/libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts b/libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts index 4781646614..762a0d00d2 100644 --- a/libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts +++ b/libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts @@ -48,6 +48,25 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'forceRemoveAllowsBelowMin', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, + ], + outputs: [ + { + name: 'ok', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'getAggregationThreshold', diff --git a/libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts b/libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts index 602dd69b0a..1629aab048 100644 --- a/libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts +++ b/libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts @@ -597,6 +597,52 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'getCumStakeSeconds', + inputs: [ + { + name: 'operator', + type: 'address', + internalType: 'address', + }, + { + name: 'asset', + type: 'tuple', + internalType: 'struct Types.Asset', + components: [ + { + name: 'kind', + type: 'uint8', + internalType: 'enum Types.AssetKind', + }, + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + }, + ], + outputs: [ + { + name: 'cum', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'lastUpdate', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'currentStake', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'getDelegation', @@ -2849,6 +2895,22 @@ const ABI = [ ], anonymous: false, }, + { + type: 'error', + name: 'AdapterChangeWhileDepositsExist', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'currentDeposits', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, ] as const; export default ABI; diff --git a/libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts b/libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts index 92243cdd8c..ae58b383a7 100644 --- a/libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts +++ b/libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts @@ -55,6 +55,19 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'HEARTBEAT_MAX_AGE', + inputs: [], + outputs: [ + { + name: '', + type: 'uint64', + internalType: 'uint64', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'HEARTBEAT_TYPEHASH', @@ -68,6 +81,45 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'MAX_METRIC_DEFINITIONS', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'MAX_METRIC_NAME_LENGTH', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'MAX_PAGE_SIZE', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'SLASH_ALERT_COOLDOWN', @@ -194,7 +246,7 @@ const ABI = [ { name: 'pairs', type: 'tuple[]', - internalType: 'struct OperatorStatusRegistry.MetricPair[]', + internalType: 'struct IOperatorStatusRegistry.MetricPair[]', components: [ { name: 'name', @@ -211,6 +263,24 @@ const ABI = [ ], stateMutability: 'pure', }, + { + type: 'function', + name: 'deregisterOperator', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', name: 'enableCustomMetrics', @@ -229,6 +299,25 @@ const ABI = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'getAllOperatorCount', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'getHeartbeatConfig', @@ -243,7 +332,7 @@ const ABI = [ { name: '', type: 'tuple', - internalType: 'struct OperatorStatusRegistry.HeartbeatConfig', + internalType: 'struct IOperatorStatusRegistry.HeartbeatConfig', components: [ { name: 'interval', @@ -327,7 +416,7 @@ const ABI = [ { name: '', type: 'tuple[]', - internalType: 'struct OperatorStatusRegistry.MetricDefinition[]', + internalType: 'struct IOperatorStatusRegistry.MetricDefinition[]', components: [ { name: 'name', @@ -440,7 +529,7 @@ const ABI = [ { name: '', type: 'tuple', - internalType: 'struct OperatorStatusRegistry.OperatorState', + internalType: 'struct IOperatorStatusRegistry.OperatorState', components: [ { name: 'lastHeartbeat', @@ -515,6 +604,40 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'getSlashableOperatorsPaginated', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'offset', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'limit', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: 'operators', + type: 'address[]', + internalType: 'address[]', + }, + { + name: 'total', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'goOffline', @@ -618,6 +741,30 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'isRegisteredOperator', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'metricValues', @@ -730,6 +877,24 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'registerOperator', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', name: 'registerServiceOwner', @@ -748,6 +913,24 @@ const ABI = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'removeInactiveOperator', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', name: 'renounceOwnership', @@ -836,6 +1019,46 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'setMetricDefinitions', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'definitions', + type: 'tuple[]', + internalType: 'struct IOperatorStatusRegistry.MetricDefinition[]', + components: [ + { + name: 'name', + type: 'string', + internalType: 'string', + }, + { + name: 'minValue', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxValue', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'required', + type: 'bool', + internalType: 'bool', + }, + ], + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', name: 'setMetricsRecorder', @@ -899,6 +1122,11 @@ const ABI = [ type: 'bytes', internalType: 'bytes', }, + { + name: 'timestamp', + type: 'uint64', + internalType: 'uint64', + }, { name: 'signature', type: 'bytes', @@ -962,6 +1190,46 @@ const ABI = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'validateAndStoreMetrics', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + internalType: 'address', + }, + { + name: 'pairs', + type: 'tuple[]', + internalType: 'struct IOperatorStatusRegistry.MetricPair[]', + components: [ + { + name: 'name', + type: 'string', + internalType: 'string', + }, + { + name: 'value', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + name: 'pairsLen', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'event', name: 'HeartbeatConfigUpdated', @@ -1055,6 +1323,37 @@ const ABI = [ ], anonymous: false, }, + { + type: 'event', + name: 'MetricViolation', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'metricName', + type: 'string', + indexed: false, + internalType: 'string', + }, + { + name: 'reason', + type: 'string', + indexed: false, + internalType: 'string', + }, + ], + anonymous: false, + }, { type: 'event', name: 'OperatorCameOnline', @@ -1074,6 +1373,44 @@ const ABI = [ ], anonymous: false, }, + { + type: 'event', + name: 'OperatorDeregistered', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'OperatorRegistered', + inputs: [ + { + name: 'serviceId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, { type: 'event', name: 'OperatorWentOffline', @@ -1220,6 +1557,38 @@ const ABI = [ }, ], }, + { + type: 'error', + name: 'HeartbeatFromFuture', + inputs: [ + { + name: 'signed', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'now_', + type: 'uint64', + internalType: 'uint64', + }, + ], + }, + { + type: 'error', + name: 'HeartbeatStale', + inputs: [ + { + name: 'signed', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'now_', + type: 'uint64', + internalType: 'uint64', + }, + ], + }, { type: 'error', name: 'OwnableInvalidOwner', diff --git a/libs/tangle-shared-ui/src/abi/tangle.ts b/libs/tangle-shared-ui/src/abi/tangle.ts index 9d1367b1a4..275ace1006 100644 --- a/libs/tangle-shared-ui/src/abi/tangle.ts +++ b/libs/tangle-shared-ui/src/abi/tangle.ts @@ -517,6 +517,13 @@ const ABI = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'claimDisputeBond', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', name: 'claimRewards', @@ -2325,6 +2332,16 @@ const ABI = [ type: 'uint256', internalType: 'uint256', }, + { + name: '__reservedAggregateCursor', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'subscriptionBaselineStake', + type: 'uint256', + internalType: 'uint256', + }, ], }, ], @@ -2471,16 +2488,16 @@ const ABI = [ type: 'bool', internalType: 'bool', }, - { - name: 'activated', - type: 'bool', - internalType: 'bool', - }, { name: 'confidentiality', type: 'uint8', internalType: 'enum Types.ConfidentialityPolicy', }, + { + name: 'activated', + type: 'bool', + internalType: 'bool', + }, ], }, ], @@ -3149,6 +3166,25 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'pendingDisputeBondRefund', + inputs: [ + { + name: 'disputer', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'pendingRewards', @@ -3984,6 +4020,11 @@ const ABI = [ type: 'tuple', internalType: 'struct Types.JobQuoteDetails', components: [ + { + name: 'requester', + type: 'address', + internalType: 'address', + }, { name: 'serviceId', type: 'uint64', @@ -4955,10 +4996,109 @@ const ABI = [ ], anonymous: false, }, + { + type: 'event', + name: 'SlashCancelled', + inputs: [ + { + name: 'slashId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, + { + name: 'canceller', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'reason', + type: 'string', + indexed: false, + internalType: 'string', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'SlashConfigUpdated', + inputs: [ + { + name: 'disputeWindow', + type: 'uint64', + indexed: false, + internalType: 'uint64', + }, + { + name: 'instantSlashEnabled', + type: 'bool', + indexed: false, + internalType: 'bool', + }, + { + name: 'maxSlashBps', + type: 'uint16', + indexed: false, + internalType: 'uint16', + }, + { + name: 'disputeResolutionDeadline', + type: 'uint64', + indexed: false, + internalType: 'uint64', + }, + { + name: 'disputeBond', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'maxPendingSlashesPerOperator', + type: 'uint16', + indexed: false, + internalType: 'uint16', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'SlashDisputed', + inputs: [ + { + name: 'slashId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, + { + name: 'disputer', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'reason', + type: 'string', + indexed: false, + internalType: 'string', + }, + ], + anonymous: false, + }, { type: 'event', name: 'SlashExecuted', inputs: [ + { + name: 'slashId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, { name: 'serviceId', type: 'uint64', @@ -4972,7 +5112,7 @@ const ABI = [ internalType: 'address', }, { - name: 'amount', + name: 'actualSlashed', type: 'uint256', indexed: false, internalType: 'uint256', @@ -4984,6 +5124,12 @@ const ABI = [ type: 'event', name: 'SlashProposed', inputs: [ + { + name: 'slashId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, { name: 'serviceId', type: 'uint64', @@ -4996,18 +5142,36 @@ const ABI = [ indexed: true, internalType: 'address', }, + { + name: 'proposer', + type: 'address', + indexed: false, + internalType: 'address', + }, { name: 'slashBps', type: 'uint16', indexed: false, internalType: 'uint16', }, + { + name: 'effectiveSlashBps', + type: 'uint16', + indexed: false, + internalType: 'uint16', + }, { name: 'evidence', type: 'bytes32', indexed: false, internalType: 'bytes32', }, + { + name: 'executeAfter', + type: 'uint64', + indexed: false, + internalType: 'uint64', + }, ], anonymous: false, }, diff --git a/libs/tangle-shared-ui/src/data/graphql/index.ts b/libs/tangle-shared-ui/src/data/graphql/index.ts index cf0c814e62..45b888689d 100644 --- a/libs/tangle-shared-ui/src/data/graphql/index.ts +++ b/libs/tangle-shared-ui/src/data/graphql/index.ts @@ -213,6 +213,7 @@ export { export { useSlashProposals, useProposableServices, + useSlashConfig, useDisputeSlashTx, useCancelSlashTx, useProposeSlashTx, diff --git a/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts b/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts index 7eb913c7b3..34d3676c82 100644 --- a/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts +++ b/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts @@ -67,6 +67,13 @@ export interface SlashProposal { status: SlashStatus; disputeReason: string | null; cancelReason: string | null; + // Dispute lifecycle (populated when status === 'Disputed' or after a dispute + // was filed and later resolved). Sourced from getSlashProposal on-chain or + // backfilled from the indexer when available. + disputer: Address; + disputeBond: bigint; + disputedAt: bigint; + disputeDeadline: bigint; } export interface ProposableService { @@ -529,6 +536,11 @@ const toPrimitiveSlashProposal = ( const slashBps = BigInt(sp.slashBps); const effectiveSlashBps = BigInt(sp.effectiveSlashBps); + // The Envio indexer schema may not yet expose v0.13.0 dispute-lifecycle fields. + // We default to zero-values; consumers that need authoritative dispute data + // should call useSlashProposalDetails (on-chain getSlashProposal) which is the + // source of truth. Once the indexer ships matching columns, surface them via + // sp.disputer / sp.disputeBond / sp.disputedAt / sp.disputeDeadline. return { id: BigInt(sp.slashId), serviceId: BigInt(sp.serviceId), @@ -545,6 +557,10 @@ const toPrimitiveSlashProposal = ( status: parseSlashStatus(sp.status), disputeReason: sp.disputeReason, cancelReason: sp.cancelReason, + disputer: zeroAddress as Address, + disputeBond: BigInt(0), + disputedAt: BigInt(0), + disputeDeadline: BigInt(0), }; }; @@ -738,6 +754,10 @@ const normalizeOnChainSlashProposal = ( slashId: bigint, proposal: any, ): SlashProposal => { + // Tuple layout from getSlashProposal (tnt-core v0.13.0): + // 0 serviceId, 1 operator, 2 proposer, 3 slashBps, 4 effectiveSlashBps, + // 5 evidence, 6 proposedAt, 7 executeAfter, 8 status, 9 disputeReason, + // 10 disputer, 11 disputeBond, 12 disputedAt, 13 disputeDeadline. const serviceId = proposal?.serviceId !== undefined ? BigInt(proposal.serviceId.toString()) @@ -767,6 +787,18 @@ const normalizeOnChainSlashProposal = ( const disputeReason = (proposal?.disputeReason ?? proposal?.[9] ?? null) as | string | null; + const disputer = (proposal?.disputer ?? + proposal?.[10] ?? + zeroAddress) as Address; + const disputeBond = BigInt( + proposal?.disputeBond?.toString() ?? proposal?.[11]?.toString() ?? 0, + ); + const disputedAt = BigInt( + proposal?.disputedAt?.toString() ?? proposal?.[12]?.toString() ?? 0, + ); + const disputeDeadline = BigInt( + proposal?.disputeDeadline?.toString() ?? proposal?.[13]?.toString() ?? 0, + ); return { id: slashId, @@ -784,6 +816,10 @@ const normalizeOnChainSlashProposal = ( status: parseSlashStatus(statusValue), disputeReason, cancelReason: null, + disputer, + disputeBond, + disputedAt, + disputeDeadline, }; }; @@ -1302,6 +1338,12 @@ export const useExecuteSlashBatchTx = () => { /** * Hook to cancel a slash proposal. + * + * NOTE (tnt-core v0.14.0): `cancelSlash` no longer auto-refunds the disputer's + * bond. After cancellation, the disputer must call `claimDisputeBond()` and + * may inspect their pending balance with `pendingDisputeBondRefund(address)`. + * TODO(v0.15.0): wire up a `useClaimDisputeBondTx` + dedicated UI affordance + * on the disputer-facing slash detail view (and surface the pending balance). */ export const useCancelSlashTx = () => { const chainId = useChainId(); diff --git a/libs/tangle-shared-ui/src/data/services/index.ts b/libs/tangle-shared-ui/src/data/services/index.ts index e0b918b1d7..4b27e39138 100644 --- a/libs/tangle-shared-ui/src/data/services/index.ts +++ b/libs/tangle-shared-ui/src/data/services/index.ts @@ -55,6 +55,15 @@ export { type UseFundServiceTxOptions, } from './useFundServiceTx'; +export { + useExpireServiceRequestTx, + isServiceRequestExpired, + getServiceRequestExpiryEligibleAt, + REQUEST_EXPIRY_GRACE_PERIOD_SECONDS, + type ExpireServiceRequestParams, + type UseExpireServiceRequestTxOptions, +} from './useExpireServiceRequest'; + export { useBillSubscriptionTx, type BillSubscriptionParams, diff --git a/libs/tangle-shared-ui/src/data/services/useExpireServiceRequest.ts b/libs/tangle-shared-ui/src/data/services/useExpireServiceRequest.ts new file mode 100644 index 0000000000..f53db691a0 --- /dev/null +++ b/libs/tangle-shared-ui/src/data/services/useExpireServiceRequest.ts @@ -0,0 +1,113 @@ +/** + * Hook for permissionlessly expiring a stale service request. + * + * `expireServiceRequest` is callable by anyone once `block.timestamp > + * req.createdAt + REQUEST_EXPIRY_GRACE_PERIOD`. Calling it refunds the + * requester and unlocks the operator candidates, so it is safe — and + * incentive-aligned — to expose as a "Clean up expired request" action. + * + * This hook only handles the on-chain write. Callers are responsible for + * gating the button on the grace period using + * REQUEST_EXPIRY_GRACE_PERIOD_SECONDS, and for invalidating any request / + * service queries on success. + */ + +import { useChainId } from 'wagmi'; +import { useQueryClient } from '@tanstack/react-query'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import useContractWrite, { TxStatus } from '../../hooks/useContractWrite'; +import TangleABI from '../../abi/tangle'; + +export { TxStatus }; + +/** + * Mirrors `ProtocolConfig.REQUEST_EXPIRY_GRACE_PERIOD` (1 hour) from tnt-core. + * The contract allows admins to override `_requestExpiryGracePeriod`, but no + * getter is currently exposed for it; this constant reflects the protocol + * default and is the conservative gate to use in the UI. + */ +export const REQUEST_EXPIRY_GRACE_PERIOD_SECONDS = BigInt(60 * 60); + +export interface ExpireServiceRequestParams { + requestId: bigint; +} + +export interface UseExpireServiceRequestTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +/** + * Returns true iff `now > createdAt + grace`. `now` defaults to the system + * clock; pass an externally-clocked value when you want the gate to advance + * at the same cadence as the rest of the page (e.g. a `useChainClock` tick). + */ +export const isServiceRequestExpired = ( + createdAt: bigint, + nowUnixSeconds = BigInt(Math.floor(Date.now() / 1000)), + graceSeconds = REQUEST_EXPIRY_GRACE_PERIOD_SECONDS, +): boolean => nowUnixSeconds > createdAt + graceSeconds; + +/** + * Returns the seconds remaining until the request can be expired. Negative + * (or zero) when the request is already eligible for expiry. + */ +export const getServiceRequestExpiryEligibleAt = ( + createdAt: bigint, + graceSeconds = REQUEST_EXPIRY_GRACE_PERIOD_SECONDS, +): bigint => createdAt + graceSeconds; + +export const useExpireServiceRequestTx = ( + options?: UseExpireServiceRequestTxOptions, +) => { + const chainId = useChainId(); + const queryClient = useQueryClient(); + + const hook = useContractWrite( + TangleABI, + (params: ExpireServiceRequestParams) => { + let contracts: ReturnType; + try { + contracts = getContractsByChainId(chainId); + } catch { + throw new Error('Tangle contract not available on this network'); + } + + return { + address: contracts.tangle, + abi: TangleABI, + functionName: 'expireServiceRequest' as const, + args: [params.requestId] as const, + }; + }, + { + txName: 'expire service request', + txDetails: (params) => + new Map([['Request ID', params.requestId.toString()]]), + getSuccessMessage: (params) => + `Service request #${params.requestId} expired and refunded.`, + onSuccess: () => { + // The expire path flips `req.rejected = true` and refunds escrow, so + // anything that reads the request, the surrounding service, or the + // requester's balance must refetch. + queryClient.invalidateQueries({ queryKey: ['serviceRequestDetails'] }); + queryClient.invalidateQueries({ queryKey: ['serviceRequests'] }); + queryClient.invalidateQueries({ queryKey: ['services'] }); + options?.onSuccess?.(); + }, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useExpireServiceRequestTx; diff --git a/scripts/sync-tnt-core-assets.mjs b/scripts/sync-tnt-core-assets.mjs index 90aa8d0591..35a38d350c 100644 --- a/scripts/sync-tnt-core-assets.mjs +++ b/scripts/sync-tnt-core-assets.mjs @@ -1,6 +1,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, '..'); @@ -41,7 +42,9 @@ const syncAbis = (tntCoreDir) => { const mappings = [ { source: 'ITangleFull.json', target: 'libs/tangle-shared-ui/src/abi/tangle.ts' }, { source: 'IMultiAssetDelegation.json', target: 'libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts' }, - { source: 'IOperatorStatusRegistry.json', target: 'libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts' }, + // tnt-core v0.13.0 ships the implementation ABI (no `I` prefix) — it carries + // the same external surface the dapp needs and the interface JSON is no longer emitted. + { source: 'OperatorStatusRegistry.json', target: 'libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts' }, { source: 'IBlueprintServiceManager.json', target: 'libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts' }, ]; @@ -75,10 +78,31 @@ const syncFixtures = (tntCoreDir) => { } }; +const formatGeneratedAbis = () => { + // Run prettier so the generated `as const` ABIs match the repo style + // (single quotes, trailing commas) instead of raw JSON.stringify output. + const targets = [ + 'libs/tangle-shared-ui/src/abi/tangle.ts', + 'libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts', + 'libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts', + 'libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts', + ].map((relative) => resolve(repoRoot, relative)); + + const result = spawnSync('npx', ['prettier', '--write', '--log-level=warn', ...targets], { + cwd: repoRoot, + stdio: 'inherit', + }); + + if (result.status !== 0) { + console.warn('[sync] prettier formatting exited with a non-zero status; please run `yarn format` manually.'); + } +}; + const main = () => { const tntCoreDir = resolveTntCoreDir(); syncAbis(tntCoreDir); syncFixtures(tntCoreDir); + formatGeneratedAbis(); }; main(); diff --git a/storybook-migration-summary.md b/storybook-migration-summary.md index 67a93ba6ce..26337c71e9 100644 --- a/storybook-migration-summary.md +++ b/storybook-migration-summary.md @@ -59,4 +59,6 @@ Please read the [Storybook 7.0.0 release article](https://storybook.js.org/blog/ official [Storybook 7.0.0 migration guide](https://storybook.js.org/docs/react/migration-guide) for more information. -You can also read the docs for the [@nx/storybook:migrate-7 generator](https://nx.dev/packages/storybook/generators/migrate-7) and our [Storybook 7 setup guide](https://nx.dev/packages/storybook/documents/storybook-7-setup). +For Nx-specific Storybook setup, see the current Nx documentation at https://nx.dev (the +historical `@nx/storybook:migrate-7` generator and `storybook-7-setup` document URLs have +been retired upstream).