Skip to content
Open
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);
}
74 changes: 74 additions & 0 deletions contracts/contracts/lens/OETHVaultLens.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// 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 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