Skip to content

fix: show loading and unavailable states for operator balance and fee - #1876

Merged
Chris-ssvlabs merged 1 commit into
stagefrom
fix/operator-balance-fee-loading-states
Sep 1, 2026
Merged

Chris-ssvlabs merged 1 commit into
stagefrom
fix/operator-balance-fee-loading-states

Conversation

@Chris-ssvlabs

Copy link
Copy Markdown
Contributor

Problem

The operator detail page renders a confident 0 ETH for Balance and Annual Fee whenever the on-chain reads are pending or failing — indistinguishable from a genuine on-chain zero.

Reported against mainnet operator 423, where the values are demonstrably non-zero on-chain:

getOperatorEarnings(423)    = 22800984310700000
getOperatorFee(423)         = 1070000000
getOperatorEarningsSSV(423) = 6769793622920000000
getOperatorFeeSSV(423)      = 479350000000

The page looks half-loaded because the header (Name, Status, Validators, 30D, Total ETH Managed) and the Validators table come from the REST API, which needs no wallet or RPC. Balance and Annual Fee are the only values read on-chain. The two sources fail independently, so the page renders complete but zeroed.

Root cause

useOperatorEarningsAndFees collapsed three distinct query states into one value:

const yearlyFeeEth = getYearlyFee(feeEth.data ?? 0n);  // loading → 0n, error → 0n, real zero → 0n
const balanceEth   = earningsEth.data ?? 0n;

The hook returned only the derived bigints, and operator.tsx never read isLoading / isError. All four reads share one batched JSON-RPC POST, so they fail together — which is also why the SSV rows vanish (both are gated on yearlyFeeSSV).

Changes

  • Expose earningsStatus / feesStatus from the hook via the existing combineQueryStatus.
  • Treat fetchStatus: "paused" as unavailable. A read that cannot reach the RPC parks at status: "pending" / fetchStatus: "paused" and never resolves to an error, so an isError-only fallback would leave the UI on a skeleton indefinitely.
  • Render a skeleton while pending and an explicit "couldn't read this value" message instead of a zero amount.
  • Gate Withdraw and Update Fee on query success rather than on the coalesced bigint. Previously a failed fee read left Update Fee enabled (data is undefined, not 0n), routing into the fee wizard with no fee loaded.

OperatorTableRow, the hook's other consumer, uses only the coalesced amounts and is unaffected.

Testing

Reproduced by pointing localStorage.customRpcUrl_1 at a dead endpoint and reloading /operators/423 — pixel-identical to the report: header + 29 validators fine, both cards 0 ETH / $0, no SSV rows, nothing logged to console.

All three states verified in the browser after the fix:

State Result
In flight skeleton, both buttons disabled
RPC unreachable "Couldn't read this value from the network." + "It will appear once the connection recovers."
Healthy 0.0228 ETH / 0.0028 ETH + 1.2527 SSV, buttons enabled

Gates: git diff --check clean, tsc -b clean, eslint clean, prettier clean, 119 tests pass.

Open question (not addressed here)

Why the reads pause at all. readContractQueryOptions returns only queryFn/queryKey, so wagmi is not overriding the networkMode: "always" set on these queries — yet they still park at paused under PersistQueryClientProvider while navigator.onLine is true. Instrumented evidence:

{ status: "pending", fetchStatus: "paused", failureCount: 1, errorUpdatedAt: 0 }

This likely affects every useReadContract in the app, not just this page. I left it alone because the plausible fix is a QueryClient-level default with app-wide blast radius. I originally added a "Try again" button here and removed it after verifying it was inert in this state — refetch() does not resume a paused query, and the state was byte-identical before and after a click.

Note for reviewers

Two pre-existing issues surfaced while working on this, both left untouched:

  1. The Balance card's SSV row is gated on yearlyFeeSSV (a fee value) rather than balanceSSV — looks wrong, but changing it alters what gets displayed.
  2. pnpm test:run fails 5 suites locally with VITE_SSV_NETWORKS is not defined — vitest runs in mode test and never loads .env.development.local. With the var supplied, 119 tests pass instead of 73, so 46 tests are silently skipped for anyone without it.

The operator detail page rendered a confident "0 ETH" for Balance and
Annual Fee whenever the on-chain reads were pending or failing, because
useOperatorEarningsAndFees coalesced `data ?? 0n`.

- expose earningsStatus / feesStatus from the hook via combineQueryStatus
- treat fetchStatus "paused" as unavailable: a read that cannot reach the
  RPC parks at status "pending" and never resolves to an error, which
  would otherwise leave the UI on a skeleton indefinitely
- render a skeleton while pending and an explicit unavailable message
  instead of a zero amount
- gate Withdraw and Update Fee on query success rather than on the
  coalesced bigint, so a failed read no longer leaves Update Fee
  clickable into the fee wizard
@Chris-ssvlabs
Chris-ssvlabs merged commit f9d7e8e into stage Sep 1, 2026
1 check passed
@Ravid-SSVLabs

Copy link
Copy Markdown

Approved we can merge to main @Chris-ssvlabs @sebastian-ssvlabs

Chris-ssvlabs added a commit that referenced this pull request Sep 2, 2026
…ates-main

fix: show loading and unavailable states for operator balance and fee (#1876)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants