OETH Vault Lens - #2953
Conversation
There was a problem hiding this comment.
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:
OTokenVaultOracle→OTokenVaultBackingRatio
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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;
There was a problem hiding this comment.
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.
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.
a7471a1 to
789076a
Compare
naddison36
left a comment
There was a problem hiding this comment.
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.
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>
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.
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.lastVerifiedBalanceTimestampis 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 ChainlinkAggregatorV3Interfacesurface is removed.OETHVaultLensProxy, governed (upgradeable) by the Timelock. The implementation holds no storage and no admin functions, so it does not inheritGovernable— upgrade authority lives on the proxy.totalValue()/oToken()interface.Deployment
005_DeployOETHVaultLens.s.solreplaces the not-yet-executed005_UpgradeCompoundingStakingStrategy(the execution ledger is keyed by script name; the old name was never recorded). It deploys the newCompoundingStakingStrategyimplementation (now including #2987'slastVerifiedBalanceTimestamp), the lens implementation (OETH_VAULT_LENS_IMPL), andOETH_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 whilelastVerifiedBalanceTimestampis unset, and the reported rate with a mocked-fresh timestamp.After the proposal executes,
getRate()keeps reverting until the firstverifyBalances()sets the timestamp — intended.Operational impact
The strategy upgrade touches the contract used by the
snapBalances/verifyBalances/stakeValidator/withdrawValidatorTalos actions: the proxy address is unchanged, the ABI only gains a view getter, andmake deploy-mainnetrefreshes thedeployments/mainnet/descriptors. No pinned addresses or curatedabi/*.jsonneed updating. The lens itself has no Talos consumers.Code Change Checklist