diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts
index a29b2e0f..9cc7c0ff 100644
--- a/apollo/subgraph.ts
+++ b/apollo/subgraph.ts
@@ -9923,7 +9923,7 @@ export type AccountQueryVariables = Exact<{
}>;
-export type AccountQuery = { __typename: 'Query', delegator?: { __typename: 'Delegator', id: string, bondedAmount: string, principal: string, unbonded: string, withdrawnFees: string, startRound: string, lastClaimRound?: { __typename: 'Round', id: string } | null, unbondingLocks?: Array<{ __typename: 'UnbondingLock', id: string, amount: string, unbondingLockId: number, withdrawRound: string, delegate: { __typename: 'Transcoder', id: string } }> | null, delegate?: { __typename: 'Transcoder', id: string, active: boolean, status: TranscoderStatus, totalStake: string, feeShare: string, rewardCut: string, ninetyDayVolumeETH: string, pools?: Array<{ __typename: 'Pool', rewardTokens?: string | null }> | null } | null } | null, transcoder?: { __typename: 'Transcoder', id: string, active: boolean, feeShare: string, rewardCut: string, status: TranscoderStatus, totalStake: string, totalVolumeETH: string, activationTimestamp: number, activationRound: string, deactivationRound: string, thirtyDayVolumeETH: string, ninetyDayVolumeETH: string, lastRewardRound?: { __typename: 'Round', id: string } | null, pools?: Array<{ __typename: 'Pool', rewardTokens?: string | null }> | null, delegators?: Array<{ __typename: 'Delegator', id: string }> | null } | null, gateway?: { __typename: 'Broadcaster', id: string, deposit: string, reserve: string, totalVolumeETH: string, ninetyDayVolumeETH: string, firstActiveDay: number, lastActiveDay: number } | null, protocol?: { __typename: 'Protocol', id: string, totalSupply: string, totalActiveStake: string, participationRate: string, inflation: string, inflationChange: string, lptPriceEth: string, roundLength: string, currentRound: { __typename: 'Round', id: string } } | null };
+export type AccountQuery = { __typename: 'Query', delegator?: { __typename: 'Delegator', id: string, bondedAmount: string, principal: string, unbonded: string, withdrawnFees: string, startRound: string, lastClaimRound?: { __typename: 'Round', id: string } | null, unbondingLocks?: Array<{ __typename: 'UnbondingLock', id: string, amount: string, unbondingLockId: number, withdrawRound: string, delegate: { __typename: 'Transcoder', id: string, lastRewardRound?: { __typename: 'Round', id: string } | null } }> | null, delegate?: { __typename: 'Transcoder', id: string, active: boolean, status: TranscoderStatus, totalStake: string, feeShare: string, rewardCut: string, ninetyDayVolumeETH: string, lastRewardRound?: { __typename: 'Round', id: string } | null, pools?: Array<{ __typename: 'Pool', rewardTokens?: string | null }> | null } | null } | null, transcoder?: { __typename: 'Transcoder', id: string, active: boolean, feeShare: string, rewardCut: string, status: TranscoderStatus, totalStake: string, totalVolumeETH: string, activationTimestamp: number, activationRound: string, deactivationRound: string, thirtyDayVolumeETH: string, ninetyDayVolumeETH: string, lastRewardRound?: { __typename: 'Round', id: string } | null, pools?: Array<{ __typename: 'Pool', rewardTokens?: string | null }> | null, delegators?: Array<{ __typename: 'Delegator', id: string }> | null } | null, gateway?: { __typename: 'Broadcaster', id: string, deposit: string, reserve: string, totalVolumeETH: string, ninetyDayVolumeETH: string, firstActiveDay: number, lastActiveDay: number } | null, protocol?: { __typename: 'Protocol', id: string, totalSupply: string, totalActiveStake: string, participationRate: string, inflation: string, inflationChange: string, lptPriceEth: string, roundLength: string, currentRound: { __typename: 'Round', id: string } } | null };
export type AccountInactiveQueryVariables = Exact<{
id: Scalars['ID'];
@@ -10129,6 +10129,9 @@ export const AccountDocument = gql`
withdrawRound
delegate {
id
+ lastRewardRound {
+ id
+ }
}
}
delegate {
@@ -10136,6 +10139,9 @@ export const AccountDocument = gql`
active
status
totalStake
+ lastRewardRound {
+ id
+ }
feeShare
rewardCut
ninetyDayVolumeETH
diff --git a/components/DelegatingView/index.tsx b/components/DelegatingView/index.tsx
index b5d03d01..58162a25 100644
--- a/components/DelegatingView/index.tsx
+++ b/components/DelegatingView/index.tsx
@@ -8,6 +8,7 @@ import { checkAddressEquality, formatAddress } from "@utils/web3";
import { AccountQueryResult, OrchestratorsSortedQueryResult } from "apollo";
import {
useAccountAddress,
+ useDelegationReview,
useEnsData,
useIsSafe,
usePendingFeesAndStakeData,
@@ -21,6 +22,7 @@ import Masonry from "react-masonry-css";
import { Address } from "viem";
import { useSimulateContract, useWriteContract } from "wagmi";
+import DelegationReview from "../DelegationReview";
import StakeTransactions from "../StakeTransactions";
const breakpointColumnsObj = {
@@ -50,6 +52,12 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => {
const pendingFeesAndStake = usePendingFeesAndStakeData(delegator?.id);
+ const { delegationWarning } = useDelegationReview({
+ delegator,
+ currentRound,
+ action: "withdrawFees",
+ });
+
const recipient = delegator?.id as Address | undefined;
const amount = pendingFeesAndStake?.pendingFees ?? "0";
@@ -377,18 +385,26 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => {
{isMyAccount && !withdrawButtonDisabled && delegator?.id && (
-
+ <>
+
+ {delegationWarning && (
+
+ )}
+ >
)}
}
diff --git a/components/DelegatingWidget/Footer.tsx b/components/DelegatingWidget/Footer.tsx
index 5de1ed0e..474430a4 100644
--- a/components/DelegatingWidget/Footer.tsx
+++ b/components/DelegatingWidget/Footer.tsx
@@ -11,6 +11,7 @@ import {
StakingAction,
useAccountAddress,
useAccountBalanceData,
+ useDelegationReview,
useExpectedChainId,
usePendingFeesAndStakeData,
} from "hooks";
@@ -18,6 +19,7 @@ import { CHAIN_INFO } from "lib/chains";
import { useMemo } from "react";
import { parseEther } from "viem";
+import DelegationReview from "../DelegationReview";
import Delegate from "./Delegate";
import Footnote from "./Footnote";
import Undelegate from "./Undelegate";
@@ -75,15 +77,23 @@ const Footer = ({
);
const accountBalance = useAccountBalanceData(accountAddress);
- const tokenBalance = useMemo(() => accountBalance?.balance, [accountBalance]);
- const transferAllowance = useMemo(
- () => accountBalance?.allowance,
- [accountBalance]
- );
+ const tokenBalance = accountBalance?.balance;
+ const transferAllowance = accountBalance?.allowance;
const delegatorStatus = useMemo(
() => getDelegatorStatus(delegator, currentRound),
[currentRound, delegator]
);
+ const delegationReviewAction = isTransferStake
+ ? "moveStake"
+ : action === "delegate"
+ ? "delegate"
+ : "undelegate";
+
+ const { delegationWarning } = useDelegationReview({
+ delegator,
+ currentRound,
+ action: delegationReviewAction,
+ });
const stakeWei = useMemo(
() =>
delegatorPendingStakeAndFees?.pendingStake
@@ -201,6 +211,12 @@ const Footer = ({
currDelegateNewPosNext: currDelegateNewPosNext,
}}
/>
+ {delegationWarning && (isTransferStake || amount) && (
+
+ )}
);
}
@@ -220,6 +236,12 @@ const Footer = ({
sufficientStake,
isMyTranscoder
)}
+ {delegationWarning && amount && (
+
+ )}
);
};
diff --git a/components/DelegationReview/index.tsx b/components/DelegationReview/index.tsx
new file mode 100644
index 00000000..4a632579
--- /dev/null
+++ b/components/DelegationReview/index.tsx
@@ -0,0 +1,34 @@
+import { Box, Flex, Text } from "@livepeer/design-system";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+
+const DelegationReview = ({
+ warning,
+ css,
+}: {
+ warning?: string | null;
+ css?: object;
+}) => {
+ if (!warning) return null;
+
+ return (
+
+
+
+ {warning}
+
+
+ );
+};
+
+export default DelegationReview;
diff --git a/components/Redelegate/index.tsx b/components/Redelegate/index.tsx
index 40945c7f..1f9bf71f 100644
--- a/components/Redelegate/index.tsx
+++ b/components/Redelegate/index.tsx
@@ -1,10 +1,24 @@
+import { ExplorerTooltip } from "@components/ExplorerTooltip";
import { bondingManager } from "@lib/api/abis/main/BondingManager";
-import { Button } from "@livepeer/design-system";
+import { Button, Flex, IconButton } from "@livepeer/design-system";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { useBondingManagerAddress } from "hooks/useContracts";
+import { useDelegationReview } from "hooks/useDelegationReview";
import { useHandleTransaction } from "hooks/useHandleTransaction";
import { useSimulateContract, useWriteContract } from "wagmi";
-const Index = ({ unbondingLockId, newPosPrev, newPosNext }) => {
+const Index = ({
+ unbondingLockId,
+ newPosPrev,
+ newPosNext,
+ delegator,
+ currentRound,
+}) => {
+ const { delegationWarning } = useDelegationReview({
+ delegator,
+ currentRound,
+ action: "redelegate",
+ });
const { data: bondingManagerAddress } = useBondingManagerAddress();
const { data: config } = useSimulateContract({
@@ -23,13 +37,39 @@ const Index = ({ unbondingLockId, newPosPrev, newPosNext }) => {
});
return (
- <>
+
+ {delegationWarning && (
+
+
+
+
+
+ )}
- >
+
);
};
diff --git a/components/RedelegateFromUndelegated/index.tsx b/components/RedelegateFromUndelegated/index.tsx
index 66d2191d..0aaf2a80 100644
--- a/components/RedelegateFromUndelegated/index.tsx
+++ b/components/RedelegateFromUndelegated/index.tsx
@@ -1,11 +1,27 @@
import { bondingManager } from "@lib/api/abis/main/BondingManager";
-import { Button } from "@livepeer/design-system";
+import { Button, Flex, IconButton } from "@livepeer/design-system";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { useAccountAddress } from "hooks";
import { useBondingManagerAddress } from "hooks/useContracts";
+import { useDelegationReview } from "hooks/useDelegationReview";
import { useHandleTransaction } from "hooks/useHandleTransaction";
import { useSimulateContract, useWriteContract } from "wagmi";
-const Index = ({ unbondingLockId, delegate, newPosPrev, newPosNext }) => {
+import { ExplorerTooltip } from "../ExplorerTooltip";
+
+const Index = ({
+ unbondingLockId,
+ delegate,
+ newPosPrev,
+ newPosNext,
+ delegator,
+ currentRound,
+}) => {
+ const { delegationWarning } = useDelegationReview({
+ delegator,
+ currentRound,
+ action: "redelegateFromUndelegated",
+ });
const accountAddress = useAccountAddress();
const { data: bondingManagerAddress } = useBondingManagerAddress();
@@ -14,7 +30,7 @@ const Index = ({ unbondingLockId, delegate, newPosPrev, newPosNext }) => {
address: bondingManagerAddress,
abi: bondingManager,
functionName: "rebondFromUnbondedWithHint",
- args: [delegate, unbondingLockId, newPosPrev, newPosNext],
+ args: [delegate.id, unbondingLockId, newPosPrev, newPosNext],
});
const { data, isPending, writeContract, error, isSuccess } =
useWriteContract();
@@ -26,7 +42,7 @@ const Index = ({ unbondingLockId, delegate, newPosPrev, newPosNext }) => {
isPending,
isSuccess,
{
- delegate,
+ delegate: delegate.id,
unbondingLockId,
newPosPrev,
newPosNext,
@@ -38,13 +54,20 @@ const Index = ({ unbondingLockId, delegate, newPosPrev, newPosNext }) => {
}
return (
- <>
+
- >
+ {delegationWarning && (
+
+
+
+
+
+ )}
+
);
};
diff --git a/components/StakeTransactions/index.tsx b/components/StakeTransactions/index.tsx
index 1b4dd409..dd15eb96 100644
--- a/components/StakeTransactions/index.tsx
+++ b/components/StakeTransactions/index.tsx
@@ -109,24 +109,28 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => {
unbondingLockId={lock.unbondingLockId}
newPosPrev={newPosPrev}
newPosNext={newPosNext}
+ delegator={delegator}
+ currentRound={currentRound}
/>
) : (
)}
)}
{
unbondingLockId={lock.unbondingLockId}
newPosPrev={newPosPrev}
newPosNext={newPosNext}
+ delegator={delegator}
+ currentRound={currentRound}
/>
) : (
)}
@@ -233,11 +241,11 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => {
)}
[0];
+type Delegator = NonNullable;
+
+const currentRound = { __typename: "Round" as const, id: "101" };
+const currentDelegate: NonNullable = {
+ __typename: "Transcoder",
+ id: "0x1111111111111111111111111111111111111111",
+ active: true,
+ status: TranscoderStatus.Registered,
+ totalStake: "100",
+ feeShare: "500000",
+ rewardCut: "100000",
+ ninetyDayVolumeETH: "1",
+ lastRewardRound: { __typename: "Round", id: "100" },
+};
+
+const makeDelegator = (overrides: Partial = {}): Delegator => ({
+ __typename: "Delegator",
+ id: "0x2222222222222222222222222222222222222222",
+ bondedAmount: "10",
+ principal: "10",
+ unbonded: "0",
+ withdrawnFees: "0",
+ startRound: "90",
+ lastClaimRound: { __typename: "Round", id: "100" },
+ delegate: currentDelegate,
+ ...overrides,
+});
+
+const review = (overrides: Partial = {}) =>
+ getDelegationWarning({
+ delegator: makeDelegator(),
+ currentRound,
+ action: "withdrawFees",
+ ...overrides,
+ });
+
+describe("getDelegationWarning", () => {
+ it.each([
+ "delegate",
+ "undelegate",
+ "moveStake",
+ "redelegate",
+ "withdrawFees",
+ ])("warns about rewards and later fees before reward for %s", (action) => {
+ const warning = review({ action });
+
+ expect(warning).toContain("this round's LPT rewards");
+ expect(warning).toContain("fees earned later this round");
+ });
+
+ it.each([
+ "delegate",
+ "undelegate",
+ "moveStake",
+ "redelegate",
+ "withdrawFees",
+ ])("still warns about later fees after reward for %s", (action) => {
+ const warning = review({
+ action,
+ delegator: makeDelegator({
+ delegate: { ...currentDelegate, lastRewardRound: currentRound },
+ }),
+ });
+
+ expect(warning).toContain("fees earned later this round");
+ expect(warning).not.toContain("LPT rewards");
+ });
+
+ it("checks the source orchestrator's reward status when moving stake", () => {
+ expect(review({ action: "moveStake" })).toBe(
+ "Moving stake to a different orchestrator before your current orchestrator calls reward will forfeit this round's LPT rewards on your existing stake. You may also forfeit fees earned later this round."
+ );
+ });
+
+ it("does not warn about rewards when the source has already rewarded", () => {
+ expect(
+ review({
+ action: "moveStake",
+ delegator: makeDelegator({
+ delegate: { ...currentDelegate, lastRewardRound: currentRound },
+ }),
+ })
+ ).toBe(
+ "Moving stake to a different orchestrator may forfeit fees earned later this round on your existing stake."
+ );
+ });
+
+ it("explains that rebonding affects the entire existing stake", () => {
+ expect(review({ action: "redelegate" })).toContain(
+ "your entire existing stake"
+ );
+ });
+
+ it.each([
+ "delegate",
+ "redelegateFromUndelegated",
+ "withdrawFees",
+ ])("does not warn for fully unbonded stake during %s", (action) => {
+ expect(
+ review({
+ action,
+ delegator: makeDelegator({
+ bondedAmount: "0",
+ startRound: "0",
+ delegate: null,
+ }),
+ })
+ ).toBeNull();
+ });
+
+ it("does not warn about pending stake that starts earning next round", () => {
+ expect(
+ review({
+ action: "delegate",
+ delegator: makeDelegator({ startRound: "102" }),
+ })
+ ).toBeNull();
+ });
+
+ it("warns once pending stake starts earning in the current round", () => {
+ expect(
+ review({ delegator: makeDelegator({ startRound: "101" }) })
+ ).toContain("LPT rewards");
+ });
+
+ it("does not warn when earnings have already been claimed this round", () => {
+ expect(
+ review({
+ delegator: makeDelegator({ lastClaimRound: currentRound }),
+ })
+ ).toBeNull();
+ });
+
+ it("does not warn for an inactive orchestrator", () => {
+ expect(
+ review({
+ delegator: makeDelegator({
+ delegate: { ...currentDelegate, active: false },
+ }),
+ })
+ ).toBeNull();
+ });
+
+ it("treats a missing lastRewardRound as not yet rewarded", () => {
+ expect(
+ review({
+ delegator: makeDelegator({
+ delegate: { ...currentDelegate, lastRewardRound: null },
+ }),
+ })
+ ).toContain("LPT rewards");
+ });
+
+ it("handles a delegator with no prior earnings claim", () => {
+ expect(
+ review({ delegator: makeDelegator({ lastClaimRound: null }) })
+ ).toContain("LPT rewards");
+ });
+
+ it("does not warn before delegator data has loaded", () => {
+ expect(review({ delegator: undefined })).toBeNull();
+ });
+
+ it("does not warn before the current round has loaded", () => {
+ expect(review({ currentRound: undefined })).toBeNull();
+ });
+});
diff --git a/hooks/useDelegationReview.tsx b/hooks/useDelegationReview.tsx
new file mode 100644
index 00000000..5c17afcc
--- /dev/null
+++ b/hooks/useDelegationReview.tsx
@@ -0,0 +1,98 @@
+import { AccountQueryResult } from "apollo";
+import { useMemo } from "react";
+
+type Delegator = NonNullable["delegator"];
+type CurrentRound = NonNullable<
+ NonNullable["protocol"]
+>["currentRound"];
+
+type DelegationAction =
+ | "delegate"
+ | "undelegate"
+ | "moveStake"
+ | "redelegate"
+ | "redelegateFromUndelegated"
+ | "withdrawFees";
+
+type DelegationReviewParams = {
+ delegator?: Delegator | null;
+ currentRound?: CurrentRound | null;
+ action: DelegationAction;
+};
+
+export const getDelegationWarning = ({
+ delegator,
+ currentRound,
+ action,
+}: DelegationReviewParams) => {
+ if (!delegator || !currentRound) {
+ return null;
+ }
+
+ const currentRoundNum = Number(currentRound.id);
+ const startRound = Number(delegator.startRound);
+ const lastClaimRound = Number(delegator.lastClaimRound?.id ?? 0);
+
+ // Earnings are claimed against the current delegate before stake is moved.
+ const orchestratorToCheck = delegator.delegate;
+ // New or fully unbonded stake starts earning next round. A claim that already
+ // covered this round also means another action cannot forfeit more earnings.
+ const hasStakeAtRisk =
+ action !== "redelegateFromUndelegated" &&
+ Number(delegator.bondedAmount) > 0 &&
+ orchestratorToCheck?.active &&
+ startRound > 0 &&
+ startRound <= currentRoundNum &&
+ lastClaimRound < currentRoundNum;
+
+ if (!hasStakeAtRisk) {
+ return null;
+ }
+
+ const orchestratorLastRewardRoundId =
+ orchestratorToCheck?.lastRewardRound?.id;
+ const orchestratorLastRewardRound = orchestratorLastRewardRoundId
+ ? parseInt(orchestratorLastRewardRoundId, 10)
+ : 0;
+ // Per LIP-36, reward eligibility depends on reward(), while fees redeemed
+ // later in the round can still be forfeited after reward() has been called.
+ const orchestratorHasntCalledReward =
+ orchestratorLastRewardRound < currentRoundNum;
+
+ const actionDescription =
+ action === "redelegate"
+ ? "Rebonding"
+ : action === "moveStake"
+ ? "Moving stake to a different orchestrator"
+ : "Performing this action";
+ const stakeDescription =
+ action === "redelegate"
+ ? "your entire existing stake"
+ : "your existing stake";
+
+ if (orchestratorHasntCalledReward) {
+ return `${actionDescription} before your current orchestrator calls reward will forfeit this round's LPT rewards on ${stakeDescription}. You may also forfeit fees earned later this round.`;
+ }
+
+ return `${actionDescription} may forfeit fees earned later this round on ${stakeDescription}.`;
+};
+
+export const useDelegationReview = ({
+ delegator,
+ currentRound,
+ action,
+}: DelegationReviewParams) => {
+ const delegationWarning = useMemo(
+ () =>
+ getDelegationWarning({
+ delegator,
+ currentRound,
+ action,
+ }),
+ [delegator, currentRound, action]
+ );
+
+ return {
+ delegationWarning,
+ };
+};
diff --git a/queries/account.graphql b/queries/account.graphql
index 29879485..31aa416c 100644
--- a/queries/account.graphql
+++ b/queries/account.graphql
@@ -16,6 +16,9 @@ query account($account: ID!) {
withdrawRound
delegate {
id
+ lastRewardRound {
+ id
+ }
}
}
delegate {
@@ -23,6 +26,9 @@ query account($account: ID!) {
active
status
totalStake
+ lastRewardRound {
+ id
+ }
feeShare
rewardCut
ninetyDayVolumeETH