diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts
index 40973d73..355dcff2 100644
--- a/apollo/subgraph.ts
+++ b/apollo/subgraph.ts
@@ -9603,7 +9603,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 } | 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, lastRewardRound?: { __typename: 'Round', id: string } | 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'];
@@ -9773,6 +9773,9 @@ export const AccountDocument = gql`
withdrawRound
delegate {
id
+ lastRewardRound {
+ id
+ }
}
}
delegate {
@@ -9780,6 +9783,9 @@ export const AccountDocument = gql`
active
status
totalStake
+ lastRewardRound {
+ id
+ }
}
}
transcoder(id: $account) {
diff --git a/components/DelegatingView/index.tsx b/components/DelegatingView/index.tsx
index 0f32a1b9..9d72b55c 100644
--- a/components/DelegatingView/index.tsx
+++ b/components/DelegatingView/index.tsx
@@ -7,6 +7,7 @@ import { QuestionMarkCircledIcon } from "@modulz/radix-icons";
import { AccountQueryResult, OrchestratorsSortedQueryResult } from "apollo";
import {
useAccountAddress,
+ useDelegationReview,
useEnsData,
usePendingFeesAndStakeData,
} from "hooks";
@@ -20,6 +21,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 = {
@@ -49,6 +51,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";
@@ -357,18 +365,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 7b542a4d..2d2da11e 100644
--- a/components/DelegatingWidget/Footer.tsx
+++ b/components/DelegatingWidget/Footer.tsx
@@ -11,11 +11,13 @@ import {
StakingAction,
useAccountAddress,
useAccountBalanceData,
+ useDelegationReview,
usePendingFeesAndStakeData,
} from "hooks";
import { useMemo } from "react";
import { parseEther } from "viem";
+import DelegationReview from "../DelegationReview";
import Delegate from "./Delegate";
import Footnote from "./Footnote";
import Undelegate from "./Undelegate";
@@ -69,15 +71,25 @@ 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,
+ targetOrchestrator:
+ delegationReviewAction === "undelegate" ? undefined : transcoder,
+ });
const stakeWei = useMemo(
() =>
delegatorPendingStakeAndFees?.pendingStake
@@ -175,6 +187,12 @@ const Footer = ({
currDelegateNewPosNext: currDelegateNewPosNext,
}}
/>
+ {delegationWarning && (isTransferStake || amount) && (
+
+ )}
);
}
@@ -194,6 +212,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..a64097e3 100644
--- a/components/RedelegateFromUndelegated/index.tsx
+++ b/components/RedelegateFromUndelegated/index.tsx
@@ -1,11 +1,28 @@
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",
+ targetOrchestrator: delegate,
+ });
const accountAddress = useAccountAddress();
const { data: bondingManagerAddress } = useBondingManagerAddress();
@@ -14,7 +31,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 +43,7 @@ const Index = ({ unbondingLockId, delegate, newPosPrev, newPosNext }) => {
isPending,
isSuccess,
{
- delegate,
+ delegate: delegate.id,
unbondingLockId,
newPosPrev,
newPosNext,
@@ -38,13 +55,20 @@ const Index = ({ unbondingLockId, delegate, newPosPrev, newPosNext }) => {
}
return (
- <>
+
- >
+ {delegationWarning && (
+
+
+
+
+
+ )}
+
);
};
diff --git a/components/StakeTransactions/index.tsx b/components/StakeTransactions/index.tsx
index 85661fca..784ade39 100644
--- a/components/StakeTransactions/index.tsx
+++ b/components/StakeTransactions/index.tsx
@@ -111,24 +111,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}
/>
) : (
)}
@@ -235,11 +243,11 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => {
)}
{
+ it("warns for rebonding from an unbonded lock when that delegate has not called reward", () => {
+ const warning = getDelegationWarning({
+ delegator: {
+ bondedAmount: "0",
+ delegate: null,
+ } as never,
+ currentRound: { id: "101" } as never,
+ action: "redelegateFromUndelegated",
+ targetOrchestrator: {
+ lastRewardRound: { id: "100" },
+ },
+ });
+
+ expect(warning).toBe(
+ "Moving stake to a different orchestrator will forfeit rewards and fees for the current round."
+ );
+ });
+
+ it("does not warn once the unbonding-lock delegate has already called reward this round", () => {
+ const warning = getDelegationWarning({
+ delegator: {
+ bondedAmount: "0",
+ delegate: null,
+ } as never,
+ currentRound: { id: "101" } as never,
+ action: "redelegateFromUndelegated",
+ targetOrchestrator: {
+ lastRewardRound: { id: "101" },
+ },
+ });
+
+ expect(warning).toBeNull();
+ });
+
+ it("treats a missing lastRewardRound as not yet rewarded", () => {
+ const warning = getDelegationWarning({
+ delegator: {
+ bondedAmount: "1",
+ delegate: {},
+ } as never,
+ currentRound: { id: "101" } as never,
+ action: "withdrawFees",
+ });
+
+ expect(warning).toBe(
+ "Performing this action before your orchestrator calls reward will forfeit rewards and fees for the current round."
+ );
+ });
+});
diff --git a/hooks/useDelegationReview.tsx b/hooks/useDelegationReview.tsx
new file mode 100644
index 00000000..f5321a95
--- /dev/null
+++ b/hooks/useDelegationReview.tsx
@@ -0,0 +1,94 @@
+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 ReviewOrchestrator = { lastRewardRound?: { id: string } | null } | null;
+
+type DelegationReviewParams = {
+ delegator?: Delegator | null;
+ currentRound?: CurrentRound | null;
+ action: DelegationAction;
+ targetOrchestrator?: ReviewOrchestrator;
+};
+
+export const getDelegationWarning = ({
+ delegator,
+ currentRound,
+ action,
+ targetOrchestrator,
+}: DelegationReviewParams) => {
+ if (!delegator || !currentRound) {
+ return null;
+ }
+
+ const isDelegated = delegator.bondedAmount && delegator.bondedAmount !== "0";
+ const hasStakeAtRisk =
+ action === "redelegateFromUndelegated"
+ ? Boolean(targetOrchestrator)
+ : Boolean(isDelegated);
+
+ // Use an explicit orchestrator when the action concerns stake outside the
+ // account's current delegate, such as rebonding from an unbonded lock.
+ const orchestratorToCheck = targetOrchestrator || delegator.delegate;
+ const orchestratorLastRewardRoundId =
+ orchestratorToCheck?.lastRewardRound?.id;
+ const orchestratorLastRewardRound = orchestratorLastRewardRoundId
+ ? parseInt(orchestratorLastRewardRoundId, 10)
+ : 0;
+ const currentRoundNum = currentRound.id ? parseInt(currentRound.id, 10) : 0;
+
+ // Per LIP-36: Warn if the relevant orchestrator hasn't called reward() yet
+ // this round and the action can still affect stake that was delegated to it.
+ const orchestratorHasntCalledReward =
+ hasStakeAtRisk &&
+ Boolean(orchestratorToCheck) &&
+ orchestratorLastRewardRound < currentRoundNum;
+
+ if (!orchestratorHasntCalledReward) {
+ return null;
+ }
+
+ switch (action) {
+ case "redelegate":
+ return "Rebonding will forfeit rewards and fees for the current round on your entire stake.";
+ case "moveStake":
+ case "redelegateFromUndelegated":
+ return "Moving stake to a different orchestrator will forfeit rewards and fees for the current round.";
+ default:
+ return "Performing this action before your orchestrator calls reward will forfeit rewards and fees for the current round.";
+ }
+};
+
+export const useDelegationReview = ({
+ delegator,
+ currentRound,
+ action,
+ targetOrchestrator,
+}: DelegationReviewParams) => {
+ const delegationWarning = useMemo(
+ () =>
+ getDelegationWarning({
+ delegator,
+ currentRound,
+ action,
+ targetOrchestrator,
+ }),
+ [delegator, currentRound, action, targetOrchestrator]
+ );
+
+ return {
+ delegationWarning,
+ };
+};
diff --git a/queries/account.graphql b/queries/account.graphql
index 010b70fb..1c85a1f3 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
+ }
}
}
transcoder(id: $account) {