diff --git a/README.md b/README.md index 5c29a6c..e134246 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,25 @@ New strategies are added as facets and registered through the curator, no vault - Performance fee gated by High Water Mark, fees only taken on net gains - Management fee accrues linearly (capped at 10% annually) - Slippage protection on strategy deposits +- NAV circuit breaker — bounds how far the share price may move between checkpoints + +### NAV circuit breaker + +The vault prices its shares on-chain (idle balance + each strategy's reported +position). To contain a bad rebalance, oracle glitch, or strategy exploit, the +owner sets `maxSharePriceDeltaBps` — the maximum share-price move tolerated +between checkpoints. Two enforcement paths: + +- **Hot-path tripwire** — every deposit/withdraw re-prices the vault and reverts + (`SharePriceDeviation`) if the move exceeds the bound, so users never transact + at an anomalous NAV. Self-healing: once the price is back in band, ops resume. +- **Latching poke** — anyone (a keeper or the curator agent) can call + `guardCheckpoint()`; on a breach it latches the vault into a paused state that + persists until the owner reviews and `unpause()`s. A revert can't latch a pause + in the same call, so this dedicated poke is what makes the auto-pause stick. + +The owner can also `pause()` / `unpause()` manually. With the bound set to `0` the +deviation check is disabled and only manual pause applies. ## Fees diff --git a/src/Vault.sol b/src/Vault.sol index f7f140d..05d23f8 100644 --- a/src/Vault.sol +++ b/src/Vault.sol @@ -9,13 +9,14 @@ import { IDiamond } from "./interfaces/IDiamond.sol"; import { LibDiamond } from "./libraries/LibDiamond.sol"; import { LibAllocator } from "./libraries/LibAllocator.sol"; import { LibFees } from "./libraries/LibFees.sol"; +import { LibGuard } from "./libraries/LibGuard.sol"; -/// @title Vault Router — modular ERC-4626 vault on the EIP-2535 Diamond pattern. +/// @title Vault Router is a modular ERC-4626 vault on the EIP-2535 Diamond pattern. /// @notice Vault.sol owns the ERC-4626 surface (deposit/withdraw/totalAssets) and /// acts as the Diamond proxy. Strategy logic, allocation policy, and /// harvesting live in facets attached via diamondCut. /// @dev Inflation attack mitigation comes from OZ ERC-4626's `_decimalsOffset` -/// virtual shares, not the literal 1 wei dead deposit pattern. +/// virtual shares. contract Vault is ERC4626 { error UnknownSelector(bytes4 selector); error StrategyTotalAssetsCallFailed(bytes32 strategyId); @@ -36,26 +37,27 @@ contract Vault is ERC4626 { LibDiamond.diamondCut(diamondCut_, init_, initCalldata_); } - /// @dev 6 decimals of virtual shares — OZ's recommended inflation-attack - /// mitigation for ERC-4626 vaults. + /// @dev 6 decimals of virtual shares, OZ's recommended inflation-attack + /// mitigation for ERC-4626 vaults. Sourced from LibGuard so the breaker's + /// share-price math and this offset can never drift apart. function _decimalsOffset() internal pure override returns (uint8) { - return 6; + return LibGuard.DECIMALS_OFFSET; } /// @notice Total assets under management = idle vault balance + sum of every /// registered strategy's reported position. /// @dev Self-staticcalls each strategy's totalAssets selector via the diamond /// fallback. When no strategies are registered the result equals the - /// vault's idle USDC balance (default ERC-4626 behaviour). + /// vault's idle USDC balance (default ERC-4626 behavior). function totalAssets() public view override returns (uint256) { uint256 total = IERC20(asset()).balanceOf(address(this)); - LibAllocator.AllocatorStorage storage s = LibAllocator.allocatorStorage(); - uint256 n = s.strategyIds.length; - for (uint256 i; i < n; i++) { - bytes32 id = s.strategyIds[i]; - LibAllocator.StrategyConfig storage cfg = s.configs[id]; - if (!cfg.active) continue; - (bool ok, bytes memory data) = address(this).staticcall(abi.encodeWithSelector(cfg.totalAssetsSelector)); + LibAllocator.AllocatorStorage storage $ = LibAllocator.allocatorStorage(); + uint256 length = $.strategyIds.length; + for (uint256 i; i < length; i++) { + bytes32 id = $.strategyIds[i]; + LibAllocator.StrategyConfig storage configs = $.configs[id]; + if (!configs.active) continue; + (bool ok, bytes memory data) = address(this).staticcall(abi.encodeWithSelector(configs.totalAssetsSelector)); if (!ok) revert StrategyTotalAssetsCallFailed(id); total += abi.decode(data, (uint256)); } @@ -67,6 +69,9 @@ contract Vault is ERC4626 { // ----------------------------------------------------------------------- function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { + // Circuit breaker: revert if paused, or if the current share price has + // moved beyond the configured bound since the last checkpoint. + _guard(); // Pre-accrue so the new depositor doesn't dilute the perf-fee owed on // yield earned by existing holders. No-op on the very first deposit // (supply == 0 → early return). @@ -87,11 +92,25 @@ contract Vault is ERC4626 { internal override { + _guard(); _accrueFees(); super._withdraw(caller, receiver, owner, assets, shares); _accrueFees(); } + /// @dev NAV circuit-breaker tripwire run on every deposit/withdraw. Reverts + /// when the breaker is latched (`EnforcedPause`) or when the live share + /// price deviates beyond the owner-set bound (`SharePriceDeviation`), + /// otherwise advances the checkpoint. Runs independently of fee accrual + /// so it is enforced even when no fee recipient is configured. + function _guard() internal { + LibGuard.GuardStorage storage g = LibGuard.guardStorage(); + if (g.paused) revert LibGuard.EnforcedPause(); + uint256 supply = totalSupply(); + if (supply == 0) return; // nothing to price yet; breaker arms on the next op + LibGuard.checkpoint(g, LibGuard.sharePrice(totalAssets(), supply)); + } + /// @dev Mints performance + management fee shares to the configured recipient. /// Performance fee is taken on any increase in share price above the HWM /// since the last accrual. Management fee accrues linearly over elapsed @@ -111,8 +130,7 @@ contract Vault is ERC4626 { } uint256 ta = totalAssets(); - uint256 effectiveSupply = supply + 10 ** _decimalsOffset(); - uint256 sharePrice = ((ta + 1) * 1e18) / effectiveSupply; + uint256 sharePrice = LibGuard.sharePrice(ta, supply); if (f.highWaterMark == 0) f.highWaterMark = sharePrice; if (f.lastFeeAccrual == 0) f.lastFeeAccrual = nowTs; diff --git a/src/facets/GuardFacet.sol b/src/facets/GuardFacet.sol new file mode 100644 index 0000000..d6feb31 --- /dev/null +++ b/src/facets/GuardFacet.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +import { LibDiamond } from "../libraries/LibDiamond.sol"; +import { LibGuard } from "../libraries/LibGuard.sol"; + +/// @title GuardFacet +/// @notice Owner controls for the NAV circuit breaker plus a permissionless +/// `guardCheckpoint` poke. The hot-path tripwire that protects every +/// deposit/withdraw lives in Vault.sol; this facet configures the bound, +/// exposes the latch, and lets a keeper or the curator agent enforce the +/// breaker continuously between user actions. +contract GuardFacet { + error InvalidBps(uint16 bps); + + event MaxSharePriceDeltaSet(uint16 bps); + event Paused(address indexed by); + event Unpaused(address indexed by, uint256 baseline); + event Checkpoint(uint256 sharePrice); + event BreakerTripped(uint256 lastSharePrice, uint256 currentSharePrice); + + // ----------------------------------------------------------------------- + // Owner-gated configuration + // ----------------------------------------------------------------------- + + /// @notice Set the max allowed share-price move between checkpoints, in bps. + /// @dev 0 disables the deviation check (pause latch still works manually). + function setMaxSharePriceDelta(uint16 bps) external { + LibDiamond.enforceIsContractOwner(); + if (bps > LibGuard.BPS_DENOMINATOR) revert InvalidBps(bps); + LibGuard.guardStorage().maxSharePriceDeltaBps = bps; + emit MaxSharePriceDeltaSet(bps); + } + + /// @notice Manually latch the breaker, halting deposits and withdrawals. + function pause() external { + LibDiamond.enforceIsContractOwner(); + LibGuard.guardStorage().paused = true; + emit Paused(msg.sender); + } + + /// @notice Clear the latch and re-baseline the checkpoint to the current + /// share price, so a normalised (or owner-accepted) NAV resumes + /// cleanly without immediately re-tripping the hot-path tripwire. + function unpause() external { + LibDiamond.enforceIsContractOwner(); + LibGuard.GuardStorage storage g = LibGuard.guardStorage(); + g.paused = false; + uint256 baseline = _currentSharePrice(); + g.lastSharePrice = baseline; + emit Unpaused(msg.sender, baseline); + } + + // ----------------------------------------------------------------------- + // Permissionless enforcement + // ----------------------------------------------------------------------- + + /// @notice Sample the current share price; if it deviates beyond the bound, + /// latch the vault into a paused state, otherwise advance the + /// checkpoint. Callable by anyone (keeper, curator agent, monitor). + /// @dev This call *commits* its state change, so the latch persists — unlike + /// the hot-path tripwire which can only revert the offending op. + function guardCheckpoint() external { + LibGuard.GuardStorage storage g = LibGuard.guardStorage(); + if (g.paused) return; + + uint256 supply = IERC20(address(this)).totalSupply(); + if (supply == 0) return; + + uint256 price = LibGuard.sharePrice(IERC4626(address(this)).totalAssets(), supply); + uint256 last = g.lastSharePrice; + if (last == 0) { + g.lastSharePrice = price; + emit Checkpoint(price); + return; + } + if (LibGuard.deviationExceeded(last, price, g.maxSharePriceDeltaBps)) { + g.paused = true; + emit BreakerTripped(last, price); + return; + } + g.lastSharePrice = price; + emit Checkpoint(price); + } + + // ----------------------------------------------------------------------- + // Readers + // ----------------------------------------------------------------------- + + function paused() external view returns (bool) { + return LibGuard.guardStorage().paused; + } + + function maxSharePriceDeltaBps() external view returns (uint16) { + return LibGuard.guardStorage().maxSharePriceDeltaBps; + } + + function lastSharePrice() external view returns (uint256) { + return LibGuard.guardStorage().lastSharePrice; + } + + // ----------------------------------------------------------------------- + // Internals + // ----------------------------------------------------------------------- + + function _currentSharePrice() internal view returns (uint256) { + uint256 supply = IERC20(address(this)).totalSupply(); + if (supply == 0) return 0; + return LibGuard.sharePrice(IERC4626(address(this)).totalAssets(), supply); + } +} diff --git a/src/libraries/LibGuard.sol b/src/libraries/LibGuard.sol new file mode 100644 index 0000000..b3be87b --- /dev/null +++ b/src/libraries/LibGuard.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title LibGuard +/// @notice Namespaced storage and logic for the vault's NAV circuit breaker. +/// Bounds how far the ERC-4626 share price may move between checkpoints; +/// a move beyond the bound either reverts the offending operation +/// (hot-path tripwire) or latches the vault into a paused state +/// (permissionless poke), depending on entry point. +/// @dev Why two behaviours: a Solidity revert rolls back *all* state in the +/// call, so an operation that detects an anomaly and reverts cannot also +/// persist `paused = true`. The hot path therefore reverts (never transact +/// at an anomalous NAV, self-healing), while a dedicated poke that does +/// nothing *but* record the breaker state can commit the latch. +/// Storage location: +/// keccak256(abi.encode(uint256(keccak256("vaultrouter.storage.guard")) - 1)) & ~bytes32(uint256(0xff)) +library LibGuard { + uint16 internal constant BPS_DENOMINATOR = 10_000; + + /// @dev Virtual-share offset for ERC-4626 inflation-attack mitigation. Kept + /// here as the single source of truth so Vault._decimalsOffset and the + /// share-price math below never drift apart. + uint8 internal constant DECIMALS_OFFSET = 6; + + bytes32 internal constant GUARD_STORAGE_SLOT = 0x2e670cc2b429ff4c75b2b5ce7b57521bb8c3d00aaafa77116d454e88d382a900; + + /// @notice Reverted on any deposit/withdraw while the breaker is latched. + error EnforcedPause(); + /// @notice Reverted on the hot path when the share price moved beyond bound. + error SharePriceDeviation(uint256 lastSharePrice, uint256 currentSharePrice, uint16 maxDeltaBps); + + /// @custom:storage-location erc7201:vaultrouter.storage.guard + struct GuardStorage { + bool paused; + /// @dev Max allowed |Δ share price| between checkpoints, in bps of the + /// previous checkpoint. 0 disables the deviation check entirely. + uint16 maxSharePriceDeltaBps; + /// @dev Last accepted share price (asset units per share, scaled 1e18). + uint256 lastSharePrice; + } + + function guardStorage() internal pure returns (GuardStorage storage s) { + bytes32 slot = GUARD_STORAGE_SLOT; + assembly { + s.slot := slot + } + } + + /// @notice Share price = (totalAssets + 1) * 1e18 / (supply + virtual shares). + /// @dev Mirrors OZ ERC-4626's conversion with the same virtual-share offset + /// Vault uses, so the breaker measures the exact price users transact at. + function sharePrice(uint256 totalAssets_, uint256 supply) internal pure returns (uint256) { + uint256 effectiveSupply = supply + 10 ** DECIMALS_OFFSET; + return ((totalAssets_ + 1) * 1e18) / effectiveSupply; + } + + /// @notice True if |current - last| exceeds `maxDeltaBps` of `last`. + /// @dev A zero bound or an unset checkpoint (`last == 0`) never trips. + function deviationExceeded(uint256 last, uint256 current, uint16 maxDeltaBps) internal pure returns (bool) { + if (maxDeltaBps == 0 || last == 0) return false; + uint256 diff = current > last ? current - last : last - current; + return diff * BPS_DENOMINATOR > uint256(maxDeltaBps) * last; + } + + /// @notice Hot-path tripwire: revert if `current` deviates beyond bound, + /// otherwise advance the checkpoint. Pause is enforced by the caller. + function checkpoint(GuardStorage storage g, uint256 current) internal { + uint256 last = g.lastSharePrice; + if (last == 0) { + g.lastSharePrice = current; // first checkpoint arms the breaker + return; + } + if (deviationExceeded(last, current, g.maxSharePriceDeltaBps)) { + revert SharePriceDeviation(last, current, g.maxSharePriceDeltaBps); + } + g.lastSharePrice = current; + } +} diff --git a/test/mocks/MockProtocol.sol b/test/mocks/MockProtocol.sol index 666cd5a..71e3390 100644 --- a/test/mocks/MockProtocol.sol +++ b/test/mocks/MockProtocol.sol @@ -35,4 +35,11 @@ contract MockProtocol { IMintable(address(asset)).mint(address(this), amount); balanceOf[account] += amount; } + + /// @notice Test-only — debits `account`'s internal balance to simulate a + /// strategy loss (exploit, bad debt, depeg). The reported position + /// drops, dragging the vault's totalAssets and share price down. + function _testSimulateLoss(address account, uint256 amount) external { + balanceOf[account] -= amount; + } } diff --git a/test/unit/CircuitBreaker.t.sol b/test/unit/CircuitBreaker.t.sol new file mode 100644 index 0000000..e92e0f8 --- /dev/null +++ b/test/unit/CircuitBreaker.t.sol @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { Vault } from "../../src/Vault.sol"; +import { IDiamond } from "../../src/interfaces/IDiamond.sol"; +import { IDiamondCut } from "../../src/interfaces/IDiamondCut.sol"; +import { IDiamondLoupe } from "../../src/interfaces/IDiamondLoupe.sol"; +import { IERC173 } from "../../src/interfaces/IERC173.sol"; +import { DiamondCutFacet } from "../../src/facets/DiamondCutFacet.sol"; +import { DiamondLoupeFacet } from "../../src/facets/DiamondLoupeFacet.sol"; +import { OwnershipFacet } from "../../src/facets/OwnershipFacet.sol"; +import { GuardFacet } from "../../src/facets/GuardFacet.sol"; +import { IdleStrategyFacet } from "../../src/facets/strategies/IdleStrategyFacet.sol"; +import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; +import { LibAllocator } from "../../src/libraries/LibAllocator.sol"; +import { LibGuard } from "../../src/libraries/LibGuard.sol"; +import { LibDiamond } from "../../src/libraries/LibDiamond.sol"; + +import { MockProtocol } from "../mocks/MockProtocol.sol"; +import { MockStrategyFacet } from "../mocks/MockStrategyFacet.sol"; + +contract MockUSDC is ERC20 { + constructor() ERC20("USD Coin", "USDC") { } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function decimals() public pure override returns (uint8) { + return 6; + } +} + +/// @notice Exercises the NAV circuit breaker: the hot-path deviation tripwire on +/// deposit/withdraw, the permissionless latching poke, and owner pause +/// controls. NAV is moved by simulating gains/losses in the mock strategy. +contract CircuitBreakerTest is Test { + MockUSDC internal usdc; + Vault internal vault; + MockProtocol internal mockProtocol; + + address internal owner = makeAddr("owner"); + address internal keeper = makeAddr("keeper"); + address internal alice = makeAddr("alice"); + + bytes32 internal constant MOCK_ID = bytes32("mock"); + uint16 internal constant BOUND_BPS = 1000; // 10% + + event Paused(address indexed by); + event Unpaused(address indexed by, uint256 baseline); + event BreakerTripped(uint256 lastSharePrice, uint256 currentSharePrice); + + function setUp() public { + usdc = new MockUSDC(); + mockProtocol = new MockProtocol(IERC20(address(usdc))); + vault = _deployVault(); + + vm.prank(owner); + MockStrategyFacet(address(vault)).mockSetProtocol(mockProtocol); + vm.prank(owner); + AllocatorFacet(address(vault)).registerStrategy(MOCK_ID, _mockStrategyConfig(0)); + + // Deposit and deploy 100% into the mock so a strategy loss maps 1:1 to NAV. + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(MOCK_ID, 10_000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + } + + // ----------------------------------------------------------------------- + // configuration / access control + // ----------------------------------------------------------------------- + + function test_SetMaxDelta_OwnerOnly() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibDiamond.NotContractOwner.selector, alice, owner)); + GuardFacet(address(vault)).setMaxSharePriceDelta(BOUND_BPS); + } + + function test_SetMaxDelta_RejectsOutOfRange() public { + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(GuardFacet.InvalidBps.selector, 10_001)); + GuardFacet(address(vault)).setMaxSharePriceDelta(10_001); + } + + function test_SetMaxDelta_StoresValue() public { + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(BOUND_BPS); + assertEq(GuardFacet(address(vault)).maxSharePriceDeltaBps(), BOUND_BPS); + } + + function test_Pause_OwnerOnly() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibDiamond.NotContractOwner.selector, alice, owner)); + GuardFacet(address(vault)).pause(); + } + + // ----------------------------------------------------------------------- + // manual pause halts deposit + withdraw + // ----------------------------------------------------------------------- + + function test_ManualPause_BlocksDeposit() public { + vm.prank(owner); + GuardFacet(address(vault)).pause(); + assertTrue(GuardFacet(address(vault)).paused()); + + usdc.mint(alice, 100 * 1e6); + vm.startPrank(alice); + usdc.approve(address(vault), 100 * 1e6); + vm.expectRevert(LibGuard.EnforcedPause.selector); + vault.deposit(100 * 1e6, alice); + vm.stopPrank(); + } + + function test_ManualPause_BlocksWithdraw() public { + vm.prank(owner); + GuardFacet(address(vault)).pause(); + + vm.prank(alice); + vm.expectRevert(LibGuard.EnforcedPause.selector); + vault.withdraw(10 * 1e6, alice, alice); + } + + function test_Unpause_ResumesAndRebaselines() public { + vm.startPrank(owner); + GuardFacet(address(vault)).pause(); + GuardFacet(address(vault)).unpause(); + vm.stopPrank(); + + assertFalse(GuardFacet(address(vault)).paused()); + // Baseline was reset to the live price, so a normal deposit goes through. + _depositToVault(alice, 100 * 1e6); + } + + // ----------------------------------------------------------------------- + // hot-path deviation tripwire + // ----------------------------------------------------------------------- + + function test_Tripwire_RevertsDepositOnLargeLoss() public { + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(BOUND_BPS); + _armBreaker(); + + // Strategy loses 20% — beyond the 10% bound. + mockProtocol._testSimulateLoss(address(vault), 200 * 1e6); + + uint256 last = GuardFacet(address(vault)).lastSharePrice(); + usdc.mint(alice, 100 * 1e6); + vm.startPrank(alice); + usdc.approve(address(vault), 100 * 1e6); + vm.expectRevert( + abi.encodeWithSelector(LibGuard.SharePriceDeviation.selector, last, _liveSharePrice(), BOUND_BPS) + ); + vault.deposit(100 * 1e6, alice); + vm.stopPrank(); + } + + function test_Tripwire_RevertsWithdrawOnLargeLoss() public { + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(BOUND_BPS); + _armBreaker(); + + mockProtocol._testSimulateLoss(address(vault), 200 * 1e6); + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector( + LibGuard.SharePriceDeviation.selector, + GuardFacet(address(vault)).lastSharePrice(), + _liveSharePrice(), + BOUND_BPS + ) + ); + vault.withdraw(10 * 1e6, alice, alice); + } + + function test_Tripwire_AllowsWithinBound() public { + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(BOUND_BPS); + _armBreaker(); + + // 5% loss — within the 10% bound. Deposit still succeeds. + mockProtocol._testSimulateLoss(address(vault), 50 * 1e6); + _depositToVault(alice, 100 * 1e6); + } + + function test_Tripwire_TriggersOnLargeGainToo() public { + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(BOUND_BPS); + _armBreaker(); + + // 30% sudden gain (e.g. price manipulation) also breaches the bound. + mockProtocol._testAccrueYield(address(vault), 300 * 1e6); + + usdc.mint(alice, 100 * 1e6); + vm.startPrank(alice); + usdc.approve(address(vault), 100 * 1e6); + vm.expectRevert( + abi.encodeWithSelector( + LibGuard.SharePriceDeviation.selector, + GuardFacet(address(vault)).lastSharePrice(), + _liveSharePrice(), + BOUND_BPS + ) + ); + vault.deposit(100 * 1e6, alice); + vm.stopPrank(); + } + + function test_Disabled_LossDoesNotTrip() public { + // maxDelta left at 0 (default disabled). + _armBreaker(); + mockProtocol._testSimulateLoss(address(vault), 500 * 1e6); // 50% loss + _depositToVault(alice, 100 * 1e6); // still works + GuardFacet(address(vault)).guardCheckpoint(); + assertFalse(GuardFacet(address(vault)).paused()); + } + + // ----------------------------------------------------------------------- + // permissionless latching poke + // ----------------------------------------------------------------------- + + function test_GuardCheckpoint_LatchesPauseOnDeviation() public { + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(BOUND_BPS); + _armBreaker(); + + mockProtocol._testSimulateLoss(address(vault), 200 * 1e6); // 20% loss + + uint256 last = GuardFacet(address(vault)).lastSharePrice(); + vm.expectEmit(false, false, false, true, address(vault)); + emit BreakerTripped(last, _liveSharePrice()); + vm.prank(keeper); // anyone can poke + GuardFacet(address(vault)).guardCheckpoint(); + + assertTrue(GuardFacet(address(vault)).paused()); + + // Once latched, deposits are halted even though the move was transient. + usdc.mint(alice, 100 * 1e6); + vm.startPrank(alice); + usdc.approve(address(vault), 100 * 1e6); + vm.expectRevert(LibGuard.EnforcedPause.selector); + vault.deposit(100 * 1e6, alice); + vm.stopPrank(); + } + + function test_GuardCheckpoint_NoOpWithinBound() public { + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(BOUND_BPS); + _armBreaker(); + + mockProtocol._testSimulateLoss(address(vault), 50 * 1e6); // 5% loss + vm.prank(keeper); + GuardFacet(address(vault)).guardCheckpoint(); + + assertFalse(GuardFacet(address(vault)).paused()); + } + + function test_OwnerCanRecoverAfterTrip() public { + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(BOUND_BPS); + _armBreaker(); + mockProtocol._testSimulateLoss(address(vault), 200 * 1e6); + vm.prank(keeper); + GuardFacet(address(vault)).guardCheckpoint(); + assertTrue(GuardFacet(address(vault)).paused()); + + // Owner reviews, accepts the new NAV, and unpauses (rebaselines). + vm.prank(owner); + GuardFacet(address(vault)).unpause(); + assertFalse(GuardFacet(address(vault)).paused()); + _depositToVault(alice, 100 * 1e6); + } + + // ----------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------- + + function _armBreaker() internal { + GuardFacet(address(vault)).guardCheckpoint(); // sets first checkpoint baseline + } + + function _liveSharePrice() internal view returns (uint256) { + return LibGuard.sharePrice(vault.totalAssets(), vault.totalSupply()); + } + + function _deployVault() internal returns (Vault) { + DiamondCutFacet cut = new DiamondCutFacet(); + DiamondLoupeFacet loupe = new DiamondLoupeFacet(); + OwnershipFacet ownership = new OwnershipFacet(); + GuardFacet guard = new GuardFacet(); + IdleStrategyFacet idle = new IdleStrategyFacet(); + AllocatorFacet allocator = new AllocatorFacet(); + MockStrategyFacet mock = new MockStrategyFacet(); + + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](7); + cuts[0] = IDiamond.FacetCut({ + facetAddress: address(cut), action: IDiamond.FacetCutAction.Add, functionSelectors: _diamondCutSelectors() + }); + cuts[1] = IDiamond.FacetCut({ + facetAddress: address(loupe), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _diamondLoupeSelectors() + }); + cuts[2] = IDiamond.FacetCut({ + facetAddress: address(ownership), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _ownershipSelectors() + }); + cuts[3] = IDiamond.FacetCut({ + facetAddress: address(guard), action: IDiamond.FacetCutAction.Add, functionSelectors: _guardSelectors() + }); + cuts[4] = IDiamond.FacetCut({ + facetAddress: address(idle), action: IDiamond.FacetCutAction.Add, functionSelectors: _idleSelectors() + }); + cuts[5] = IDiamond.FacetCut({ + facetAddress: address(allocator), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _allocatorSelectors() + }); + cuts[6] = IDiamond.FacetCut({ + facetAddress: address(mock), action: IDiamond.FacetCutAction.Add, functionSelectors: _mockSelectors() + }); + + return new Vault(IERC20(address(usdc)), "Vault Router", "vUSDC", owner, cuts, address(0), ""); + } + + function _depositToVault(address from, uint256 amount) internal { + usdc.mint(from, amount); + vm.startPrank(from); + usdc.approve(address(vault), amount); + vault.deposit(amount, from); + vm.stopPrank(); + } + + function _setSingleAllocation(bytes32 id, uint16 bps) internal { + bytes32[] memory ids = new bytes32[](1); + uint16[] memory b = new uint16[](1); + ids[0] = id; + b[0] = bps; + vm.prank(owner); + AllocatorFacet(address(vault)).setAllocation(ids, b); + } + + function _mockStrategyConfig(uint16 capBps) internal pure returns (LibAllocator.StrategyConfig memory) { + return LibAllocator.StrategyConfig({ + totalAssetsSelector: MockStrategyFacet.mockTotalAssets.selector, + depositSelector: MockStrategyFacet.mockDeposit.selector, + withdrawSelector: MockStrategyFacet.mockWithdraw.selector, + harvestSelector: MockStrategyFacet.mockHarvest.selector, + capBps: capBps, + active: false + }); + } + + function _diamondCutSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](1); + s[0] = IDiamondCut.diamondCut.selector; + } + + function _diamondLoupeSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](4); + s[0] = IDiamondLoupe.facets.selector; + s[1] = IDiamondLoupe.facetFunctionSelectors.selector; + s[2] = IDiamondLoupe.facetAddresses.selector; + s[3] = IDiamondLoupe.facetAddress.selector; + } + + function _ownershipSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](2); + s[0] = IERC173.owner.selector; + s[1] = IERC173.transferOwnership.selector; + } + + function _guardSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](7); + s[0] = GuardFacet.setMaxSharePriceDelta.selector; + s[1] = GuardFacet.pause.selector; + s[2] = GuardFacet.unpause.selector; + s[3] = GuardFacet.guardCheckpoint.selector; + s[4] = GuardFacet.paused.selector; + s[5] = GuardFacet.maxSharePriceDeltaBps.selector; + s[6] = GuardFacet.lastSharePrice.selector; + } + + function _idleSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](1); + s[0] = IdleStrategyFacet.idleTotalAssets.selector; + } + + function _allocatorSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](13); + s[0] = AllocatorFacet.registerStrategy.selector; + s[1] = AllocatorFacet.removeStrategy.selector; + s[2] = AllocatorFacet.setAllocation.selector; + s[3] = AllocatorFacet.setIdleReserve.selector; + s[4] = AllocatorFacet.setStrategyCap.selector; + s[5] = AllocatorFacet.setGlobalStrategyCap.selector; + s[6] = AllocatorFacet.rebalance.selector; + s[7] = AllocatorFacet.strategies.selector; + s[8] = AllocatorFacet.strategyConfig.selector; + s[9] = AllocatorFacet.targetAllocation.selector; + s[10] = AllocatorFacet.idleReserveBps.selector; + s[11] = AllocatorFacet.strategyTotalAssets.selector; + s[12] = AllocatorFacet.idleAssets.selector; + } + + function _mockSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](6); + s[0] = MockStrategyFacet.mockSetProtocol.selector; + s[1] = MockStrategyFacet.mockProtocol.selector; + s[2] = MockStrategyFacet.mockTotalAssets.selector; + s[3] = MockStrategyFacet.mockDeposit.selector; + s[4] = MockStrategyFacet.mockWithdraw.selector; + s[5] = MockStrategyFacet.mockHarvest.selector; + } +}