Skip to content

Latest commit

 

History

History
361 lines (250 loc) · 24 KB

File metadata and controls

361 lines (250 loc) · 24 KB

Staking & Rewards

Why staking?

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.

Overview

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:

  1. On-chain contracts — staking programs deployed on each supported chain, queried via ethers-multicall
  2. Subgraph — GraphQL API for rewards history and active staking contract detection
  3. Context providers — React contexts that poll on-chain and subgraph data, manage program selection, and derive reward state
  4. 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)

User journey

Onboarding

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 (from frontend/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 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

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 from currentEpochLifetime in useStakingDetails.
  • Streak — consecutive epochs with rewards earned, with a flame icon. Uses optimisticStreak (adds 1 if isEligibleForRewards is 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 via useStakingContractCountdown
    • "Start agent to join staking" — agent not running and not staked
  • "Manage Staking" button — navigates to the Agent Staking page (/agentStaking)

Agent Staking page

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

Staking migration

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)
  • stakingProgramIdToMigrateTo is set in StakingProgramProvider on selection
  • On next agent start, updateServiceIfNeeded detects the updatedStakingProgramId and patches staking_program_id in 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 integration

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() returns true (i.e. livenessPeriod ≤ now − tsCheckpoint) and isEligibleForRewards is true, overrides eligibility to false so the agent runs and triggers the on-chain checkpoint
  • Returns true (eligible), false (not eligible), or undefined (data missing)

Scanner logic (useAutoRunScanner.ts):

  • eligibility === true → agent already earned this epoch → skip, wait for next scan slot
  • eligibility === false → agent hasn't earned yet → start the agent
  • eligibility === undefined → data missing → retry after SCAN_LOADING_RETRY_SECONDS (30 s)

Reward-triggered rotation (useAutoRunLifecycle.ts):

  • lastRewardsEligibilityRef tracks 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: isRotatingRef blocks overlapping rotation/startup

Source of truth

  • frontend/config/stakingPrograms/index.tsStakingProgramConfig type, aggregated STAKING_PROGRAMS map
  • frontend/config/stakingPrograms/{gnosis,base,mode,optimism,polygon}.ts — per-chain program definitions
  • frontend/constants/stakingProgram.tsStakingProgramId constants by chain
  • frontend/types/Autonolas.tsStakingContractDetails, ServiceStakingDetails, StakingRewardsInfo, StakingState
  • frontend/context/StakingProgramProvider.tsx — active/default/selected program state
  • frontend/context/StakingContractDetailsProvider.tsx — contract details caching + refetch logic
  • frontend/context/RewardProvider.tsx — rewards state + optimistic calculation
  • frontend/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 eligibility
  • frontend/hooks/useStakingContractCountdown.ts — minimum staking duration countdown timer
  • frontend/hooks/useStakingDetails.ts — reward streak + current epoch lifetime
  • frontend/hooks/useActiveStakingProgramId.ts — subgraph-based active program detection
  • frontend/hooks/useStakingRewardsOf.ts — multi-service rewards aggregation across a chain
  • frontend/hooks/useAgentStakingRewardsDetails.ts — single-service rewards + eligibility query
  • frontend/hooks/useRewardsHistory.ts — GraphQL subgraph query + epoch grouping + streak
  • frontend/utils/stakingProgram.tsderiveStakingProgramId() address normalization
  • frontend/utils/stakingRewards.tsfetchAgentStakingRewardsInfo() shared fetcher

Contract / schema

Staking program configuration

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).

On-chain types

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)

Subgraph schema

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).

Service object staking fields

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
}

Context shapes

  • StakingProgramContext — defined in frontend/context/StakingProgramProvider.tsx. Key fields: activeStakingProgramId (subgraph-derived, most authoritative), defaultStakingProgramId (from agent config), selectedStakingProgramId (resolved with fallback priority).
  • StakingContractDetailsContext — defined in frontend/context/StakingContractDetailsProvider.tsx.
  • RewardContext — defined in frontend/context/RewardProvider.tsx. Key field: optimisticRewardsEarnedForEpoch equals availableRewardsForEpochEth when eligible, otherwise undefined.

Runtime behavior

Program selection (StakingProgramProvider)

selectedStakingProgramId is resolved with a three-tier fallback:

  1. Subgraph valueactiveStakingProgramId from subgraph's latestStakingContract, mapped to a StakingProgramId via serviceApi.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 by useStakingRewardsOf for multi-service scenarios.
  2. Service-stored valuechain_configs[homeChain].chain_data.user_params.staking_program_id (used during migration before on-chain update)
  3. Default fallbackselectedAgentConfig.defaultStakingProgramId

While loading, selectedStakingProgramId is null. defaultStakingProgramId resets when selectedAgentConfig changes.

Contract details polling (StakingContractDetailsProvider)

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.

Rewards polling (RewardProvider)

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 determination

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 mech contract): 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.

Decoupled-activity regime (new staking contracts)

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) returns activityThisEpoch >= target when a target is set, and falls back to the on-chain KPI (isEligibleForRewards) when it isn't — so legacy programs behave exactly as before.
  • isEpochTargetMet is the single "agent has done its epoch work" signal. RewardProvider exposes it for the selected service; useAllInstancesRewardStatus computes 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 raw isEligibleForRewards is 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 >= target from 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.

Active contract eligibility (useActiveStakingContractDetails)

Derives several status flags from selectedStakingContractDetails:

  • isAgentEvictedserviceStakingState === StakingState.Evicted
  • isServiceStakedserviceStakingStartTime > 0 and state is Staked
  • hasEnoughServiceSlotsserviceIds.length < maxNumServices (null if data missing)
  • hasEnoughRewardsAndSlots — rewards available AND slots available
  • isServiceStakedForMinimumDuration — current time minus start time >= minimum duration
  • isEligibleForStakinghasEnoughRewardsAndSlots is non-nil (not necessarily true) AND (if evicted, minimum duration has passed). This means for non-evicted services, isEligibleForStaking can be true even when hasEnoughRewardsAndSlots is false — likely a bug

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. 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.

Reward streak (useRewardsHistory)

latestRewardStreak counts consecutive earned: true checkpoints from the most recent epoch backward. Stops at the first non-earned checkpoint.

Optimistic streak (useStakingDetails)

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).

Countdown timer (useStakingContractCountdown)

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.

Multi-service rewards (useStakingRewardsOf)

Aggregates rewards across all services on a given chain:

  1. Filters services matching the chain
  2. For each service, queries activeStakingProgramId via createActiveStakingProgramIdQuery (multicall, not subgraph)
  3. For each service + program pair, queries rewards via createStakingRewardsQuery
  4. Sums accruedServiceStakingRewards across all successful queries

Rewards history subgraph (useRewardsHistory)

Fetches all checkpoints for a service from the per-chain subgraph:

  1. Queries with serviceId (NFT token ID as string)
  2. Zod-validates the response with ServiceResponseSchema
  3. Groups checkpoints by contractAddress
  4. Transforms each into a Checkpoint with derived start/end timestamps
  5. Sorts all checkpoints by epochEndTimeStamp descending
  6. Refetches once per day (ONE_DAY_IN_MS)
  7. Also refetches when serviceNftTokenId changes

Epoch start time derivation: for epoch 0 or when the previous checkpoint is missing, uses blockTimestamp - epochLength. Otherwise uses the previous checkpoint's blockTimestamp.

Failure / guard behavior

  • Offline: Reward queries (useAgentStakingRewardsDetails, useAvailableRewardsForEpoch) are disabled when isOnline is false. Contract detail queries (StakingContractDetailsProvider) do NOT check isOnline — they continue polling regardless of online status.
  • Paused: Selected contract details query respects isPaused flag (used during staking operations)
  • Missing staking program: If selectedStakingProgramId is null or doesn't exist in STAKING_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 (refetchInterval returns false on success)
  • Contract detail merge failure: Promise.allSettled allows 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 serviceStakingStartTime or minimumStakingDuration is nil
  • Eviction window: Between eviction and minimum duration expiry, isEligibleForStaking is false — the service cannot be unstaked or re-staked during this window
  • Available rewards precedence bug: isRewardsAvailable uses availableRewards ?? 0 > 0 which due to operator precedence is equivalent to availableRewards ?? (0 > 0) — always truthy when availableRewards is defined (even if 0)

Test-relevant notes

  • No dedicated middleware API endpoints for staking — all staking data comes from on-chain multicall queries and subgraph GraphQL
  • StakingProgramProvider is thin (no polling) — test selectedStakingProgramId fallback priority with different combinations of active/service/default values
  • StakingContractDetailsProvider has two query strategies with different refetch behaviors — test the "stop on success" logic for all-programs vs continuous polling for selected program
  • useActiveStakingContractDetails derives 6 boolean flags — test combinations of staking state, service slots, rewards, and eviction
  • isRewardsAvailable has an operator precedence issue (?? 0 > 0) — test with availableRewards = 0 to verify behavior
  • useRewardsHistory epoch start derivation has edge cases: epoch 0, missing previous checkpoint, and normal consecutive epochs
  • latestRewardStreak counts from most recent backward — test with gaps (earned, not-earned, earned) to verify it stops at first non-earned
  • useStakingContractCountdown uses useInterval(fn, 1000) — mock with jest.useFakeTimers() or mock usehooks-ts
  • useStakingContracts ordering depends on isActiveStakingProgramLoaded — returns empty array while false
  • useStakingRewardsOf uses createActiveStakingProgramIdQuery (multicall-based) rather than useActiveStakingProgramId (subgraph-based) — different code paths for multi-service vs single-service
  • RewardProvider persists firstStakingRewardAchieved to Electron store on first eligibility — mock useElectronApi
  • optimisticRewardsEarnedForEpoch equals availableRewardsForEpochEth only when eligible — test both eligible and ineligible paths
  • Subgraph URL is chain-specific via REWARDS_HISTORY_SUBGRAPH_URLS_BY_EVM_CHAIN — mock graphql-request