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

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