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