Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actions/foundry-setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ runs:
steps:
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
# Pinned: forge 1.8.0 (released 2026-08-27) fails every vm.createFork
# against Alchemy endpoints (anvil_nodeInfo probe returns HTTP 400) and
# its forge fmt disagrees with the repo's committed formatting.
version: v1.7.1

- name: Cache dependencies
uses: actions/cache@v4
Expand Down
42 changes: 42 additions & 0 deletions contracts/contracts/interfaces/IOETHVaultLens.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import { IOToken } from "./IOToken.sol";
import { IVault } from "./IVault.sol";

/**
* @title OETH Vault Lens Interface
* @author Origin Protocol Inc
*/
interface IOETHVaultLens {
/**
* @notice Returns the Vault used to calculate the OToken rate.
* @return The Vault contract.
*/
function vault() external view returns (IVault);

/**
* @notice Returns the OToken whose rate is reported.
* @return The OToken contract.
*/
function oToken() external view returns (IOToken);

/**
* @notice Returns the staking strategy whose verified balance freshness gates getRate.
* @return The staking strategy address.
*/
function stakingStrategy() external view returns (address);

/**
* @notice Returns the maximum age of the staking strategy's last verified balance
* before getRate reverts.
* @return The maximum age in seconds.
*/
function MAX_VERIFIED_BALANCE_AGE() external view returns (uint256);

/**
* @notice Returns the value of one OToken in the Vault's underlying asset.
* @return The rate with 18 decimals.
*/
function getRate() external view returns (uint256);
}
77 changes: 77 additions & 0 deletions contracts/contracts/lens/OETHVaultLens.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import { ICompoundingStakingStrategy } from "../interfaces/strategies/ICompoundingStakingStrategy.sol";
import { IOETHVaultLens } from "../interfaces/IOETHVaultLens.sol";
import { IOToken } from "../interfaces/IOToken.sol";
import { IVault } from "../interfaces/IVault.sol";

/**
* @title OETH Vault Lens
* @notice Reports the value of one OToken in the Vault's underlying asset (the NAV rate).
* @dev The rate has 18 decimals and is calculated from the Vault's total value
* divided by the OToken's total supply.
* getRate reverts if the staking strategy's balances have not been verified
* against the beacon chain within the last MAX_VERIFIED_BALANCE_AGE seconds,
* so a stale beacon chain state can not be reported as a current rate.
* @author Origin Protocol Inc
*/
contract OETHVaultLens is IOETHVaultLens {
/// @notice The maximum age of the staking strategy's last verified balance
/// before getRate reverts.
uint256 public constant override MAX_VERIFIED_BALANCE_AGE = 24 hours;

/// @notice The Vault used to calculate the OToken rate.
IVault public immutable vault;

/// @notice The OToken whose rate is reported.
IOToken public immutable oToken;

/// @notice The staking strategy whose verified balance freshness gates getRate.
address public immutable stakingStrategy;

/**
* @notice Constructs an OETH Vault Lens.
* @param _vault The Vault used to calculate the rate and resolve the OToken.
* @param _stakingStrategy The staking strategy whose verified balance freshness
* gates getRate.
*/
constructor(address _vault, address _stakingStrategy) {
require(_vault != address(0), "Vault is zero address");
require(_stakingStrategy != address(0), "Strategy is zero address");

vault = IVault(_vault);
address _oToken = vault.oToken();
require(_oToken != address(0), "OToken is zero address");
oToken = IOToken(_oToken);
stakingStrategy = _stakingStrategy;
}

/**
* @notice Returns the value of one OToken in the Vault's underlying asset.
* @dev This is the NAV rate, not the redeemable rate. The NAV rate can be
* above 1e18 when the Vault holds yield that has not been realized
* through a rebase yet, while the redeemable rate is capped at 1e18.
* Reverts if the staking strategy's last verified balance is older than
* MAX_VERIFIED_BALANCE_AGE, if the OToken supply is zero, or if the
* calculated rate is zero.
* @return rate The rate with 18 decimals.
*/
function getRate() external view override returns (uint256 rate) {
// The uint64 timestamp is promoted to uint256 by the constant,
// so the comparison can neither overflow nor underflow.
require(
ICompoundingStakingStrategy(stakingStrategy)
.lastVerifiedBalanceTimestamp() +
MAX_VERIFIED_BALANCE_AGE >=
block.timestamp,
"Stale verified balance"
);

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

rate = (vault.totalValue() * 1e18) / supply;
require(rate > 0, "Invalid rate");
}
}
7 changes: 7 additions & 0 deletions contracts/contracts/proxies/Proxies.sol
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,10 @@ contract OUSDMorphoV2StrategyProxy is InitializeGovernedUpgradeabilityProxy {
contract OETHSupernovaAMOProxy is InitializeGovernedUpgradeabilityProxy {

}

/**
* @notice OETHVaultLensProxy delegates calls to an OETHVaultLens implementation
*/
contract OETHVaultLensProxy is InitializeGovernedUpgradeabilityProxy {

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,31 @@ import {GovProposal} from "scripts/deploy/helpers/DeploymentTypes.sol";

// Contracts
import {InitializeGovernedUpgradeabilityProxy} from "contracts/proxies/InitializeGovernedUpgradeabilityProxy.sol";
import {OETHVaultLensProxy} from "contracts/proxies/Proxies.sol";
import {OETHVaultLens} from "contracts/lens/OETHVaultLens.sol";
import {CompoundingStakingStrategy} from "contracts/strategies/NativeStaking/CompoundingStakingStrategy.sol";
import {InitializableAbstractStrategy} from "contracts/utils/InitializableAbstractStrategy.sol";
import {ICompoundingStakingStrategy} from "contracts/interfaces/strategies/ICompoundingStakingStrategy.sol";

// Mainnet addresses
import {Mainnet} from "tests/utils/Addresses.sol";

/// @title 005_UpgradeCompoundingStakingStrategy
/// @notice Makes snapBalances() and verifyBalances() permissionless now that validator consolidation is complete.
contract $005_UpgradeCompoundingStakingStrategy is AbstractDeployScript("005_UpgradeCompoundingStakingStrategy") {
/// @title 005_DeployOETHVaultLens
/// @notice Upgrades the Compounding Staking Strategy (permissionless balance proofs,
/// lastVerifiedBalanceTimestamp, 1 ETH initial deposit) and deploys the
/// OETH Vault Lens that reports the OETH/WETH NAV rate gated on that timestamp.
contract $005_DeployOETHVaultLens is AbstractDeployScript("005_DeployOETHVaultLens") {
using GovHelper for GovProposal;

uint64 internal constant BEACON_GENESIS_TIMESTAMP = 1_606_824_023;
// Limit exposure while a new validator's withdrawal credentials are still unverified.
uint256 internal constant INITIAL_DEPOSIT_AMOUNT = 1 ether;
address internal constant GOVERNOR = Mainnet.Timelock;

// ==================== Deployment Logic ==================== //

function _execute() internal override {
// 1. New CompoundingStakingStrategy implementation.
CompoundingStakingStrategy newImpl = new CompoundingStakingStrategy(
InitializableAbstractStrategy.BaseStrategyConfig({
platformAddress: address(0), vaultAddress: resolver.resolve("OETH_VAULT_PROXY")
Expand All @@ -37,18 +44,31 @@ contract $005_UpgradeCompoundingStakingStrategy is AbstractDeployScript("005_Upg
);

_recordDeployment("COMPOUNDING_STAKING_STRATEGY_IMPL", address(newImpl), type(CompoundingStakingStrategy).name);

// 2. OETH Vault Lens implementation and proxy, governed by the Timelock.
OETHVaultLensProxy lensProxy = new OETHVaultLensProxy();
OETHVaultLens lensImpl = new OETHVaultLens(
resolver.resolve("OETH_VAULT_PROXY"), resolver.resolve("COMPOUNDING_STAKING_STRATEGY_PROXY")
);
lensProxy.initialize(address(lensImpl), GOVERNOR, "");

_recordDeployment("OETH_VAULT_LENS_IMPL", address(lensImpl), type(OETHVaultLens).name);
_recordDeployment("OETH_VAULT_LENS_PROXY", address(lensProxy), type(OETHVaultLensProxy).name);
}

// ==================== Governance Proposal ==================== //

function _buildGovernanceProposal() internal override {
govProposal.setDescription(
"Make Compounding Staking Strategy balance proofs permissionless\n\n"
"Upgrade the Compounding Staking Strategy and enable the OETH Vault Lens\n\n"
"Validator consolidation is complete, so the ConsolidationController is no longer the "
"strategy registrator. This proposal upgrades the CompoundingStakingStrategy to allow "
"anyone to call snapBalances() and verifyBalances(). The existing snapshot delay and "
"beacon proof verification continue to protect the accounting inputs. It also lowers "
"the maximum first validator deposit to 1 ETH."
"the maximum first validator deposit to 1 ETH. The new implementation additionally "
"exposes lastVerifiedBalanceTimestamp, the timestamp of the last balance snapshot "
"verified against beacon chain data, which the newly deployed OETH Vault Lens reads "
"to refuse reporting an OETH/WETH rate when balance verification is more than 24 " "hours old."
);
address proxy = resolver.resolve("COMPOUNDING_STAKING_STRATEGY_PROXY");
govProposal.action(
Expand All @@ -75,6 +95,43 @@ contract $005_UpgradeCompoundingStakingStrategy is AbstractDeployScript("005_Upg
require(strategy.validatorRegistrator() != address(0), "Registrator cleared");

_verifyPermissionlessBalanceCalls(proxy);
_verifyLens(proxy);
}

function _verifyLens(address strategyProxy) internal {
address lensProxyAddr = resolver.resolve("OETH_VAULT_LENS_PROXY");
InitializeGovernedUpgradeabilityProxy lensProxy = InitializeGovernedUpgradeabilityProxy(payable(lensProxyAddr));

require(lensProxy.implementation() == resolver.resolve("OETH_VAULT_LENS_IMPL"), "Unexpected lens impl");
require(lensProxy.governor() == GOVERNOR, "Unexpected lens governor");

OETHVaultLens lens = OETHVaultLens(lensProxyAddr);
require(address(lens.vault()) == resolver.resolve("OETH_VAULT_PROXY"), "Unexpected lens vault");
require(address(lens.oToken()) == resolver.resolve("OETH_PROXY"), "Unexpected lens oToken");
require(lens.stakingStrategy() == strategyProxy, "Unexpected lens strategy");

// Right after the upgrade no verifyBalances() has run against the new implementation,
// so lastVerifiedBalanceTimestamp is 0 and the lens must refuse to report a rate.
// Guarded on actual staleness so the check stays valid once real balance
// verifications happen on-chain, as _fork() re-runs on every fork and smoke run.
uint256 lastVerified = ICompoundingStakingStrategy(strategyProxy).lastVerifiedBalanceTimestamp();
if (lastVerified + lens.MAX_VERIFIED_BALANCE_AGE() < block.timestamp) {
(bool success,) = lensProxyAddr.staticcall(abi.encodeCall(OETHVaultLens.getRate, ()));
require(!success, "getRate should revert while stale");
}

// With a fresh verified balance the lens reports the Vault's value per OToken.
vm.mockCall(
strategyProxy,
ICompoundingStakingStrategy.lastVerifiedBalanceTimestamp.selector,
abi.encode(uint64(block.timestamp))
);
uint256 rate = lens.getRate();
require(rate > 0, "Invalid rate");
require(rate == (lens.vault().totalValue() * 1e18) / lens.oToken().totalSupply(), "Unexpected rate");
// Clears all mocked calls so the mock can not leak into smoke tests,
// which run this script as part of their setUp.
vm.clearMockedCalls();
}

function _verifyPermissionlessBalanceCalls(address proxy) internal {
Expand Down
31 changes: 31 additions & 0 deletions contracts/tests/mocks/MockOETHVaultLensDependencies.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

contract MockOETHVaultLensVault {
uint256 public totalValue;
address public oToken;

function setTotalValue(uint256 _totalValue) external {
totalValue = _totalValue;
}

function setOToken(address _oToken) external {
oToken = _oToken;
}
}

contract MockOETHVaultLensToken {
uint256 public totalSupply;

function setTotalSupply(uint256 _totalSupply) external {
totalSupply = _totalSupply;
}
}

contract MockOETHVaultLensStrategy {
uint64 public lastVerifiedBalanceTimestamp;

function setLastVerifiedBalanceTimestamp(uint64 _timestamp) external {
lastVerifiedBalanceTimestamp = _timestamp;
}
}
49 changes: 49 additions & 0 deletions contracts/tests/unit/lens/OETHVaultLens/concrete/GetRate.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import {Unit_OETHVaultLens_Shared_Test} from "../shared/Shared.t.sol";

contract Unit_Concrete_OETHVaultLens_GetRate_Test is Unit_OETHVaultLens_Shared_Test {
function test_getRate_isOneAtRebase() public view {
assertEq(lens.getRate(), 1e18);
}

function test_getRate_increasesWithUnrebasedYield() public {
mockVault.setTotalValue(105e18);
assertEq(lens.getRate(), 1.05e18);
}

function test_getRate_returnsFractionalRate() public {
mockVault.setTotalValue(101e18);
assertEq(lens.getRate(), 1.01e18);
}

function test_getRate_atExactMaxVerifiedBalanceAge() public {
vm.warp(uint256(mockStrategy.lastVerifiedBalanceTimestamp()) + lens.MAX_VERIFIED_BALANCE_AGE());
assertEq(lens.getRate(), 1e18);
}

function test_getRate_RevertWhen_oneSecondPastMaxVerifiedBalanceAge() public {
vm.warp(uint256(mockStrategy.lastVerifiedBalanceTimestamp()) + lens.MAX_VERIFIED_BALANCE_AGE() + 1);
vm.expectRevert("Stale verified balance");
lens.getRate();
}

function test_getRate_RevertWhen_balancesNeverVerified() public {
mockStrategy.setLastVerifiedBalanceTimestamp(0);
vm.expectRevert("Stale verified balance");
lens.getRate();
}

function test_getRate_RevertWhen_supplyIsZero() public {
mockOToken.setTotalSupply(0);
vm.expectRevert("No oToken supply");
lens.getRate();
}

function test_getRate_RevertWhen_rateIsZero() public {
mockVault.setTotalValue(0);
vm.expectRevert("Invalid rate");
lens.getRate();
}
}
31 changes: 31 additions & 0 deletions contracts/tests/unit/lens/OETHVaultLens/concrete/Proxy.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import {Lens} from "tests/utils/artifacts/Lens.sol";
import {Unit_OETHVaultLens_Shared_Test} from "../shared/Shared.t.sol";

contract Unit_Concrete_OETHVaultLens_Proxy_Test is Unit_OETHVaultLens_Shared_Test {
function test_proxy_setsGovernorAndImplementation() public view {
assertEq(lensProxy.governor(), governor);
assertEq(lensProxy.admin(), governor);
assertEq(lensProxy.implementation(), lensImpl);
}

function test_upgradeTo_updatesImplementation() public {
address newImpl = vm.deployCode(Lens.OETH_VAULT_LENS, abi.encode(address(mockVault), address(mockStrategy)));

vm.prank(governor);
lensProxy.upgradeTo(newImpl);

assertEq(lensProxy.implementation(), newImpl);
assertEq(lens.getRate(), 1e18);
}

function test_upgradeTo_RevertWhen_notGovernor() public {
address newImpl = vm.deployCode(Lens.OETH_VAULT_LENS, abi.encode(address(mockVault), address(mockStrategy)));

vm.prank(alice);
vm.expectRevert("Caller is not the Governor");
lensProxy.upgradeTo(newImpl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import {Unit_OETHVaultLens_Shared_Test} from "../shared/Shared.t.sol";

contract Unit_Concrete_OETHVaultLens_ViewFunctions_Test is Unit_OETHVaultLens_Shared_Test {
function test_constructor_setsConfiguration() public view {
assertEq(address(lens.vault()), address(mockVault));
assertEq(address(lens.oToken()), address(mockOToken));
assertEq(lens.stakingStrategy(), address(mockStrategy));
assertEq(lens.MAX_VERIFIED_BALANCE_AGE(), 24 hours);
}
}
Loading
Loading