From 787ba6419163c4793709bd7fdb7fe44d93df4f1c Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 27 May 2026 19:29:53 +0200 Subject: [PATCH 1/3] feat: warn delegators when an orchestrator's reward cut is rising sharply Render the existing delegation widget banner as a yellow warning when an orchestrator's reward cut had any >=50 percentage point upward swing within any rolling 7-day window in the last 180 days. Surfaces sharp reward cut increases at the point of delegation so delegators can review the history before committing stake. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + .prettierignore | 1 + components/DelegatingWidget/Delegate.tsx | 86 ++++++++++++++----- eslint.config.mjs | 2 + hooks/useOrchestratorCutHistory.tsx | 47 ++++++++--- hooks/useOrchestratorRewardCutSpike.tsx | 102 +++++++++++++++++++++++ 6 files changed, 209 insertions(+), 30 deletions(-) create mode 100644 hooks/useOrchestratorRewardCutSpike.tsx 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..14eb4f0c 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,14 +13,41 @@ 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 { const { data, loading } = useTranscoderUpdateEventsQuery({ variables: { - where: { - delegate: transcoder?.id, - }, + where: { delegate: transcoder?.id }, first: 1000, orderBy: TranscoderUpdateEvent_OrderBy.Timestamp, orderDirection: OrderDirection.Asc, @@ -32,8 +59,8 @@ export function useOrchestratorCutHistory(transcoder?: Transcoder) { const events: CutDataPoint[] = (data?.transcoderUpdateEvents ?? []).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, }) ); @@ -47,8 +74,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.tsx b/hooks/useOrchestratorRewardCutSpike.tsx new file mode 100644 index 00000000..8f1fc07e --- /dev/null +++ b/hooks/useOrchestratorRewardCutSpike.tsx @@ -0,0 +1,102 @@ +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 { + const { data } = useTranscoderUpdateEventsQuery({ + variables: { + where: { delegate: orchestratorId }, + first: 1000, + orderBy: TranscoderUpdateEvent_OrderBy.Timestamp, + orderDirection: OrderDirection.Asc, + }, + skip: !orchestratorId, + }); + + return useMemo( + () => findRecentRewardCutSpike(data?.transcoderUpdateEvents ?? []), + [data] + ); +} From 00eb77deb12127b5aeb96c6ef06dcd85cbee23ca Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 27 May 2026 20:20:47 +0200 Subject: [PATCH 2/3] fix: fetch newest events first in transcoder update queries For orchestrators with >1000 lifetime update events, Asc + first:1000 dropped the recent 180-day window entirely. Flip both queries to Desc so the recent slice is always present; chart hook sorts ascending in its mapping step to preserve display order. Co-Authored-By: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) --- hooks/useOrchestratorCutHistory.tsx | 11 ++++++----- hooks/useOrchestratorRewardCutSpike.tsx | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/hooks/useOrchestratorCutHistory.tsx b/hooks/useOrchestratorCutHistory.tsx index 14eb4f0c..c7b0461a 100644 --- a/hooks/useOrchestratorCutHistory.tsx +++ b/hooks/useOrchestratorCutHistory.tsx @@ -45,24 +45,25 @@ export type UseOrchestratorCutHistoryReturn = { 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 }, 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) / 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. diff --git a/hooks/useOrchestratorRewardCutSpike.tsx b/hooks/useOrchestratorRewardCutSpike.tsx index 8f1fc07e..5c67a143 100644 --- a/hooks/useOrchestratorRewardCutSpike.tsx +++ b/hooks/useOrchestratorRewardCutSpike.tsx @@ -85,12 +85,13 @@ export function findRecentRewardCutSpike( 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.Asc, + orderDirection: OrderDirection.Desc, }, skip: !orchestratorId, }); From deb58a23723bba051d4f617de809ccfd8ba914f3 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 27 May 2026 20:31:51 +0200 Subject: [PATCH 3/3] test: add unit tests for findRecentRewardCutSpike Covers threshold, window cutoff, up-only direction, and most-recent-spike selection. Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) --- hooks/useOrchestratorRewardCutSpike.test.ts | 82 +++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 hooks/useOrchestratorRewardCutSpike.test.ts 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); + }); +});