Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ next-env.d.ts
.code-workspace
.claude/
.pnpm-store/
.playwright-mcp/

# debug
npm-debug.log*
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ node_modules
.pnpm-store
.github
.vscode
.playwright-mcp
@types
apollo
codegen.yml
Expand Down
86 changes: 66 additions & 20 deletions components/DelegatingWidget/Delegate.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
css={{
alignItems: "center",
gap: "$3",
padding: "$3",
marginBottom: "$3",
borderRadius: "$3",
background: "$yellow3",
border: "1px solid $yellow7",
}}
>
<Box
as={ExclamationTriangleIcon}
css={{ color: "$yellow11", flexShrink: 0, width: 16, height: 16 }}
/>
<Text css={{ fontSize: "$2", color: "$yellow11", lineHeight: 1.5 }}>
This orchestrator&apos;s reward cut increased sharply{" "}
{dayjs(spike.endTimestamp).fromNow()} (
{formatPercent(spike.fromRewardCut, { precision: 0 })} →{" "}
{formatPercent(spike.toRewardCut, { precision: 0 })}). Review history
before delegating.
</Text>
</Flex>
);
}

return (
<Flex
css={{
alignItems: "center",
gap: "$3",
padding: "$3",
marginBottom: "$3",
borderRadius: "$3",
background: "$neutral3",
}}
>
<Box
as={InfoCircledIcon}
css={{ color: "white", flexShrink: 0, width: 16, height: 16 }}
/>
<Text css={{ fontSize: "$2", color: "white", lineHeight: 1.5 }}>
Please ensure you have checked the reward & fee cut history before
delegating.
</Text>
</Flex>
);
};

const Delegate = ({
to,
amount,
Expand Down Expand Up @@ -170,25 +234,7 @@ const Delegate = ({
}

const cutChangeNotice = isMyTranscoder ? null : (
<Flex
css={{
alignItems: "center",
gap: "$3",
padding: "$3",
marginBottom: "$3",
borderRadius: "$3",
background: "$neutral3",
}}
>
<Box
as={InfoCircledIcon}
css={{ color: "white", flexShrink: 0, width: 16, height: 16 }}
/>
<Text css={{ fontSize: "$2", color: "white", lineHeight: 1.5 }}>
Please ensure you have checked the reward & fee cut history before
delegating.
</Text>
</Flex>
<CutChangeNotice orchestratorId={to} />
);

if (showApproveFlow) {
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const eslintConfig = defineConfig([
},
globalIgnores([
".claude/**",
".playwright-mcp/**",
".vscode/**",
"apollo/**",
"@types/**",
".next/**",
Expand Down
58 changes: 43 additions & 15 deletions hooks/useOrchestratorCutHistory.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,29 +13,57 @@ type CutDataPoint = {
feeCut: number;
};

type Transcoder = NonNullable<AccountQueryResult["data"]>["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<CutDataPoint[]>(() => {
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.
Expand All @@ -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,
});
}

Expand Down
82 changes: 82 additions & 0 deletions hooks/useOrchestratorRewardCutSpike.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Comment on lines +27 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Threshold assertions are stale (50pp) and now fail CI against the 80pp rule

  • Problem: Line 27, Line 51, and Line 57 encode a 50pp threshold expectation, but runtime logic now uses REWARD_CUT_SPIKE_PP = 0.8 (80pp).
  • Why it matters: CI is red (reported failures at Line 29 and Line 54), so this PR cannot merge with reliable test signal.
  • Suggested fix: Update these cases to 80pp semantics (and ideally derive assertions from REWARD_CUT_SPIKE_PP so future threshold tuning doesn’t break tests again).
Proposed patch
 import {
   type CutEvent,
   findRecentRewardCutSpike,
+  REWARD_CUT_SPIKE_PP,
 } from "./useOrchestratorRewardCutSpike";
@@
-  it("fires on a single-event 50pp jump", () => {
-    const spike = findRecentRewardCutSpike([event(10, 0), event(5, 50)], opts);
+  it("fires on a single-event 80pp jump", () => {
+    const spike = findRecentRewardCutSpike([event(10, 0), event(5, 80)], opts);
     expect(spike?.fromRewardCut).toBe(0);
-    expect(spike?.toRewardCut).toBe(0.5);
+    expect(spike?.toRewardCut).toBe(0.8);
   });
@@
-  it("fires at exactly the 50pp threshold", () => {
+  it("fires at exactly the configured threshold", () => {
     expect(
-      findRecentRewardCutSpike([event(10, 25), event(5, 75)], opts)
+      findRecentRewardCutSpike(
+        [event(10, 0), event(5, REWARD_CUT_SPIKE_PP * 100)],
+        opts
+      )
     ).not.toBeNull();
   });
@@
-  it("does not fire below the 50pp threshold", () => {
+  it("does not fire below the configured threshold", () => {
     expect(
-      findRecentRewardCutSpike([event(10, 0), event(5, 49)], opts)
+      findRecentRewardCutSpike(
+        [event(10, 0), event(5, REWARD_CUT_SPIKE_PP * 100 - 1)],
+        opts
+      )
     ).toBeNull();
   });
🧰 Tools
🪛 GitHub Actions: CI / 0_lint-and-test.txt

[error] 29-29: Jest assertion failed in test 'findRecentRewardCutSpike › fires on a single-event 50pp jump'. Expected spike?.fromRewardCut to be 0, but received undefined.


[error] 54-54: Jest assertion failed in test 'findRecentRewardCutSpike › fires at exactly the 50pp threshold'. Expected result not to be null, but received null.

🪛 GitHub Actions: CI / lint-and-test

[error] 29-29: Jest test failed in 'findRecentRewardCutSpike › fires on a single-event 50pp jump'. Expected spike?.fromRewardCut to be 0, received undefined.


[error] 54-54: Jest test failed in 'findRecentRewardCutSpike › fires at exactly the 50pp threshold'. Expected result not to be null, but received null.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/useOrchestratorRewardCutSpike.test.ts` around lines 27 - 61, Tests
reference a stale 50pp threshold; update assertions in
useOrchestratorRewardCutSpike.test.ts to use the current REWARD_CUT_SPIKE_PP
(0.8) instead of hardcoded 50/75/49 values: modify calls to
findRecentRewardCutSpike and the event rewardCut values so the spike-generating
differences match REWARD_CUT_SPIKE_PP*100 (e.g., use REWARD_CUT_SPIKE_PP*100 and
(1-REWARD_CUT_SPIKE_PP)*100 as needed) and replace expected
fromRewardCut/toRewardCut assertions to derive from REWARD_CUT_SPIKE_PP (e.g.,
expect(spike?.toRewardCut).toBe(REWARD_CUT_SPIKE_PP) and
expect(spike?.fromRewardCut).toBe(1-REWARD_CUT_SPIKE_PP) or compute percent
equivalents) so tests use the constant rather than hardcoded 50/49/75 values;
update the three failing cases around the single-event threshold, the "exactly
threshold" test, and the "below threshold" test accordingly.


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);
});
});
Loading
Loading