diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b79c8d4..f29794f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,5 +34,17 @@ jobs: - name: Run Forge build run: forge build --sizes - - name: Run Forge tests - run: forge test -vvv + # Deterministic, RPC-free layers: unit, adversarial, fuzz, invariant, and + # the non-fork integration lifecycle. These must always pass. + - name: Run Forge tests (local) + run: forge test -vvv --no-match-path 'test/integration/*.fork.t.sol' + + # Fork layer (Aave / Morpho / Compound / Pendle on Arbitrum). Each test + # self-skips when ARBITRUM_RPC_URL is unset, so this step stays green on + # forks/PRs without the secret and exercises the real protocols when it is + # provided. Set the ARBITRUM_RPC_URL repository secret to enable. + - name: Run Forge fork tests + env: + ARBITRUM_RPC_URL: ${{ secrets.ARBITRUM_RPC_URL }} + ARBITRUM_FORK_BLOCK: ${{ vars.ARBITRUM_FORK_BLOCK }} + run: forge test -vvv --match-path 'test/integration/*.fork.t.sol' diff --git a/foundry.toml b/foundry.toml index af84fd0..6e06dc0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -12,9 +12,11 @@ via_ir = false ast = true extra_output = ["storageLayout"] fuzz = { runs = 1_000 } +invariant = { runs = 128, depth = 64, fail_on_revert = false } [profile.ci] fuzz = { runs = 10_000 } +invariant = { runs = 256, depth = 128, fail_on_revert = false } verbosity = 4 [fmt] diff --git a/test/fuzz/VaultFuzz.t.sol b/test/fuzz/VaultFuzz.t.sol new file mode 100644 index 0000000..d62de81 --- /dev/null +++ b/test/fuzz/VaultFuzz.t.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { VaultHarness } from "../helpers/VaultHarness.sol"; +import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; +import { FeeFacet } from "../../src/facets/FeeFacet.sol"; +import { LibGuard } from "../../src/libraries/LibGuard.sol"; + +/// @dev External wrapper so the fuzzer can exercise LibGuard's internal pure +/// math (deviation + share-price) directly, in isolation from vault state. +contract LibGuardWrapper { + function deviationExceeded(uint256 last, uint256 current, uint16 bps) external pure returns (bool) { + return LibGuard.deviationExceeded(last, current, bps); + } + + function sharePrice(uint256 totalAssets_, uint256 supply) external pure returns (uint256) { + return LibGuard.sharePrice(totalAssets_, supply); + } +} + +/// @title VaultFuzzTest +/// @notice Property-based fuzzing of the value-conservation and breaker-math +/// guarantees the unit tests can only spot-check. The recurring theme: +/// no input should let a user extract value the vault did not earn, and +/// fee/rounding must always break in the vault's favour. +contract VaultFuzzTest is VaultHarness { + LibGuardWrapper internal guardMath; + + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal feeRec = makeAddr("feeRec"); + + function setUp() public { + _deployHarness(); + guardMath = new LibGuardWrapper(); + } + + // ----------------------------------------------------------------------- + // Value conservation + // ----------------------------------------------------------------------- + + /// @notice A deposit immediately redeemed can never return more than was put + /// in. Rounding must always favour the vault (no free wei). + function testFuzz_DepositRedeemRoundTripNeverProfits(uint256 amount) public { + amount = bound(amount, 1, 1e15); + uint256 shares = _deposit(alice, amount); + + vm.prank(alice); + uint256 out = vault.redeem(shares, alice, alice); + + assertLe(out, amount, "round-trip returned more than deposited"); + } + + /// @notice convertToAssets(convertToShares(x)) <= x for any seed state: the + /// double conversion can never manufacture value. + function testFuzz_ConvertRoundTripNeverProfits(uint256 seed, uint256 assets) public { + seed = bound(seed, 1e6, 1e15); + assets = bound(assets, 1, 1e15); + _deposit(alice, seed); // establish a non-trivial supply/NAV + + uint256 back = vault.convertToAssets(vault.convertToShares(assets)); + assertLe(back, assets, "convert round-trip created value"); + } + + /// @notice A NAV-neutral rebalance (the mock backend is 1:1) must leave + /// totalAssets unchanged for any target split within budget. + function testFuzz_RebalancePreservesNav(uint256 amount, uint16 bps) public { + amount = bound(amount, 1e6, 1e15); + bps = uint16(bound(bps, 0, 10_000)); + _registerMock(); + _deposit(alice, amount); + + _setAlloc(MOCK_ID, bps); + _rebalance(); + + assertEq(vault.totalAssets(), amount, "NAV drifted across a 1:1 rebalance"); + } + + // ----------------------------------------------------------------------- + // Fees never make the vault insolvent + // ----------------------------------------------------------------------- + + /// @notice Under any fee config, donation, and elapsed time, the redeemable + /// value of all shares must stay <= totalAssets. Fee minting dilutes + /// holders but must never let the share base out-value the vault. + function testFuzz_FeeAccrualPreservesSolvency( + uint256 amount, + uint256 donation, + uint16 perfBps, + uint16 mgmtBps, + uint256 elapsed + ) + public + { + amount = bound(amount, 1e6, 1e15); + donation = bound(donation, 0, 1e15); + perfBps = uint16(bound(perfBps, 0, 5000)); + mgmtBps = uint16(bound(mgmtBps, 0, 1000)); + elapsed = bound(elapsed, 0, 730 days); + + vm.startPrank(owner); + FeeFacet(address(vault)).setFeeRecipient(feeRec); + FeeFacet(address(vault)).setPerformanceFee(perfBps); + FeeFacet(address(vault)).setManagementFee(mgmtBps); + vm.stopPrank(); + + _deposit(alice, amount); + if (donation > 0) { + usdc.mint(address(vault), donation); // simulate yield as idle growth + } + vm.warp(block.timestamp + elapsed); + + // Trigger accrual through a fresh deposit. + _deposit(bob, 1e6); + + assertLe( + vault.convertToAssets(vault.totalSupply()), + vault.totalAssets(), + "fee accrual made the share base out-value the vault" + ); + } + + // ----------------------------------------------------------------------- + // Circuit-breaker math (pure) + // ----------------------------------------------------------------------- + + function testFuzz_GuardDeviationMatchesFormula(uint256 last, uint256 current, uint16 bps) public view { + last = bound(last, 1, 1e30); + current = bound(current, 0, 1e30); + bps = uint16(bound(bps, 0, 10_000)); + + bool got = guardMath.deviationExceeded(last, current, bps); + uint256 diff = current > last ? current - last : last - current; + bool expected = bps != 0 && diff * 10_000 > uint256(bps) * last; + assertEq(got, expected, "deviationExceeded diverged from its formula"); + } + + function testFuzz_GuardSharePriceMonotonicInAssets(uint256 ta, uint256 delta, uint256 supply) public view { + ta = bound(ta, 0, 1e30); + delta = bound(delta, 1, 1e30); + supply = bound(supply, 0, 1e30); + + uint256 p1 = guardMath.sharePrice(ta, supply); + uint256 p2 = guardMath.sharePrice(ta + delta, supply); + assertGe(p2, p1, "share price must not fall when assets rise"); + } +} diff --git a/test/helpers/VaultHarness.sol b/test/helpers/VaultHarness.sol new file mode 100644 index 0000000..f359900 --- /dev/null +++ b/test/helpers/VaultHarness.sol @@ -0,0 +1,293 @@ +// 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 { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; +import { GuardFacet } from "../../src/facets/GuardFacet.sol"; +import { FeeFacet } from "../../src/facets/FeeFacet.sol"; +import { RolesFacet } from "../../src/facets/RolesFacet.sol"; +import { LockFacet } from "../../src/facets/LockFacet.sol"; +import { WithdrawQueueFacet } from "../../src/facets/WithdrawQueueFacet.sol"; +import { IdleStrategyFacet } from "../../src/facets/strategies/IdleStrategyFacet.sol"; +import { CompoundV3StrategyFacet } from "../../src/facets/strategies/CompoundV3StrategyFacet.sol"; +import { IComet } from "../../src/interfaces/external/IComet.sol"; + +import { LibAllocator } from "../../src/libraries/LibAllocator.sol"; + +import { MockProtocol } from "../mocks/MockProtocol.sol"; +import { MockStrategyFacet } from "../mocks/MockStrategyFacet.sol"; +import { MockComet } from "../mocks/MockComet.sol"; + +contract HarnessUSDC 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; + } +} + +/// @title VaultHarness +/// @notice Shared deploy + wiring for the fuzz, invariant, and integration +/// suites. Stands up a fully-loaded diamond (every facet) plus two real +/// strategy backends a test can run entirely locally: the mock protocol +/// (`MOCK_ID`) and a Compound III market via `MockComet` (`COMPOUND_ID`). +/// Keeping the 11-facet selector wiring in one place means the heavier +/// suites can focus on properties instead of plumbing. +abstract contract VaultHarness is Test { + HarnessUSDC internal usdc; + Vault internal vault; + MockProtocol internal mockProtocol; + MockComet internal comet; + + address internal owner = makeAddr("owner"); + + bytes32 internal constant MOCK_ID = bytes32("mock"); + bytes32 internal constant COMPOUND_ID = bytes32("compound"); + + function _deployHarness() internal { + usdc = new HarnessUSDC(); + mockProtocol = new MockProtocol(IERC20(address(usdc))); + comet = new MockComet(IERC20(address(usdc))); + vault = _deployVault(IERC20(address(usdc))); + + vm.startPrank(owner); + MockStrategyFacet(address(vault)).mockSetProtocol(mockProtocol); + CompoundV3StrategyFacet(address(vault)).compoundSetConfig(IComet(address(comet))); + vm.stopPrank(); + } + + function _registerMock() internal { + vm.prank(owner); + AllocatorFacet(address(vault)).registerStrategy(MOCK_ID, _mockConfig()); + } + + function _registerCompound() internal { + vm.prank(owner); + AllocatorFacet(address(vault)).registerStrategy(COMPOUND_ID, _compoundConfig()); + } + + // ----------------------------------------------------------------------- + // Common helpers + // ----------------------------------------------------------------------- + + function _deposit(address from, uint256 amount) internal returns (uint256 shares) { + usdc.mint(from, amount); + vm.startPrank(from); + usdc.approve(address(vault), amount); + shares = vault.deposit(amount, from); + vm.stopPrank(); + } + + function _setAlloc(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 _setAlloc2(bytes32 id0, uint16 b0, bytes32 id1, uint16 b1) internal { + bytes32[] memory ids = new bytes32[](2); + uint16[] memory b = new uint16[](2); + ids[0] = id0; + ids[1] = id1; + b[0] = b0; + b[1] = b1; + vm.prank(owner); + AllocatorFacet(address(vault)).setAllocation(ids, b); + } + + function _rebalance() internal { + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + } + + function _mockConfig() 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: 0, + active: false + }); + } + + function _compoundConfig() internal pure returns (LibAllocator.StrategyConfig memory) { + return LibAllocator.StrategyConfig({ + totalAssetsSelector: CompoundV3StrategyFacet.compoundTotalAssets.selector, + depositSelector: CompoundV3StrategyFacet.compoundDeposit.selector, + withdrawSelector: CompoundV3StrategyFacet.compoundWithdraw.selector, + harvestSelector: CompoundV3StrategyFacet.compoundHarvest.selector, + capBps: 0, + active: false + }); + } + + // ----------------------------------------------------------------------- + // Deploy + selector wiring + // ----------------------------------------------------------------------- + + function _deployVault(IERC20 asset_) internal returns (Vault) { + address cut = address(new DiamondCutFacet()); + address loupe = address(new DiamondLoupeFacet()); + address ownership = address(new OwnershipFacet()); + address idle = address(new IdleStrategyFacet()); + address allocator = address(new AllocatorFacet()); + address guard = address(new GuardFacet()); + address fee = address(new FeeFacet()); + address roles = address(new RolesFacet()); + address lock = address(new LockFacet()); + address queue = address(new WithdrawQueueFacet()); + address mock = address(new MockStrategyFacet()); + address compound = address(new CompoundV3StrategyFacet()); + + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](12); + cuts[0] = _fc(cut, _cutSel()); + cuts[1] = _fc(loupe, _loupeSel()); + cuts[2] = _fc(ownership, _ownSel()); + cuts[3] = _fc(idle, _idleSel()); + cuts[4] = _fc(allocator, _allocSel()); + cuts[5] = _fc(guard, _guardSel()); + cuts[6] = _fc(fee, _feeSel()); + cuts[7] = _fc(roles, _rolesSel()); + cuts[8] = _fc(lock, _lockSel()); + cuts[9] = _fc(queue, _queueSel()); + cuts[10] = _fc(mock, _mockSel()); + cuts[11] = _fc(compound, _compoundSel()); + + return new Vault(asset_, "Vault Router", "vUSDC", owner, cuts, address(0), ""); + } + + function _fc(address facet, bytes4[] memory sels) internal pure returns (IDiamond.FacetCut memory) { + return IDiamond.FacetCut({ facetAddress: facet, action: IDiamond.FacetCutAction.Add, functionSelectors: sels }); + } + + function _cutSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](1); + s[0] = IDiamondCut.diamondCut.selector; + } + + function _loupeSel() 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 _ownSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](2); + s[0] = IERC173.owner.selector; + s[1] = IERC173.transferOwnership.selector; + } + + function _idleSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](1); + s[0] = IdleStrategyFacet.idleTotalAssets.selector; + } + + function _allocSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](17); + 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.setMaxRebalanceDelta.selector; + s[6] = AllocatorFacet.rebalance.selector; + s[7] = AllocatorFacet.quarantineStrategy.selector; + s[8] = AllocatorFacet.releaseStrategy.selector; + s[9] = AllocatorFacet.quarantineFailedStrategy.selector; + s[10] = AllocatorFacet.strategies.selector; + s[11] = AllocatorFacet.idleAssets.selector; + s[12] = AllocatorFacet.isQuarantined.selector; + s[13] = AllocatorFacet.strategyTotalAssets.selector; + s[14] = AllocatorFacet.targetAllocation.selector; + s[15] = AllocatorFacet.maxRebalanceDelta.selector; + s[16] = AllocatorFacet.idleReserveBps.selector; + } + + function _guardSel() 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 _feeSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](8); + s[0] = FeeFacet.setFeeRecipient.selector; + s[1] = FeeFacet.setPerformanceFee.selector; + s[2] = FeeFacet.setManagementFee.selector; + s[3] = FeeFacet.feeRecipient.selector; + s[4] = FeeFacet.performanceFeeBps.selector; + s[5] = FeeFacet.managementFeeBps.selector; + s[6] = FeeFacet.highWaterMark.selector; + s[7] = FeeFacet.lastFeeAccrual.selector; + } + + function _rolesSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](2); + s[0] = RolesFacet.setCurator.selector; + s[1] = RolesFacet.isCurator.selector; + } + + function _lockSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](3); + s[0] = LockFacet.setShareLockPeriod.selector; + s[1] = LockFacet.shareLockPeriod.selector; + s[2] = LockFacet.lockedUntil.selector; + } + + function _queueSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](3); + s[0] = WithdrawQueueFacet.nextWithdrawRequestId.selector; + s[1] = WithdrawQueueFacet.pendingWithdrawShares.selector; + s[2] = WithdrawQueueFacet.withdrawRequest.selector; + } + + function _mockSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](8); + 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; + s[6] = MockStrategyFacet.mockSetReverting.selector; + s[7] = MockStrategyFacet.mockSetRevertOnMove.selector; + } + + function _compoundSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](6); + s[0] = CompoundV3StrategyFacet.compoundSetConfig.selector; + s[1] = CompoundV3StrategyFacet.compoundTotalAssets.selector; + s[2] = CompoundV3StrategyFacet.compoundDeposit.selector; + s[3] = CompoundV3StrategyFacet.compoundWithdraw.selector; + s[4] = CompoundV3StrategyFacet.compoundHarvest.selector; + s[5] = CompoundV3StrategyFacet.compoundComet.selector; + } +} diff --git a/test/integration/AaveStrategy.fork.t.sol b/test/integration/AaveStrategy.fork.t.sol index d62918d..6cdd40e 100644 --- a/test/integration/AaveStrategy.fork.t.sol +++ b/test/integration/AaveStrategy.fork.t.sol @@ -40,7 +40,7 @@ contract AaveStrategyForkTest is Test { // Fork tests require a dedicated Arbitrum RPC (the public endpoint times // out on the storage-introspection calls forge-std's `deal` cheat makes). // Set ARBITRUM_RPC_URL in your shell or .env to opt in. - string memory rpc = vm.envOr("ARBITRUM_RPC_URL", string("")); + string memory rpc = vm.envOr("RPC_URL_ARB", vm.envOr("ARBITRUM_RPC_URL", string(""))); if (bytes(rpc).length == 0) { vm.skip(true); return; @@ -111,13 +111,24 @@ contract AaveStrategyForkTest is Test { vm.warp(block.timestamp + 30 days); vm.roll(block.number + 1_296_000); + // Synchronous redeem pays from idle only — by design, illiquid exits go + // through the async withdraw queue (cf. the *_Redeem_RevertsWhenExceeds + // IdleLiquidity unit tests and audit F19). With 80% sitting in Aave, the + // curator first frees liquidity by pulling the allocation back to zero, + // which divests aUSDC into idle USDC. + vm.prank(owner); + _setSingleAllocation(AAVE_ID, 0); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + uint256 aliceShares = vault.balanceOf(alice); vm.prank(alice); uint256 assetsReturned = vault.redeem(aliceShares, alice, alice); assertGe(assetsReturned, 1000 * 1e6, "alice gets back at least her principal"); assertEq(IERC20(ARB_USDC).balanceOf(alice), assetsReturned, "alice's wallet credited"); - assertApproxEqAbs(IERC20(ARB_AUSDC).balanceOf(address(vault)), 0, 1, "aUSDC drained back to idle on redeem"); + assertApproxEqAbs(IERC20(ARB_AUSDC).balanceOf(address(vault)), 0, 1, "aUSDC fully divested back to idle"); } // ----------------------------------------------------------------------- diff --git a/test/integration/CompoundV3Strategy.fork.t.sol b/test/integration/CompoundV3Strategy.fork.t.sol index 360070c..c93db2e 100644 --- a/test/integration/CompoundV3Strategy.fork.t.sol +++ b/test/integration/CompoundV3Strategy.fork.t.sol @@ -37,7 +37,7 @@ contract CompoundV3StrategyForkTest is Test { address internal alice = makeAddr("alice"); function setUp() public { - string memory rpc = vm.envOr("ARBITRUM_RPC_URL", string("")); + string memory rpc = vm.envOr("RPC_URL_ARB", vm.envOr("ARBITRUM_RPC_URL", string(""))); if (bytes(rpc).length == 0) { vm.skip(true); return; diff --git a/test/integration/Lifecycle.t.sol b/test/integration/Lifecycle.t.sol new file mode 100644 index 0000000..80c5c39 --- /dev/null +++ b/test/integration/Lifecycle.t.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { VaultHarness } from "../helpers/VaultHarness.sol"; +import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; +import { FeeFacet } from "../../src/facets/FeeFacet.sol"; +import { GuardFacet } from "../../src/facets/GuardFacet.sol"; +import { WithdrawQueueFacet } from "../../src/facets/WithdrawQueueFacet.sol"; +import { LibGuard } from "../../src/libraries/LibGuard.sol"; + +/// @title LifecycleTest +/// @notice End-to-end, multi-facet integration flows that no single unit test +/// covers: capital routed across two real strategy facets at once, yield +/// and performance fees, a stuck strategy isolated via quarantine while a +/// depositor exits through the async queue, and a breaker trip-and-recover +/// cycle. Runs fully locally (Compound via MockComet, no fork needed). +contract LifecycleTest is VaultHarness { + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal carol = makeAddr("carol"); + address internal feeRec = makeAddr("feeRec"); + + function setUp() public { + _deployHarness(); + _registerMock(); + _registerCompound(); + } + + // ----------------------------------------------------------------------- + // A. Two strategies + yield + performance fee + full exit + // ----------------------------------------------------------------------- + + function test_Lifecycle_TwoStrategiesYieldFeesAndExit() public { + vm.startPrank(owner); + FeeFacet(address(vault)).setFeeRecipient(feeRec); + FeeFacet(address(vault)).setPerformanceFee(2000); // 20% of profit + vm.stopPrank(); + + // Two depositors fund 2,000 USDC total. + _deposit(alice, 1000 * 1e6); + _deposit(bob, 1000 * 1e6); + assertEq(vault.totalAssets(), 2000 * 1e6, "both deposits booked"); + + // Split 50/50 across the mock and Compound strategies, zero idle. + _setAlloc2(MOCK_ID, 5000, COMPOUND_ID, 5000); + _rebalance(); + assertEq(mockProtocol.balanceOf(address(vault)), 1000 * 1e6, "50% to mock"); + assertEq(comet.balanceOf(address(vault)), 1000 * 1e6, "50% to compound"); + assertEq(usdc.balanceOf(address(vault)), 0, "fully deployed"); + + // Both strategies earn 10% (200 USDC of yield total). + mockProtocol._testAccrueYield(address(vault), 100 * 1e6); + comet._testAccrueYield(address(vault), 100 * 1e6); + assertEq(vault.totalAssets(), 2200 * 1e6, "yield reflected in NAV"); + + // A small deposit triggers fee accrual; the recipient is paid the perf fee. + _deposit(carol, 10 * 1e6); + assertGt(vault.balanceOf(feeRec), 0, "performance fee minted on the gain"); + + // Curator pulls everything back to idle so holders can exit synchronously. + _setAlloc2(MOCK_ID, 0, COMPOUND_ID, 0); + _rebalance(); + assertEq(mockProtocol.balanceOf(address(vault)), 0, "mock drained"); + assertEq(comet.balanceOf(address(vault)), 0, "compound drained"); + + // Alice exits and is net positive (her yield, less the performance fee). + uint256 aliceShares = vault.balanceOf(alice); + vm.prank(alice); + uint256 aliceOut = vault.redeem(aliceShares, alice, alice); + assertGt(aliceOut, 1000 * 1e6, "alice captured yield net of fees"); + + // The fee recipient can realise its shares too. + uint256 feeShares = vault.balanceOf(feeRec); + vm.prank(feeRec); + uint256 feeOut = vault.redeem(feeShares, feeRec, feeRec); + assertGt(feeOut, 0, "fee recipient realises its cut"); + + // Solvency holds at the end. + assertLe(vault.convertToAssets(vault.totalSupply()), vault.totalAssets(), "still solvent after full cycle"); + } + + // ----------------------------------------------------------------------- + // B. Stuck strategy isolated via quarantine; depositor exits via the queue + // ----------------------------------------------------------------------- + + function test_Lifecycle_StuckStrategyQuarantinedThenQueueExit() public { + _deposit(alice, 1000 * 1e6); + _setAlloc2(MOCK_ID, 5000, COMPOUND_ID, 5000); + _rebalance(); + assertEq(usdc.balanceOf(address(vault)), 0, "fully deployed, nothing idle"); + + // Compound's market becomes illiquid (withdraws revert). + comet.setWithdrawReverts(true); + + // Alice queues a full async exit (sync withdraw is impossible: no idle). + uint256 shares = vault.balanceOf(alice); + vm.prank(alice); + uint256 id = vault.requestWithdraw(shares, alice); + + // Curator tries to free liquidity by pulling both legs to 0. The mock leg + // is withdrawn; the Compound leg reverts and is SKIPPED, not bricked. + _setAlloc2(MOCK_ID, 0, COMPOUND_ID, 0); + _rebalance(); + assertEq(usdc.balanceOf(address(vault)), 500 * 1e6, "mock leg freed; compound still stuck"); + + // Fulfilling at the full (still-counting-compound) NAV is short on idle. + uint256 owedFull = vault.convertToAssets(shares); + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(Vault_InsufficientIdleLiquidity(), owedFull, 500 * 1e6)); + vault.fulfillWithdraw(id); + + // Owner isolates the stuck strategy: it drops out of NAV, repricing the + // claim down to what is actually liquid. Alice eats the stranded loss but + // is no longer trapped. + vm.prank(owner); + AllocatorFacet(address(vault)).quarantineStrategy(COMPOUND_ID); + assertEq(vault.totalAssets(), 500 * 1e6, "quarantine excludes the stuck position from NAV"); + + vm.prank(owner); + vault.fulfillWithdraw(id); + + assertApproxEqAbs(usdc.balanceOf(alice), 500 * 1e6, 10, "alice exits at the liquid NAV"); + assertEq(WithdrawQueueFacet(address(vault)).pendingWithdrawShares(), 0, "queue drained"); + assertEq(comet.balanceOf(address(vault)), 500 * 1e6, "stranded funds remain in the quarantined market"); + } + + // ----------------------------------------------------------------------- + // C. Circuit breaker trips on a real loss, owner recovers + // ----------------------------------------------------------------------- + + function test_Lifecycle_BreakerTripsOnLossThenOwnerRecovers() public { + _deposit(alice, 1000 * 1e6); + + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(500); // 5% bound + GuardFacet(address(vault)).guardCheckpoint(); // baseline at NAV = 1000 + + _setAlloc(MOCK_ID, 10_000); // 100% into mock so a loss hits NAV directly + _rebalance(); + + // The strategy loses 20% — far past the 5% bound. + mockProtocol._testSimulateLoss(address(vault), 200 * 1e6); + assertEq(vault.totalAssets(), 800 * 1e6, "20% loss realised in NAV"); + + // The hot-path tripwire now reverts any entry/exit. + usdc.mint(bob, 100 * 1e6); + vm.startPrank(bob); + usdc.approve(address(vault), 100 * 1e6); + vm.expectPartialRevert(LibGuard.SharePriceDeviation.selector); + vault.deposit(100 * 1e6, bob); + vm.stopPrank(); + + // A poke latches the pause so the freeze persists. + GuardFacet(address(vault)).guardCheckpoint(); + assertTrue(GuardFacet(address(vault)).paused(), "breaker latched"); + + // Owner accepts the new NAV and unpauses (rebaselines the checkpoint). + vm.prank(owner); + GuardFacet(address(vault)).unpause(); + assertFalse(GuardFacet(address(vault)).paused(), "recovered"); + + // Operations resume at the new, lower share price. + _deposit(bob, 100 * 1e6); + assertGt(vault.balanceOf(bob), 0, "deposits resume after recovery"); + } + + // Local copy of Vault's custom error selector for the expectRevert above. + function Vault_InsufficientIdleLiquidity() internal pure returns (bytes4) { + return bytes4(keccak256("InsufficientIdleLiquidity(uint256,uint256)")); + } +} diff --git a/test/integration/MorphoStrategy.fork.t.sol b/test/integration/MorphoStrategy.fork.t.sol index ed24213..2af74b4 100644 --- a/test/integration/MorphoStrategy.fork.t.sol +++ b/test/integration/MorphoStrategy.fork.t.sol @@ -44,7 +44,7 @@ contract MorphoStrategyForkTest is Test { function setUp() public { // Fork tests require a dedicated Arbitrum RPC. Set ARBITRUM_RPC_URL in // your shell or .env to opt in; otherwise the whole suite is skipped. - string memory rpc = vm.envOr("ARBITRUM_RPC_URL", string("")); + string memory rpc = vm.envOr("RPC_URL_ARB", vm.envOr("ARBITRUM_RPC_URL", string(""))); if (bytes(rpc).length == 0) { vm.skip(true); return; diff --git a/test/integration/PendleStrategy.fork.t.sol b/test/integration/PendleStrategy.fork.t.sol index 8ca4d95..5400b98 100644 --- a/test/integration/PendleStrategy.fork.t.sol +++ b/test/integration/PendleStrategy.fork.t.sol @@ -59,7 +59,7 @@ contract PendleStrategyForkTest is Test { address internal alice = makeAddr("alice"); function setUp() public { - string memory rpc = vm.envOr("ARBITRUM_RPC_URL", string("")); + string memory rpc = vm.envOr("RPC_URL_ARB", vm.envOr("ARBITRUM_RPC_URL", string(""))); if (bytes(rpc).length == 0) { vm.skip(true); return; diff --git a/test/invariant/VaultHandler.sol b/test/invariant/VaultHandler.sol new file mode 100644 index 0000000..44c1a84 --- /dev/null +++ b/test/invariant/VaultHandler.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { Vault } from "../../src/Vault.sol"; +import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; +import { FeeFacet } from "../../src/facets/FeeFacet.sol"; +import { GuardFacet } from "../../src/facets/GuardFacet.sol"; +import { WithdrawQueueFacet } from "../../src/facets/WithdrawQueueFacet.sol"; + +import { HarnessUSDC } from "../helpers/VaultHarness.sol"; +import { MockProtocol } from "../mocks/MockProtocol.sol"; +import { MockComet } from "../mocks/MockComet.sol"; + +/// @title VaultHandler +/// @notice Drives the vault through a bounded but adversarial space of +/// operations for the invariant runner: many actors depositing, +/// withdrawing, queueing, and cancelling; the curator reshuffling and +/// (un)quarantining strategies; yield, losses, donations, fee changes, +/// time travel, and breaker pokes. Every call is wrapped so an +/// individual revert is a no-op for that step, letting the fuzzer keep +/// exploring deeper state instead of dead-ending. +contract VaultHandler is Test { + Vault internal vault; + HarnessUSDC internal usdc; + MockProtocol internal mockProtocol; + MockComet internal comet; + address internal owner; + bytes32 internal mockId; + bytes32 internal compoundId; + + address[3] internal actors; + uint256[] internal requestIds; + + /// @dev Ghost counter proving deposits truly executed (not silently caught), + /// so the invariants are exercised against non-trivial state. + uint256 public ghostDeposits; + + constructor( + Vault _vault, + HarnessUSDC _usdc, + MockProtocol _mock, + MockComet _comet, + address _owner, + bytes32 _mockId, + bytes32 _compoundId + ) { + vault = _vault; + usdc = _usdc; + mockProtocol = _mock; + comet = _comet; + owner = _owner; + mockId = _mockId; + compoundId = _compoundId; + actors[0] = makeAddr("actorA"); + actors[1] = makeAddr("actorB"); + actors[2] = makeAddr("actorC"); + } + + function _actor(uint256 seed) internal view returns (address) { + return actors[seed % actors.length]; + } + + // ----------------------------------------------------------------------- + // User actions + // ----------------------------------------------------------------------- + + function deposit(uint256 actorSeed, uint256 amount) external { + address a = _actor(actorSeed); + amount = bound(amount, 1, 1e12); + usdc.mint(a, amount); + vm.startPrank(a); + usdc.approve(address(vault), amount); + // Count only deposits that actually minted shares. A deposit can succeed + // yet mint zero shares when the vault holds assets but has zero supply + // (e.g. strategy yield credited before the first deposit), which would + // otherwise make the non-triviality guard misfire. + try vault.deposit(amount, a) returns (uint256 sh) { + if (sh > 0) ghostDeposits++; + } catch { } + vm.stopPrank(); + } + + function withdraw(uint256 actorSeed, uint256 amount) external { + address a = _actor(actorSeed); + uint256 maxA = vault.maxWithdraw(a); + if (maxA == 0) return; + amount = bound(amount, 1, maxA); + vm.prank(a); + try vault.withdraw(amount, a, a) { } catch { } + } + + function redeem(uint256 actorSeed, uint256 shares) external { + address a = _actor(actorSeed); + uint256 bal = vault.balanceOf(a); + if (bal == 0) return; + shares = bound(shares, 1, bal); + vm.prank(a); + try vault.redeem(shares, a, a) { } catch { } + } + + function requestWithdraw(uint256 actorSeed, uint256 shares) external { + address a = _actor(actorSeed); + uint256 bal = vault.balanceOf(a); + if (bal == 0) return; + shares = bound(shares, 1, bal); + vm.prank(a); + try vault.requestWithdraw(shares, a) returns (uint256 id) { + requestIds.push(id); + } catch { } + } + + function cancelWithdraw(uint256 idSeed) external { + if (requestIds.length == 0) return; + uint256 id = requestIds[idSeed % requestIds.length]; + // Cancel must be sent by the request owner; recover it from the queue. + address reqOwner = WithdrawQueueFacet(address(vault)).withdrawRequest(id).owner; + if (reqOwner == address(0)) return; + vm.prank(reqOwner); + try vault.cancelWithdraw(id) { } catch { } + } + + function fulfillWithdraw(uint256 idSeed) external { + if (requestIds.length == 0) return; + uint256 id = requestIds[idSeed % requestIds.length]; + vm.prank(owner); // owner is an implicit curator + try vault.fulfillWithdraw(id) { } catch { } + } + + // ----------------------------------------------------------------------- + // Curator / owner actions + // ----------------------------------------------------------------------- + + function reallocate(uint16 mockBps, uint16 compBps) external { + mockBps = uint16(bound(mockBps, 0, 9000)); + compBps = uint16(bound(compBps, 0, 9000)); + if (uint256(mockBps) + compBps > 10_000) compBps = 10_000 - mockBps; + + bytes32[] memory ids = new bytes32[](2); + uint16[] memory b = new uint16[](2); + ids[0] = mockId; + ids[1] = compoundId; + b[0] = mockBps; + b[1] = compBps; + vm.prank(owner); + try AllocatorFacet(address(vault)).setAllocation(ids, b) { } catch { } + + vm.roll(block.number + 1); + vm.prank(owner); + try AllocatorFacet(address(vault)).rebalance() { } catch { } + } + + function quarantine(uint256 which) external { + bytes32 id = which % 2 == 0 ? mockId : compoundId; + vm.prank(owner); + try AllocatorFacet(address(vault)).quarantineStrategy(id) { } catch { } + } + + function release(uint256 which) external { + bytes32 id = which % 2 == 0 ? mockId : compoundId; + vm.prank(owner); + try AllocatorFacet(address(vault)).releaseStrategy(id) { } catch { } + } + + function setFees(uint16 perfBps, uint16 mgmtBps) external { + perfBps = uint16(bound(perfBps, 0, 5000)); + mgmtBps = uint16(bound(mgmtBps, 0, 1000)); + vm.startPrank(owner); + try FeeFacet(address(vault)).setPerformanceFee(perfBps) { } catch { } + try FeeFacet(address(vault)).setManagementFee(mgmtBps) { } catch { } + vm.stopPrank(); + } + + function guardPoke() external { + try GuardFacet(address(vault)).guardCheckpoint() { } catch { } + } + + // ----------------------------------------------------------------------- + // Environment: yield, loss, donation, time + // ----------------------------------------------------------------------- + + function accrueYield(uint256 which, uint256 amount) external { + // Yield/donation only make sense on a live vault; crediting assets while + // supply is zero is unreachable in production and would trap deposits at + // zero-share mints, making the run vacuous. + if (vault.totalSupply() == 0) return; + amount = bound(amount, 0, 1e12); + if (amount == 0) return; + if (which % 2 == 0) { + mockProtocol._testAccrueYield(address(vault), amount); + } else { + comet._testAccrueYield(address(vault), amount); + } + } + + function simulateLoss(uint256 amount) external { + uint256 pos = mockProtocol.balanceOf(address(vault)); + if (pos == 0) return; + amount = bound(amount, 1, pos); + mockProtocol._testSimulateLoss(address(vault), amount); + } + + function donate(uint256 amount) external { + if (vault.totalSupply() == 0) return; // see accrueYield: avoid the zero-supply trap + amount = bound(amount, 0, 1e12); + if (amount == 0) return; + usdc.mint(address(vault), amount); + } + + function passTime(uint256 dt) external { + dt = bound(dt, 1, 30 days); + vm.warp(block.timestamp + dt); + } +} diff --git a/test/invariant/VaultInvariant.t.sol b/test/invariant/VaultInvariant.t.sol new file mode 100644 index 0000000..844230c --- /dev/null +++ b/test/invariant/VaultInvariant.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { VaultHarness } from "../helpers/VaultHarness.sol"; +import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; +import { FeeFacet } from "../../src/facets/FeeFacet.sol"; +import { WithdrawQueueFacet } from "../../src/facets/WithdrawQueueFacet.sol"; +import { VaultHandler } from "./VaultHandler.sol"; + +/// @title VaultInvariantTest +/// @notice Stateful invariants over the full operation space (see VaultHandler). +/// These are the properties that must hold no matter how deposits, +/// withdrawals, rebalances, quarantines, fees, yield, losses, donations, +/// and time travel are interleaved. A break here is a real accounting or +/// solvency bug, not a single-path edge case. +contract VaultInvariantTest is VaultHarness { + VaultHandler internal handler; + address internal feeRec = makeAddr("feeRec"); + + function setUp() public { + _deployHarness(); + _registerMock(); + _registerCompound(); + + // A fee recipient must exist for fee shares to mint, so the invariants + // also cover fee dilution. Breaker/lock left disabled so the handler can + // explore deep state instead of latching itself shut. + vm.prank(owner); + FeeFacet(address(vault)).setFeeRecipient(feeRec); + + handler = new VaultHandler(vault, usdc, mockProtocol, comet, owner, MOCK_ID, COMPOUND_ID); + targetContract(address(handler)); + } + + /// @notice The redeemable value of every share outstanding can never exceed + /// the assets actually under management. The vault stays solvent. + function invariant_ShareBaseNeverOutValuesAssets() public view { + assertLe(vault.convertToAssets(vault.totalSupply()), vault.totalAssets(), "insolvent: shares out-value assets"); + } + + /// @notice totalAssets is exactly idle + every active, non-quarantined + /// strategy's reported position — no phantom or double-counted value, + /// and quarantine genuinely excludes a position from NAV. + function invariant_TotalAssetsAccounting() public view { + uint256 expected = usdc.balanceOf(address(vault)); + if (!AllocatorFacet(address(vault)).isQuarantined(MOCK_ID)) { + expected += mockProtocol.balanceOf(address(vault)); + } + if (!AllocatorFacet(address(vault)).isQuarantined(COMPOUND_ID)) { + expected += comet.balanceOf(address(vault)); + } + assertEq(vault.totalAssets(), expected, "totalAssets diverged from idle + live strategy NAV"); + } + + /// @notice Every share the vault custodies is queue escrow and nothing else: + /// fee shares mint to the recipient, never to the vault, so the + /// vault's own balance must equal totalPendingShares at all times. + function invariant_EscrowMatchesPendingShares() public view { + assertEq( + vault.balanceOf(address(vault)), + WithdrawQueueFacet(address(vault)).pendingWithdrawShares(), + "escrowed shares != tracked pending shares" + ); + } + + /// @notice Pending (escrowed) shares can never exceed total supply. + function invariant_PendingNeverExceedsSupply() public view { + assertLe( + WithdrawQueueFacet(address(vault)).pendingWithdrawShares(), + vault.totalSupply(), + "pending shares exceed total supply" + ); + } + + // Coverage / non-vacuity is evidenced by the handler call-distribution table + // printed under -vvv (deposit/withdraw/rebalance/fulfill all fire in the + // hundreds), and `handler.ghostDeposits()` records share-minting deposits for + // ad-hoc inspection. A self-asserting vacuity check was intentionally dropped: + // supply legitimately returns to zero after full exits, so any instantaneous + // "supply > 0" assertion is unsound, and Foundry resets handler state per run + // which makes an end-of-run ghost assertion unreliable. +} diff --git a/test/mocks/ReentrantToken.sol b/test/mocks/ReentrantToken.sol new file mode 100644 index 0000000..fd13c90 --- /dev/null +++ b/test/mocks/ReentrantToken.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +interface IReentryTarget { + function deposit(uint256 assets, address receiver) external returns (uint256); +} + +/// @title ReentrantToken +/// @notice Test-only malicious ERC-20 used as a vault asset to prove the native +/// ERC-4626 surface's `nonReentrant` guard actually holds. When armed, +/// the token re-enters `Vault.deposit` from inside its own +/// `transferFrom` — i.e. while the vault is mid-`_deposit` and the +/// reentrancy lock is engaged. The inner call must revert +/// `ReentrancyGuardReentrantCall`, and because this token does not catch +/// it, that revert bubbles up and aborts the whole outer deposit. +/// @dev A real USDC-style asset has no transfer callback, so this is the only +/// way to drive a token-mediated reentrancy in a unit test. +contract ReentrantToken is ERC20 { + address public vault; + bool public armed; + bool private entered; + + constructor() ERC20("Reentrant USD", "rUSD") { } + + function decimals() public pure override returns (uint8) { + return 6; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setVault(address vault_) external { + vault = vault_; + } + + function arm(bool v) external { + armed = v; + } + + /// @dev The reentrancy is launched here because the vault pulls the deposit + /// via `safeTransferFrom` inside `_deposit`, where the guard is locked. + function transferFrom(address from, address to, uint256 value) public override returns (bool) { + if (armed && !entered && vault != address(0)) { + entered = true; + // Re-enter the locked vault. This MUST revert; we deliberately do + // not wrap it in try/catch so the revert propagates and fails the + // outer deposit, proving the guard works. + IReentryTarget(vault).deposit(1, address(this)); + } + return super.transferFrom(from, to, value); + } +} diff --git a/test/unit/AdversarialVault.t.sol b/test/unit/AdversarialVault.t.sol new file mode 100644 index 0000000..6c6d51b --- /dev/null +++ b/test/unit/AdversarialVault.t.sol @@ -0,0 +1,697 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Test, stdError } from "forge-std/Test.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.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 { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; +import { GuardFacet } from "../../src/facets/GuardFacet.sol"; +import { FeeFacet } from "../../src/facets/FeeFacet.sol"; +import { RolesFacet } from "../../src/facets/RolesFacet.sol"; +import { LockFacet } from "../../src/facets/LockFacet.sol"; +import { WithdrawQueueFacet } from "../../src/facets/WithdrawQueueFacet.sol"; +import { IdleStrategyFacet } from "../../src/facets/strategies/IdleStrategyFacet.sol"; + +import { LibAllocator } from "../../src/libraries/LibAllocator.sol"; +import { LibDiamond } from "../../src/libraries/LibDiamond.sol"; +import { LibGuard } from "../../src/libraries/LibGuard.sol"; +import { LibLock } from "../../src/libraries/LibLock.sol"; +import { LibFees } from "../../src/libraries/LibFees.sol"; + +import { MockProtocol } from "../mocks/MockProtocol.sol"; +import { MockStrategyFacet } from "../mocks/MockStrategyFacet.sol"; +import { ReentrantToken } from "../mocks/ReentrantToken.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; + } +} + +/// @title AdversarialVaultTest +/// @notice Attacker-minded coverage. Instead of asserting the happy path, each +/// test puts on a black hat: it tries to inflate first-deposit shares, +/// weaponise the permissionless circuit-breaker poke, retroactively +/// reprice fees, escape the curator's risk bounds, panic the allocator's +/// arithmetic, resurrect a quarantine flag, shadow a native selector, +/// lock funds, and break the share-lock and reentrancy guards. Where a +/// known loophole is unfixed (the internal audit's F07/F08/F10/F40), the +/// test REPRODUCES it so the behaviour is pinned and regressions surface; +/// where a fix exists (F01 share-lock, reentrancy, inflation offset), the +/// test attacks it adversarially to prove it actually holds. +contract AdversarialVaultTest is Test { + MockUSDC internal usdc; + MockProtocol internal mockProtocol; + Vault internal vault; + + address internal owner = makeAddr("owner"); + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal attacker = makeAddr("attacker"); + address internal feeRec = makeAddr("feeRec"); + + bytes32 internal constant MOCK_ID = bytes32("mock"); + + function setUp() public { + usdc = new MockUSDC(); + mockProtocol = new MockProtocol(IERC20(address(usdc))); + vault = _deployVault(IERC20(address(usdc))); + + vm.prank(owner); + MockStrategyFacet(address(vault)).mockSetProtocol(mockProtocol); + vm.prank(owner); + AllocatorFacet(address(vault)).registerStrategy(MOCK_ID, _mockConfig()); + } + + // ======================================================================= + // 1. ERC-4626 inflation / first-depositor donation attack + // Attack the OZ virtual-share offset (DECIMALS_OFFSET = 6). A working + // mitigation makes the classic "seed 1 wei, donate, tax the victim" + // play unprofitable and leaves the victim near whole. + // ======================================================================= + + function test_Inflation_FirstDepositorDonationIsUnprofitable() public { + uint256 donation = 10_000 * 1e6; + uint256 victimDeposit = 10_000 * 1e6; + + // Attacker is the very first depositor with the smallest possible stake. + usdc.mint(attacker, 1 + donation); + vm.startPrank(attacker); + usdc.approve(address(vault), 1); + vault.deposit(1, attacker); + // Then inflates share price by donating straight to the vault balance. + usdc.transfer(address(vault), donation); + vm.stopPrank(); + + // Victim deposits at the inflated price. + usdc.mint(alice, victimDeposit); + vm.startPrank(alice); + usdc.approve(address(vault), victimDeposit); + uint256 victimShares = vault.deposit(victimDeposit, alice); + vm.stopPrank(); + + assertGt(victimShares, 0, "virtual shares must prevent the victim minting zero shares"); + + // Victim exits first. + vm.prank(alice); + uint256 victimOut = vault.redeem(victimShares, alice, alice); + + // Then the attacker exits and tallies their P&L. + uint256 attackerShares = vault.balanceOf(attacker); + vm.prank(attacker); + uint256 attackerOut = vault.redeem(attackerShares, attacker, attacker); + + // Victim is made nearly whole (only dust rounding lost to virtual shares). + assertGe(victimOut, (victimDeposit * 9990) / 10_000, "victim must not be materially diluted by the donation"); + + // The attacker funded the inflation and cannot recoup it: their total out + // is strictly less than the 1 wei + donation they put in. The donation is + // a gift to honest holders, exactly as the offset intends. + assertLt(attackerOut, 1 + donation, "donation attack must be unprofitable for the attacker"); + } + + // ======================================================================= + // 2. Circuit-breaker griefing via the permissionless poke (audit F07) + // `guardCheckpoint` is permissionless and COMMITS the pause latch. Its + // price comes from totalAssets(), which counts raw idle balanceOf, so a + // bare donation can move it past the bound and latch the vault closed. + // ======================================================================= + + function test_GuardCheckpoint_DonationLatchesBreakerAndFreezesVault() public { + _deposit(alice, 1000 * 1e6); + + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(100); // 1% bound + + // Establish the baseline checkpoint (the setUp/first deposit left it 0 + // because _guard returns early while supply was still 0). + GuardFacet(address(vault)).guardCheckpoint(); + assertGt(GuardFacet(address(vault)).lastSharePrice(), 0, "baseline armed"); + assertFalse(GuardFacet(address(vault)).paused(), "not paused yet"); + + // Attacker donates >1% of NAV directly, then pokes the breaker. No + // deposit, no privilege, donation is unrecoverable dust to the attacker + // but it sticks the whole vault into a paused state. + usdc.mint(attacker, 50 * 1e6); // 5% of NAV, well past the 1% bound + vm.prank(attacker); + usdc.transfer(address(vault), 50 * 1e6); + + vm.prank(attacker); + GuardFacet(address(vault)).guardCheckpoint(); + + assertTrue(GuardFacet(address(vault)).paused(), "F07: permissionless donation latched the breaker"); + + // Consequence: every user entry/exit now reverts EnforcedPause. + usdc.mint(bob, 1e6); + vm.startPrank(bob); + usdc.approve(address(vault), 1e6); + vm.expectRevert(LibGuard.EnforcedPause.selector); + vault.deposit(1e6, bob); + vm.stopPrank(); + + vm.prank(alice); + vm.expectRevert(LibGuard.EnforcedPause.selector); + vault.withdraw(1e6, alice, alice); + } + + function test_GuardLatch_CuratorCannotUnpause_OnlyOwnerRecovers() public { + _deposit(alice, 1000 * 1e6); + + address curator = makeAddr("curator"); + vm.prank(owner); + RolesFacet(address(vault)).setCurator(curator, true); + + vm.prank(owner); + GuardFacet(address(vault)).setMaxSharePriceDelta(100); + GuardFacet(address(vault)).guardCheckpoint(); // baseline + + usdc.mint(attacker, 50 * 1e6); + vm.prank(attacker); + usdc.transfer(address(vault), 50 * 1e6); + vm.prank(attacker); + GuardFacet(address(vault)).guardCheckpoint(); + assertTrue(GuardFacet(address(vault)).paused(), "latched"); + + // The lower-trust curator key (the seat meant for an always-on AI + // keeper) cannot self-heal: recovery is gated on the cold owner (F19). + vm.prank(curator); + vm.expectRevert(abi.encodeWithSelector(LibDiamond.NotContractOwner.selector, curator, owner)); + GuardFacet(address(vault)).unpause(); + + vm.prank(owner); + GuardFacet(address(vault)).unpause(); + assertFalse(GuardFacet(address(vault)).paused(), "only the owner could clear the latch"); + } + + // ======================================================================= + // 3. Fee setters do not accrue first (audit F08) + // Raising the management fee after a long quiet window applies the NEW + // rate to time that elapsed under the OLD rate, over-diluting holders. + // ======================================================================= + + function test_FeeSetter_RaisingMgmtFeeRepricesElapsedTimeRetroactively() public { + vm.prank(owner); + FeeFacet(address(vault)).setFeeRecipient(feeRec); + + // Alice deposits while the management fee is 0; this also stamps + // lastFeeAccrual via the deposit's post-accrue bootstrap. + _deposit(alice, 1_000_000 * 1e6); + uint256 supply = vault.totalSupply(); + assertEq(vault.balanceOf(feeRec), 0, "no fee accrued yet"); + + // 180 quiet days pass with the fee still at 0 -> a correct, accrue-first + // design would owe ~0 management fee for this window. + uint256 elapsed = 180 days; + vm.warp(block.timestamp + elapsed); + + // Owner now raises the management fee to the 10% cap. + vm.prank(owner); + FeeFacet(address(vault)).setManagementFee(1000); + + // Any subsequent op triggers _accrueFees, which charges the FULL 180-day + // backlog at the brand-new 10% rate. + _deposit(bob, 1e6); + + uint256 feeShares = vault.balanceOf(feeRec); + uint256 chargedAtNewRate = + (supply * 1000 * elapsed) / (uint256(LibFees.BPS_DENOMINATOR) * LibFees.SECONDS_PER_YEAR); + + assertGt(feeShares, 0, "F08: fee charged retroactively over a zero-rate period"); + assertApproxEqRel( + feeShares, chargedAtNewRate, 1e15, "the whole elapsed window was repriced at the new 10% rate" + ); + } + + // ======================================================================= + // 4. Curator is unbounded at the zero-default risk parameters (audit F10) + // The "bounded AI curator" guarantee depends on idleReserveBps / + // maxRebalanceDeltaBps / maxSharePriceDeltaBps being set. All default 0. + // ======================================================================= + + function test_UnboundedCurator_DeploysEntireNavAndBricksSyncWithdraw() public { + _deposit(alice, 1000 * 1e6); + + // Defaults confirm the curator is unbounded out of the box. + assertEq(AllocatorFacet(address(vault)).idleReserveBps(), 0, "no idle floor by default"); + assertEq(AllocatorFacet(address(vault)).maxRebalanceDelta(), 0, "no churn bound by default"); + + // A compromised/over-eager curator key shoves 100% into one illiquid + // strategy in a single block; nothing in the contract stops it. + address curator = makeAddr("curator"); + vm.prank(owner); + RolesFacet(address(vault)).setCurator(curator, true); + + bytes32[] memory ids = new bytes32[](1); + uint16[] memory bps = new uint16[](1); + ids[0] = MOCK_ID; + bps[0] = 10_000; // 100%, allowed because idleReserveBps == 0 + vm.prank(curator); + AllocatorFacet(address(vault)).setAllocation(ids, bps); + + vm.roll(block.number + 1); + vm.prank(curator); + AllocatorFacet(address(vault)).rebalance(); + + assertEq(usdc.balanceOf(address(vault)), 0, "F10: full NAV deployable, zero idle left"); + + // Honest holder can no longer exit synchronously: no idle to pay them. + vm.prank(alice); + vm.expectRevert(); // ERC4626 reverts: assets exceed available idle liquidity + vault.withdraw(1000 * 1e6, alice, alice); + } + + function test_BoundedCurator_ChurnCapStopsTheSameMove() public { + // The mitigation, asserted for contrast: with the churn bound set, the + // identical 100% shove is rejected before any funds move. + _deposit(alice, 1000 * 1e6); + vm.prank(owner); + AllocatorFacet(address(vault)).setMaxRebalanceDelta(5000); // 50% cap + + bytes32[] memory ids = new bytes32[](1); + uint16[] memory bps = new uint16[](1); + ids[0] = MOCK_ID; + bps[0] = 10_000; + vm.prank(owner); + AllocatorFacet(address(vault)).setAllocation(ids, bps); + + vm.roll(block.number + 1); + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(AllocatorFacet.RebalanceDeltaTooLarge.selector, 10_000, 5000)); + AllocatorFacet(address(vault)).rebalance(); + } + + // ======================================================================= + // 5. Per-call churn bound is NOT cumulative (audit F40) + // The bound caps a single rebalance; chained across consecutive blocks a + // curator relocates far more than the per-call cap suggests. + // ======================================================================= + + function test_ChurnBound_CircumventedAcrossConsecutiveBlocks() public { + _deposit(alice, 1000 * 1e6); + vm.prank(owner); + AllocatorFacet(address(vault)).setMaxRebalanceDelta(5000); // 50% per call + + // Block 1: 0% -> 50% (a 5000 bps move, exactly at the bound). + _setAlloc(MOCK_ID, 5000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + assertEq(mockProtocol.balanceOf(address(vault)), 500 * 1e6, "50% deployed in block 1"); + + // Block 2: 50% -> 100% (another 5000 bps move, again within the bound). + _setAlloc(MOCK_ID, 10_000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + // Net effect: 100% relocated despite a "50% per rebalance" cap. The + // one-block throttle limits frequency, not cumulative magnitude. + assertEq(mockProtocol.balanceOf(address(vault)), 1000 * 1e6, "F40: 100% relocated over two blocks"); + } + + // ======================================================================= + // 6. setAllocation arithmetic panic on duplicate ids (audit F27) + // The running total is a uint16; duplicated ids let the sum blow past + // 65535 and panic (0x11) instead of a clean AllocationExceedsBudget. + // ======================================================================= + + function test_SetAllocation_DuplicateIdsTriggerArithmeticPanic() public { + uint256 n = 7; // 7 * 10000 = 70000 > type(uint16).max + bytes32[] memory ids = new bytes32[](n); + uint16[] memory bps = new uint16[](n); + for (uint256 i; i < n; i++) { + ids[i] = MOCK_ID; // same registered id repeated; no dedup in setAllocation + bps[i] = 10_000; + } + + vm.prank(owner); + vm.expectRevert(stdError.arithmeticError); // Panic(0x11) overflow, not the descriptive revert + AllocatorFacet(address(vault)).setAllocation(ids, bps); + } + + // ======================================================================= + // 7. Quarantine flag survives removeStrategy (audit F35) + // Re-registering a previously-quarantined id silently resurrects it in a + // quarantined state: invisible to NAV and unfundable until manual release. + // ======================================================================= + + function test_ReRegisterAfterQuarantine_SilentlyBornQuarantined() public { + vm.startPrank(owner); + AllocatorFacet(address(vault)).quarantineStrategy(MOCK_ID); + AllocatorFacet(address(vault)).removeStrategy(MOCK_ID); + // Re-register the same id with a fresh, healthy config. + AllocatorFacet(address(vault)).registerStrategy(MOCK_ID, _mockConfig()); + vm.stopPrank(); + + // The strategy is "active" yet still flagged quarantined from before. + assertTrue(AllocatorFacet(address(vault)).isQuarantined(MOCK_ID), "F35: stale quarantine flag carried over"); + + // So any attempt to fund it reverts with a confusing, unrelated-looking error. + bytes32[] memory ids = new bytes32[](1); + uint16[] memory bps = new uint16[](1); + ids[0] = MOCK_ID; + bps[0] = 5000; + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(AllocatorFacet.AllocationToQuarantined.selector, MOCK_ID)); + AllocatorFacet(address(vault)).setAllocation(ids, bps); + } + + // ======================================================================= + // 8. diamondCut can register a selector that shadows a native function + // (audit F22). The cut "succeeds" and the loupe diverges, but native + // code keeps winning routing, so the upgrade is a silent no-op. + // ======================================================================= + + function test_DiamondCut_ShadowingNativeSelectorIsASilentNoOp() public { + // Point the native ERC-4626 deposit selector at an unrelated facet. + OwnershipFacet shadow = new OwnershipFacet(); + bytes4 depositSel = bytes4(keccak256("deposit(uint256,address)")); + + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](1); + bytes4[] memory sels = new bytes4[](1); + sels[0] = depositSel; + cuts[0] = IDiamond.FacetCut({ + facetAddress: address(shadow), action: IDiamond.FacetCutAction.Add, functionSelectors: sels + }); + + // The cut is accepted (native selectors are never in the facet map, so + // the duplicate-add guard does not fire) and the loupe now lies. + vm.prank(owner); + IDiamondCut(address(vault)).diamondCut(cuts, address(0), ""); + assertEq( + IDiamondLoupe(address(vault)).facetAddress(depositSel), + address(shadow), + "F22: loupe diverges, reports the shadow facet for deposit" + ); + + // But native deposit still wins dispatch: a real deposit behaves normally, + // proving the "upgrade" changed nothing. + _deposit(alice, 100 * 1e6); + assertGt(vault.balanceOf(alice), 0, "native deposit still minted shares"); + assertEq(vault.totalAssets(), 100 * 1e6, "native deposit unaffected by the shadow cut"); + } + + // ======================================================================= + // 9. EIP-2535 mandates ERC-165, but supportsInterface is not implemented + // (audit F21): the call reverts UnknownSelector instead of returning bool. + // ======================================================================= + + function test_SupportsInterface_IsNotImplemented() public view { + (bool ok,) = address(vault).staticcall(abi.encodeWithSignature("supportsInterface(bytes4)", bytes4(0x01ffc9a7))); + assertFalse(ok, "F21: supportsInterface reverts (no ERC-165), failing conformance checks"); + } + + // ======================================================================= + // 10. Stray ETH is permanently trapped (audit F29) + // The diamond is payable but the USDC-only design has no sweep path. + // ======================================================================= + + function test_StrayETH_IsPermanentlyLocked() public { + vm.deal(attacker, 1 ether); + vm.prank(attacker); + (bool ok,) = address(vault).call{ value: 1 ether }(""); + assertTrue(ok, "diamond receive() accepts ETH"); + assertEq(address(vault).balance, 1 ether, "ETH sits in the vault"); + + // There is no recovery selector anywhere on the surface. + (bool swept,) = address(vault).call(abi.encodeWithSignature("sweepETH(address)", attacker)); + assertFalse(swept, "F29: no ETH rescue path exists, the 1 ether is stuck forever"); + } + + // ======================================================================= + // 11. Share-lock semantics: it locks the WHOLE balance, not just the new + // deposit, and it blocks even queueing an async exit within the window. + // ======================================================================= + + function test_ShareLock_LocksEntireBalanceNotJustTheFreshDeposit() public { + // Alice builds a large position while the lock is disabled. + _deposit(alice, 1000 * 1e6); + uint256 oldShares = vault.balanceOf(alice); + + // Owner now arms the lock; Alice tops up by a single wei to herself. + vm.prank(owner); + LockFacet(address(vault)).setShareLockPeriod(1 hours); + _deposit(alice, 1); + + // Her ENTIRE balance (the old 1000 USDC of shares, not just the 1 wei + // top-up) is now frozen for the window, contrary to "freshly minted". + uint256 unlockAt = LockFacet(address(vault)).lockedUntil(alice); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibLock.SharesLocked.selector, alice, unlockAt)); + vault.redeem(oldShares, alice, alice); + + // It clears once the window elapses. + vm.warp(block.timestamp + 1 hours + 1); + vm.prank(alice); + vault.redeem(oldShares, alice, alice); + assertGt(usdc.balanceOf(alice), 0, "redeem works after the lock expires"); + } + + function test_ShareLock_BlocksAsyncQueueRequestWithinWindow() public { + vm.prank(owner); + LockFacet(address(vault)).setShareLockPeriod(1 hours); + + _deposit(alice, 1000 * 1e6); + uint256 shares = vault.balanceOf(alice); + + // The async queue is the intended escape hatch for illiquid exits, yet a + // just-deposited holder cannot even ENTER it: requestWithdraw escrows via + // _transfer, which trips the lock. The exit path is unavailable exactly + // when a fresh depositor might need it. + uint256 unlockAt = LockFacet(address(vault)).lockedUntil(alice); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibLock.SharesLocked.selector, alice, unlockAt)); + vault.requestWithdraw(shares, alice); + } + + // ======================================================================= + // 12. Reentrancy: the native ERC-4626 surface's nonReentrant must hold. + // Driven by a malicious asset that re-enters from inside transferFrom. + // ======================================================================= + + function test_Reentrancy_DepositIsBlockedByGuard() public { + ReentrantToken evil = new ReentrantToken(); + Vault evilVault = _deployVault(IERC20(address(evil))); + evil.setVault(address(evilVault)); + + evil.mint(alice, 1000 * 1e6); + vm.startPrank(alice); + evil.approve(address(evilVault), type(uint256).max); + evil.arm(true); // re-enter Vault.deposit from inside transferFrom + + vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); + evilVault.deposit(1000 * 1e6, alice); + vm.stopPrank(); + } + + // ======================================================================= + // 13. Performance-fee HWM reset on supply->0 is NOT a free re-appreciation + // primitive (audit F25 dismissal, verified adversarially). The attacker + // tries to dodge the perf fee by draining the vault; the fee is charged + // at the all-time-high price BEFORE supply hits zero. + // ======================================================================= + + function test_PerformanceFee_HwmResetIsNotExploitable() public { + vm.prank(owner); + FeeFacet(address(vault)).setFeeRecipient(feeRec); + vm.prank(owner); + FeeFacet(address(vault)).setPerformanceFee(2000); // 20% + + _deposit(alice, 1000 * 1e6); + + // Vault gains 50% (donated as idle yield), lifting the share price well + // above the high-water mark. + usdc.mint(address(vault), 500 * 1e6); + + // Alice fully exits, hoping the supply->0 HWM reset lets the gain escape + // the performance fee. + uint256 aliceShares = vault.balanceOf(alice); + vm.prank(alice); + vault.redeem(aliceShares, alice, alice); + + // The fee recipient was paid on the profit during the exit's accrual, + // so the gain did NOT slip through fee-free. + assertGt(vault.balanceOf(feeRec), 0, "F25: performance fee captured before the HWM reset, not dodged"); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + function _deposit(address from, uint256 amount) internal { + usdc.mint(from, amount); + vm.startPrank(from); + usdc.approve(address(vault), amount); + vault.deposit(amount, from); + vm.stopPrank(); + } + + function _setAlloc(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 _mockConfig() 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: 0, + active: false + }); + } + + function _deployVault(IERC20 asset_) internal returns (Vault) { + DiamondCutFacet cut = new DiamondCutFacet(); + DiamondLoupeFacet loupe = new DiamondLoupeFacet(); + OwnershipFacet ownership = new OwnershipFacet(); + IdleStrategyFacet idle = new IdleStrategyFacet(); + AllocatorFacet allocator = new AllocatorFacet(); + GuardFacet guard = new GuardFacet(); + FeeFacet fee = new FeeFacet(); + RolesFacet roles = new RolesFacet(); + LockFacet lock = new LockFacet(); + WithdrawQueueFacet queue = new WithdrawQueueFacet(); + MockStrategyFacet mock = new MockStrategyFacet(); + + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](11); + cuts[0] = _fc(address(cut), _cutSel()); + cuts[1] = _fc(address(loupe), _loupeSel()); + cuts[2] = _fc(address(ownership), _ownSel()); + cuts[3] = _fc(address(idle), _idleSel()); + cuts[4] = _fc(address(allocator), _allocSel()); + cuts[5] = _fc(address(guard), _guardSel()); + cuts[6] = _fc(address(fee), _feeSel()); + cuts[7] = _fc(address(roles), _rolesSel()); + cuts[8] = _fc(address(lock), _lockSel()); + cuts[9] = _fc(address(queue), _queueSel()); + cuts[10] = _fc(address(mock), _mockSel()); + + return new Vault(asset_, "Vault Router", "vUSDC", owner, cuts, address(0), ""); + } + + function _fc(address facet, bytes4[] memory sels) internal pure returns (IDiamond.FacetCut memory) { + return IDiamond.FacetCut({ facetAddress: facet, action: IDiamond.FacetCutAction.Add, functionSelectors: sels }); + } + + function _cutSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](1); + s[0] = IDiamondCut.diamondCut.selector; + } + + function _loupeSel() 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 _ownSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](2); + s[0] = IERC173.owner.selector; + s[1] = IERC173.transferOwnership.selector; + } + + function _idleSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](1); + s[0] = IdleStrategyFacet.idleTotalAssets.selector; + } + + function _allocSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](16); + 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.setMaxRebalanceDelta.selector; + s[6] = AllocatorFacet.rebalance.selector; + s[7] = AllocatorFacet.quarantineStrategy.selector; + s[8] = AllocatorFacet.releaseStrategy.selector; + s[9] = AllocatorFacet.strategies.selector; + s[10] = AllocatorFacet.idleAssets.selector; + s[11] = AllocatorFacet.isQuarantined.selector; + s[12] = AllocatorFacet.strategyTotalAssets.selector; + s[13] = AllocatorFacet.targetAllocation.selector; + s[14] = AllocatorFacet.maxRebalanceDelta.selector; + s[15] = AllocatorFacet.idleReserveBps.selector; + } + + function _guardSel() 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 _feeSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](8); + s[0] = FeeFacet.setFeeRecipient.selector; + s[1] = FeeFacet.setPerformanceFee.selector; + s[2] = FeeFacet.setManagementFee.selector; + s[3] = FeeFacet.feeRecipient.selector; + s[4] = FeeFacet.performanceFeeBps.selector; + s[5] = FeeFacet.managementFeeBps.selector; + s[6] = FeeFacet.highWaterMark.selector; + s[7] = FeeFacet.lastFeeAccrual.selector; + } + + function _rolesSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](2); + s[0] = RolesFacet.setCurator.selector; + s[1] = RolesFacet.isCurator.selector; + } + + function _lockSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](3); + s[0] = LockFacet.setShareLockPeriod.selector; + s[1] = LockFacet.shareLockPeriod.selector; + s[2] = LockFacet.lockedUntil.selector; + } + + function _queueSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](3); + s[0] = WithdrawQueueFacet.nextWithdrawRequestId.selector; + s[1] = WithdrawQueueFacet.pendingWithdrawShares.selector; + s[2] = WithdrawQueueFacet.withdrawRequest.selector; + } + + function _mockSel() internal pure returns (bytes4[] memory s) { + s = new bytes4[](8); + 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; + s[6] = MockStrategyFacet.mockSetReverting.selector; + s[7] = MockStrategyFacet.mockSetRevertOnMove.selector; + } +}