diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts
index 2a53b8fc..6fa7dbd7 100644
--- a/apollo/subgraph.ts
+++ b/apollo/subgraph.ts
@@ -9569,7 +9569,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, 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 } }> | 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, 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'];
@@ -9730,6 +9730,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..dd281e5a 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,22 @@ 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 { delegationWarning } = useDelegationReview({
+ delegator,
+ currentRound,
+ action: isTransferStake
+ ? "moveStake"
+ : action === "delegate"
+ ? "delegate"
+ : "undelegate",
+ targetOrchestrator: action === "delegate" ? transcoder : undefined,
+ });
const stakeWei = useMemo(
() =>
delegatorPendingStakeAndFees?.pendingStake
@@ -175,6 +184,12 @@ const Footer = ({
currDelegateNewPosNext: currDelegateNewPosNext,
}}
/>
+ {delegationWarning && (isTransferStake || amount) && (
+
+ )}
);
}
@@ -194,6 +209,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..e63723bb 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 { Box, Button, Flex } 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,32 @@ const Index = ({ unbondingLockId, newPosPrev, newPosNext }) => {
});
return (
- <>
+
+ {delegationWarning && (
+
+
+
+
+
+ )}
- >
+
);
};
diff --git a/components/RedelegateFromUndelegated/index.tsx b/components/RedelegateFromUndelegated/index.tsx
index 66d2191d..cbf09bb2 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 { Box, Button, Flex } 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();
@@ -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 85661fca..1d737c7c 100644
--- a/components/StakeTransactions/index.tsx
+++ b/components/StakeTransactions/index.tsx
@@ -111,6 +111,8 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => {
unbondingLockId={lock.unbondingLockId}
newPosPrev={newPosPrev}
newPosNext={newPosNext}
+ delegator={delegator}
+ currentRound={currentRound}
/>
) : (
{
delegate={lock.delegate.id}
newPosPrev={newPosPrev}
newPosNext={newPosNext}
+ delegator={delegator}
+ currentRound={currentRound}
/>
)}
)}
{
unbondingLockId={lock.unbondingLockId}
newPosPrev={newPosPrev}
newPosNext={newPosNext}
+ delegator={delegator}
+ currentRound={currentRound}
/>
) : (
{
delegate={lock.delegate.id}
newPosPrev={newPosPrev}
newPosNext={newPosNext}
+ delegator={delegator}
+ currentRound={currentRound}
/>
)}
@@ -235,11 +243,11 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => {
)}
["delegator"];
+type CurrentRound = NonNullable<
+ NonNullable["protocol"]
+>["currentRound"];
+
+type DelegationAction =
+ | "delegate"
+ | "undelegate"
+ | "moveStake"
+ | "redelegate"
+ | "redelegateFromUndelegated"
+ | "withdrawFees";
+
+export const useDelegationReview = ({
+ delegator,
+ currentRound,
+ action,
+ targetOrchestrator,
+}: {
+ delegator?: Delegator | null;
+ currentRound?: CurrentRound | null;
+ action: DelegationAction;
+ targetOrchestrator?: { lastRewardRound?: { id: string } | null } | null;
+}) => {
+ const delegationWarning = useMemo(() => {
+ // Safety check
+ if (!delegator || !currentRound) {
+ return null;
+ }
+
+ const isDelegated =
+ delegator.bondedAmount && delegator.bondedAmount !== "0";
+
+ // Get orchestrator's last reward round
+ // Use targetOrchestrator if provided (for moving stake), otherwise use current delegate
+ const orchestratorToCheck = targetOrchestrator || delegator.delegate;
+ const orchestratorLastRewardRound = orchestratorToCheck?.lastRewardRound?.id
+ ? parseInt(orchestratorToCheck.lastRewardRound.id, 10)
+ : 0;
+
+ const currentRoundNum = currentRound.id ? parseInt(currentRound.id, 10) : 0;
+
+ // Per LIP-36: Warn if orchestrator hasn't called reward() yet this round
+ // This affects bond(), unbond(), rebond(), rebondFromUnbonded(), and withdrawFees()
+ // Only warn if we have valid delegate data to avoid false positives
+ const orchestratorHasntCalledReward =
+ isDelegated &&
+ orchestratorToCheck?.lastRewardRound?.id &&
+ orchestratorLastRewardRound < currentRoundNum;
+
+ if (!orchestratorHasntCalledReward) {
+ return null;
+ }
+
+ // Action-specific warning messages
+ 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.";
+ }
+ }, [delegator, currentRound, action, targetOrchestrator]);
+
+ return {
+ delegationWarning,
+ };
+};
diff --git a/queries/account.graphql b/queries/account.graphql
index 39104fd2..311950fc 100644
--- a/queries/account.graphql
+++ b/queries/account.graphql
@@ -23,6 +23,9 @@ query account($account: ID!) {
active
status
totalStake
+ lastRewardRound {
+ id
+ }
}
}
transcoder(id: $account) {