Skip to content

Commit 794ba72

Browse files
Merge pull request #2080 from valory-xyz/fix/ope-1846-no-staking-rewards-warning
feat(staking): warn on empty reward-pool contracts [OPE-1846]
2 parents 338a294 + e1b760a commit 794ba72

11 files changed

Lines changed: 542 additions & 320 deletions

File tree

.supply-chain/install-hooks.allowlist

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,3 @@ classic-level # node-gyp-build for the LevelDB C++ binding (hardhat>@nomicfound
1515
electron # postinstall downloads the Electron binary tarball for the current platform (direct devDep — required for `yarn dev` and electron-builder)
1616
keccak # node-gyp-build for the Keccak hash native binding (ethereum-cryptography>keccak + hardhat>keccak — used by ethers v5 for blockchain hashing; install falls back to JS via `|| exit 0` if compile fails)
1717
secp256k1 # node-gyp-build for the secp256k1 ECC signing binding (ethereum-cryptography>secp256k1 — used by ethers for transaction signing; install falls back to JS via `|| exit 0` if compile fails)
18-
sharp # libvips-backed image optimizer that Next.js 15 ships as a direct dep for next/image; install/check.js looks for a prebuilt binary for the current platform/libc and falls back to building from source if missing — no network beyond the prebuild fetch

frontend/.supply-chain/install-hooks.allowlist

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,4 @@
1111
# "package # ?" is a TODO, not justification.
1212

1313
@parcel/watcher # native fs-watcher prebuild fallback (sass>chokidar transitive); compiles a small N-API binding from source if no prebuilt is shipped for the platform
14-
sharp # libvips-backed image optimizer that Next.js 15 ships as a direct dep for next/image; install/check.js looks for a prebuilt binary for the current platform/libc and falls back to building from source if missing — no network beyond the prebuild fetch
1514
unrs-resolver # napi-postinstall stub that prints a platform-mismatch warning if the prebuilt binary's triple doesn't match the host (eslint-config-next>eslint-import-resolver-typescript>unrs-resolver — runs once at install, no network or file mutation beyond stdout)

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

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@
1111
"form-data": "4.0.6",
1212
"glob": "10.5.0",
1313
"handlebars": "4.7.9",
14-
"immutable": "5.1.5",
14+
"immutable": "5.1.9",
1515
"ip-address": "10.1.1",
1616
"js-yaml": "4.3.0",
1717
"lodash": "4.18.1",
1818
"lodash-es": "4.18.1",
1919
"minimatch": "9.0.9",
2020
"picomatch": "4.0.4",
2121
"postcss": "8.5.10",
22+
"sharp": "0.35.3",
2223
"tar": "7.5.16",
2324
"ws": "8.21.0"
2425
},
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)