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) {