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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,6 +77,43 @@ const ServiceRequestDetailModal: FC<Props> = ({
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,
{
Expand Down Expand Up @@ -271,27 +310,74 @@ const ServiceRequestDetailModal: FC<Props> = ({
/>
</ModalBody>

{expireError ? (
<div className="px-6 pt-2">
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 space-y-2">
<Text variant="body3" className="text-destructive">
{expireError.message ||
'Failed to expire the service request. Please try again.'}
</Text>
<button
type="button"
className="text-xs underline text-destructive"
onClick={resetExpire}
>
Dismiss
</button>
</div>
</div>
) : null}

{!viewOnly && (
<div className="flex justify-end gap-3 p-6 pt-4 shrink-0 bg-background">
<div className="flex flex-wrap justify-end gap-3 p-6 pt-4 shrink-0 bg-background">
{canExpireRequest ? (
<Button
variant="secondary"
onClick={() => void handleExpireRequest()}
isLoading={isExpiring}
isDisabled={
isExpiring || isApproving || isRejecting || !canExpireRequest
}
title="Refunds the requester and frees the operator candidates. Anyone can call this once the grace period has passed."
>
Expire request
</Button>
) : null}

<Button
variant="secondary"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onReject}
isLoading={isRejecting}
isDisabled={isRejecting}
isDisabled={isRejecting || isExpiring}
>
Reject
</Button>

<Button variant="primary" onClick={handleApproveClick}>
<Button
variant="primary"
onClick={handleApproveClick}
isDisabled={isExpiring}
>
Approve
</Button>
</div>
)}

{viewOnly && (
<div className="flex justify-end gap-3 p-6 pt-4 shrink-0 bg-background">
<Button variant="secondary" onClick={onClose}>
<div className="flex flex-wrap justify-end gap-3 p-6 pt-4 shrink-0 bg-background">
{canExpireRequest ? (
<Button
variant="secondary"
onClick={() => void handleExpireRequest()}
isLoading={isExpiring}
isDisabled={isExpiring || !canExpireRequest}
title="Refunds the requester and frees the operator candidates. Anyone can call this once the grace period has passed."
>
Expire request
</Button>
) : null}
<Button variant="secondary" onClick={onClose} isDisabled={isExpiring}>
Close
</Button>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="p-4">
<Text variant="body3" className="text-muted-foreground">
Loading slashing parameters...
</Text>
</Card>
);
}

return (
<Card className="p-4 space-y-3">
<div>
<Text variant="body2" fw="bold">
Slashing parameters
</Text>
<Text variant="body3" className="text-muted-foreground">
Protocol-wide settings from the active SlashConfig. Proposals,
disputes, and execution all enforce these.
</Text>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2">
<div>
<Text variant="body3" className="text-muted-foreground">
Maximum slash per proposal
</Text>
<Text variant="body2" fw="semibold">
{formatBps(config.maxSlashBps)}
</Text>
</div>
<div>
<Text variant="body3" className="text-muted-foreground">
Dispute window
</Text>
<Text variant="body2" fw="semibold">
{formatDuration(config.disputeWindow)}
</Text>
</div>
<div>
<Text variant="body3" className="text-muted-foreground">
Dispute resolution deadline
</Text>
<Text variant="body2" fw="semibold">
{formatDuration(config.disputeResolutionDeadline)}
</Text>
</div>
<div>
<Text variant="body3" className="text-muted-foreground">
Required dispute bond
</Text>
<Text variant="body2" fw="semibold">
{config.disputeBond > BigInt(0)
? `${formatEthAmount(config.disputeBond)} ETH`
: 'None'}
</Text>
</div>
<div>
<Text variant="body3" className="text-muted-foreground">
Max pending slashes per operator
</Text>
<Text variant="body2" fw="semibold">
{config.maxPendingSlashesPerOperator > 0
? config.maxPendingSlashesPerOperator.toLocaleString()
: 'Unlimited'}
</Text>
</div>
<div>
<Text variant="body3" className="text-muted-foreground">
Instant slash
</Text>
<Text variant="body2" fw="semibold">
{config.instantSlashEnabled ? 'Enabled' : 'Disabled'}
</Text>
</div>
</div>
</Card>
);
};

export default SlashingParametersCard;
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChangeEvent } from 'react';
import { formatUnits, zeroAddress } from 'viem';
import {
SlashActionPermissions,
SlashDisputeEligibility,
Expand All @@ -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;
Expand All @@ -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 = ({
Expand All @@ -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 (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
Expand Down Expand Up @@ -130,6 +157,41 @@ const DisputeSlashModal = ({
<Text variant="body3" className="font-mono break-all">
{selectedSlash?.evidence ?? '-'}
</Text>
<Text variant="body3" className="text-muted-foreground">
Required Dispute Bond:
</Text>
<Text variant="body3">
{disputeBond > BigInt(0)
? `${formatEthAmount(disputeBond)} ETH (refunded if dispute upheld)`
: 'No bond required'}
</Text>
{isAlreadyDisputed && hasKnownDisputer ? (
<>
<Text variant="body3" className="text-muted-foreground">
Disputer:
</Text>
<Text
variant="body3"
className="font-mono"
title={selectedSlash?.disputer ?? undefined}
>
{selectedSlash ? shortenHex(selectedSlash.disputer) : '-'}
</Text>
</>
) : null}
{isAlreadyDisputed &&
disputeResolutionSecondsRemaining !== null ? (
<>
<Text variant="body3" className="text-muted-foreground">
Resolution Deadline:
</Text>
<Text variant="body3">
{disputeResolutionSecondsRemaining > 0
? formatTimeRemaining(disputeResolutionSecondsRemaining)
: 'Deadline passed'}
</Text>
</>
) : null}
</div>
</div>
<div>
Expand Down
Loading
Loading