Skip to content

Commit f9d7e8e

Browse files
Merge pull request #1876 from ssvlabs/fix/operator-balance-fee-loading-states
fix: show loading and unavailable states for operator balance and fee
2 parents 6240223 + b53afd8 commit f9d7e8e

2 files changed

Lines changed: 125 additions & 35 deletions

File tree

‎src/app/routes/dashboard/operators/operator.tsx‎

Lines changed: 86 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,43 @@ import { IncreaseOperatorFeeStatusBadge } from "@/components/operator/increase-o
1919
import { OperatorValidatorsList } from "@/components/operator/operator-validators-list";
2020
import { OperatorStatusBadge } from "@/components/operator/operator-status-badge";
2121
import { useOperatorEarningsAndFees } from "@/hooks/operator/use-operator-earnings-and-fees";
22+
import { Skeleton } from "@/components/ui/skeleton";
23+
24+
const AmountSkeleton: FC<{ "data-testid"?: string }> = (props) => (
25+
<div className="flex flex-col gap-2" {...props}>
26+
<Skeleton className="h-7 w-32" />
27+
<Skeleton className="h-4 w-16" />
28+
</div>
29+
);
30+
31+
const AmountUnavailable: FC<{ "data-testid"?: string }> = (props) => (
32+
<div className="flex flex-col items-start gap-2" {...props}>
33+
<Text variant="body-2-medium" className="text-error-500">
34+
Couldn't read this value from the network.
35+
</Text>
36+
<Text variant="body-3-medium" className="text-gray-500">
37+
It will appear once the connection recovers.
38+
</Text>
39+
</div>
40+
);
2241

2342
export const Operator: FC<ComponentPropsWithoutRef<"div">> = ({ ...props }) => {
2443
const params = useOperatorPageParams();
2544
const operatorId = BigInt(params.operatorId!);
2645
const operator = useOperator(operatorId!);
27-
const { feeEth, yearlyFeeEth, yearlyFeeSSV, balanceEth, balanceSSV } =
28-
useOperatorEarningsAndFees(operatorId);
46+
const {
47+
feeEth,
48+
yearlyFeeEth,
49+
yearlyFeeSSV,
50+
balanceEth,
51+
balanceSSV,
52+
earningsStatus,
53+
feesStatus,
54+
} = useOperatorEarningsAndFees(operatorId);
2955

30-
const hasBalance = balanceEth > 0n || balanceSSV > 0n;
56+
const hasBalance =
57+
earningsStatus.isSuccess && (balanceEth > 0n || balanceSSV > 0n);
58+
const canUpdateFee = feesStatus.isSuccess && feeEth.data !== 0n;
3159

3260
if (!operator.data) return null;
3361

@@ -111,31 +139,48 @@ export const Operator: FC<ComponentPropsWithoutRef<"div">> = ({ ...props }) => {
111139
</div>
112140
<Container variant="horizontal" size="xl" {...props} className="h-full">
113141
<div className="flex items-stretch gap-6 flex-col flex-1">
114-
<Card data-testid="dashboard-operator-balance-card" className="w-full">
142+
<Card
143+
data-testid="dashboard-operator-balance-card"
144+
className="w-full"
145+
>
115146
<Text
116147
data-testid="dashboard-operator-balance-label"
117148
variant="headline4"
118149
className="text-gray-500"
119150
>
120151
Balance
121152
</Text>
122-
<div className="flex flex-col gap-4">
123-
<BalanceDisplay
124-
data-testid="dashboard-operator-balance-eth"
125-
amount={balanceEth}
126-
token="ETH"
127-
/>
128-
{yearlyFeeSSV !== 0n && !operator.data.migrated && (
153+
{earningsStatus.isUnavailable ? (
154+
<AmountUnavailable data-testid="dashboard-operator-balance-error" />
155+
) : earningsStatus.isPending ? (
156+
<AmountSkeleton data-testid="dashboard-operator-balance-loading" />
157+
) : (
158+
<div className="flex flex-col gap-4">
129159
<BalanceDisplay
130-
data-testid="dashboard-operator-balance-ssv"
131-
amount={balanceSSV}
132-
token="SSV"
160+
data-testid="dashboard-operator-balance-eth"
161+
amount={balanceEth}
162+
token="ETH"
133163
/>
134-
)}
135-
</div>
164+
{yearlyFeeSSV !== 0n && !operator.data.migrated && (
165+
<BalanceDisplay
166+
data-testid="dashboard-operator-balance-ssv"
167+
amount={balanceSSV}
168+
token="SSV"
169+
/>
170+
)}
171+
</div>
172+
)}
136173
<Tooltip
137174
asChild
138-
content={!hasBalance ? "No balance to withdraw" : undefined}
175+
content={
176+
earningsStatus.isUnavailable
177+
? "Balance unavailable"
178+
: earningsStatus.isPending
179+
? "Loading balance"
180+
: !hasBalance
181+
? "No balance to withdraw"
182+
: undefined
183+
}
139184
>
140185
<Button
141186
data-testid="dashboard-operator-withdraw-btn"
@@ -160,24 +205,34 @@ export const Operator: FC<ComponentPropsWithoutRef<"div">> = ({ ...props }) => {
160205
</Text>
161206
<IncreaseOperatorFeeStatusBadge />
162207
</div>
163-
<div className="flex flex-col gap-4">
164-
<BalanceDisplay
165-
data-testid="dashboard-operator-yearly-fee-eth"
166-
amount={yearlyFeeEth}
167-
token="ETH"
168-
/>
169-
{yearlyFeeSSV > 0 && (
208+
{feesStatus.isUnavailable ? (
209+
<AmountUnavailable data-testid="dashboard-operator-yearly-fee-error" />
210+
) : feesStatus.isPending ? (
211+
<AmountSkeleton data-testid="dashboard-operator-yearly-fee-loading" />
212+
) : (
213+
<div className="flex flex-col gap-4">
170214
<BalanceDisplay
171-
data-testid="dashboard-operator-yearly-fee-ssv"
172-
amount={yearlyFeeSSV}
173-
token="SSV"
215+
data-testid="dashboard-operator-yearly-fee-eth"
216+
amount={yearlyFeeEth}
217+
token="ETH"
174218
/>
175-
)}
176-
</div>
219+
{yearlyFeeSSV > 0 && (
220+
<BalanceDisplay
221+
data-testid="dashboard-operator-yearly-fee-ssv"
222+
amount={yearlyFeeSSV}
223+
token="SSV"
224+
/>
225+
)}
226+
</div>
227+
)}
177228
<Tooltip
178229
asChild
179230
content={
180-
feeEth.data === 0n ? (
231+
feesStatus.isUnavailable ? (
232+
"Fee unavailable"
233+
) : feesStatus.isPending ? (
234+
"Loading fee"
235+
) : feeEth.data === 0n ? (
181236
<>
182237
Operators with a fee of 0 cannot change their fee.{" "}
183238
<Button
@@ -196,7 +251,7 @@ export const Operator: FC<ComponentPropsWithoutRef<"div">> = ({ ...props }) => {
196251
<Button
197252
data-testid="dashboard-operator-update-fee-btn"
198253
as={Link}
199-
disabled={feeEth.isLoading || feeEth.data === 0n}
254+
disabled={!canUpdateFee}
200255
to="fee/update"
201256
variant="secondary"
202257
size="xl"

‎src/hooks/operator/use-operator-earnings-and-fees.ts‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,52 @@ import { useGetOperatorEarnings } from "@/lib/contract-interactions/hooks/getter
22
import { useGetOperatorEarningsSSV } from "@/lib/contract-interactions/hooks/getter";
33
import { useGetOperatorFee } from "@/lib/contract-interactions/hooks/getter";
44
import { useGetOperatorFeeSSV } from "@/lib/contract-interactions/hooks/getter";
5+
import { combineQueryStatus } from "@/lib/react-query";
56
import { getYearlyFee } from "@/lib/utils/operator";
67

8+
type FetchStatusOnly = { fetchStatus: "fetching" | "paused" | "idle" };
9+
10+
// A read that cannot reach the RPC parks at fetchStatus "paused" and never
11+
// resolves to an error, so paused counts as unavailable too.
12+
const withUnavailable = <T extends { isError: boolean; isPending: boolean }>(
13+
status: T,
14+
queries: FetchStatusOnly[],
15+
) => {
16+
const isPaused = queries.some((query) => query.fetchStatus === "paused");
17+
return {
18+
...status,
19+
isPaused,
20+
isUnavailable: status.isError || isPaused,
21+
isPending: status.isPending && !isPaused,
22+
};
23+
};
24+
725
export const useOperatorEarningsAndFees = (operatorId: bigint) => {
8-
const earningsEth = useGetOperatorEarnings({ id: operatorId });
9-
const earningsSSV = useGetOperatorEarningsSSV({ id: operatorId });
26+
const queryOptions = { retry: 2, networkMode: "always" } as const;
1027

11-
const feeEth = useGetOperatorFee({ operatorId });
12-
const feeSSV = useGetOperatorFeeSSV({ operatorId });
28+
const earningsEth = useGetOperatorEarnings({ id: operatorId }, queryOptions);
29+
const earningsSSV = useGetOperatorEarningsSSV(
30+
{ id: operatorId },
31+
queryOptions,
32+
);
33+
34+
const feeEth = useGetOperatorFee({ operatorId }, queryOptions);
35+
const feeSSV = useGetOperatorFeeSSV({ operatorId }, queryOptions);
1336
const yearlyFeeEth = getYearlyFee(feeEth.data ?? 0n);
1437
const yearlyFeeSSV = getYearlyFee(feeSSV.data ?? 0n);
1538
const balanceEth = earningsEth.data ?? 0n;
1639
const balanceSSV = earningsSSV.data ?? 0n;
1740

41+
// `?? 0n` above is indistinguishable from an on-chain zero — gate on these.
42+
const earningsStatus = withUnavailable(
43+
combineQueryStatus(earningsEth, earningsSSV),
44+
[earningsEth, earningsSSV],
45+
);
46+
const feesStatus = withUnavailable(combineQueryStatus(feeEth, feeSSV), [
47+
feeEth,
48+
feeSSV,
49+
]);
50+
1851
return {
1952
earningsEth,
2053
earningsSSV,
@@ -24,5 +57,7 @@ export const useOperatorEarningsAndFees = (operatorId: bigint) => {
2457
yearlyFeeSSV,
2558
balanceEth,
2659
balanceSSV,
60+
earningsStatus,
61+
feesStatus,
2762
};
2863
};

0 commit comments

Comments
 (0)