Skip to content

OETH Vault Lens - #2953

Merged
sparrowDom merged 11 commits into
masterfrom
nicka/vault-oracle
Aug 28, 2026
Merged

OETH Vault Lens#2953
sparrowDom merged 11 commits into
masterfrom
nicka/vault-oracle

Conversation

@naddison36

@naddison36 naddison36 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Reworks the vault oracle into OETHVaultLens, per the oracle-replacement decisions: a read-only lens (Morpho/Euler-style) that a push oracle (RedStone or Origin-run) will publish from, rather than a Chainlink-shaped feed that always claims freshness.

getRate() = vault.totalValue() * 1e18 / oToken.totalSupply();  // 18 decimals
  • Reverts when the Compounding Staking Strategy's lastVerifiedBalanceTimestamp is more than 24 hours old, so a stale beacon-chain state cannot be reported as a current rate; also reverts on zero supply or a zero rate. The Chainlink AggregatorV3Interface surface is removed.
  • Deployed behind OETHVaultLensProxy, governed (upgradeable) by the Timelock. The implementation holds no storage and no admin functions, so it does not inherit Governable — upgrade authority lives on the proxy.
  • Scope is OETH mainnet only; the OUSD and superOETHb instances were dropped. The lens is OETH-specific: the staking strategy is a required constructor argument and the staleness gate always applies.
  • No Vault or OToken change — the lens is a peripheral contract composing on the existing totalValue()/oToken() interface.

Deployment

005_DeployOETHVaultLens.s.sol replaces the not-yet-executed 005_UpgradeCompoundingStakingStrategy (the execution ledger is keyed by script name; the old name was never recorded). It deploys the new CompoundingStakingStrategy implementation (now including #2987's lastVerifiedBalanceTimestamp), the lens implementation (OETH_VAULT_LENS_IMPL), and OETH_VAULT_LENS_PROXY, under one governance proposal: upgradeTo(newImpl) + setInitialDepositAmount(1 ether). Fork verification carries over the permissionless snap/verify checks and additionally asserts lens wiring, the stale revert while lastVerifiedBalanceTimestamp is unset, and the reported rate with a mocked-fresh timestamp.

After the proposal executes, getRate() keeps reverting until the first verifyBalances() sets the timestamp — intended.

Operational impact

The strategy upgrade touches the contract used by the snapBalances/verifyBalances/stakeValidator/withdrawValidator Talos actions: the proxy address is unchanged, the ABI only gains a view getter, and make deploy-mainnet refreshes the deployments/mainnet/ descriptors. No pinned addresses or curated abi/*.json need updating. The lens itself has no Talos consumers.

Code Change Checklist

  • Contract code is complete
  • Executable Foundry deployment files
  • Fork verification after deployment
  • Unit and fuzz tests
  • Owner full checklist review
  • Two internal approvals

@naddison36
naddison36 marked this pull request as draft July 29, 2026 11:46
@naddison36 naddison36 changed the title OToken Vault Oracles WIP OToken Vault Oracles Jul 29, 2026
@naddison36 naddison36 changed the title WIP OToken Vault Oracles OToken Vault Oracles Aug 3, 2026
@naddison36
naddison36 marked this pull request as ready for review August 4, 2026 05:02

@sparrowDom sparrowDom left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see a couple of issues with this approach.

1. Curve AMO self reference

Curve AMO strategy's checkBalance reads the virtualPrice on the Curve pool. And in that pool there are real backing assets as well as printed OToken. The AMO strategy, for that reason, has a virtual increased weight on the oracle price when some other strategy becomes under-backed.
e.g. AMO Strategy can be 40% of Vault Value, but if you withdrawAll from it it can represent only 10% of Vault Value. Meaning while funds are in the AMO they have artificial too strong weight.
The oracle would still show depeg it would just soften it because of the AMO.

2. Donation attack & Rebase accounting

Already left comments inline regarding the donation attack. A similar issue is with rebase accounting. Not all OToken supply is rebasing. So if we were to correctly report the "pending yield" or in other words by what amount OTokens are priced above 1e18 because the yield hasn't been rebased yet... this becomes a bigger accounting issue / gas usage issue. We should really only get the previewYield(super gas expensive) and apply it to rebasingSupply - considering we chose to ignore the value nonRebasingSupply is foregoing by choosing to opt out. That would give us a ceiling for the premium OToken price we can report. The proposed fix to hardcode the ceiling to 1e18 addresses both of these issues.

3. Correct oracle naming / framing.

This isn't a market price oracle and is rather a pure solvency/backing oracle. And I think we should be really verbose about that. I would rename the descriptions to:

  • "OETH / WETH Vault Backing Ratio"
  • "OUSD / USDC Vault Backing Ratio"
  • "superOETHb / WETH Vault Backing Ratio"
    We can also change the Natspec to be explicit that this isn't some sort of "Exchange rate" oracle.
    We might also want to rename contracts:
    OTokenVaultOracleOTokenVaultBackingRatio

I propose we update the Natspec to - and we should be explicit... otherwise we will be hammered with Immunefy reports:

/**
 * @title OToken Vault Backing Ratio
 * @notice Reports the Vault's backing per OToken, in the Vault's underlying asset, capped at par.
 * @dev THIS IS NOT A MARKET PRICE AND NEVER READS ONE. It reports book value:
 *      min(1e18, vault.totalValue() * 1e18 / oToken.totalSupply())
 *      It reads 1e18 whenever the Vault is fully backed and below 1e18 only when it is not.
 *      Treat it as a solvency signal, not a price.
 *
 *      Known limitations, in the order that matters:
 *
 *      1. LOSSES ARE UNDER-REPORTED. Part of Vault value is the Vault's own OToken held in AMO
 *         positions and marked at par, so a real backing loss surfaces smaller than it is. The
 *         ratio still crosses 1e18 at exactly the right moment; only the magnitude is compressed.
 *      2. INPUTS ARE NOT FRESH. `updatedAt` is the read timestamp, not a freshness attestation.
 *         E.g. the validator strategy updates its balances once daily on a cadence controlled
 *         off-chain, so the on-chain data this feed reads can be days old.
 *      3. NO OPERATIONAL ENVELOPE. No heartbeat, no deviation threshold, no multi-node
 *         aggregation, no pause, no owner. `roundId` is synthetic and constant.
 *      4. IT CAN REVERT. Any contributing strategy reverting reverts this feed. Chainlink feeds
 *         effectively never do; decide whether a revert means freeze or fall back.
 *      5. THE READ SURFACE IS MUTABLE. The set of strategies this traverses changes behind a 48h
 *         governance timelock, and strategy weights change with no timelock at all.
 *
 *      Pricing the OToken as collateral: take min(thisFeed, independentMarketFeed).
 *      As debt: take max(...).
 */

uint256 supply = oToken.totalSupply();
require(supply > 0, "No data present");

return (vault.totalValue() * 1e18) / supply;

@sparrowDom sparrowDom Aug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Vault's totalValue might contain funds that are unreachable - redeemable by the token holders. This is enforced by different yield/rebase caps which prevent the token from capturing all of the VaultValue in a single rebase.

Also this has me slightly worried that offering such an on-chain oracle that reads immediate data opens us up for a read-only oracle attack.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh regarding nominator of vault value what I had in mind was:

uint256 vaultValue = vault.totalValue();

uint256 accumulatedYield = vault.previewYield();
uint256 valueWithYield = accumulatedYield + supply;
// only show Vault value according to the value amount that is allowed to rebase
vaultValue = valueWithYield > vaultValue ? vaultValue : valueWithYield;

return vaultValue * 1e18 / supply;

This fixes the donation lever problem, where one could donate to the Vault and inflate the price value instantly.

The downside is that this operation becomes super costly gas wise:

  • OETH: totalValue -> 130k gas, previewYield -> 150k gas
  • OUSD: totalValue -> 690k gas, previewYield -> 705k gas

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we could achieve a similar outcome by just upper bounding it. And thus prevent any donation lever:

uint256 price = (vault.totalValue() * 1e18) / supply;
return price > 1e18 ? 1e18 : price;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's ok to have the rate increase with a donation. The rate is reflective of the vault collateral. The rate is not the redeemable rate which is always 1 even if there are more assets than supply.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yes good point, agreed.

@sparrowDom sparrowDom changed the title OToken Vault Oracles OETH Vault Lens Aug 27, 2026
The lens only returns the NAV rate via getRate() and reverts when the
Compounding Staking Strategy's lastVerifiedBalanceTimestamp is more than
24 hours old. Deployed behind OETHVaultLensProxy governed by the
Timelock, on mainnet for OETH only. The 005 deploy script upgrades the
staking strategy and deploys the lens under a single governance
proposal.
The staking strategy staleness gate and MAX_VERIFIED_BALANCE_AGE are
specific to the OETH deployment, so the interface carries the OETH name.
forge 1.8.0, released 2026-08-27, fails every vm.createFork against
Alchemy endpoints (the new anvil_nodeInfo network-family probe gets an
HTTP 400 and aborts) and its forge fmt disagrees with the repo's
committed formatting. Pin the last green version until a fixed release
is out.
The staleness gate is specific to the OETH deployment, so the lens is
now strictly OETH: the staking strategy is a required constructor
argument and the verified-balance check always applies.

@naddison36 naddison36 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the simplicity of the OETHVaultLens contract.

The solution handles outstanding withdrawal request as the totalSupply is reduced when withdrawals are requested and totalAssets excludes any outstanding withdrawal requests.

While the Vault’s accounted value exceeds the total supply because yield has not yet been rebased, the lens rate will be above 1. Rebases gradually increases the total supply according to the drip and rate limits. If no additional yield or losses occur, continued rebases will bring the rate back toward 1.

The rate can easily be reconciled with the OETH Vault. A rate at a past block can be reconciled with the vault's total supply and total assets at the same block.

Integrators need to be aware that the rate is reflective of vault collateralization and not the redeemable rate. The redeemable rate will always be 1 WETH for 1 OETH even if the vault is over collateralized.

@sparrowDom
sparrowDom merged commit ab0e85f into master Aug 28, 2026
12 checks passed
@sparrowDom
sparrowDom deleted the nicka/vault-oracle branch August 28, 2026 08:14
sparrowDom added a commit that referenced this pull request Aug 28, 2026
Master gained 005_DeployOETHVaultLens.s.sol (#2953) after this branch
diverged, so the OETHb V3 mainnet scripts would collide with it on merge.
Bump file names, contract names and constructor deployment IDs, and update
the cross-references in base/002, base/003, the RemoteWOTokenStrategy fork
test comment, DESIGN.md and README.md. Neither script has been broadcast,
so no deployment state is affected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sparrowDom added a commit that referenced this pull request Aug 28, 2026
Master gained 005_DeployOETHVaultLens.s.sol (#2953) after this branch
diverged, so the OETHb V3 mainnet scripts would collide with it on merge.
Bump file names, contract names and constructor deployment IDs, and update
the cross-references in base/002, base/003, the RemoteWOTokenStrategy fork
test comment, DESIGN.md and README.md. Neither script has been broadcast,
so no deployment state is affected.
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