Skip to content
Open
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
14 changes: 13 additions & 1 deletion contracts/oracles/ERC4626Oracle.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ import { CorrelatedTokenOracle } from "./common/CorrelatedTokenOracle.sol";
/**
* @title ERC4626Oracle
* @author Venus
* @notice This oracle fetches the price of ERC4626 tokens
* @notice This oracle fetches the price of ERC4626 tokens.
* @dev CAPO (Capped Asset Price Oracle) is mandatory for ERC-4626 tokens to prevent
* donation-based exchange rate manipulation attacks. The convertToAssets() function
* can be inflated by directly transferring underlying tokens to the vault contract,
* which would allow attackers to borrow against artificially inflated collateral.
*/
contract ERC4626Oracle is CorrelatedTokenOracle {
uint256 public immutable ONE_CORRELATED_TOKEN;

/// @notice Thrown when CAPO parameters are not set (required for ERC-4626 tokens)
error CAPORequired();

/// @notice Constructor for the implementation contract.
/// @dev Enforces CAPO: annualGrowthRate, snapshotInterval, and initial snapshot values must be non-zero
constructor(
address correlatedToken,
address underlyingToken,
Expand All @@ -36,6 +44,10 @@ contract ERC4626Oracle is CorrelatedTokenOracle {
_snapshotGap
)
{
// ERC-4626 vaults are vulnerable to donation attacks that inflate convertToAssets().
// CAPO must be active to cap the exchange rate growth and prevent manipulation.
if (annualGrowthRate == 0 || _snapshotInterval == 0) revert CAPORequired();

ONE_CORRELATED_TOKEN = 10 ** IERC4626(correlatedToken).decimals();
}

Expand Down
155 changes: 155 additions & 0 deletions contracts/oracles/PriceCircuitBreaker.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: BSD-3-Clause
pragma solidity 0.8.25;

import { OracleInterface } from "../interfaces/OracleInterface.sol";
import { IAccessControlManagerV8 } from "@venusprotocol/governance-contracts/contracts/Governance/IAccessControlManagerV8.sol";

/**
* @title PriceCircuitBreaker
* @author Venus
* @notice Oracle wrapper that trips a circuit breaker when an asset's price drops
* beyond a configurable threshold within a time window. This prevents lending protocols
* from accepting collateral at stale high prices during rapid price crashes on
* low-liquidity tokens (e.g. THE/Thena attack, March 2026).
*
* When the circuit breaker trips, getPrice() reverts, effectively pausing the
* market for that asset until governance resets it.
*
* @dev Deploy as the main oracle in ResilientOracle's token config, wrapping
* the actual price source (e.g. Chainlink).
*/
contract PriceCircuitBreaker is OracleInterface {
struct AssetConfig {
/// @notice Maximum allowed price drop in basis points (e.g. 3000 = 30%)
uint256 maxDropBps;
/// @notice Time window in seconds over which the drop is measured
uint256 windowSeconds;
/// @notice Last recorded price (scaled 1e18)
uint256 lastPrice;
/// @notice Timestamp of last recorded price
uint256 lastTimestamp;
/// @notice Whether the circuit breaker has tripped
bool tripped;
}

/// @notice The underlying oracle to fetch prices from
OracleInterface public immutable UNDERLYING_ORACLE;

/// @notice Access control manager
IAccessControlManagerV8 public immutable ACCESS_CONTROL_MANAGER;

/// @notice Circuit breaker config per asset
mapping(address => AssetConfig) public assetConfigs;

/// @notice Default max drop: 30% in basis points
uint256 public constant DEFAULT_MAX_DROP_BPS = 3000;

/// @notice Default window: 1 hour
uint256 public constant DEFAULT_WINDOW_SECONDS = 3600;

event CircuitBreakerTripped(address indexed asset, uint256 previousPrice, uint256 currentPrice, uint256 dropBps);
event CircuitBreakerReset(address indexed asset);
event AssetConfigSet(address indexed asset, uint256 maxDropBps, uint256 windowSeconds);

error CircuitBreakerActive(address asset);
error Unauthorized(address sender, address calledContract, string methodSignature);

constructor(address _underlyingOracle, address _accessControlManager) {
UNDERLYING_ORACLE = OracleInterface(_underlyingOracle);
ACCESS_CONTROL_MANAGER = IAccessControlManagerV8(_accessControlManager);
}

/**
* @notice Configure circuit breaker parameters for an asset
* @param asset The asset address
* @param maxDropBps Maximum allowed price drop in basis points
* @param windowSeconds Time window for measuring price drops
*/
function setAssetConfig(address asset, uint256 maxDropBps, uint256 windowSeconds) external {
_checkAccessAllowed("setAssetConfig(address,uint256,uint256)");
assetConfigs[asset].maxDropBps = maxDropBps;
assetConfigs[asset].windowSeconds = windowSeconds;
emit AssetConfigSet(asset, maxDropBps, windowSeconds);
}

/**
* @notice Reset a tripped circuit breaker (governance only)
* @param asset The asset to reset
*/
function resetCircuitBreaker(address asset) external {
_checkAccessAllowed("resetCircuitBreaker(address)");
assetConfigs[asset].tripped = false;
assetConfigs[asset].lastPrice = 0;
assetConfigs[asset].lastTimestamp = 0;
emit CircuitBreakerReset(asset);
}

/**
* @notice Get price with circuit breaker protection
* @param asset Asset address
* @return price The price if circuit breaker has not tripped
*/
function getPrice(address asset) external view override returns (uint256) {
AssetConfig storage config = assetConfigs[asset];

// If circuit breaker has tripped, revert
if (config.tripped) revert CircuitBreakerActive(asset);

// Fetch price from underlying oracle
uint256 currentPrice = UNDERLYING_ORACLE.getPrice(asset);

// If no previous price recorded, return current price
if (config.lastPrice == 0) {
return currentPrice;
}

// Check if price has dropped beyond threshold within the time window
uint256 maxDrop = config.maxDropBps > 0 ? config.maxDropBps : DEFAULT_MAX_DROP_BPS;
uint256 window = config.windowSeconds > 0 ? config.windowSeconds : DEFAULT_WINDOW_SECONDS;

if (block.timestamp - config.lastTimestamp <= window && currentPrice < config.lastPrice) {
uint256 dropBps = ((config.lastPrice - currentPrice) * 10000) / config.lastPrice;
if (dropBps >= maxDrop) {
// In a view function we can't write state, but we can revert
revert CircuitBreakerActive(asset);
}
}

return currentPrice;
}

/**
* @notice Record the current price snapshot. Should be called periodically.
* @param asset Asset address
*/
function updatePriceSnapshot(address asset) external {
AssetConfig storage config = assetConfigs[asset];
if (config.tripped) revert CircuitBreakerActive(asset);

uint256 currentPrice = UNDERLYING_ORACLE.getPrice(asset);

// Check for circuit breaker trip before updating
if (config.lastPrice > 0) {
uint256 maxDrop = config.maxDropBps > 0 ? config.maxDropBps : DEFAULT_MAX_DROP_BPS;
uint256 window = config.windowSeconds > 0 ? config.windowSeconds : DEFAULT_WINDOW_SECONDS;

if (block.timestamp - config.lastTimestamp <= window && currentPrice < config.lastPrice) {
uint256 dropBps = ((config.lastPrice - currentPrice) * 10000) / config.lastPrice;
if (dropBps >= maxDrop) {
config.tripped = true;
emit CircuitBreakerTripped(asset, config.lastPrice, currentPrice, dropBps);
return;
}
}
}

config.lastPrice = currentPrice;
config.lastTimestamp = block.timestamp;
}

function _checkAccessAllowed(string memory signature) internal view {
if (!ACCESS_CONTROL_MANAGER.isAllowedToCall(msg.sender, signature)) {
revert Unauthorized(msg.sender, address(this), signature);
}
}
}
30 changes: 30 additions & 0 deletions contracts/test/MockERC4626.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: BSD-3-Clause
pragma solidity 0.8.25;

import { IERC4626 } from "../interfaces/IERC4626.sol";

contract MockERC4626 is IERC4626 {
string public name;
string public symbol;
uint8 internal _decimals;
uint256 internal _convertToAssets;

constructor(string memory _name, string memory _symbol, uint8 decimals_) {
name = _name;
symbol = _symbol;
_decimals = decimals_;
_convertToAssets = 10 ** decimals_;
}

function decimals() external view override returns (uint8) {
return _decimals;
}

function convertToAssets(uint256) external view override returns (uint256) {
return _convertToAssets;
}

function setConvertToAssets(uint256 rate) external {
_convertToAssets = rate;
}
}
24 changes: 18 additions & 6 deletions deploy/14-deploy-ERC4626Oracle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseUnits } from "ethers/lib/utils";
import { ethers } from "hardhat";
import { DeployFunction } from "hardhat-deploy/dist/types";
import { HardhatRuntimeEnvironment } from "hardhat/types";
Expand All @@ -9,19 +10,30 @@ const func: DeployFunction = async function ({ getNamedAccounts, deployments, ne
const { deployer } = await getNamedAccounts();
const { sUSDe, USDe, acm } = ADDRESSES[network.name];

// const SNAPSHOT_UPDATE_INTERVAL = ethers.constants.MaxUint256;
// const sUSDe_ANNUAL_GROWTH_RATE = ethers.utils.parseUnits("0.15", 18);
// const block = await ethers.provider.getBlock("latest");
// const vault = await ethers.getContractAt("IERC4626", sUSDe);
// const exchangeRate = await vault.convertToAssets(parseUnits("1", 18));
const SNAPSHOT_UPDATE_INTERVAL = 86400; // 24 hours
const sUSDe_ANNUAL_GROWTH_RATE = ethers.utils.parseUnits("0.15", 18); // 15% annual
const SNAPSHOT_GAP = parseUnits("0.01", 18); // 1% safety margin
const resilientOracle = await ethers.getContract("ResilientOracle");
const block = await ethers.provider.getBlock("latest");
const vault = await ethers.getContractAt("IERC4626", sUSDe);
const exchangeRate = await vault.convertToAssets(parseUnits("1", 18));

await deploy("sUSDe_ERC4626Oracle", {
contract: "ERC4626Oracle",
from: deployer,
log: true,
deterministicDeployment: false,
args: [sUSDe, USDe, resilientOracle.address, 0, 0, 0, 0, acm, 0],
args: [
sUSDe,
USDe,
resilientOracle.address,
sUSDe_ANNUAL_GROWTH_RATE,
SNAPSHOT_UPDATE_INTERVAL,
exchangeRate,
block.timestamp,
acm,
SNAPSHOT_GAP,
],
});
};

Expand Down
5 changes: 3 additions & 2 deletions deploy/17-deploy-wUSDM-oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ const func: DeployFunction = async function ({ getNamedAccounts, deployments, ne
const { deployer } = await getNamedAccounts();
const { wUSDM, USDM, acm } = ADDRESSES[network.name];

const SNAPSHOT_UPDATE_INTERVAL = ethers.constants.MaxUint256;
const SNAPSHOT_UPDATE_INTERVAL = 86400; // 24 hours - enables automatic snapshot updates
const wUSDM_ANNUAL_GROWTH_RATE = ethers.utils.parseUnits("0.15", 18);
const SNAPSHOT_GAP = parseUnits("0.01", 18); // 1% safety margin against rate fluctuations
const resilientOracle = await ethers.getContract("ResilientOracle");
const block = await ethers.provider.getBlock("latest");
const vault = await ethers.getContractAt("IERC4626", wUSDM);
Expand All @@ -31,7 +32,7 @@ const func: DeployFunction = async function ({ getNamedAccounts, deployments, ne
exchangeRate,
block.timestamp,
acm,
0,
SNAPSHOT_GAP,
],
});
};
Expand Down
18 changes: 11 additions & 7 deletions deploy/21-deploy-asBNB-oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,9 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }:

const { asBNB, slisBNB, acm } = ADDRESSES[network.name];

const SNAPSHOT_UPDATE_INTERVAL = 0;
const asBNB_ANNUAL_GROWTH_RATE = 0;
const EXCHANGE_RATE = 0;
const SNAPSHOT_TIMESTAMP = 0;
const SNAPSHOT_GAP = 0;
const SNAPSHOT_UPDATE_INTERVAL = 86400; // 24 hours - CAPO must be active
const asBNB_ANNUAL_GROWTH_RATE = ethers.utils.parseUnits("0.10", 18); // 10% annual for staking
const SNAPSHOT_GAP = ethers.utils.parseUnits("0.01", 18); // 1% safety margin

// Deploy dependencies for testnet
if (network.name === "bsctestnet") {
Expand All @@ -41,6 +39,12 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }:
});
}

const asBNBContract = await ethers.getContractAt("IAsBNB", asBNB);
const minterAddress = await asBNBContract.minter();
const minterContract = await ethers.getContractAt("IAsBNBMinter", minterAddress);
const exchangeRate = await minterContract.convertToTokens(ethers.utils.parseUnits("1", 18));
const block = await ethers.provider.getBlock("latest");

await deploy("AsBNBOracle", {
from: deployer,
log: true,
Expand All @@ -51,8 +55,8 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }:
oracle.address,
asBNB_ANNUAL_GROWTH_RATE,
SNAPSHOT_UPDATE_INTERVAL,
EXCHANGE_RATE,
SNAPSHOT_TIMESTAMP,
exchangeRate,
block.timestamp,
acm,
SNAPSHOT_GAP,
],
Expand Down
Loading