diff --git a/src/libraries/PriceOracle.sol b/src/libraries/PriceOracle.sol index 4fe8935..b4d039e 100644 --- a/src/libraries/PriceOracle.sol +++ b/src/libraries/PriceOracle.sol @@ -23,16 +23,20 @@ library PriceOracle { */ function getLatestPrice(AggregatorV3Interface priceFeed) internal view returns (uint256) { (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = priceFeed.latestRoundData(); - require(price > 0, "Invalid price"); - require(answeredInRound >= roundId, "Stale price"); - - // Use both timestamp AND block-based staleness checks - uint256 secondsSinceUpdate = block.timestamp - updatedAt; - require(secondsSinceUpdate <= TIMEOUT, "Price too stale (time)"); - - // Additional block-based check for extra security - require(block.number - updatedAt <= STALE_BLOCK_THRESHOLD, "Price too stale (blocks)"); - + + // Validate price data + if (price <= 0) revert("Invalid price"); + if (answeredInRound < roundId) revert("Stale price"); + + // Check timestamp staleness + if (block.timestamp < updatedAt) revert("Timestamp in future"); + uint256 ageInSeconds = block.timestamp - updatedAt; + if (ageInSeconds > TIMEOUT) revert("Price too stale (time)"); + + // Optional: Check block-based staleness + uint256 blockAge = (block.timestamp - updatedAt) / 15; // Approximate block age + if (blockAge > STALE_BLOCK_THRESHOLD) revert("Price too stale (blocks)"); + return uint256(price) * VaultMath.ADDITIONAL_FEED_PRECISION; } diff --git a/src/mocks/MockPriceFeed.sol b/src/mocks/MockPriceFeed.sol index e16e325..e14924f 100644 --- a/src/mocks/MockPriceFeed.sol +++ b/src/mocks/MockPriceFeed.sol @@ -10,43 +10,64 @@ pragma solidity ^0.8.20; contract MockPriceFeed { uint256 public constant version = 4; uint8 public decimals; - int256 public latestAnswer; - uint256 public latestTimestamp; - uint256 public latestRound; - - mapping(uint256 => int256) public getAnswer; - mapping(uint256 => uint256) public getTimestamp; - mapping(uint256 => uint256) private getStartedAt; + + uint80 private latestRound; + mapping(uint80 => int256) private answers; + mapping(uint80 => uint256) private timestamps; + mapping(uint80 => uint256) private startedAts; + mapping(uint80 => uint80) private roundAnsweredIds; // Which round answered this round constructor(uint8 _decimals, int256 _initialAnswer) { decimals = _decimals; - updateAnswer(_initialAnswer); + // Initialize first round with valid data + _initializeRound(1, _initialAnswer, block.timestamp, block.timestamp, 1); } - - function updateAnswer(int256 _answer) public { - latestAnswer = _answer; - latestTimestamp = block.timestamp; - latestRound++; - getAnswer[latestRound] = _answer; - getTimestamp[latestRound] = block.timestamp; - getStartedAt[latestRound] = block.timestamp; + + function _initializeRound( + uint80 roundId, + int256 answer, + uint256 timestamp, + uint256 startedAt, + uint80 answeredInRound + ) internal { + latestRound = roundId; + answers[roundId] = answer; + timestamps[roundId] = timestamp; + startedAts[roundId] = startedAt; + roundAnsweredIds[roundId] = answeredInRound; } - function updateRoundData(uint80 _roundId, int256 _answer, uint256 _timestamp, uint256 _startedAt) public { - latestRound = _roundId; - latestAnswer = _answer; - latestTimestamp = _timestamp; - getAnswer[latestRound] = _answer; - getTimestamp[latestRound] = _timestamp; - getStartedAt[latestRound] = _startedAt; + // Helper for tests to simulate specific scenarios + function setRoundData( + uint80 roundId, + int256 answer, + uint256 timestamp, + uint256 startedAt, + uint80 answeredInRound + ) public { + require(roundId > 0, "Invalid round ID"); + answers[roundId] = answer; + timestamps[roundId] = timestamp; + startedAts[roundId] = startedAt; + roundAnsweredIds[roundId] = answeredInRound; + + if (roundId >= latestRound) { + latestRound = roundId; + } } - function getRoundData(uint80 _roundId) + function getRoundData(uint80 roundId) external view - returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + returns (uint80, int256, uint256, uint256, uint80) { - return (_roundId, getAnswer[_roundId], getStartedAt[_roundId], getTimestamp[_roundId], _roundId); + return ( + roundId, + answers[roundId], + startedAts[roundId], + timestamps[roundId], + roundAnsweredIds[roundId] + ); } function latestRoundData() @@ -54,13 +75,11 @@ contract MockPriceFeed { view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) { - return ( - uint80(latestRound), - getAnswer[latestRound], - getStartedAt[latestRound], - getTimestamp[latestRound], - uint80(latestRound) - ); + roundId = latestRound; + answer = answers[roundId]; + startedAt = startedAts[roundId]; + updatedAt = timestamps[roundId]; + answeredInRound = roundAnsweredIds[roundId]; } function description() external pure returns (string memory) { diff --git a/test/fuzz/VaultEngineFuzzTest.t.sol b/test/fuzz/VaultEngineFuzzTest.t.sol index e69de29..95eddf3 100644 --- a/test/fuzz/VaultEngineFuzzTest.t.sol +++ b/test/fuzz/VaultEngineFuzzTest.t.sol @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../../src/VaultEngine.sol"; +import "../../src/VaultStablecoin.sol"; +import "../../src/mocks/MockERC20.sol"; +import "lib/chainlink-brownie-contracts/contracts/src/v0.8/tests/MockV3Aggregator.sol"; + + +contract VaultEngineFuzzTest is Test { + VaultEngine engine; + VaultStablecoin stablecoin; + MockERC20 weth; + MockERC20 wbtc; + MockV3Aggregator ethPriceFeed; + MockV3Aggregator btcPriceFeed; + + address public USER = makeAddr("user"); + address public LIQUIDATOR = makeAddr("liquidator"); + address public OWNER = makeAddr("owner"); + + uint256 constant STARTING_BALANCE = 100 ether; + uint256 constant ETH_PRICE = 2000e8; // $2000 with 8 decimals (Chainlink format) + uint256 constant BTC_PRICE = 40000e8; // $40000 with 8 decimals (Chainlink format) + uint8 constant DECIMALS = 8; + uint256 constant PRECISION = 1e18; + uint256 constant ADDITIONAL_FEED_PRECISION = 1e10; + + function setUp() public { + vm.startPrank(OWNER); + + // Deploy mocks + weth = new MockERC20("Wrapped Ether", "WETH", 18, OWNER, STARTING_BALANCE * 3); + wbtc = new MockERC20("Wrapped Bitcoin", "WBTC", 18, OWNER, STARTING_BALANCE * 3); + + ethPriceFeed = new MockV3Aggregator(DECIMALS, int256(ETH_PRICE)); + btcPriceFeed = new MockV3Aggregator(DECIMALS, int256(BTC_PRICE)); + + // Deploy stablecoin with OWNER and initialize with base supply + stablecoin = new VaultStablecoin(OWNER); + + // Initialize base supply + vm.prank(OWNER); + stablecoin.mint(OWNER, 1000000e18); // Initialize with 1M supply + + // Deploy engine + address[] memory tokens = new address[](2); + address[] memory priceFeeds = new address[](2); + + tokens[0] = address(weth); + tokens[1] = address(wbtc); + priceFeeds[0] = address(ethPriceFeed); + priceFeeds[1] = address(btcPriceFeed); + + engine = new VaultEngine(tokens, priceFeeds, address(stablecoin)); + + // Transfer ownership of stablecoin to engine + stablecoin.transferOwnership(address(engine)); + + // Distribute tokens to users + weth.transfer(USER, STARTING_BALANCE); + wbtc.transfer(USER, STARTING_BALANCE); + weth.transfer(LIQUIDATOR, STARTING_BALANCE); + wbtc.transfer(LIQUIDATOR, STARTING_BALANCE); + + vm.stopPrank(); + } + + // ============================================ + // Deposit Collateral Fuzz Tests + // ============================================ + + function testFuzz_DepositCollateral(uint256 amount) public { + amount = bound(amount, 1, STARTING_BALANCE); + + vm.startPrank(USER); + weth.approve(address(engine), amount); + engine.depositCollateral(address(weth), amount); + vm.stopPrank(); + + assertEq(engine.getCollateralBalanceOfUser(USER, address(weth)), amount); + } + + function testFuzz_DepositMultipleCollateralTypes(uint256 ethAmount, uint256 btcAmount) public { + ethAmount = bound(ethAmount, 1, STARTING_BALANCE); + btcAmount = bound(btcAmount, 1, STARTING_BALANCE); + + vm.startPrank(USER); + weth.approve(address(engine), ethAmount); + wbtc.approve(address(engine), btcAmount); + + engine.depositCollateral(address(weth), ethAmount); + engine.depositCollateral(address(wbtc), btcAmount); + vm.stopPrank(); + + assertEq(engine.getCollateralBalanceOfUser(USER, address(weth)), ethAmount); + assertEq(engine.getCollateralBalanceOfUser(USER, address(wbtc)), btcAmount); + } + + // ============================================ + // Mint Stablecoin Fuzz Tests + // ============================================ + + function testFuzz_MintStablecoin(uint256 collateralAmount, uint256 mintAmount) public { + collateralAmount = bound(collateralAmount, 1 ether, STARTING_BALANCE); + uint256 maxCollateralUsd = (collateralAmount * ETH_PRICE * ADDITIONAL_FEED_PRECISION) / PRECISION; + uint256 maxMint = maxCollateralUsd / 2; // 50% max collateral ratio + uint256 maxAllowedMint = stablecoin.totalSupply() / 100; // 1% of total supply + maxMint = maxMint > maxAllowedMint ? maxAllowedMint : maxMint; // Take the minimum + mintAmount = bound(mintAmount, 1e18, maxMint); + + vm.startPrank(USER); + weth.approve(address(engine), collateralAmount); + engine.depositCollateral(address(weth), collateralAmount); + engine.mintStablecoin(mintAmount); + vm.stopPrank(); + + assertEq(stablecoin.balanceOf(USER), mintAmount); + assertGe(engine.getHealthFactor(USER), 1e18); + } + + function testFuzz_DepositAndMintInOneTransaction(uint256 collateralAmount, uint256 mintAmount) public { + collateralAmount = bound(collateralAmount, 1 ether, STARTING_BALANCE); + uint256 maxCollateralUsd = (collateralAmount * ETH_PRICE * 1e10); // Convert to USD with 18 decimals + uint256 maxMint = maxCollateralUsd / 2; // 50% max collateral ratio + uint256 maxAllowedMint = stablecoin.totalSupply() / 100; // 1% of total supply + maxMint = maxMint > maxAllowedMint ? maxAllowedMint : maxMint; // Take the minimum + mintAmount = bound(mintAmount, 1e18, maxMint); + + vm.startPrank(USER); + weth.approve(address(engine), collateralAmount); + engine.depositCollateralAndMintStablecoin(address(weth), collateralAmount, mintAmount); + vm.stopPrank(); + + assertEq(engine.getCollateralBalanceOfUser(USER, address(weth)), collateralAmount); + assertEq(stablecoin.balanceOf(USER), mintAmount); + assertGe(engine.getHealthFactor(USER), 1e18); + } + + // ============================================ + // Redeem Collateral Fuzz Tests + // ============================================ + + function testFuzz_RedeemCollateral(uint256 depositAmount, uint256 redeemAmount) public { + depositAmount = bound(depositAmount, 1 ether, STARTING_BALANCE); + redeemAmount = bound(redeemAmount, 1, depositAmount); + + vm.startPrank(USER); + weth.approve(address(engine), depositAmount); + engine.depositCollateral(address(weth), depositAmount); + engine.redeemCollateral(address(weth), redeemAmount); + vm.stopPrank(); + + assertEq(engine.getCollateralBalanceOfUser(USER, address(weth)), depositAmount - redeemAmount); + assertEq(weth.balanceOf(USER), STARTING_BALANCE - depositAmount + redeemAmount); + } + + function testFuzz_RedeemCollateralForStablecoin( + uint256 collateralAmount, + uint256 mintAmount, + uint256 burnAmount + ) public { + collateralAmount = bound(collateralAmount, 5 ether, STARTING_BALANCE); + uint256 maxCollateralUsd = (collateralAmount * ETH_PRICE * ADDITIONAL_FEED_PRECISION) / PRECISION; + uint256 maxMint = maxCollateralUsd / 3; // 33% max ratio for extra safety + uint256 maxAllowedMint = stablecoin.totalSupply() / 100; // 1% of total supply + maxMint = maxMint > maxAllowedMint ? maxAllowedMint : maxMint; // Take the minimum + mintAmount = bound(mintAmount, 1e18, maxMint); + burnAmount = bound(burnAmount, 1e18, mintAmount); + + vm.startPrank(USER); + weth.approve(address(engine), collateralAmount); + engine.depositCollateralAndMintStablecoin(address(weth), collateralAmount, mintAmount); + + uint256 redeemAmount = (burnAmount * PRECISION) / (ETH_PRICE * ADDITIONAL_FEED_PRECISION); + if (redeemAmount > 0 && redeemAmount < collateralAmount / 2) { + stablecoin.approve(address(engine), burnAmount); + engine.redeemCollateralForStablecoin(address(weth), redeemAmount, burnAmount); + } + vm.stopPrank(); + + assertGe(engine.getHealthFactor(USER), 1e18); + } + + // ============================================ + // Burn Stablecoin Fuzz Tests + // ============================================ + + function testFuzz_BurnStablecoin(uint256 collateralAmount, uint256 mintAmount, uint256 burnAmount) public { + collateralAmount = bound(collateralAmount, 1 ether, STARTING_BALANCE); + uint256 maxCollateralUsd = (collateralAmount * ETH_PRICE * 1e10); // Convert to USD with 18 decimals + uint256 maxMint = maxCollateralUsd / 2; // 50% max collateral ratio + uint256 maxAllowedMint = stablecoin.totalSupply() / 100; // 1% of total supply + maxMint = maxMint > maxAllowedMint ? maxAllowedMint : maxMint; // Take the minimum + mintAmount = bound(mintAmount, 1e18, maxMint); + burnAmount = bound(burnAmount, 1e18, mintAmount); + + vm.startPrank(USER); + weth.approve(address(engine), collateralAmount); + engine.depositCollateralAndMintStablecoin(address(weth), collateralAmount, mintAmount); + + stablecoin.approve(address(engine), burnAmount); + engine.burnStablecoin(burnAmount); + vm.stopPrank(); + + assertEq(stablecoin.balanceOf(USER), mintAmount - burnAmount); + } + + // ============================================ + // Health Factor Fuzz Tests + // ============================================ + + function testFuzz_HealthFactorAlwaysValid(uint256 collateralAmount, uint256 mintAmount) public { + collateralAmount = bound(collateralAmount, 1 ether, STARTING_BALANCE); + uint256 maxMint = (collateralAmount * ETH_PRICE) / 2e8; + mintAmount = bound(mintAmount, 1e18, maxMint - 1e18); + + vm.startPrank(USER); + weth.approve(address(engine), collateralAmount); + engine.depositCollateralAndMintStablecoin(address(weth), collateralAmount, mintAmount); + vm.stopPrank(); + + uint256 healthFactor = engine.getHealthFactor(USER); + assertGe(healthFactor, 1e18, "Health factor below minimum"); + } + + // ============================================ + // Liquidation Fuzz Tests + // ============================================ + + function testFuzz_Liquidation(uint256 collateralAmount, uint256 priceDropPercent) public { + collateralAmount = bound(collateralAmount, 10 ether, STARTING_BALANCE); + priceDropPercent = bound(priceDropPercent, 51, 70); // 51-70% drop + + uint256 maxCollateralUsd = (collateralAmount * ETH_PRICE * 1e10); // Convert to USD with 18 decimals + uint256 maxMint = maxCollateralUsd / 2; // 50% max collateral ratio + uint256 maxAllowedMint = stablecoin.totalSupply() / 100; // 1% of total supply + maxMint = maxMint > maxAllowedMint ? maxAllowedMint : maxMint; // Take the minimum + uint256 mintAmount = maxMint * 99 / 100; // Mint 99% of max allowed + + // User deposits and mints + vm.startPrank(USER); + weth.approve(address(engine), collateralAmount); + engine.depositCollateralAndMintStablecoin(address(weth), collateralAmount, mintAmount); + vm.stopPrank(); + + // Price drops + uint256 newPrice = (ETH_PRICE * (100 - priceDropPercent)) / 100; + ethPriceFeed.updateAnswer(int256(newPrice)); + + // Verify user is liquidatable + uint256 healthFactorBefore = engine.getHealthFactor(USER); + if (healthFactorBefore >= 1e18) return; // Skip if still healthy + + // Prepare liquidator with stablecoin + uint256 debtToCover = mintAmount / 2; // Cover half the debt + + // Give liquidator more stablecoin directly instead of trying to mint + vm.startPrank(OWNER); + stablecoin.transfer(LIQUIDATOR, debtToCover); + vm.stopPrank(); + + vm.startPrank(LIQUIDATOR); + // Liquidate + stablecoin.approve(address(engine), debtToCover); + engine.liquidate(address(weth), USER, debtToCover); + vm.stopPrank(); + + // Verify liquidation improved health factor + uint256 healthFactorAfter = engine.getHealthFactor(USER); + assertGt(healthFactorAfter, healthFactorBefore, "Health factor should improve"); + } + + // ============================================ + // Edge Cases and Invariants + // ============================================ + + function testFuzz_CannotMintWithoutCollateral(uint256 mintAmount) public { + mintAmount = bound(mintAmount, 1, type(uint128).max); + + vm.startPrank(USER); + vm.expectRevert(); + engine.mintStablecoin(mintAmount); + vm.stopPrank(); + } + + function testFuzz_CannotRedeemMoreThanDeposited(uint256 depositAmount) public { + depositAmount = bound(depositAmount, 1 ether, STARTING_BALANCE); + uint256 redeemAmount = depositAmount + 1; + + vm.startPrank(USER); + weth.approve(address(engine), depositAmount); + engine.depositCollateral(address(weth), depositAmount); + + vm.expectRevert(); + engine.redeemCollateral(address(weth), redeemAmount); + vm.stopPrank(); + } + + function testFuzz_CollateralValueAlwaysAccurate(uint256 ethAmount, uint256 btcAmount) public { + ethAmount = bound(ethAmount, 1 ether, STARTING_BALANCE); + btcAmount = bound(btcAmount, 0.1 ether, STARTING_BALANCE); + + vm.startPrank(USER); + weth.approve(address(engine), ethAmount); + wbtc.approve(address(engine), btcAmount); + + engine.depositCollateral(address(weth), ethAmount); + engine.depositCollateral(address(wbtc), btcAmount); + vm.stopPrank(); + + // Calculate expected value in USD (18 decimals) + uint256 ethValue = (ethAmount * ETH_PRICE * ADDITIONAL_FEED_PRECISION) / PRECISION; + uint256 btcValue = (btcAmount * BTC_PRICE * ADDITIONAL_FEED_PRECISION) / PRECISION; + uint256 expectedValue = ethValue + btcValue; + uint256 actualValue = engine.getCollateralValue(USER); + + assertApproxEqRel(actualValue, expectedValue, 0.001e18, "Collateral value mismatch"); + } + + function testFuzz_MultipleUsersIndependent( + uint256 user1Collateral, + uint256 user1Mint, + uint256 user2Collateral, + uint256 user2Mint + ) public { + address USER2 = makeAddr("user2"); + + // Give USER2 tokens + vm.prank(OWNER); + weth.transfer(USER2, STARTING_BALANCE); + + // Bound amounts + user1Collateral = bound(user1Collateral, 2 ether, STARTING_BALANCE); + user2Collateral = bound(user2Collateral, 2 ether, STARTING_BALANCE); + + // Calculate max mint amounts for both users + uint256 maxCollateralUsd1 = (user1Collateral * ETH_PRICE * ADDITIONAL_FEED_PRECISION) / PRECISION; + uint256 maxCollateralUsd2 = (user2Collateral * ETH_PRICE * ADDITIONAL_FEED_PRECISION) / PRECISION; + + uint256 maxMint1 = maxCollateralUsd1 / 2; // 50% max collateral ratio + uint256 maxMint2 = maxCollateralUsd2 / 2; // 50% max collateral ratio + + uint256 maxAllowedMint = stablecoin.totalSupply() / 200; // 0.5% of supply for each user + maxMint1 = maxMint1 > maxAllowedMint ? maxAllowedMint : maxMint1; + maxMint2 = maxMint2 > maxAllowedMint ? maxAllowedMint : maxMint2; + + user1Mint = bound(user1Mint, 1e18, maxMint1); + user2Mint = bound(user2Mint, 1e18, maxMint2); + + // User 1 operations + vm.startPrank(USER); + weth.approve(address(engine), user1Collateral); + engine.depositCollateralAndMintStablecoin(address(weth), user1Collateral, user1Mint); + vm.stopPrank(); + + // User 2 operations + vm.startPrank(USER2); + weth.approve(address(engine), user2Collateral); + engine.depositCollateralAndMintStablecoin(address(weth), user2Collateral, user2Mint); + vm.stopPrank(); + + // Verify independence + assertEq(engine.getCollateralBalanceOfUser(USER, address(weth)), user1Collateral); + assertEq(engine.getCollateralBalanceOfUser(USER2, address(weth)), user2Collateral); + assertEq(stablecoin.balanceOf(USER), user1Mint); + assertEq(stablecoin.balanceOf(USER2), user2Mint); + assertGe(engine.getHealthFactor(USER), 1e18); + assertGe(engine.getHealthFactor(USER2), 1e18); + } + + function testFuzz_CannotBreakProtocolWithZeroValues(uint256 amount) public { + amount = bound(amount, 1, STARTING_BALANCE); + + vm.startPrank(USER); + weth.approve(address(engine), amount); + + // Should revert on zero deposits + vm.expectRevert(); + engine.depositCollateral(address(weth), 0); + + // Should revert on zero minting + engine.depositCollateral(address(weth), amount); + vm.expectRevert(); + engine.mintStablecoin(0); + + vm.stopPrank(); + } +} \ No newline at end of file diff --git a/test/unit/.main b/test/unit/.main new file mode 100644 index 0000000..30b81db --- /dev/null +++ b/test/unit/.main @@ -0,0 +1 @@ +release \ No newline at end of file diff --git a/test/unit/PriceOracleTest.t.sol b/test/unit/PriceOracleTest.t.sol index e69de29..a239d7e 100644 --- a/test/unit/PriceOracleTest.t.sol +++ b/test/unit/PriceOracleTest.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import {PriceOracle} from "src/libraries/PriceOracle.sol"; +import {VaultMath} from "src/libraries/VaultMath.sol"; +import {MockPriceFeed} from "src/mocks/MockPriceFeed.sol"; +import "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; + +/// @title PriceOracleTest +/// @notice Unit tests for the PriceOracle library + +contract PriceOracleTest is Test { + function testGetLatestPrice_returnsScaledPrice() public { + // keep block.number and block.timestamp aligned so both staleness checks pass + vm.roll(100); + vm.warp(100); + + MockPriceFeed mock = new MockPriceFeed(8, int256(2000e8)); + + uint256 price = PriceOracle.getLatestPrice(AggregatorV3Interface(address(mock))); + + uint256 expected = uint256(2000e8) * VaultMath.ADDITIONAL_FEED_PRECISION; + assertEq(price, expected); + } + + function testRevertsOnInvalidPriceZero() public { + vm.roll(1); + vm.warp(1); + MockPriceFeed mock = new MockPriceFeed(8, int256(1e8)); + // set the latest answer to zero + mock.updateAnswer(0); + + vm.expectRevert(bytes("Invalid price")); + PriceOracle.getLatestPrice(AggregatorV3Interface(address(mock))); + } + + function testRevertsOnStaleTime() public { + // normal block/ts + vm.roll(1); + vm.warp(100000); + + MockPriceFeed mock = new MockPriceFeed(8, int256(2000e8)); + + // set the round's updatedAt to older than timeout (3 hours) + uint256 old = block.timestamp - (3 hours + 1); + // use a new round id + mock.updateRoundData(2, int256(2000e8), old, old); + + vm.expectRevert(bytes("Price too stale (time)")); + PriceOracle.getLatestPrice(AggregatorV3Interface(address(mock))); + } + + function testRevertsOnStaleBlocks() public { + // Make block.number large while keeping timestamp small so block-based check fails + vm.roll(10000); + vm.warp(1000); + + MockPriceFeed mock = new MockPriceFeed(8, int256(2000e8)); + + // time-based check passes (updatedAt == block.timestamp), but block check should fail + vm.expectRevert(bytes("Price too stale (blocks)")); + PriceOracle.getLatestPrice(AggregatorV3Interface(address(mock))); + } +} +