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
8 changes: 3 additions & 5 deletions src/app/routes/create-cluster/select-operators/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { SelectedOperators } from "@/components/operator/operator-picker/selecte
import { useSearchOperators } from "@/hooks/use-search-operators";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useRegisterValidatorContext } from "@/guard/register-validator-guard";
import { getYearlyFee } from "@/lib/utils/operator";
import { getYearlyFee, sumOperatorsFee } from "@/lib/utils/operator";
import { formatSSV } from "@/lib/utils/number";
import { Divider } from "@/components/ui/divider";
import { SearchInput } from "@/components/ui/search-input";
Expand Down Expand Up @@ -107,11 +107,9 @@ export const SelectOperators: FCProps = ({ className, ...props }) => {
enabled: Boolean(clusterHash),
});
const feeMode = currentCluster.data?.migrated === false ? "ssv" : "eth";
const feeField = feeMode === "eth" ? "eth_fee" : "fee";
const feeDenomination = feeMode === "eth" ? "ETH" : "SSV";
const totalYearlyFee = selectedOperators.reduce(
(acc, operator) => acc + getYearlyFee(BigInt(operator[feeField] || "0")),
0n,
const totalYearlyFee = getYearlyFee(
sumOperatorsFee(selectedOperators, feeMode),
);

const isClusterExists =
Expand Down
15 changes: 3 additions & 12 deletions src/components/wizard/operator-fee-comparison.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Divider } from "@/components/ui/divider.tsx";
import { Text } from "@/components/ui/text.tsx";
import { Collapse } from "react-collapse";
import type { Operator } from "@/types/api.ts";
import { getYearlyFee } from "@/lib/utils/operator.ts";
import { getYearlyFee, sumOperatorsFee } from "@/lib/utils/operator.ts";
import {
bigintFormatter,
currencyFormatter,
Expand All @@ -33,17 +33,8 @@ export const OperatorFeeComparison = ({

// Calculate totals from operators
const totals = useMemo(() => {
const totalYearlyFeeSSV = operators.reduce(
(sum, op) => sum + getYearlyFee(BigInt(op.fee || "0")),
0n,
);
const totalYearlyFeeETH = operators.reduce((sum, op) => {
const hasEthFee = ![undefined, null, "0", 0].includes(op.eth_fee);
if (hasEthFee) {
return sum + getYearlyFee(BigInt(op.eth_fee));
}
return sum;
}, 0n);
const totalYearlyFeeSSV = getYearlyFee(sumOperatorsFee(operators, "ssv"));
const totalYearlyFeeETH = getYearlyFee(sumOperatorsFee(operators, "eth"));
const totalSSVFormatted = `${formatSSV(totalYearlyFeeSSV)} SSV`;
const totalETHFormatted = `${formatETH(totalYearlyFeeETH)} ETH`;

Expand Down
4 changes: 0 additions & 4 deletions src/components/wizard/switch-wizard-step-two.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,28 +98,24 @@ export const SwitchWizardStepTwo = ({
fundingDays: currentRunwayDays,
operators,
effectiveBalance: effectiveBalanceEth,
ignoreRemovedOperators: true,
});

const halfYearCostsQuery = useFundingCostETH({
fundingDays: periods["half-year"],
operators,
effectiveBalance: effectiveBalanceEth,
ignoreRemovedOperators: true,
});

const yearCostsQuery = useFundingCostETH({
fundingDays: periods.year,
operators,
effectiveBalance: effectiveBalanceEth,
ignoreRemovedOperators: true,
});

const customCostsQuery = useFundingCostETH({
fundingDays: values.custom,
operators,
effectiveBalance: effectiveBalanceEth,
ignoreRemovedOperators: true,
});

const mapQueryToCosts = (data: typeof halfYearCostsQuery.data) =>
Expand Down
11 changes: 6 additions & 5 deletions src/hooks/use-compute-funding-cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,12 @@ export type UseFundingCostArgs = {
fundingDays: number;
/** Effective balance in ETH (human-readable). Examples: 32n (1 validator), 64n (2 validators) */
effectiveBalance: bigint;
ignoreRemovedOperators?: boolean;
};

export const useFundingCostETH = ({
operators,
fundingDays,
effectiveBalance,
ignoreRemovedOperators = false,
}: UseFundingCostArgs) => {
const {
isSuccess,
Expand All @@ -67,14 +65,17 @@ export const useFundingCostETH = ({
gcTime: 0,
queryKey: stringifyBigints([
"fundingCost",
operators.map(op => ({ id: op.id, eth_fee: op.eth_fee, is_deleted: op.is_deleted })),
operators.map((op) => ({
id: op.id,
eth_fee: op.eth_fee,
is_deleted: op.is_deleted,
})),
fundingDays,
effectiveBalance,
ignoreRemovedOperators,
]),
queryFn: async () =>
computeFundingCost({
operatorsFee: sumOperatorsFee(operators, "eth", ignoreRemovedOperators),
operatorsFee: sumOperatorsFee(operators, "eth"),
fundingDays,
networkFee: ssvNetworkFee.data!,
liquidationCollateralPeriod: liquidationThresholdPeriod.data!,
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/use-migration-calculation-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const useMigrationCalculationData = ({
// Calculate operator fees for the selected period (per 32 ETH validator)
const operatorFees: OperatorFeeDisplay[] = operators.map((operator) => {
const periodFee = computeDailyAmount(
getOperatorEthFee(operator, true),
getOperatorEthFee(operator),
fundingDays,
);
return {
Expand Down
143 changes: 143 additions & 0 deletions src/lib/utils/__tests__/operator-fee.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { globals } from "@/config";
import { calculateRunway } from "@/lib/utils/cluster";
import {
createDefaultOperator,
getOperatorEthFee,
getOperatorSsvFee,
sumOperatorsFee,
} from "@/lib/utils/operator";

// 3 live operators + 1 removed one. The API keeps the removed operator's last
// (stale) fee; on-chain it is 0.
const LIVE_ETH = 3n * 1_000_000_000n;
const LIVE_SSV = 3n * 2_000_000n;

const ops = [
createDefaultOperator({ id: 1, fee: "2000000", eth_fee: "1000000000" }),
createDefaultOperator({ id: 2, fee: "2000000", eth_fee: "1000000000" }),
createDefaultOperator({ id: 3, fee: "2000000", eth_fee: "1000000000" }),
createDefaultOperator({
id: 4,
fee: "2000000",
eth_fee: "1000000000",
is_deleted: true,
}),
];
const removed = ops[3];

describe("util:operator fee sums exclude removed operators (SSV-WEB-001)", () => {
it("getOperatorEthFee returns 0n for a removed operator", () => {
expect(getOperatorEthFee(removed)).toBe(0n);
expect(getOperatorEthFee(ops[0])).toBe(1_000_000_000n);
});

it("getOperatorSsvFee returns 0n for a removed operator", () => {
expect(getOperatorSsvFee(removed)).toBe(0n);
expect(getOperatorSsvFee(ops[0])).toBe(2_000_000n);
});

it("sumOperatorsFee eth excludes removed operators", () => {
expect(sumOperatorsFee(ops, "eth")).toBe(LIVE_ETH);
});

it("sumOperatorsFee ssv excludes removed operators", () => {
expect(sumOperatorsFee(ops, "ssv")).toBe(LIVE_SSV);
});

it("sumOperatorsFee defaults to eth", () => {
expect(sumOperatorsFee(ops)).toBe(LIVE_ETH);
});

it("sumOperatorsFee treats empty or missing fee values as 0n", () => {
const blank = createDefaultOperator({ id: 5, fee: "", eth_fee: "" });
const missing = createDefaultOperator({
id: 6,
fee: undefined as unknown as string,
eth_fee: undefined as unknown as string,
});
expect(sumOperatorsFee([blank, missing], "eth")).toBe(0n);
expect(sumOperatorsFee([blank, missing], "ssv")).toBe(0n);
});

describe("runway built like use-cluster-runway is not shortened by a removed operator", () => {
const runwayFor = (balance: bigint, feesPerBlock: bigint) =>
calculateRunway({
balance,
feesPerBlock,
effectiveBalance: 32n,
liquidationThresholdBlocks: 0n,
minimumLiquidationCollateral: 0n,
}).runway;

it("eth", () => {
// balance funds exactly 100 days at live-operator fees
const balance = LIVE_ETH * globals.BLOCKS_PER_DAY * 100n;
expect(runwayFor(balance, sumOperatorsFee(ops, "eth"))).toBe(
runwayFor(balance, LIVE_ETH),
);
expect(runwayFor(balance, sumOperatorsFee(ops, "eth"))).toBe(100n);
});

it("ssv", () => {
const balance = LIVE_SSV * globals.BLOCKS_PER_DAY * 100n;
expect(runwayFor(balance, sumOperatorsFee(ops, "ssv"))).toBe(
runwayFor(balance, LIVE_SSV),
);
expect(runwayFor(balance, sumOperatorsFee(ops, "ssv"))).toBe(100n);
});
});
});

describe("util:operator warns when an active operator has no fee (SSV-WEB-001)", () => {
const spy = () => vi.spyOn(console, "warn").mockImplementation(() => {});
afterEach(() => vi.restoreAllMocks());

it("returns 0n and warns once for an active operator with a missing eth_fee", () => {
const warn = spy();
const op = createDefaultOperator({
id: 9001,
fee: "2000000",
eth_fee: undefined as unknown as string,
});
expect(getOperatorEthFee(op)).toBe(0n);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("9001");
expect(warn.mock.calls[0][0]).toContain("eth_fee");
});

it("does not warn again for the same operator and field", () => {
const warn = spy();
const op = createDefaultOperator({ id: 9002, fee: "", eth_fee: "1" });
expect(getOperatorSsvFee(op)).toBe(0n);
expect(getOperatorSsvFee(op)).toBe(0n);
expect(sumOperatorsFee([op], "ssv")).toBe(0n);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("fee");
});

it("does not warn for a removed operator with a missing fee", () => {
const warn = spy();
const op = createDefaultOperator({
id: 9003,
fee: "",
eth_fee: "",
is_deleted: true,
});
expect(getOperatorEthFee(op)).toBe(0n);
expect(getOperatorSsvFee(op)).toBe(0n);
expect(warn).not.toHaveBeenCalled();
});

it("does not warn for an active operator with a valid fee", () => {
const warn = spy();
const op = createDefaultOperator({
id: 9004,
fee: "2000000",
eth_fee: "1000000000",
});
expect(getOperatorEthFee(op)).toBe(1_000_000_000n);
expect(getOperatorSsvFee(op)).toBe(2_000_000n);
expect(warn).not.toHaveBeenCalled();
});
});
44 changes: 32 additions & 12 deletions src/lib/utils/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,28 +146,48 @@ export const prepareOperatorsForShares = (

export const isOperatorRemoved = (operator: Operator) => operator.is_deleted;

export const getOperatorEthFee = (
operator: Operator,
ignoreRemoved = false,
) => {
if (ignoreRemoved && isOperatorRemoved(operator)) return 0n;
return BigInt(operator.eth_fee);
const warnedMissingFees = new Set<string>();

/**
* A missing fee counts as 0, which shortens burn rate and lengthens runway.
* Surface it (once per operator and field) instead of failing silently.
*/
const readFee = (operator: Operator, field: "fee" | "eth_fee") => {
const value = operator[field];
if (value) return BigInt(value);
const key = `${operator.id}:${field}`;
if (!warnedMissingFees.has(key)) {
warnedMissingFees.add(key);
console.warn(
`Operator ${operator.id} has no "${field}"; counting it as 0 (burn rate may be understated)`,
);
}
return 0n;
};

/**
* A removed operator's fee is 0 on-chain. The API keeps returning its last
* fee, which is display-only and must never be summed into burn rates,
* runway or funding amounts.
*/
export const getOperatorEthFee = (operator: Operator) =>
isOperatorRemoved(operator) ? 0n : readFee(operator, "eth_fee");

export const getOperatorSsvFee = (operator: Operator) =>
isOperatorRemoved(operator) ? 0n : readFee(operator, "fee");

export const sumOperatorsFee = (
operators: Operator[],
by: "eth" | "ssv" = "eth",
ignoreRemovedForEth = false,
) => {
return operators.reduce(
) =>
operators.reduce(
(acc, operator) =>
acc +
(by === "eth"
? getOperatorEthFee(operator, ignoreRemovedForEth)
: BigInt(operator.fee)),
? getOperatorEthFee(operator)
: getOperatorSsvFee(operator)),
0n,
);
};

export function getOperatorIds<T extends { id: number }[] | number[]>(
operators: T,
Expand Down
Loading