Skip to content

Commit 35b8e45

Browse files
feat(staking): warn on empty reward-pool contracts [OPE-1846]
Surface a non-blocking warning when a staking contract's reward pool is empty (availableRewards === 0). Previously the app computed isRewardsAvailable but never used it: users could select/keep a rewards-exhausted contract with no signal, and the middleware would silently deploy the service unstaked (stake omitted, deploy proceeds). - New shared NoStakingRewardsAlert (type="warning"), optional "Switch Staking Contract" action. - Migration/onboarding: per-card warning in SelectActivityRewardsConfiguration when the contract has no rewards; card stays selectable (useCanMigrate unchanged — non-blocking). - Main page: warning added to AgentDisabledAlert, rendered alongside the self-hiding low-balance alerts so neither is masked. Gating reads loaded contract details (availableRewards === 0), not raw isRewardsAvailable, to avoid flashing during load / firing on no_staking. Tests: NoStakingRewardsAlert render + switch button; StakingRewardsWarning load/available/empty gating. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 338a294 commit 35b8e45

5 files changed

Lines changed: 188 additions & 1 deletion

File tree

frontend/components/MainPage/Home/Overview/AgentInfo/AgentDisabledAlert/index.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ReactNode, useMemo } from 'react';
22

33
import { AgentLowBalanceAlert } from '@/components/AgentLowBalanceAlert';
44
import { STEPS } from '@/components/AgentWallet/types';
5+
import { NoStakingRewardsAlert } from '@/components/NoStakingRewardsAlert';
56
import {
67
AgentSetupCompleteModal,
78
ContentTransition,
@@ -42,6 +43,7 @@ export const AgentDisabledAlert = () => {
4243
isEligibleForStaking,
4344
hasEnoughServiceSlots,
4445
isServiceStaked,
46+
selectedStakingContractDetails,
4547
} = useActiveStakingContractDetails();
4648
const { isInitialFunded } = useIsInitiallyFunded();
4749
const { isAnotherAgentRunning } = useAgentRunning();
@@ -122,11 +124,24 @@ export const AgentDisabledAlert = () => {
122124
return { key: 'evicted', content: <EvictedAlert /> };
123125
}
124126

127+
// Non-blocking: the reward pool of the selected contract is empty, so the
128+
// agent runs but earns nothing until it's refilled (OPE-1846). Rendered
129+
// alongside the low-balance alerts (which self-hide) so neither is masked.
130+
const hasNoStakingRewards =
131+
!isSelectedStakingContractDetailsLoading &&
132+
selectedStakingContractDetails?.availableRewards === 0;
133+
125134
// NOTE: Low-balance alerts, each component controls its own visibility.
126135
return {
127136
key: 'low-balance',
128137
content: (
129138
<>
139+
{hasNoStakingRewards && (
140+
<NoStakingRewardsAlert
141+
className="mt-16"
142+
onSwitch={() => goto(PAGES.SelectStaking)}
143+
/>
144+
)}
130145
<AgentLowBalanceAlert
131146
onFund={() =>
132147
goto(PAGES.AgentWallet, {
@@ -153,6 +168,7 @@ export const AgentDisabledAlert = () => {
153168
isSelectedStakingContractDetailsLoading,
154169
isServiceStaked,
155170
selectedAgentConfig,
171+
selectedStakingContractDetails,
156172
selectedStakingProgramMeta,
157173
]);
158174

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Button, Typography } from 'antd';
2+
3+
import { Alert } from '@/components/ui';
4+
5+
const { Text } = Typography;
6+
7+
type NoStakingRewardsAlertProps = {
8+
/**
9+
* When provided, renders a "Switch Staking Contract" button that invokes it.
10+
* Omit on surfaces where the user is already choosing a contract.
11+
*/
12+
onSwitch?: () => void;
13+
className?: string;
14+
};
15+
16+
/**
17+
* Non-blocking warning shown when a staking contract's reward pool is empty
18+
* (`availableRewards === 0`). The agent can still run and be staked, but it
19+
* won't earn staking rewards until the contract is refilled (OPE-1846).
20+
*/
21+
export const NoStakingRewardsAlert = ({
22+
onSwitch,
23+
className,
24+
}: NoStakingRewardsAlertProps) => (
25+
<Alert
26+
showIcon
27+
type="warning"
28+
className={className}
29+
message={
30+
<>
31+
<Text className="text-sm font-weight-500">
32+
No staking rewards available
33+
</Text>
34+
<Text className={`text-sm flex mt-4 ${onSwitch ? 'mb-8' : ''}`}>
35+
This staking contract&apos;s reward pool is currently empty. Your
36+
agent can still run, but it won&apos;t earn staking rewards until the
37+
contract is refilled.
38+
</Text>
39+
{onSwitch && (
40+
<Button size="small" onClick={onSwitch}>
41+
Switch Staking Contract
42+
</Button>
43+
)}
44+
</>
45+
}
46+
/>
47+
);

frontend/components/SelectStakingPage/components/SelectActivityRewardsConfiguration.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ import { Button as AntdButton, Flex, Typography } from 'antd';
33
import { ReactNode, useCallback, useEffect, useState } from 'react';
44
import styled from 'styled-components';
55

6+
import { NoStakingRewardsAlert } from '@/components/NoStakingRewardsAlert';
67
import { StakingContractCard } from '@/components/StakingContractCard';
78
import { MainContentContainer } from '@/components/ui';
89
import { PAGES, StakingProgramId } from '@/constants';
9-
import { usePageState, useStakingContracts, useStakingProgram } from '@/hooks';
10+
import {
11+
usePageState,
12+
useStakingContractDetails,
13+
useStakingContracts,
14+
useStakingProgram,
15+
} from '@/hooks';
1016
import { Nullable } from '@/types';
1117

1218
import { MigrateButtonText, useCanMigrate } from '../hooks/useCanMigrate';
@@ -84,6 +90,30 @@ const SwitchStakingButton = ({
8490
);
8591
};
8692

93+
/**
94+
* Non-blocking warning surfaced on a contract card whose reward pool is empty.
95+
* The contract stays selectable (`useCanMigrate` is unchanged) — this only
96+
* tells the user the agent won't earn rewards there until it's refilled.
97+
*/
98+
export const StakingRewardsWarning = ({
99+
stakingProgramId,
100+
}: {
101+
stakingProgramId: StakingProgramId;
102+
}) => {
103+
const { stakingContractInfo, isRewardsAvailable } =
104+
useStakingContractDetails(stakingProgramId);
105+
106+
// Only warn once details have loaded — `isRewardsAvailable` is false while
107+
// undefined, which would otherwise flash the warning during loading.
108+
if (!stakingContractInfo || isRewardsAvailable) return null;
109+
110+
return (
111+
<Flex className="px-24 mt-16">
112+
<NoStakingRewardsAlert className="w-full" />
113+
</Flex>
114+
);
115+
};
116+
87117
type SelectActivityRewardsConfigurationProps = {
88118
mode: SelectMode;
89119
backButton?: ReactNode;
@@ -145,6 +175,7 @@ export const SelectActivityRewardsConfiguration = ({
145175
stakingProgramId={stakingProgramId}
146176
renderAction={() => (
147177
<>
178+
<StakingRewardsWarning stakingProgramId={stakingProgramId} />
148179
{mode === 'onboard' && (
149180
<Flex className="px-24 pb-24 mt-40" gap={16}>
150181
<SelectStakingButton
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
3+
import { NoStakingRewardsAlert } from '@/components/NoStakingRewardsAlert';
4+
5+
describe('NoStakingRewardsAlert', () => {
6+
it('renders the empty-reward-pool warning message', () => {
7+
render(<NoStakingRewardsAlert />);
8+
expect(
9+
screen.getByText('No staking rewards available'),
10+
).toBeInTheDocument();
11+
expect(
12+
screen.getByText(/reward pool is currently empty/),
13+
).toBeInTheDocument();
14+
});
15+
16+
it('does not render a switch button when onSwitch is omitted', () => {
17+
render(<NoStakingRewardsAlert />);
18+
expect(
19+
screen.queryByRole('button', { name: 'Switch Staking Contract' }),
20+
).not.toBeInTheDocument();
21+
});
22+
23+
it('renders and fires the switch button when onSwitch is provided', () => {
24+
const onSwitch = jest.fn();
25+
render(<NoStakingRewardsAlert onSwitch={onSwitch} />);
26+
27+
fireEvent.click(
28+
screen.getByRole('button', { name: 'Switch Staking Contract' }),
29+
);
30+
expect(onSwitch).toHaveBeenCalledTimes(1);
31+
});
32+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { render, screen } from '@testing-library/react';
2+
3+
import { StakingRewardsWarning } from '../../../components/SelectStakingPage/components/SelectActivityRewardsConfiguration';
4+
import { StakingProgramId } from '../../../constants';
5+
import { useStakingContractDetails } from '../../../hooks';
6+
import { makeStakingContractDetails } from '../../helpers/factories';
7+
8+
jest.mock('../../../hooks', () => ({
9+
useStakingContractDetails: jest.fn(),
10+
}));
11+
12+
// Isolate the gating logic from the alert's markup.
13+
jest.mock('../../../components/NoStakingRewardsAlert', () => ({
14+
NoStakingRewardsAlert: () => <div data-testid="no-rewards-alert" />,
15+
}));
16+
17+
const mockUseStakingContractDetails =
18+
useStakingContractDetails as jest.MockedFunction<
19+
typeof useStakingContractDetails
20+
>;
21+
22+
const programId = 'pearl_beta_mech_marketplace_1' as StakingProgramId;
23+
24+
const setup = (
25+
value: Partial<ReturnType<typeof useStakingContractDetails>>,
26+
) => {
27+
mockUseStakingContractDetails.mockReturnValue({
28+
stakingContractInfo: undefined,
29+
isRewardsAvailable: false,
30+
hasEnoughServiceSlots: false,
31+
hasEnoughRewardsAndSlots: false,
32+
...value,
33+
});
34+
return render(<StakingRewardsWarning stakingProgramId={programId} />);
35+
};
36+
37+
describe('StakingRewardsWarning', () => {
38+
beforeEach(() => jest.clearAllMocks());
39+
40+
it('renders nothing while contract details are still loading', () => {
41+
// stakingContractInfo undefined => not loaded; must not flash the warning
42+
setup({ stakingContractInfo: undefined, isRewardsAvailable: false });
43+
expect(screen.queryByTestId('no-rewards-alert')).not.toBeInTheDocument();
44+
});
45+
46+
it('renders nothing when rewards are available', () => {
47+
setup({
48+
stakingContractInfo: makeStakingContractDetails({ availableRewards: 10 }),
49+
isRewardsAvailable: true,
50+
});
51+
expect(screen.queryByTestId('no-rewards-alert')).not.toBeInTheDocument();
52+
});
53+
54+
it('renders the warning when the reward pool is empty', () => {
55+
setup({
56+
stakingContractInfo: makeStakingContractDetails({ availableRewards: 0 }),
57+
isRewardsAvailable: false,
58+
});
59+
expect(screen.getByTestId('no-rewards-alert')).toBeInTheDocument();
60+
});
61+
});

0 commit comments

Comments
 (0)