Skip to content

OToken Vault Oracles - #2953

Open
naddison36 wants to merge 4 commits into
masterfrom
nicka/vault-oracle
Open

OToken Vault Oracles#2953
naddison36 wants to merge 4 commits into
masterfrom
nicka/vault-oracle

Conversation

@naddison36

@naddison36 naddison36 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds immutable Vault-backed price oracles for OETH, OUSD, and superOETHb.

Each oracle reports the value of one OToken in its Vault’s underlying asset:

price = vault.totalValue() * 1e18 / oToken.totalSupply();

Prices use 18 decimals and are exposed through:

  • price()
  • latestAnswer()
  • Chainlink-compatible latestRoundData()
  • Chainlink-compatible getRoundData(1)

The OToken address is resolved directly from the configured Vault to prevent mismatched Vault/token deployments.

Deployments

Ethereum Mainnet

Deployment Vault constructor argument Description
OETH_VAULT_ORACLE OETH_VAULT_PROXY (0x39254033945AA2E4809Cc2977E7087BEE48bd7Ab) OETH / WETH
OUSD_VAULT_ORACLE OUSD_VAULT_PROXY (0xE75D77B1865Ae93c7eaa3040B038D7aA7BC02F70) OUSD / USDC

Base

Deployment Vault constructor argument Description
OETHBASE_VAULT_ORACLE OETHBASE_VAULT_PROXY (0x98a0CbeF61bD2D21435f433bE4CD42B56B38CC93) superOETHb / WETH

The Foundry deployment scripts verify the Vault, OToken, description, decimals, version, and that the returned price is positive. No governance actions are required.

Behavior

  • Returns 1e18 when Vault value and OToken supply are equal.
  • Increases above 1e18 as unreleased yield accumulates between rebases.
  • Can return below 1e18 if the Vault becomes insolvent and its assets are worth less than the outstanding OToken supply.
  • Does not apply a price cap, floor, or peg adjustment.
  • Reports the raw ratio between the Vault’s assets and the OToken supply.
  • Supports OUSD’s 6-decimal USDC underlying because Vault.totalValue() is normalized to 18 decimals.
  • Reverts with No data present when OToken supply is zero.
  • Uses a synthetic Chainlink round ID of 1; the price is calculated live rather than historically stored.

Testing

Added concrete and fuzz coverage for:

  • Constructor configuration
  • Price at rebase
  • Accumulated yield and prices above one
  • Fractional prices
  • Zero supply
  • Chainlink-compatible accessors and metadata
  • Arbitrary total-value/total-supply ratios

Operational impact

The deployed oracle names and callable interface are not currently referenced by Talos actions. No curated action ABI, pinned address, or existing Hardhat deployment artifact requires updating in this PR.

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;

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