Staking is the mechanism that lets a Pearl agent earn OLAS rewards. When a service is deployed, its NFT is locked into an on-chain staking program. Without staking:
- The agent earns no OLAS rewards regardless of how much work it does.
- If all slots in a program are full, an unstaked agent cannot start ("No available slots" blocker in
useDeployability). - Inactive agents are evicted — if a service fails the liveness check (insufficient mech requests or multisig nonce increments), it is removed from the staking program and blocked from restarting until the minimum staking duration has elapsed.
One service can be staked in exactly one program at a time. Switching programs requires stopping the agent, migrating, then restarting.
The staking system allows users to stake their agent's service NFT in on-chain staking programs and earn OLAS rewards. A service can be staked in exactly one program at a time. Rewards are distributed per epoch (typically 1 day) based on whether the service met a liveness threshold — measured by mech request counts or multisig nonce increments.
The system has four layers:
- On-chain contracts — staking programs deployed on each supported chain, queried via ethers-multicall
- Subgraph — GraphQL API for rewards history and active staking contract detection
- Context providers — React contexts that poll on-chain and subgraph data, manage program selection, and derive reward state
- Consumer hooks — focused accessors for contract details, eligibility, countdowns, streaks, and per-service rewards
On-chain contracts (multicall)
└── StakingContractDetailsProvider (contract state + polling)
├── useActiveStakingContractDetails (eligibility + eviction)
└── useStakingContractDetails (per-program slot/reward checks)
Subgraph (GraphQL)
└── useRewardsHistory (checkpoints + streak + active contract)
└── useActiveStakingProgramId (address → program ID)
StakingProgramProvider (program selection)
└── useStakingProgram (metadata + available programs)
└── useStakingContracts (ordered program list)
RewardProvider (rewards state)
├── useAgentStakingRewardsDetails (eligibility + accrued rewards)
└── useAvailableRewardsForEpoch (epoch reward pool)
useStakingDetails (streak + epoch lifetime)
useStakingContractCountdown (minimum duration timer)
useStakingRewardsOf (multi-service rewards aggregation)
Staking is introduced in two steps during agent onboarding:
1. Funding requirement display (frontend/components/SetupPage/AgentOnboarding/FundingRequirementStep.tsx)
Shows the user exactly how much OLAS is required before the first deploy. Two line items are distinct:
- Minimum staking requirement — OLAS amount to stake (e.g. 20 OLAS for Predict Trader on Gnosis). Read from the selected staking program's
stakingRequirements. - Minimum funding requirements — native token amounts for the agent EOA and Safe to operate (gas, operational costs). Read from
useInitialFundingRequirements().
2. Staking program selection (frontend/components/SelectStakingPage/index.tsx)
The user picks which staking program to use. The view cycles through these states:
| State | Description |
|---|---|
LOADING |
Waiting for services/staking data to load |
CONFIGURE |
Recommended (default) program shown; user can continue or change |
CONFIGURE_MANUAL |
User backed from list; shows configure view again |
LIST_AUTO |
Non-default program active; list shown automatically |
LIST_MANUAL |
User clicked "Change Configuration"; full list shown |
SWITCHING |
Selection in-flight; list frozen to avoid flicker |
- Default program =
selectedAgentConfig.defaultStakingProgramId(fromfrontend/config/agents.ts) - "Change Configuration" button toggles to list view (
LIST_MANUAL) - List is ordered by
useStakingContracts: current program first (shown even if deprecated), other deprecated programs hidden, filtered byagentsSupported.currentStakingProgramIdhere is the on-chain (subgraph) program falling back to the service-storedstaking_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,
SelectStakingButtonwrites the chosenstaking_program_idto the service config before first deploy
Component: frontend/components/MainPage/Home/Overview/Staking/Staking.tsx
Always visible in the main page Overview section. Displays:
EpochClock— countdown to epoch end. Color-coded: warning (yellow) when < 12 h remain, danger (red) when < 3 h remain. Derived fromcurrentEpochLifetimeinuseStakingDetails.Streak— consecutive epochs with rewards earned, with a flame icon. UsesoptimisticStreak(adds 1 ifisEligibleForRewardsis true, since the subgraph lags the current epoch).- Status alerts (mutually exclusive, priority order):
- "Under construction" —
selectedAgentConfig.isUnderConstruction - "Agent evicted" —
isAgentEvicted && !isEligibleForStaking; includes eviction countdown viauseStakingContractCountdown - "Start agent to join staking" — agent not running and not staked
- "Under construction" —
- "Manage Staking" button — navigates to the Agent Staking page (
/agentStaking)
Component: frontend/components/AgentStaking/AgentStaking.tsx
Two tabs:
Staking Contract tab (StakingContractDetails.tsx)
| Field | Source |
|---|---|
| APY | stakingContractInfo.apy |
| Rewards per epoch | stakingContractInfo.rewardsPerWorkPeriod |
| Required deposit | stakingContractInfo.minStakingDeposit (OLAS) |
| 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)
- Checkpoints grouped by month/year with collapsible sections
- Each checkpoint: staking program name, epoch number (hover = duration + tx link), earned amount (green if > 0, gray if 0)
- Monthly totals
- In-progress epoch shown at top with optimistic reward (
optimisticRewardsEarnedForEpoch) - Data from GraphQL subgraph, refetched once per day
When the user clicks "Switch Staking Contract", SelectStakingPage opens in migrate mode. In this mode:
- Always shows the full program list (skips the configure view)
stakingProgramIdToMigrateTois set inStakingProgramProvideron selection- On next agent start,
updateServiceIfNeededdetects theupdatedStakingProgramIdand patchesstaking_program_idin the service config before starting
Migration requires the agent to be stopped first (or auto-run to rotate away). The new program's slot availability and rewards are checked before the UI allows selection.
AutoRun uses staking eligibility as its primary scheduling signal. The key function is refreshRewardsEligibility() in frontend/context/AutoRunProvider/utils/autoRunHelpers.ts.
refreshRewardsEligibility(candidate):
- Throttled per
serviceConfigId(minimum 120 s between fetches) - Calls
fetchAgentStakingRewardsInfo()to get the current epoch snapshot - Stale epoch detection: if
isStakingEpochExpired()returnstrue(i.e.livenessPeriod ≤ now − tsCheckpoint) andisEligibleForRewardsistrue, overrides eligibility tofalseso the agent runs and triggers the on-chain checkpoint - Returns
true(eligible),false(not eligible), orundefined(data missing)
Scanner logic (useAutoRunScanner.ts):
eligibility === true→ agent already earned this epoch → skip, wait for next scan sloteligibility === false→ agent hasn't earned yet → start the agenteligibility === undefined→ data missing → retry afterSCAN_LOADING_RETRY_SECONDS(30 s)
Reward-triggered rotation (useAutoRunLifecycle.ts):
lastRewardsEligibilityReftracks the previous eligibility value per agent- On transition from
false → true(reward earned), rotation is triggered: stop current agent → 20 s cooldown →scanAndStartNext - Prevents duplicate rotation:
isRotatingRefblocks overlapping rotation/startup
frontend/config/stakingPrograms/index.ts—StakingProgramConfigtype, aggregatedSTAKING_PROGRAMSmapfrontend/config/stakingPrograms/{gnosis,base,mode,optimism,polygon}.ts— per-chain program definitionsfrontend/constants/stakingProgram.ts—StakingProgramIdconstants by chainfrontend/types/Autonolas.ts—StakingContractDetails,ServiceStakingDetails,StakingRewardsInfo,StakingStatefrontend/context/StakingProgramProvider.tsx— active/default/selected program statefrontend/context/StakingContractDetailsProvider.tsx— contract details caching + refetch logicfrontend/context/RewardProvider.tsx— rewards state + optimistic calculationfrontend/hooks/useStakingProgram.ts— program metadata (active, default, selected, all available)frontend/hooks/useStakingContracts.ts— ordered list of available programs (active first, deprecated filtered)frontend/hooks/useStakingContractDetails.ts— contract state for any program + active contract eligibilityfrontend/hooks/useStakingContractCountdown.ts— minimum staking duration countdown timerfrontend/hooks/useStakingDetails.ts— reward streak + current epoch lifetimefrontend/hooks/useActiveStakingProgramId.ts— subgraph-based active program detectionfrontend/hooks/useStakingRewardsOf.ts— multi-service rewards aggregation across a chainfrontend/hooks/useAgentStakingRewardsDetails.ts— single-service rewards + eligibility queryfrontend/hooks/useRewardsHistory.ts— GraphQL subgraph query + epoch grouping + streakfrontend/utils/stakingProgram.ts—deriveStakingProgramId()address normalizationfrontend/utils/stakingRewards.ts—fetchAgentStakingRewardsInfo()shared fetcher
Each chain has a map of staking programs keyed by StakingProgramId. StakingProgramConfig is defined in frontend/config/stakingPrograms/index.ts, with per-chain definitions in frontend/config/stakingPrograms/{gnosis,base,mode,optimism,polygon}.ts. Programs are aggregated into STAKING_PROGRAMS keyed by chain ID.
Example program IDs: pearl_beta_6 (Gnosis), meme_base_beta (Base), modius_alpha (Mode), optimus_alpha_4 (Optimism), polygon_beta_1 (Polygon).
All defined in frontend/types/Autonolas.ts:
StakingContractDetails— contract-level state (available rewards, max services, staking duration, APY, epoch info)ServiceStakingDetails— per-service state (staking start time, staking state)StakingState— enum:NotStaked(0),Staked(1),Evicted(2)StakingRewardsInfo— detailed rewards + eligibility for a service (Zod-validated). Key fields:isEligibleForRewards,accruedServiceStakingRewards,tsCheckpoint(hex → int via Zod transform)
The subgraph is queried per service NFT token ID in frontend/hooks/useRewardsHistory.ts. The response is Zod-validated with ServiceResponseSchema. Returns latestStakingContract (address or null) and rewardsHistory with epoch, reward amounts (OLAS in wei), and checkpoint data.
Transformed into Checkpoint records (also in useRewardsHistory.ts). Key derived fields: epochStartTimeStamp (from previous epoch or blockTimestamp - epochLength), reward (OLAS formatted as ETH, 18 decimals), earned (true if rewardAmount > 0).
Staking configuration is stored in the service object at chain_configs[chain].chain_data.user_params:
{
"staking_program_id": "polygon_beta_1",
"use_staking": true,
"use_mech_marketplace": false
}StakingProgramContext— defined infrontend/context/StakingProgramProvider.tsx. Key fields:activeStakingProgramId(subgraph-derived, most authoritative),defaultStakingProgramId(from agent config),selectedStakingProgramId(resolved with fallback priority).StakingContractDetailsContext— defined infrontend/context/StakingContractDetailsProvider.tsx.RewardContext— defined infrontend/context/RewardProvider.tsx. Key field:optimisticRewardsEarnedForEpochequalsavailableRewardsForEpochEthwhen eligible, otherwise undefined.
selectedStakingProgramId is resolved with a three-tier fallback:
- Subgraph value —
activeStakingProgramIdfrom subgraph'slatestStakingContract, mapped to aStakingProgramIdviaserviceApi.getStakingProgramIdByAddress(). Note: despite the name, this is subgraph-derived, not a direct on-chain query. The legacy on-chain multicall path (createActiveStakingProgramIdQuery) is only used byuseStakingRewardsOffor multi-service scenarios. - Service-stored value —
chain_configs[homeChain].chain_data.user_params.staking_program_id(used during migration before on-chain update) - Default fallback —
selectedAgentConfig.defaultStakingProgramId
While loading, selectedStakingProgramId is null. defaultStakingProgramId resets when selectedAgentConfig changes.
Two polling strategies run concurrently:
| Query | Interval | Behavior |
|---|---|---|
| All programs | 30s (dynamic) | Polls until success, then stops refetching for that program |
| Selected program | 5s (dynamic) | Continuous polling; includes ServiceStakingDetails if valid token ID |
Both intervals are scaled by useDynamicRefetchInterval based on window visibility. The selected program query is disabled when isPaused is true. setIsPaused(true) halts refetching during operations like unstaking.
The selected program query uses Promise.allSettled to merge StakingContractDetails and ServiceStakingDetails — either can fail without breaking the other.
Two parallel data streams:
| Query | Source | Interval | Guard |
|---|---|---|---|
| Staking rewards details | fetchAgentStakingRewardsInfo() (multicall) |
5s (dynamic) | online + serviceConfigId + stakingProgram + multisig + valid tokenId |
| Available rewards for epoch | serviceApi.getAvailableRewardsForEpoch() |
5s (fixed) | online + serviceConfigId + stakingProgram |
optimisticRewardsEarnedForEpoch equals availableRewardsForEpochEth when isEpochTargetMet is true (see Decoupled-activity regime below), otherwise undefined.
On first staking reward achievement, firstStakingRewardAchieved is persisted to Electron store.
Eligibility is determined on-chain by checking whether the service exceeded the activity threshold during the current epoch. Two methods exist depending on program configuration:
- Mech-based (programs with
mechcontract): counts new mech requests since last checkpoint - Nonce-based (programs without
mech): counts multisig nonce increments since last checkpoint
The required activity count is derived from: (effectivePeriod * livenessRatio) / 1e18 + SAFETY_MARGIN.
Newer staking contracts set the on-chain threshold to ~1 (staking unlocks after a single request) and move the real per-epoch target off-chain. Such a program carries an activityTarget in its config (StakingProgramConfig.activityTarget); its presence is what marks the new regime.
- Signal source: every agent reader now surfaces
StakingRewardsInfo.activityThisEpoch— the raw on-chain request count this epoch (currentCount − checkpointSnapshot).deriveIsEpochTargetMet(info, target)returnsactivityThisEpoch >= targetwhen a target is set, and falls back to the on-chain KPI (isEligibleForRewards) when it isn't — so legacy programs behave exactly as before. isEpochTargetMetis the single "agent has done its epoch work" signal.RewardProviderexposes it for the selected service;useAllInstancesRewardStatuscomputes it per-service for the sidebar. It drives the green idle banner, streak flame, sidebar dot, the earned notification, the optimistic earned amount, and AutoRun rotation. The rawisEligibleForRewardsis retained only as the documented on-chain KPI.- Derived on-chain, so it persists across service stop / app restart. The agent healthcheck is deliberately not consumed — the agent computes the same
completed >= targetfrom the same on-chain values, so reading it would add nothing and would vanish when the agent stops. - Phase-1 targets are hardcoded per program (8 for the trader-style agents, 1 for the DeFi agents) and must match each agent's
ACTIVITY_TARGET.
Derives several status flags from selectedStakingContractDetails:
isAgentEvicted—serviceStakingState === StakingState.EvictedisServiceStaked—serviceStakingStartTime > 0and state isStakedhasEnoughServiceSlots—serviceIds.length < maxNumServices(null if data missing)hasEnoughRewardsAndSlots— rewards available AND slots availableisServiceStakedForMinimumDuration— current time minus start time >= minimum durationisEligibleForStaking—hasEnoughRewardsAndSlotsis non-nil (not necessarily true) AND (if evicted, minimum duration has passed). This means for non-evicted services,isEligibleForStakingcan betrueeven whenhasEnoughRewardsAndSlotsisfalse— likely a bug
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:
- Place the current staking program first — even if deprecated, so a user staked in a legacy contract can still see it marked as selected
- Skip remaining deprecated programs
- Skip programs that don't support the selected agent type
- Append remaining programs in original order
Returns empty array while isActiveStakingProgramLoaded is false.
latestRewardStreak counts consecutive earned: true checkpoints from the most recent epoch backward. Stops at the first non-earned checkpoint.
Adds 1 to the subgraph streak if isEligibleForRewards is true (since the subgraph doesn't account for the current in-progress epoch).
currentEpochLifetime is calculated as (tsCheckpoint + ONE_DAY_IN_S) * 1000 (in milliseconds).
Updates every 1 second via useInterval. Computes time remaining until the minimum staking duration has elapsed:
secondsUntilReady = minimumStakingDuration - (now - serviceStakingStartTime)
Clamps to 0 when negative. Returns countdownDisplay as a formatted string via formatCountdownDisplay.
Aggregates rewards across all services on a given chain:
- Filters services matching the chain
- For each service, queries
activeStakingProgramIdviacreateActiveStakingProgramIdQuery(multicall, not subgraph) - For each service + program pair, queries rewards via
createStakingRewardsQuery - Sums
accruedServiceStakingRewardsacross all successful queries
Fetches all checkpoints for a service from the per-chain subgraph:
- Queries with
serviceId(NFT token ID as string) - Zod-validates the response with
ServiceResponseSchema - Groups checkpoints by
contractAddress - Transforms each into a
Checkpointwith derived start/end timestamps - Sorts all checkpoints by
epochEndTimeStampdescending - Refetches once per day (
ONE_DAY_IN_MS) - Also refetches when
serviceNftTokenIdchanges
Epoch start time derivation: for epoch 0 or when the previous checkpoint is missing, uses blockTimestamp - epochLength. Otherwise uses the previous checkpoint's blockTimestamp.
- Offline: Reward queries (
useAgentStakingRewardsDetails,useAvailableRewardsForEpoch) are disabled whenisOnlineis false. Contract detail queries (StakingContractDetailsProvider) do NOT checkisOnline— they continue polling regardless of online status. - Paused: Selected contract details query respects
isPausedflag (used during staking operations) - Missing staking program: If
selectedStakingProgramIdis null or doesn't exist inSTAKING_PROGRAMS, reward queries return null - Invalid service token ID: Queries guarded by
isValidServiceId(serviceNftTokenId)— disables query if token is nil or <= 0 - All programs query — stop on success: Once a program's details are successfully fetched, that individual query stops refetching (
refetchIntervalreturnsfalseon success) - Contract detail merge failure:
Promise.allSettledallows contract details and service staking details to fail independently — partial data is still provided - Subgraph parse failure: Logged to console, returns null (empty checkpoints)
- Countdown guards: Returns early if
serviceStakingStartTimeorminimumStakingDurationis nil - Eviction window: Between eviction and minimum duration expiry,
isEligibleForStakingis false — the service cannot be unstaked or re-staked during this window - Available rewards precedence bug:
isRewardsAvailableusesavailableRewards ?? 0 > 0which due to operator precedence is equivalent toavailableRewards ?? (0 > 0)— always truthy whenavailableRewardsis defined (even if 0)
- No dedicated middleware API endpoints for staking — all staking data comes from on-chain multicall queries and subgraph GraphQL
StakingProgramProvideris thin (no polling) — testselectedStakingProgramIdfallback priority with different combinations of active/service/default valuesStakingContractDetailsProviderhas two query strategies with different refetch behaviors — test the "stop on success" logic for all-programs vs continuous polling for selected programuseActiveStakingContractDetailsderives 6 boolean flags — test combinations of staking state, service slots, rewards, and evictionisRewardsAvailablehas an operator precedence issue (?? 0 > 0) — test withavailableRewards = 0to verify behavioruseRewardsHistoryepoch start derivation has edge cases: epoch 0, missing previous checkpoint, and normal consecutive epochslatestRewardStreakcounts from most recent backward — test with gaps (earned, not-earned, earned) to verify it stops at first non-earneduseStakingContractCountdownusesuseInterval(fn, 1000)— mock withjest.useFakeTimers()or mockusehooks-tsuseStakingContractsordering depends onisActiveStakingProgramLoaded— returns empty array while falseuseStakingRewardsOfusescreateActiveStakingProgramIdQuery(multicall-based) rather thanuseActiveStakingProgramId(subgraph-based) — different code paths for multi-service vs single-serviceRewardProviderpersistsfirstStakingRewardAchievedto Electron store on first eligibility — mockuseElectronApioptimisticRewardsEarnedForEpochequalsavailableRewardsForEpochEthonly when eligible — test both eligible and ineligible paths- Subgraph URL is chain-specific via
REWARDS_HISTORY_SUBGRAPH_URLS_BY_EVM_CHAIN— mockgraphql-request