diff --git a/.gitignore b/.gitignore
index 952834aa..0fb341bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ next-env.d.ts
.code-workspace
.claude/
.pnpm-store/
+.playwright-mcp/
# debug
npm-debug.log*
diff --git a/.prettierignore b/.prettierignore
index bfcb559b..667e38ae 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -12,6 +12,7 @@ node_modules
.pnpm-store
.github
.vscode
+.playwright-mcp
@types
apollo
codegen.yml
diff --git a/components/DelegatingWidget/Delegate.tsx b/components/DelegatingWidget/Delegate.tsx
index 4983cb08..272f3b44 100644
--- a/components/DelegatingWidget/Delegate.tsx
+++ b/components/DelegatingWidget/Delegate.tsx
@@ -1,19 +1,83 @@
import { bondingManager } from "@lib/api/abis/main/BondingManager";
import { livepeerToken } from "@lib/api/abis/main/LivepeerToken";
+import dayjs from "@lib/dayjs";
import { MAXIMUM_VALUE_UINT256 } from "@lib/utils";
import { Box, Button, Flex, Text } from "@livepeer/design-system";
-import { InfoCircledIcon } from "@radix-ui/react-icons";
+import {
+ ExclamationTriangleIcon,
+ InfoCircledIcon,
+} from "@radix-ui/react-icons";
+import { formatPercent } from "@utils/numberFormatters";
import {
useBondingManagerAddress,
useLivepeerTokenAddress,
} from "hooks/useContracts";
import { useHandleTransaction } from "hooks/useHandleTransaction";
+import { useOrchestratorRewardCutSpike } from "hooks/useOrchestratorRewardCutSpike";
import { useMemo, useState } from "react";
import { parseEther } from "viem";
import { useSimulateContract, useWriteContract } from "wagmi";
import ProgressSteps from "../ProgressSteps";
+/**
+ * Info banner above the Delegate button. Becomes a yellow warning when
+ * `useOrchestratorRewardCutSpike` returns a qualifying spike.
+ */
+const CutChangeNotice = ({ orchestratorId }: { orchestratorId?: string }) => {
+ const spike = useOrchestratorRewardCutSpike(orchestratorId);
+
+ if (spike) {
+ return (
+
+
+
+ This orchestrator's reward cut increased sharply{" "}
+ {dayjs(spike.endTimestamp).fromNow()} (
+ {formatPercent(spike.fromRewardCut, { precision: 0 })} →{" "}
+ {formatPercent(spike.toRewardCut, { precision: 0 })}). Review history
+ before delegating.
+
+
+ );
+ }
+
+ return (
+
+
+
+ Please ensure you have checked the reward & fee cut history before
+ delegating.
+
+
+ );
+};
+
const Delegate = ({
to,
amount,
@@ -170,25 +234,7 @@ const Delegate = ({
}
const cutChangeNotice = isMyTranscoder ? null : (
-
-
-
- Please ensure you have checked the reward & fee cut history before
- delegating.
-
-
+
);
if (showApproveFlow) {
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 7d7475ec..cdd6ed23 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -23,6 +23,8 @@ const eslintConfig = defineConfig([
},
globalIgnores([
".claude/**",
+ ".playwright-mcp/**",
+ ".vscode/**",
"apollo/**",
"@types/**",
".next/**",
diff --git a/hooks/useOrchestratorCutHistory.tsx b/hooks/useOrchestratorCutHistory.tsx
index d34bc36e..c7b0461a 100644
--- a/hooks/useOrchestratorCutHistory.tsx
+++ b/hooks/useOrchestratorCutHistory.tsx
@@ -1,5 +1,5 @@
import type { ChartDatum } from "@components/ExplorerChart";
-import type { AccountQueryResult } from "apollo";
+import { PERCENTAGE_PRECISION_MILLION } from "@utils/web3";
import {
OrderDirection,
TranscoderUpdateEvent_OrderBy,
@@ -13,29 +13,57 @@ type CutDataPoint = {
feeCut: number;
};
-type Transcoder = NonNullable["transcoder"];
+/**
+ * Minimal orchestrator shape this hook consumes. Compatible with both the
+ * full `account.transcoder` and partial `delegator.delegate` fragments;
+ * missing fields degrade gracefully via optional access.
+ */
+type OrchestratorRef = Partial<{
+ id: string;
+ activationTimestamp: number;
+ rewardCut: string;
+ feeShare: string;
+}>;
-export function useOrchestratorCutHistory(transcoder?: Transcoder) {
+export type UseOrchestratorCutHistoryReturn = {
+ /** Reward cut over time, ready for `ExplorerChart`. */
+ rewardCutData: ChartDatum[];
+ /** Fee cut over time, ready for `ExplorerChart`. */
+ feeCutData: ChartDatum[];
+ /** Current reward cut (0..1), or 0 if unknown. */
+ baseRewardCut: number;
+ /** Current fee cut (0..1), or 0 if unknown. */
+ baseFeeCut: number;
+ /** True while the underlying subgraph query is in flight. */
+ loading: boolean;
+};
+
+/**
+ * Reward-cut and fee-cut time series for chart plotting. Adds activation
+ * and "now" anchors so the chart extends end-to-end.
+ */
+export function useOrchestratorCutHistory(
+ transcoder?: OrchestratorRef | null
+): UseOrchestratorCutHistoryReturn {
+ // 1000 most recent events, no pagination — chart truncates older history.
const { data, loading } = useTranscoderUpdateEventsQuery({
variables: {
- where: {
- delegate: transcoder?.id,
- },
+ where: { delegate: transcoder?.id },
first: 1000,
orderBy: TranscoderUpdateEvent_OrderBy.Timestamp,
- orderDirection: OrderDirection.Asc,
+ orderDirection: OrderDirection.Desc,
},
skip: !transcoder?.id,
});
const points = useMemo(() => {
- const events: CutDataPoint[] = (data?.transcoderUpdateEvents ?? []).map(
- (event) => ({
+ const events: CutDataPoint[] = [...(data?.transcoderUpdateEvents ?? [])]
+ .sort((a, b) => a.timestamp - b.timestamp)
+ .map((event) => ({
timestamp: event.timestamp * 1000, // Convert to ms
- rewardCut: Number(event.rewardCut) / 1000000,
- feeCut: 1 - Number(event.feeShare) / 1000000,
- })
- );
+ rewardCut: Number(event.rewardCut) / PERCENTAGE_PRECISION_MILLION,
+ feeCut: 1 - Number(event.feeShare) / PERCENTAGE_PRECISION_MILLION,
+ }));
// No update events — synthesize a starting anchor from current
// on-chain values at activation time so the chart shows a flat line.
@@ -47,8 +75,8 @@ export function useOrchestratorCutHistory(transcoder?: Transcoder) {
) {
events.push({
timestamp: Number(transcoder.activationTimestamp) * 1000,
- rewardCut: Number(transcoder.rewardCut) / 1000000,
- feeCut: 1 - Number(transcoder.feeShare) / 1000000,
+ rewardCut: Number(transcoder.rewardCut) / PERCENTAGE_PRECISION_MILLION,
+ feeCut: 1 - Number(transcoder.feeShare) / PERCENTAGE_PRECISION_MILLION,
});
}
diff --git a/hooks/useOrchestratorRewardCutSpike.test.ts b/hooks/useOrchestratorRewardCutSpike.test.ts
new file mode 100644
index 00000000..dcd094f9
--- /dev/null
+++ b/hooks/useOrchestratorRewardCutSpike.test.ts
@@ -0,0 +1,82 @@
+import {
+ type CutEvent,
+ findRecentRewardCutSpike,
+} from "./useOrchestratorRewardCutSpike";
+
+const NOW_MS = Date.UTC(2026, 0, 1);
+const NOW_SEC = Math.floor(NOW_MS / 1000);
+const SEC_PER_DAY = 86_400;
+const MS_PER_DAY = 86_400_000;
+
+const event = (daysAgo: number, rewardCutPct: number): CutEvent => ({
+ timestamp: NOW_SEC - daysAgo * SEC_PER_DAY,
+ rewardCut: String(Math.round(rewardCutPct * 10_000)),
+});
+
+const opts = { now: NOW_MS };
+
+describe("findRecentRewardCutSpike", () => {
+ it("returns null for empty input", () => {
+ expect(findRecentRewardCutSpike([], opts)).toBeNull();
+ });
+
+ it("returns null for a single event", () => {
+ expect(findRecentRewardCutSpike([event(10, 50)], opts)).toBeNull();
+ });
+
+ it("fires on a single-event 50pp jump", () => {
+ const spike = findRecentRewardCutSpike([event(10, 0), event(5, 50)], opts);
+ expect(spike?.fromRewardCut).toBe(0);
+ expect(spike?.toRewardCut).toBe(0.5);
+ });
+
+ it("fires on a 4x25pp climb spread over 6 days", () => {
+ const spike = findRecentRewardCutSpike(
+ [event(10, 0), event(9, 25), event(8, 50), event(7, 75), event(5, 100)],
+ opts
+ );
+ expect(spike?.fromRewardCut).toBe(0);
+ expect(spike?.toRewardCut).toBe(1);
+ });
+
+ it("fires on a dip-then-spike within 7 days", () => {
+ const spike = findRecentRewardCutSpike(
+ [event(30, 100), event(5, 0), event(2, 100)],
+ opts
+ );
+ expect(spike?.fromRewardCut).toBe(0);
+ expect(spike?.toRewardCut).toBe(1);
+ });
+
+ it("fires at exactly the 50pp threshold", () => {
+ expect(
+ findRecentRewardCutSpike([event(10, 25), event(5, 75)], opts)
+ ).not.toBeNull();
+ });
+
+ it("does not fire below the 50pp threshold", () => {
+ expect(
+ findRecentRewardCutSpike([event(10, 0), event(5, 49)], opts)
+ ).toBeNull();
+ });
+
+ it("does not fire on downward-only changes", () => {
+ expect(
+ findRecentRewardCutSpike([event(10, 100), event(5, 50)], opts)
+ ).toBeNull();
+ });
+
+ it("does not fire on spikes outside the 180-day cutoff", () => {
+ expect(
+ findRecentRewardCutSpike([event(200, 0), event(190, 100)], opts)
+ ).toBeNull();
+ });
+
+ it("returns the most recent qualifying spike", () => {
+ const spike = findRecentRewardCutSpike(
+ [event(150, 0), event(145, 100), event(50, 0), event(45, 100)],
+ opts
+ );
+ expect(spike?.endTimestamp).toBe(NOW_MS - 45 * MS_PER_DAY);
+ });
+});
diff --git a/hooks/useOrchestratorRewardCutSpike.tsx b/hooks/useOrchestratorRewardCutSpike.tsx
new file mode 100644
index 00000000..5c67a143
--- /dev/null
+++ b/hooks/useOrchestratorRewardCutSpike.tsx
@@ -0,0 +1,103 @@
+import { PERCENTAGE_PRECISION_MILLION } from "@utils/web3";
+import {
+ OrderDirection,
+ TranscoderUpdateEvent_OrderBy,
+ useTranscoderUpdateEventsQuery,
+} from "apollo";
+import { useMemo } from "react";
+
+/** Minimum upward swing (0..1) within any ROLLING_WINDOW_DAYS window to count as a spike. */
+export const REWARD_CUT_SPIKE_PP = 0.5;
+/** Width of the sliding window within which any qualifying upward swing fires the warning. */
+export const ROLLING_WINDOW_DAYS = 7;
+/** Age cutoff beyond which a spike no longer drives a warning. */
+export const WARNING_WINDOW_DAYS = 180;
+
+const MS_PER_DAY = 86_400_000;
+
+export type CutEvent = {
+ timestamp: number;
+ rewardCut: string;
+};
+
+export type RewardCutSpike = {
+ /** ms — timestamp of the event that closes the qualifying window. */
+ endTimestamp: number;
+ /** Lowest reward cut in the window, 0..1. */
+ fromRewardCut: number;
+ /** Reward cut at the end of the window, 0..1. */
+ toRewardCut: number;
+};
+
+/**
+ * Most recent ≥REWARD_CUT_SPIKE_PP upward swing in reward cut across any
+ * ROLLING_WINDOW_DAYS-day window in the last WARNING_WINDOW_DAYS days, or
+ * null. Up-only — downward changes never fire. Events sorted defensively.
+ */
+export function findRecentRewardCutSpike(
+ events: ReadonlyArray,
+ options: { now?: number } = {}
+): RewardCutSpike | null {
+ if (events.length < 2) return null;
+
+ const now = options.now ?? Date.now();
+ const warningCutoffMs = now - WARNING_WINDOW_DAYS * MS_PER_DAY;
+ const windowMs = ROLLING_WINDOW_DAYS * MS_PER_DAY;
+
+ // Walk newest -> oldest, return on the first qualifying spike. Break
+ // (not continue) on the cutoff since later indices are also older.
+ const eventsDesc = [...events].sort((a, b) => b.timestamp - a.timestamp);
+ for (let i = 0; i < eventsDesc.length - 1; i++) {
+ const endEvent = eventsDesc[i];
+ const endMs = endEvent.timestamp * 1000;
+ if (endMs < warningCutoffMs) break;
+
+ // Lowest cut anywhere in the window. The first event at-or-before
+ // windowStart caps the search — it's the cut value active when the
+ // window opened, and older events were already superseded by it.
+ const windowStartMs = endMs - windowMs;
+ let minCut = Infinity;
+ for (let j = i + 1; j < eventsDesc.length; j++) {
+ const ej = eventsDesc[j];
+ const cutJ = Number(ej.rewardCut) / PERCENTAGE_PRECISION_MILLION;
+ if (cutJ < minCut) minCut = cutJ;
+ if (ej.timestamp * 1000 <= windowStartMs) break;
+ }
+
+ const endCut = Number(endEvent.rewardCut) / PERCENTAGE_PRECISION_MILLION;
+ if (endCut - minCut >= REWARD_CUT_SPIKE_PP) {
+ return {
+ endTimestamp: endMs,
+ fromRewardCut: minCut,
+ toRewardCut: endCut,
+ };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Most recent reward-cut spike (a rolling-window rise past the configured
+ * threshold), or null. Shares the `useOrchestratorCutHistory` Apollo
+ * query (cache-deduped to one fetch).
+ */
+export function useOrchestratorRewardCutSpike(
+ orchestratorId?: string | null
+): RewardCutSpike | null {
+ // 1000 most recent events, no pagination — enough for the 180-day window.
+ const { data } = useTranscoderUpdateEventsQuery({
+ variables: {
+ where: { delegate: orchestratorId },
+ first: 1000,
+ orderBy: TranscoderUpdateEvent_OrderBy.Timestamp,
+ orderDirection: OrderDirection.Desc,
+ },
+ skip: !orchestratorId,
+ });
+
+ return useMemo(
+ () => findRecentRewardCutSpike(data?.transcoderUpdateEvents ?? []),
+ [data]
+ );
+}