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
117 changes: 86 additions & 31 deletions src/app/routes/dashboard/operators/operator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,43 @@ import { IncreaseOperatorFeeStatusBadge } from "@/components/operator/increase-o
import { OperatorValidatorsList } from "@/components/operator/operator-validators-list";
import { OperatorStatusBadge } from "@/components/operator/operator-status-badge";
import { useOperatorEarningsAndFees } from "@/hooks/operator/use-operator-earnings-and-fees";
import { Skeleton } from "@/components/ui/skeleton";

const AmountSkeleton: FC<{ "data-testid"?: string }> = (props) => (
<div className="flex flex-col gap-2" {...props}>
<Skeleton className="h-7 w-32" />
<Skeleton className="h-4 w-16" />
</div>
);

const AmountUnavailable: FC<{ "data-testid"?: string }> = (props) => (
<div className="flex flex-col items-start gap-2" {...props}>
<Text variant="body-2-medium" className="text-error-500">
Couldn't read this value from the network.
</Text>
<Text variant="body-3-medium" className="text-gray-500">
It will appear once the connection recovers.
</Text>
</div>
);

export const Operator: FC<ComponentPropsWithoutRef<"div">> = ({ ...props }) => {
const params = useOperatorPageParams();
const operatorId = BigInt(params.operatorId!);
const operator = useOperator(operatorId!);
const { feeEth, yearlyFeeEth, yearlyFeeSSV, balanceEth, balanceSSV } =
useOperatorEarningsAndFees(operatorId);
const {
feeEth,
yearlyFeeEth,
yearlyFeeSSV,
balanceEth,
balanceSSV,
earningsStatus,
feesStatus,
} = useOperatorEarningsAndFees(operatorId);

const hasBalance = balanceEth > 0n || balanceSSV > 0n;
const hasBalance =
earningsStatus.isSuccess && (balanceEth > 0n || balanceSSV > 0n);
const canUpdateFee = feesStatus.isSuccess && feeEth.data !== 0n;

if (!operator.data) return null;

Expand Down Expand Up @@ -111,31 +139,48 @@ export const Operator: FC<ComponentPropsWithoutRef<"div">> = ({ ...props }) => {
</div>
<Container variant="horizontal" size="xl" {...props} className="h-full">
<div className="flex items-stretch gap-6 flex-col flex-1">
<Card data-testid="dashboard-operator-balance-card" className="w-full">
<Card
data-testid="dashboard-operator-balance-card"
className="w-full"
>
<Text
data-testid="dashboard-operator-balance-label"
variant="headline4"
className="text-gray-500"
>
Balance
</Text>
<div className="flex flex-col gap-4">
<BalanceDisplay
data-testid="dashboard-operator-balance-eth"
amount={balanceEth}
token="ETH"
/>
{yearlyFeeSSV !== 0n && !operator.data.migrated && (
{earningsStatus.isUnavailable ? (
<AmountUnavailable data-testid="dashboard-operator-balance-error" />
) : earningsStatus.isPending ? (
<AmountSkeleton data-testid="dashboard-operator-balance-loading" />
) : (
<div className="flex flex-col gap-4">
<BalanceDisplay
data-testid="dashboard-operator-balance-ssv"
amount={balanceSSV}
token="SSV"
data-testid="dashboard-operator-balance-eth"
amount={balanceEth}
token="ETH"
/>
)}
</div>
{yearlyFeeSSV !== 0n && !operator.data.migrated && (
<BalanceDisplay
data-testid="dashboard-operator-balance-ssv"
amount={balanceSSV}
token="SSV"
/>
)}
</div>
)}
<Tooltip
asChild
content={!hasBalance ? "No balance to withdraw" : undefined}
content={
earningsStatus.isUnavailable
? "Balance unavailable"
: earningsStatus.isPending
? "Loading balance"
: !hasBalance
? "No balance to withdraw"
: undefined
}
>
<Button
data-testid="dashboard-operator-withdraw-btn"
Expand All @@ -160,24 +205,34 @@ export const Operator: FC<ComponentPropsWithoutRef<"div">> = ({ ...props }) => {
</Text>
<IncreaseOperatorFeeStatusBadge />
</div>
<div className="flex flex-col gap-4">
<BalanceDisplay
data-testid="dashboard-operator-yearly-fee-eth"
amount={yearlyFeeEth}
token="ETH"
/>
{yearlyFeeSSV > 0 && (
{feesStatus.isUnavailable ? (
<AmountUnavailable data-testid="dashboard-operator-yearly-fee-error" />
) : feesStatus.isPending ? (
<AmountSkeleton data-testid="dashboard-operator-yearly-fee-loading" />
) : (
<div className="flex flex-col gap-4">
<BalanceDisplay
data-testid="dashboard-operator-yearly-fee-ssv"
amount={yearlyFeeSSV}
token="SSV"
data-testid="dashboard-operator-yearly-fee-eth"
amount={yearlyFeeEth}
token="ETH"
/>
)}
</div>
{yearlyFeeSSV > 0 && (
<BalanceDisplay
data-testid="dashboard-operator-yearly-fee-ssv"
amount={yearlyFeeSSV}
token="SSV"
/>
)}
</div>
)}
<Tooltip
asChild
content={
feeEth.data === 0n ? (
feesStatus.isUnavailable ? (
"Fee unavailable"
) : feesStatus.isPending ? (
"Loading fee"
) : feeEth.data === 0n ? (
<>
Operators with a fee of 0 cannot change their fee.{" "}
<Button
Expand All @@ -196,7 +251,7 @@ export const Operator: FC<ComponentPropsWithoutRef<"div">> = ({ ...props }) => {
<Button
data-testid="dashboard-operator-update-fee-btn"
as={Link}
disabled={feeEth.isLoading || feeEth.data === 0n}
disabled={!canUpdateFee}
to="fee/update"
variant="secondary"
size="xl"
Expand Down
43 changes: 39 additions & 4 deletions src/hooks/operator/use-operator-earnings-and-fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,52 @@ import { useGetOperatorEarnings } from "@/lib/contract-interactions/hooks/getter
import { useGetOperatorEarningsSSV } from "@/lib/contract-interactions/hooks/getter";
import { useGetOperatorFee } from "@/lib/contract-interactions/hooks/getter";
import { useGetOperatorFeeSSV } from "@/lib/contract-interactions/hooks/getter";
import { combineQueryStatus } from "@/lib/react-query";
import { getYearlyFee } from "@/lib/utils/operator";

type FetchStatusOnly = { fetchStatus: "fetching" | "paused" | "idle" };

// A read that cannot reach the RPC parks at fetchStatus "paused" and never
// resolves to an error, so paused counts as unavailable too.
const withUnavailable = <T extends { isError: boolean; isPending: boolean }>(
status: T,
queries: FetchStatusOnly[],
) => {
const isPaused = queries.some((query) => query.fetchStatus === "paused");
return {
...status,
isPaused,
isUnavailable: status.isError || isPaused,
isPending: status.isPending && !isPaused,
};
};

export const useOperatorEarningsAndFees = (operatorId: bigint) => {
const earningsEth = useGetOperatorEarnings({ id: operatorId });
const earningsSSV = useGetOperatorEarningsSSV({ id: operatorId });
const queryOptions = { retry: 2, networkMode: "always" } as const;

const feeEth = useGetOperatorFee({ operatorId });
const feeSSV = useGetOperatorFeeSSV({ operatorId });
const earningsEth = useGetOperatorEarnings({ id: operatorId }, queryOptions);
const earningsSSV = useGetOperatorEarningsSSV(
{ id: operatorId },
queryOptions,
);

const feeEth = useGetOperatorFee({ operatorId }, queryOptions);
const feeSSV = useGetOperatorFeeSSV({ operatorId }, queryOptions);
const yearlyFeeEth = getYearlyFee(feeEth.data ?? 0n);
const yearlyFeeSSV = getYearlyFee(feeSSV.data ?? 0n);
const balanceEth = earningsEth.data ?? 0n;
const balanceSSV = earningsSSV.data ?? 0n;

// `?? 0n` above is indistinguishable from an on-chain zero — gate on these.
const earningsStatus = withUnavailable(
combineQueryStatus(earningsEth, earningsSSV),
[earningsEth, earningsSSV],
);
const feesStatus = withUnavailable(combineQueryStatus(feeEth, feeSSV), [
feeEth,
feeSSV,
]);

return {
earningsEth,
earningsSSV,
Expand All @@ -24,5 +57,7 @@ export const useOperatorEarningsAndFees = (operatorId: bigint) => {
yearlyFeeSSV,
balanceEth,
balanceSSV,
earningsStatus,
feesStatus,
};
};
Loading