Skip to content

fix(espresso): validate CRE workflow metadata in EspressoRewardsConsumer - #184

Merged
BkChoy merged 2 commits into
mainfrom
fix/espresso-rewards-consumer-validate-workflow-metadata
Jul 27, 2026
Merged

fix(espresso): validate CRE workflow metadata in EspressoRewardsConsumer#184
BkChoy merged 2 commits into
mainfrom
fix/espresso-rewards-consumer-validate-workflow-metadata

Conversation

@1marcghannam

@1marcghannam 1marcghannam commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

EspressoRewardsConsumer.onReport authenticated reports with a single check, msg.sender == forwarder, and ignored the Keystone report metadata (workflow_owner / workflow_name). That is not sufficient to authenticate a CRE report.

The Chainlink KeystoneForwarder (0x0b93082D9b3C7C97fAcd250082899BAcf3af3885, live typeAndVersion = KeystoneForwarder 1.0.0):

  • has a permissionless report() (no caller gating; security is signature based),
  • verifies DON signatures over keccak256(keccak256(rawReport) || reportContext) only, so the receiver is not part of the signed payload and any DON-signed report can be routed to any receiver by anyone,
  • scopes replay protection per receiver (transmissionId = keccak(receiver, workflowExecutionId, reportId)), so a report already delivered to the attacker's own contract can be re-delivered to this consumer with a fresh id.

The same shared DON (configId 0x100000001) signs reports for every workflow running on it. The only field identifying which workflow produced a given report is the metadata that onReport discarded. As a result, any party able to run a workflow on that DON could:

  1. emit a body abi.encode([vaultId], [inflatedLifetimeRewards]),
  2. capture (rawReport, reportContext, signatures) from their own (normal) delivery,
  3. call forwarder.report(EspressoRewardsConsumer, rawReport, reportContext, signatures),
  4. pass the msg.sender check, and push arbitrary lifetimeRewards into the strategy (sized to stay under maxRewardChangeBPS, 3%, to clear the RewardsTooHigh guard).

Each accepted report inflates totalDeposits with no ESP entering the protocol, minting unbacked stESP. Repeated across rebase cycles this compounds at roughly 3%/cycle and drives the pool toward insolvency. The phantom rewards can never be realized (claiming requires valid Espresso authData), and because _updateLifetimeRewards is monotonic, the inflation also bricks the legitimate oracle's lower, true updates.

Fix

Validate the report source in onReport, the required Keystone receiver pattern (cf. Chainlink's own KeystoneFeedsConsumer):

  • Extract workflow_name (10 bytes) and workflow_owner (20 bytes) from the metadata the forwarder passes (workflow_cid(32) || workflow_name(10) || workflow_owner(20) || report_id(2)).
  • Revert with UnauthorizedWorkflow unless both match the authorized CRE workflow, which is set immutably at construction.

Immutable (rather than owner settable) values keep the contract's existing minimal, all-immutable design and minimize the authorization attack surface. If the workflow is ever redeployed, deploy a new consumer and point the strategy at it via setRewardsOracle.

Tests

test/espressoStaking/espresso-rewards-consumer.test.ts extended:

  • authorized workflow forwards rewards (happy path),
  • forged report with an unauthorized workflow_owner reverts UnauthorizedWorkflow and the strategy is left untouched (updateCount == 0),
  • unauthorized workflow_name reverts,
  • malformed / empty metadata reverts,
  • existing "not forwarder" and supportsInterface tests preserved.

npx hardhat test --network hardhat test/espressoStaking/*.test.ts -> 67 passing, no regressions.

Deployment / migration notes

The constructor signature changed to (_forwarder, _strategy, _workflowOwner, _workflowName). The authorized workflow is already wired in 4-deploy-rewards-consumer.ts with values verified against a live report delivered to the current consumer (0xe69D92f6910b45dA1D6Ddfb380efaa6AF56e33F9):

  • workflowOwner = 0x90510C6bAB8cc0f6C964c46970264Dbb8B7B2857
  • workflowName = 0x39363364363236363730 (UTF-8 "963d626670")

Rolling this out to the live system:

  1. Deploy the new ESP_EspressoRewardsConsumer (new address; the constructor changed).
  2. Repoint the CRE workflow's write target (receiver) from the current consumer 0xe69D92... to the new consumer address. The workflow logic, report body, owner, and name stay the same.
  3. EspressoStrategy.setRewardsOracle(newConsumer).

Pair steps 2 and 3 so the active oracle and the workflow's delivery target match. A missed update in the brief window is harmless (lifetimeRewards is monotonic; the next report catches up). Do not re-register the workflow under a new owner/name, or the metadata would no longer match the consumer's configured values.

Context

Addresses the Immunefi report "Forged Keystone reports mint unbacked stESP: EspressoRewardsConsumer ignores report metadata." Verified against the live deployment: the consumer is the active rewardsOracle on the strategy, maxRewardChangeBPS == 300, the forwarder is the canonical KeystoneForwarder 1.0.0, and the configured workflow owner/name match the metadata of a real report delivered to the live consumer.

🤖 Generated with Claude Code

onReport only checked msg.sender == forwarder and ignored the Keystone report metadata. The KeystoneForwarder verifies DON signatures over the report bytes only and routes any DON-signed report to any caller-chosen receiver, so the sender check alone lets any party able to run a workflow on the same shared DON forge a report and inflate lifetimeRewards, minting unbacked stESP (capped per rebase by maxRewardChangeBPS, compounding toward insolvency).

Validate the workflow owner and workflow name from the report metadata against the authorized CRE workflow (set immutably at construction) before acting on a report, per Chainlink's required Keystone receiver pattern. Update the prod deploy script and tests accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@1marcghannam
1marcghannam force-pushed the fix/espresso-rewards-consumer-validate-workflow-metadata branch from 61e42dc to e527e05 Compare June 23, 2026 03:45
@BkChoy
BkChoy changed the base branch from develop to main July 27, 2026 16:32
@BkChoy
BkChoy merged commit a98d3b1 into main Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants