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
4 changes: 2 additions & 2 deletions .github/workflows/snyk-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ jobs:
# Runs Snyk Code (SAST) analysis and uploads result into GitHub.
# Use || true to not fail the pipeline
- name: Snyk Code test
run: snyk code test --sarif > snyk-code.sarif
run: snyk code test --remote-repo-url=https://github.com/${{ github.repository }} --sarif > snyk-code.sarif
continue-on-error: true

# Runs Snyk Open Source (SCA) analysis and uploads result to Snyk.
- name: Snyk Open Source monitor
run: snyk monitor --all-projects
run: snyk monitor --remote-repo-url=https://github.com/${{ github.repository }} --all-projects

# Disabled because this repo does not use IaC or Docker yet

Expand Down
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
4 changes: 3 additions & 1 deletion docs/features/autorun.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,9 @@ All waits are guarded by `enabledRef.current`, `sleepAwareDelay()`, and hard tim
| `started` | Agent deployed and running | Done |
| `agent_blocked` | Deterministic blocker (low balance, evicted, etc.) | Skip with notification, advance queue |
| `infra_failed` | Transient failure (RPC/network/timeout) | Mark `hasInfraFailed`, continue scan to try remaining candidates; schedule short rescan if all fail |
| `aborted` | Auto-run disabled or sleep detected | Stop processing |
| `aborted` | Auto-run disabled | Stop processing |

`aborted` is returned only when auto-run is actually disabled (`enabledRef.current` is false). Transient gates that fail while auto-run is still enabled — a balance-wait timeout, an interrupted retry backoff — return `infra_failed`, not `aborted`, so a single stalled agent does not pause the whole rotation.

The `infra_failed` handling prevents the scanner from permanently rotating the selected agent on a transient failure. Remaining candidates in the current scan cycle are still tried (they may have different staking contracts and sufficient balance), but the selected agent is not replaced as the preferred choice.

Expand Down
12 changes: 8 additions & 4 deletions docs/features/staking-and-rewards.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ The user picks which staking program to use. The view cycles through these state

- Default program = `selectedAgentConfig.defaultStakingProgramId` (from `frontend/config/agents.ts`)
- "Change Configuration" button toggles to list view (`LIST_MANUAL`)
- List is ordered by `useStakingContracts`: active program first, deprecated programs hidden, filtered by `agentsSupported`
- List is ordered by `useStakingContracts`: current program first (shown even if deprecated), other deprecated programs hidden, filtered by `agentsSupported`. `currentStakingProgramId` here is the on-chain (subgraph) program falling back to the service-stored `staking_program_id` — it never falls back to the agent-config default, so the "Selected" badge can't land on a contract the user never joined (OPE-1841)
- On selection, `SelectStakingButton` writes the chosen `staking_program_id` to the service config before first deploy

### Main page — Staking block
Expand Down Expand Up @@ -104,6 +104,8 @@ Two tabs:
| Epoch countdown | `currentEpochLifetime` from `useStakingDetails` |
| Stats card | Total rewards earned, current streak |

Contract name and stats are both keyed off `useStakingContracts().currentStakingProgramId` (single source — active/subgraph, then service-stored, never the config default), so the header can't name one contract while the stats show another (OPE-1841).

"Switch Staking Contract" button → opens SelectStakingPage in migrate mode (see Staking Migration below).

**Rewards History tab** (`RewardsHistory.tsx`)
Expand Down Expand Up @@ -275,11 +277,13 @@ Derives several status flags from `selectedStakingContractDetails`:

### Program ordering (useStakingContracts)

`currentStakingProgramId` = subgraph-derived `activeStakingProgramId`, falling back to the service-stored `user_params.staking_program_id`, else `null`. It deliberately does **not** fall back to `defaultStakingProgramId` — the default is not evidence of a stake, and surfacing it as "current" marked contracts the user never joined as "Selected" (OPE-1841).

`orderedStakingProgramIds` is built by filtering and sorting available programs:

1. Skip deprecated programs
2. Skip programs that don't support the selected agent type
3. Place the active staking program first
1. Place the current staking program first — even if deprecated, so a user staked in a legacy contract can still see it marked as selected
2. Skip remaining deprecated programs
3. Skip programs that don't support the selected agent type
4. Append remaining programs in original order

Returns empty array while `isActiveStakingProgramLoaded` is false.
Expand Down
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)
25 changes: 14 additions & 11 deletions frontend/components/AgentStaking/StakingContractDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { TbLock, TbSparkles, TbSquareRoundedPercentage } from 'react-icons/tb';
import styled from 'styled-components';

import { CardFlex, Divider, InfoTooltip, Tooltip } from '@/components/ui';
import { STAKING_PROGRAMS } from '@/config/stakingPrograms';
import { COLOR, NA, PAGES } from '@/constants';
import {
usePageState,
useServices,
useStakingContractDetails,
useStakingContracts,
useStakingDetails,
useStakingProgram,
} from '@/hooks';
import { secondsToHours } from '@/utils';

Expand Down Expand Up @@ -65,14 +67,15 @@ const ContractDetailsSection = ({

export const StakingContractDetails = () => {
const { goto } = usePageState();
const {
activeStakingProgramId,
defaultStakingProgramId,
isActiveStakingProgramLoaded,
selectedStakingProgramMeta,
} = useStakingProgram();
const currentStakingProgramId = isActiveStakingProgramLoaded
? activeStakingProgramId || defaultStakingProgramId
const { selectedAgentConfig } = useServices();
// Single source for the contract shown here — name and stats must come from
// the same program id, and it must never be the agent-config default
// presented as if the user were staked in it (OPE-1841).
const { currentStakingProgramId } = useStakingContracts();
const currentStakingProgramMeta = currentStakingProgramId
? STAKING_PROGRAMS[selectedAgentConfig.evmHomeChainId]?.[
currentStakingProgramId
]
: null;
const { stakingContractInfo } = useStakingContractDetails(
currentStakingProgramId,
Expand All @@ -86,7 +89,7 @@ export const StakingContractDetails = () => {
} = stakingContractInfo || {};
const { currentEpochLifetime } = useStakingDetails();

if (!stakingContractInfo || !selectedStakingProgramMeta)
if (!stakingContractInfo || !currentStakingProgramMeta)
return (
<Flex justify="center" align="center" className="mt-32 mb-32">
<Text>Staking contract details are not available.</Text>
Expand All @@ -97,7 +100,7 @@ export const StakingContractDetails = () => {
<Flex justify="space-between" align="center">
<Flex align="center" gap={8}>
<Title level={5} className="m-0">
{selectedStakingProgramMeta?.name}
{currentStakingProgramMeta?.name}
</Title>
<InfoTooltip iconColor={COLOR.BLACK} iconSize={18}>
This is the staking contract your agent is currently joined to. The
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,16 @@ export const useAutoRunStartOperations = ({
// Lightweight global balance gate before the expensive start call.
const balancesReady = await waitForBalancesReady();
if (!balancesReady) {
return { status: AUTO_RUN_START_STATUS.ABORTED };
// Balances can be unready because auto-run was disabled, or just because
// this agent's balances timed out. Only stop the rotation when disabled;
// otherwise report a transient failure so the scanner tries other agents.
if (!enabledRef.current) {
return { status: AUTO_RUN_START_STATUS.ABORTED };
}
return {
status: AUTO_RUN_START_STATUS.INFRA_FAILED,
reason: 'balances not ready',
};
}

onAutoRunStartStateChange?.(true);
Expand Down
6 changes: 5 additions & 1 deletion frontend/hooks/useRewardsHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,11 @@ const useServiceRewardsHistory = (

if (parsedResponse.error) {
console.error('Failed to parse service rewards:', parsedResponse.error);
return null;
// Throw instead of returning null — a null here is cached as a
// *successful* result for ONE_DAY_IN_MS, wiping the active staking
// program (and everything derived from it) for a whole day after a
// single malformed subgraph response (OPE-1841).
throw new Error('Failed to parse service rewards history');
}

return parsedResponse.data.service;
Expand Down
28 changes: 21 additions & 7 deletions frontend/hooks/useStakingContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,25 @@ import { StakingProgramId } from '@/constants';
import { useServices, useStakingProgram } from '@/hooks';

export const useStakingContracts = () => {
const { selectedAgentConfig, selectedAgentType } = useServices();
const { selectedAgentConfig, selectedAgentType, selectedService } =
useServices();
const { evmHomeChainId } = selectedAgentConfig;
const { isActiveStakingProgramLoaded, selectedStakingProgramId } =
const { isActiveStakingProgramLoaded, activeStakingProgramId } =
useStakingProgram();

// The program stored on the middleware service record — set when the user
// picks a contract, so it reflects an actual user choice (unlike the
// agent-config default).
const serviceStakingProgramId =
selectedService?.chain_configs?.[selectedService?.home_chain]?.chain_data
?.user_params?.staking_program_id ?? null;

// "Current" must never fall back to the agent-config default: showing the
// default as the joined/selected contract fabricates a stake the user never
// made (OPE-1841). Prefer the on-chain (subgraph) value, then the
// service-stored choice; otherwise admit we don't know.
const currentStakingProgramId = isActiveStakingProgramLoaded
? selectedStakingProgramId
? (activeStakingProgramId ?? serviceStakingProgramId)
: null;

// Memoize so the array ref is stable across renders — `Object.keys(...).map(...)`
Expand All @@ -31,14 +43,16 @@ export const useStakingContracts = () => {
(acc: StakingProgramId[], stakingProgramId: StakingProgramId) => {
if (!isActiveStakingProgramLoaded) return acc;

// Put the current staking program at the top — even if deprecated,
// the user must be able to see the contract they are actually
// staked in, otherwise the list can never mark it as selected.
if (stakingProgramId === currentStakingProgramId)
return [stakingProgramId, ...acc];

// If the program is deprecated, ignore it
if (STAKING_PROGRAMS[evmHomeChainId][stakingProgramId].deprecated)
return acc;

// Put the active staking program at the top
if (stakingProgramId === currentStakingProgramId)
return [stakingProgramId, ...acc];

// if the program is not supported by the agent type, ignore it
if (
!STAKING_PROGRAMS[selectedAgentConfig.evmHomeChainId][
Expand Down
5 changes: 3 additions & 2 deletions 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.2.0",
"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
Loading
Loading