Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .supply-chain/install-hooks.allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,3 @@ classic-level # node-gyp-build for the LevelDB C++ binding (hardhat>@nomicfound
electron # postinstall downloads the Electron binary tarball for the current platform (direct devDep — required for `yarn dev` and electron-builder)
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)
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)
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
1 change: 0 additions & 1 deletion frontend/.supply-chain/install-hooks.allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,4 @@
# "package # ?" is a TODO, not justification.

@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
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
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)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ReactNode, useMemo } from 'react';

import { AgentLowBalanceAlert } from '@/components/AgentLowBalanceAlert';
import { STEPS } from '@/components/AgentWallet/types';
import { NoStakingRewardsAlert } from '@/components/NoStakingRewardsAlert';
import {
AgentSetupCompleteModal,
ContentTransition,
Expand Down Expand Up @@ -42,6 +43,7 @@ export const AgentDisabledAlert = () => {
isEligibleForStaking,
hasEnoughServiceSlots,
isServiceStaked,
selectedStakingContractDetails,
} = useActiveStakingContractDetails();
const { isInitialFunded } = useIsInitiallyFunded();
const { isAnotherAgentRunning } = useAgentRunning();
Expand Down Expand Up @@ -122,11 +124,24 @@ export const AgentDisabledAlert = () => {
return { key: 'evicted', content: <EvictedAlert /> };
}

// Non-blocking: the reward pool of the selected contract is empty, so the
// agent runs but earns nothing until it's refilled (OPE-1846). Rendered
// alongside the low-balance alerts (which self-hide) so neither is masked.
const hasNoStakingRewards =
!isSelectedStakingContractDetailsLoading &&
selectedStakingContractDetails?.availableRewards === 0;

// NOTE: Low-balance alerts, each component controls its own visibility.
return {
key: 'low-balance',
content: (
<>
{hasNoStakingRewards && (
<NoStakingRewardsAlert
className="mt-16"
onSwitch={() => goto(PAGES.SelectStaking)}
/>
)}
<AgentLowBalanceAlert
onFund={() =>
goto(PAGES.AgentWallet, {
Expand All @@ -153,6 +168,7 @@ export const AgentDisabledAlert = () => {
isSelectedStakingContractDetailsLoading,
isServiceStaked,
selectedAgentConfig,
selectedStakingContractDetails,
selectedStakingProgramMeta,
]);

Expand Down
47 changes: 47 additions & 0 deletions frontend/components/NoStakingRewardsAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Button, Typography } from 'antd';

import { Alert } from '@/components/ui';

const { Text } = Typography;

type NoStakingRewardsAlertProps = {
/**
* When provided, renders a "Switch Staking Contract" button that invokes it.
* Omit on surfaces where the user is already choosing a contract.
*/
onSwitch?: () => void;
className?: string;
};

/**
* Non-blocking warning shown when a staking contract's reward pool is empty
* (`availableRewards === 0`). The agent can still run and be staked, but it
* won't earn staking rewards until the contract is refilled (OPE-1846).
*/
export const NoStakingRewardsAlert = ({
onSwitch,
className,
}: NoStakingRewardsAlertProps) => (
<Alert
showIcon
type="warning"
className={className}
message={
<>
<Text className="text-sm font-weight-500">
No staking rewards available
</Text>
<Text className={`text-sm flex mt-4 ${onSwitch ? 'mb-8' : ''}`}>
This staking contract&apos;s reward pool is currently empty. Your
agent can still run, but it won&apos;t earn staking rewards until the
contract is refilled.
</Text>
{onSwitch && (
<Button size="small" onClick={onSwitch}>
Switch Staking Contract
</Button>
)}
</>
}
/>
);
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ import { Button as AntdButton, Flex, Typography } from 'antd';
import { ReactNode, useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';

import { NoStakingRewardsAlert } from '@/components/NoStakingRewardsAlert';
import { StakingContractCard } from '@/components/StakingContractCard';
import { MainContentContainer } from '@/components/ui';
import { PAGES, StakingProgramId } from '@/constants';
import { usePageState, useStakingContracts, useStakingProgram } from '@/hooks';
import {
usePageState,
useStakingContractDetails,
useStakingContracts,
useStakingProgram,
} from '@/hooks';
import { Nullable } from '@/types';

import { MigrateButtonText, useCanMigrate } from '../hooks/useCanMigrate';
Expand Down Expand Up @@ -84,6 +90,30 @@ const SwitchStakingButton = ({
);
};

/**
* Non-blocking warning surfaced on a contract card whose reward pool is empty.
* The contract stays selectable (`useCanMigrate` is unchanged) — this only
* tells the user the agent won't earn rewards there until it's refilled.
*/
export const StakingRewardsWarning = ({
stakingProgramId,
}: {
stakingProgramId: StakingProgramId;
}) => {
const { stakingContractInfo, isRewardsAvailable } =
useStakingContractDetails(stakingProgramId);

// Only warn once details have loaded — `isRewardsAvailable` is false while
// undefined, which would otherwise flash the warning during loading.
if (!stakingContractInfo || isRewardsAvailable) return null;

return (
<Flex className="px-24 mt-16">
<NoStakingRewardsAlert className="w-full" />
</Flex>
);
};

type SelectActivityRewardsConfigurationProps = {
mode: SelectMode;
backButton?: ReactNode;
Expand Down Expand Up @@ -145,6 +175,7 @@ export const SelectActivityRewardsConfiguration = ({
stakingProgramId={stakingProgramId}
renderAction={() => (
<>
<StakingRewardsWarning stakingProgramId={stakingProgramId} />
{mode === 'onboard' && (
<Flex className="px-24 pb-24 mt-40" gap={16}>
<SelectStakingButton
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
"form-data": "4.0.6",
"glob": "10.5.0",
"handlebars": "4.7.9",
"immutable": "5.1.5",
"immutable": "5.1.9",
"ip-address": "10.1.1",
"js-yaml": "4.3.0",
"lodash": "4.18.1",
"lodash-es": "4.18.1",
"minimatch": "9.0.9",
"picomatch": "4.0.4",
"postcss": "8.5.10",
"sharp": "0.35.3",
"tar": "7.5.16",
"ws": "8.21.0"
},
Expand Down
32 changes: 32 additions & 0 deletions frontend/tests/components/NoStakingRewardsAlert.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react';

import { NoStakingRewardsAlert } from '@/components/NoStakingRewardsAlert';

describe('NoStakingRewardsAlert', () => {
it('renders the empty-reward-pool warning message', () => {
render(<NoStakingRewardsAlert />);
expect(
screen.getByText('No staking rewards available'),
).toBeInTheDocument();
expect(
screen.getByText(/reward pool is currently empty/),
).toBeInTheDocument();
});

it('does not render a switch button when onSwitch is omitted', () => {
render(<NoStakingRewardsAlert />);
expect(
screen.queryByRole('button', { name: 'Switch Staking Contract' }),
).not.toBeInTheDocument();
});

it('renders and fires the switch button when onSwitch is provided', () => {
const onSwitch = jest.fn();
render(<NoStakingRewardsAlert onSwitch={onSwitch} />);

fireEvent.click(
screen.getByRole('button', { name: 'Switch Staking Contract' }),
);
expect(onSwitch).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react';

import { StakingRewardsWarning } from '../../../components/SelectStakingPage/components/SelectActivityRewardsConfiguration';
import { StakingProgramId } from '../../../constants';
import { useStakingContractDetails } from '../../../hooks';
import { makeStakingContractDetails } from '../../helpers/factories';

jest.mock('../../../hooks', () => ({
useStakingContractDetails: jest.fn(),
}));

// Isolate the gating logic from the alert's markup.
jest.mock('../../../components/NoStakingRewardsAlert', () => ({
NoStakingRewardsAlert: () => <div data-testid="no-rewards-alert" />,
}));

const mockUseStakingContractDetails =
useStakingContractDetails as jest.MockedFunction<
typeof useStakingContractDetails
>;

const programId = 'pearl_beta_mech_marketplace_1' as StakingProgramId;

const setup = (
value: Partial<ReturnType<typeof useStakingContractDetails>>,
) => {
mockUseStakingContractDetails.mockReturnValue({
stakingContractInfo: undefined,
isRewardsAvailable: false,
hasEnoughServiceSlots: false,
hasEnoughRewardsAndSlots: false,
...value,
});
return render(<StakingRewardsWarning stakingProgramId={programId} />);
};

describe('StakingRewardsWarning', () => {
beforeEach(() => jest.clearAllMocks());

it('renders nothing while contract details are still loading', () => {
// stakingContractInfo undefined => not loaded; must not flash the warning
setup({ stakingContractInfo: undefined, isRewardsAvailable: false });
expect(screen.queryByTestId('no-rewards-alert')).not.toBeInTheDocument();
});

it('renders nothing when rewards are available', () => {
setup({
stakingContractInfo: makeStakingContractDetails({ availableRewards: 10 }),
isRewardsAvailable: true,
});
expect(screen.queryByTestId('no-rewards-alert')).not.toBeInTheDocument();
});

it('renders the warning when the reward pool is empty', () => {
setup({
stakingContractInfo: makeStakingContractDetails({ availableRewards: 0 }),
isRewardsAvailable: false,
});
expect(screen.getByTestId('no-rewards-alert')).toBeInTheDocument();
});
});
Loading
Loading