diff --git a/src/facets/strategies/CompoundV3StrategyFacet.sol b/src/facets/strategies/CompoundV3StrategyFacet.sol new file mode 100644 index 0000000..a82cf19 --- /dev/null +++ b/src/facets/strategies/CompoundV3StrategyFacet.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +import { LibDiamond } from "../../libraries/LibDiamond.sol"; +import { IComet } from "../../interfaces/external/IComet.sol"; + +/// @title CompoundV3StrategyFacet +/// @notice Strategy facet that supplies the vault's underlying asset to a +/// Compound III (Comet) base market and reports its position via the +/// market's rebasing `balanceOf`. +/// @dev Selectors are prefixed with `compound*` so the facet coexists with other +/// strategy facets in the same Diamond without selector collisions. State +/// lives at EIP-7201 slot `vaultrouter.strategy.compound`. +/// +/// Shape mirrors `AaveStrategyFacet`: Comet's `balanceOf` is a non-standard +/// rebasing balance that already includes accrued supply interest, so the +/// position needs no receipt-token bookkeeping and `harvest` is a no-op. +/// Unlike the Aave facet it validates `comet.baseToken()` against the +/// diamond's asset at config time (the same defensive check the Morpho facet +/// makes), so a market for the wrong asset can never be wired in. +contract CompoundV3StrategyFacet { + using SafeERC20 for IERC20; + + /// @notice Thrown when the Comet market has not been configured. + error CompoundCometNotConfigured(); + /// @notice Thrown when the configured market reports a zero base token. + error CompoundBaseNotConfigured(); + /// @notice Thrown when the market's base token differs from the diamond's asset. + error CompoundAssetMismatch(); + /// @notice Thrown when a supply credits fewer base units than supplied (beyond + /// acceptable rounding) — e.g. a fee-on-supply or misconfigured market. + error CompoundDepositFailed(uint256 expected, uint256 received); + /// @notice Thrown when a withdraw returns fewer base units than requested. + error CompoundWithdrawFailed(uint256 expected, uint256 received); + + /// @notice Emitted when the Comet market is configured (or reconfigured). + /// @param comet The Comet market now active for this strategy. + /// @param baseToken The market's base asset (must equal the diamond's asset). + event CompoundConfigSet(IComet indexed comet, address indexed baseToken); + + /// @dev Slack (in base units) tolerated between the amount supplied and the + /// present value credited by Comet. Comet stores principal as + /// `presentValue * 1e15 / baseSupplyIndex` (rounded down), so a supply can + /// credit a wei or two less than supplied. A shortfall beyond this is + /// treated as a real failure. Withdrawals transfer the exact requested + /// amount, so the same slack only ever helps there. + uint256 internal constant SUPPLY_ROUNDING_SLACK = 2; + + /// @dev Precomputed erc7201("vaultrouter.strategy.compound"): + /// keccak256(abi.encode(uint256(keccak256(id)) - 1)) & ~bytes32(uint256(0xff)) + bytes32 internal constant COMPOUND_STORAGE_SLOT = + 0x2695057c79bcfe520225f23a9e04dfe44b4fdf099be81c65c6c26e611ce7be00; + + /// @custom:storage-location erc7201:vaultrouter.strategy.compound + struct CompoundStorage { + IComet comet; + } + + function _cs() internal pure returns (CompoundStorage storage s) { + bytes32 slot = COMPOUND_STORAGE_SLOT; + assembly { + s.slot := slot + } + } + + // ----------------------------------------------------------------------- + // Curator-gated setup + // ----------------------------------------------------------------------- + + /// @notice Set the Compound III market this strategy supplies to. Must be + /// called once before the strategy is registered with the allocator. + /// @dev Owner-gated. Validates that the market's base token matches the + /// diamond's ERC4626 underlying before persisting, so capital can never + /// be routed into a market denominated in the wrong asset. + /// @param comet The Comet base market (e.g. cUSDCv3 on Arbitrum). + function compoundSetConfig(IComet comet) external { + LibDiamond.enforceIsContractOwner(); + if (address(comet) == address(0)) revert CompoundCometNotConfigured(); + address base = comet.baseToken(); + if (base == address(0)) revert CompoundBaseNotConfigured(); + if (base != IERC4626(address(this)).asset()) revert CompoundAssetMismatch(); + _cs().comet = comet; + emit CompoundConfigSet(comet, base); + } + + // ----------------------------------------------------------------------- + // IStrategy surface (prefixed) + // ----------------------------------------------------------------------- + + /// @notice Current asset value held by the strategy. Comet's `balanceOf` + /// rebases upward as supply interest accrues, so it is the exact + /// present value of the position in underlying units. + /// @dev Returns 0 (rather than reverting) when unconfigured, matching the Aave + /// facet, so an unconfigured-but-registered strategy reads as empty + /// instead of bricking the allocator's NAV sweep. + function compoundTotalAssets() external view returns (uint256) { + IComet comet = _cs().comet; + if (address(comet) == address(0)) return 0; + return comet.balanceOf(address(this)); + } + + /// @notice Pulls `amount` of the underlying from idle and supplies it to Comet. + /// @dev Called via diamond fallback by the AllocatorFacet during rebalance. + /// Verifies the rebasing balance increased by at least `amount` (minus + /// `SUPPLY_ROUNDING_SLACK`) so a fee-on-supply or broken market is caught. + function compoundDeposit(uint256 amount) external { + LibDiamond.enforceIsSelf(); + CompoundStorage storage s = _cs(); + if (address(s.comet) == address(0)) revert CompoundCometNotConfigured(); + IERC20 underlying = IERC20(IERC4626(address(this)).asset()); + uint256 balBefore = s.comet.balanceOf(address(this)); + underlying.forceApprove(address(s.comet), amount); + s.comet.supply(address(underlying), amount); + uint256 received = s.comet.balanceOf(address(this)) - balBefore; + if (received + SUPPLY_ROUNDING_SLACK < amount) revert CompoundDepositFailed(amount, received); + } + + /// @notice Withdraws `amount` of the underlying from Comet back to idle. + /// @dev Clamps the request to the current position so a withdraw can never + /// overshoot the supply balance and flip into a borrow. Measures the + /// underlying actually received (Comet's `withdraw` returns nothing) and + /// reverts if it falls short of the clamped request. + function compoundWithdraw(uint256 amount) external { + LibDiamond.enforceIsSelf(); + CompoundStorage storage s = _cs(); + if (address(s.comet) == address(0)) revert CompoundCometNotConfigured(); + IERC20 underlying = IERC20(IERC4626(address(this)).asset()); + + uint256 bal = s.comet.balanceOf(address(this)); + uint256 toWithdraw = amount > bal ? bal : amount; + if (toWithdraw == 0) return; + + uint256 idleBefore = underlying.balanceOf(address(this)); + s.comet.withdraw(address(underlying), toWithdraw); + uint256 received = underlying.balanceOf(address(this)) - idleBefore; + if (received + SUPPLY_ROUNDING_SLACK < toWithdraw) revert CompoundWithdrawFailed(toWithdraw, received); + } + + /// @notice No-op for Comet — base supply interest auto-accrues into the + /// rebasing `balanceOf`, so there is nothing to claim. (COMP incentive + /// rewards, where present, accrue separately via CometRewards and are + /// out of scope for this facet: they are a non-underlying token that + /// would need its own claim + sell + accounting path.) + function compoundHarvest() external pure { } + + // ----------------------------------------------------------------------- + // Readers + // ----------------------------------------------------------------------- + + /// @notice The currently configured Comet market (address(0) if unset). + function compoundComet() external view returns (IComet) { + return _cs().comet; + } +} diff --git a/src/interfaces/external/IComet.sol b/src/interfaces/external/IComet.sol new file mode 100644 index 0000000..78386a5 --- /dev/null +++ b/src/interfaces/external/IComet.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IComet +/// @notice Minimal interface for a Compound III (Comet) market — only the methods +/// the strategy facet calls, plus the two view rate-readers a curator uses +/// to price the market on-chain. The full Comet ABI is much larger; +/// trimmed here to keep the dependency surface small and the audit diff +/// narrow (same philosophy as `IAavePool`). +/// @dev Reference: https://docs.compound.finance/ +/// `cUSDCv3` is a non-standard rebasing token: `balanceOf` returns the +/// present value of the supplied base asset and grows as supply interest +/// accrues, exactly like an Aave aToken. There is no separate receipt token. +interface IComet { + /// @notice Supplies `amount` of `asset` to the market. Supplying the base + /// token increases the caller's supply balance (and earns interest); + /// the credited present value is reflected in `balanceOf`. + /// @param asset The asset to supply. For a base-asset supply this MUST equal `baseToken()`. + /// @param amount The amount of `asset` to supply. + function supply(address asset, uint256 amount) external; + + /// @notice Withdraws `amount` of `asset` to the caller (`msg.sender`). For a + /// pure base-asset supplier this reduces the supply balance; it never + /// tips into a borrow as long as `amount <= balanceOf(caller)`. + /// @param asset The asset to withdraw. For a base-asset withdrawal this MUST equal `baseToken()`. + /// @param amount The amount of `asset` to withdraw. + function withdraw(address asset, uint256 amount) external; + + /// @notice Present value of the caller's base-asset supply, denominated in the + /// base token. Accrues upward with supply interest between blocks. + function balanceOf(address account) external view returns (uint256); + + /// @notice The base asset of this market (e.g. native USDC on Arbitrum). + function baseToken() external view returns (address); + + /// @notice Current market utilization, scaled by 1e18. Input to `getSupplyRate`. + /// @dev Exposed for curator/reporting reads, not used by the facet itself. + function getUtilization() external view returns (uint256); + + /// @notice Per-second supply rate at a given `utilization`, scaled by 1e18. + /// Annualize with `rate * 365 days` to get an APR. Unlike Aave's + /// pre-annualized `currentLiquidityRate`, Comet returns a spot + /// per-second rate parameterized by utilization. + /// @dev Exposed for curator/reporting reads, not used by the facet itself. + function getSupplyRate(uint256 utilization) external view returns (uint64); +} diff --git a/test/integration/CompoundV3Strategy.fork.t.sol b/test/integration/CompoundV3Strategy.fork.t.sol new file mode 100644 index 0000000..360070c --- /dev/null +++ b/test/integration/CompoundV3Strategy.fork.t.sol @@ -0,0 +1,255 @@ +// 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 { 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 { CompoundV3StrategyFacet } from "../../src/facets/strategies/CompoundV3StrategyFacet.sol"; +import { IComet } from "../../src/interfaces/external/IComet.sol"; +import { LibAllocator } from "../../src/libraries/LibAllocator.sol"; + +/// @title CompoundV3StrategyForkTest +/// @notice Exercises the CompoundV3StrategyFacet end-to-end against the real +/// Compound III (Comet) cUSDCv3 market on Arbitrum One. Skipped +/// automatically when no Arbitrum RPC is available — set ARBITRUM_RPC_URL +/// to opt in. +contract CompoundV3StrategyForkTest is Test { + // ----------------------------------------------------------------------- + // Arbitrum One Compound III — native USDC (cUSDCv3) market. + // Comet proxy + base token verified against arbiscan. + // ----------------------------------------------------------------------- + address internal constant ARB_COMET = 0x9c4ec768c28520B50860ea7a15bd7213a9fF58bf; + address internal constant ARB_USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; + + bytes32 internal constant COMPOUND_ID = bytes32("compound"); + + Vault internal vault; + address internal owner = makeAddr("owner"); + address internal alice = makeAddr("alice"); + + function setUp() public { + string memory rpc = vm.envOr("ARBITRUM_RPC_URL", string("")); + if (bytes(rpc).length == 0) { + vm.skip(true); + return; + } + vm.createSelectFork(rpc, vm.envOr("ARBITRUM_FORK_BLOCK", uint256(300_000_000))); + + vault = _deployVault(); + + vm.startPrank(owner); + CompoundV3StrategyFacet(address(vault)).compoundSetConfig(IComet(ARB_COMET)); + AllocatorFacet(address(vault)).registerStrategy(COMPOUND_ID, _compoundStrategyConfig()); + _setSingleAllocation(COMPOUND_ID, 8000); // 80% to Compound + vm.stopPrank(); + } + + // ----------------------------------------------------------------------- + // Tests + // ----------------------------------------------------------------------- + + function test_DepositRebalanceDeploysToComet() public { + _seedAndDeposit(alice, 1000 * 1e6); + + assertEq(IERC20(ARB_USDC).balanceOf(address(vault)), 1000 * 1e6, "USDC sits idle pre-rebalance"); + assertEq(IComet(ARB_COMET).balanceOf(address(vault)), 0, "no Comet position yet"); + + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + // 80% supplied to Comet, 20% idle. Comet credits present value ~1:1. + assertEq(IERC20(ARB_USDC).balanceOf(address(vault)), 200 * 1e6, "20% idle"); + assertApproxEqAbs( + IComet(ARB_COMET).balanceOf(address(vault)), + 800 * 1e6, + 2, // present-value rounding slack + "80% supplied to Comet as cUSDCv3" + ); + assertApproxEqAbs(vault.totalAssets(), 1000 * 1e6, 2, "totalAssets unchanged"); + } + + function test_InterestAccruesIntoCometBalance() public { + _seedAndDeposit(alice, 1000 * 1e6); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + uint256 cBefore = IComet(ARB_COMET).balanceOf(address(vault)); + + // Comet's balanceOf recomputes the supply index to the current timestamp, + // so warping forward is enough to surface accrued interest — no explicit + // accrue call needed. + vm.warp(block.timestamp + 30 days); + vm.roll(block.number + 1_000_000); + + uint256 cAfter = IComet(ARB_COMET).balanceOf(address(vault)); + assertGt(cAfter, cBefore, "cUSDCv3 balance grew from supply interest"); + assertGt(vault.totalAssets(), 1000 * 1e6, "vault TVL grew"); + } + + /// @dev Real withdraw path: dropping the allocation to 0 and rebalancing + /// pulls the whole position back out of the live Comet market into idle. + function test_RebalanceToZeroDrainsComet() public { + _seedAndDeposit(alice, 1000 * 1e6); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + vm.warp(block.timestamp + 7 days); // accrue a little interest first + vm.roll(block.number + 100_000); + + _setSingleAllocationAsOwner(COMPOUND_ID, 0); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + assertApproxEqAbs(IComet(ARB_COMET).balanceOf(address(vault)), 0, 2, "Comet position drained back to idle"); + // Principal + accrued interest is now idle; at least the original 1000. + assertGe(IERC20(ARB_USDC).balanceOf(address(vault)), 1000 * 1e6, "idle holds principal plus interest"); + } + + /// @dev Redeems covered by the idle reserve succeed. (The Vault has no + /// strategy pull-back hook yet, so redeems beyond idle revert — see the + /// Morpho unit test for that documented boundary; here we stay within idle.) + function test_RedeemSucceedsWithinIdle() public { + _seedAndDeposit(alice, 1000 * 1e6); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); // 200 idle, 800 in Comet + + uint256 redeemShares = vault.balanceOf(alice) / 10; // ~10% -> ~100 USDC <= idle + vm.prank(alice); + uint256 assetsOut = vault.redeem(redeemShares, alice, alice); + + assertGt(assetsOut, 0, "alice received underlying"); + assertEq(IERC20(ARB_USDC).balanceOf(alice), assetsOut, "alice's wallet credited"); + assertApproxEqAbs(IComet(ARB_COMET).balanceOf(address(vault)), 800 * 1e6, 2, "Comet position untouched"); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + function _seedAndDeposit(address from, uint256 amount) internal { + deal(ARB_USDC, from, amount); + vm.startPrank(from); + IERC20(ARB_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; + AllocatorFacet(address(vault)).setAllocation(ids, b); + } + + function _setSingleAllocationAsOwner(bytes32 id, uint16 bps) internal { + vm.prank(owner); + _setSingleAllocation(id, bps); + } + + function _deployVault() internal returns (Vault) { + DiamondCutFacet cut = new DiamondCutFacet(); + DiamondLoupeFacet loupe = new DiamondLoupeFacet(); + OwnershipFacet ownership = new OwnershipFacet(); + AllocatorFacet allocator = new AllocatorFacet(); + CompoundV3StrategyFacet compound = new CompoundV3StrategyFacet(); + + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](5); + 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(allocator), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _allocatorSelectors() + }); + cuts[4] = IDiamond.FacetCut({ + facetAddress: address(compound), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _compoundSelectors() + }); + + return new Vault(IERC20(ARB_USDC), "Vault Router", "vUSDC", owner, cuts, address(0), ""); + } + + function _compoundStrategyConfig() 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 + }); + } + + 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 _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 _compoundSelectors() 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/mocks/MockComet.sol b/test/mocks/MockComet.sol new file mode 100644 index 0000000..1c6430b --- /dev/null +++ b/test/mocks/MockComet.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { IMintable } from "./MockProtocol.sol"; + +/// @title MockComet +/// @notice Test-only stand-in for a Compound III (Comet) base market. Models the +/// pieces the strategy facet touches: a 1:1 present-value `balanceOf`, +/// base-asset `supply`/`withdraw`, and `baseToken`. Adds test hooks the +/// real market doesn't need: +/// - `setSupplyShortfallBps` credits fewer base units than supplied, to +/// exercise the facet's `CompoundDepositFailed` guard (fee-on-supply). +/// - `setWithdrawShortfallBps` transfers fewer base units than requested, +/// to exercise `CompoundWithdrawFailed`. +/// - `setWithdrawReverts` makes `withdraw` revert, to exercise the +/// allocator's per-strategy rebalance-skip path on an illiquid market. +/// - `_testAccrueYield` lifts a supplier's balance to simulate accrued +/// supply interest without modelling a real rate curve. +contract MockComet { + IERC20 internal immutable _base; + mapping(address => uint256) internal _balances; + + uint256 public supplyShortfallBps; + uint256 public withdrawShortfallBps; + bool public withdrawReverts; + + // Canned rate-reader returns, so a curator-style on-chain read can be tested. + uint256 internal _utilization; + uint64 internal _supplyRate; + + error MockCometWrongAsset(); + error MockCometWithdrawPaused(); + + constructor(IERC20 base_) { + _base = base_; + } + + // ----------------------------------------------------------------------- + // Comet surface + // ----------------------------------------------------------------------- + + function baseToken() external view returns (address) { + return address(_base); + } + + function balanceOf(address account) external view returns (uint256) { + return _balances[account]; + } + + function supply(address asset, uint256 amount) external { + if (asset != address(_base)) revert MockCometWrongAsset(); + _base.transferFrom(msg.sender, address(this), amount); + uint256 credited = supplyShortfallBps == 0 ? amount : (amount * (10_000 - supplyShortfallBps)) / 10_000; + _balances[msg.sender] += credited; + } + + function withdraw(address asset, uint256 amount) external { + if (asset != address(_base)) revert MockCometWrongAsset(); + if (withdrawReverts) revert MockCometWithdrawPaused(); + // Underflows (reverts) if the caller overdraws — the real market would + // tip into a borrow; the facet's clamp keeps `amount <= balanceOf` so + // this path is never hit in normal operation. + _balances[msg.sender] -= amount; + uint256 sent = withdrawShortfallBps == 0 ? amount : (amount * (10_000 - withdrawShortfallBps)) / 10_000; + _base.transfer(msg.sender, sent); + } + + function getUtilization() external view returns (uint256) { + return _utilization; + } + + function getSupplyRate(uint256) external view returns (uint64) { + return _supplyRate; + } + + // ----------------------------------------------------------------------- + // Test hooks + // ----------------------------------------------------------------------- + + function setSupplyShortfallBps(uint256 bps) external { + supplyShortfallBps = bps; + } + + function setWithdrawShortfallBps(uint256 bps) external { + withdrawShortfallBps = bps; + } + + function setWithdrawReverts(bool v) external { + withdrawReverts = v; + } + + function setRateReads(uint256 utilization_, uint64 supplyRate_) external { + _utilization = utilization_; + _supplyRate = supplyRate_; + } + + /// @notice Test-only — mint `amount` of base into the market and credit + /// `account`'s supply balance, simulating accrued supply interest. + function _testAccrueYield(address account, uint256 amount) external { + IMintable(address(_base)).mint(address(this), amount); + _balances[account] += amount; + } +} diff --git a/test/unit/CompoundV3Strategy.t.sol b/test/unit/CompoundV3Strategy.t.sol new file mode 100644 index 0000000..e0001fd --- /dev/null +++ b/test/unit/CompoundV3Strategy.t.sol @@ -0,0 +1,493 @@ +// 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 { CompoundV3StrategyFacet } from "../../src/facets/strategies/CompoundV3StrategyFacet.sol"; +import { IComet } from "../../src/interfaces/external/IComet.sol"; +import { LibAllocator } from "../../src/libraries/LibAllocator.sol"; +import { LibDiamond } from "../../src/libraries/LibDiamond.sol"; + +import { MockComet } from "../mocks/MockComet.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 CompoundV3StrategyTest +/// @notice Unit coverage for `CompoundV3StrategyFacet` against a mock Comet +/// market — no RPC required. Mirrors the Morpho/Aave strategy tests and +/// adds the revert paths a fork test can't easily trigger (asset +/// mismatch, supply/withdraw shortfall, unconfigured market). The +/// onlySelf fund-movers are exercised both directly (pranking the diamond +/// as itself) and end-to-end through the allocator's self-dispatch. +contract CompoundV3StrategyTest is Test { + MockUSDC internal usdc; + MockComet internal comet; + Vault internal vault; + + address internal owner = makeAddr("owner"); + address internal alice = makeAddr("alice"); + + bytes32 internal constant COMPOUND_ID = bytes32("compound"); + + function setUp() public { + usdc = new MockUSDC(); + comet = new MockComet(IERC20(address(usdc))); + vault = _deployVault(); + // The market is intentionally NOT configured here — several tests exercise + // the unconfigured paths. Tests that need a live strategy call + // `_configure()` / `_register()` explicitly. + } + + // ----------------------------------------------------------------------- + // compoundSetConfig — gating & validation + // ----------------------------------------------------------------------- + + function test_SetConfig_SetsCometAndEmits() public { + vm.expectEmit(true, true, false, false, address(vault)); + emit CompoundV3StrategyFacet.CompoundConfigSet(IComet(address(comet)), address(usdc)); + + vm.prank(owner); + CompoundV3StrategyFacet(address(vault)).compoundSetConfig(IComet(address(comet))); + + assertEq( + address(CompoundV3StrategyFacet(address(vault)).compoundComet()), + address(comet), + "configured market is readable" + ); + } + + function test_SetConfig_RevertsOnZeroAddress() public { + vm.prank(owner); + vm.expectRevert(CompoundV3StrategyFacet.CompoundCometNotConfigured.selector); + CompoundV3StrategyFacet(address(vault)).compoundSetConfig(IComet(address(0))); + } + + function test_SetConfig_RevertsForNonOwner() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibDiamond.NotContractOwner.selector, alice, owner)); + CompoundV3StrategyFacet(address(vault)).compoundSetConfig(IComet(address(comet))); + } + + function test_SetConfig_RevertsOnAssetMismatch() public { + // A Comet market whose base token differs from the diamond's asset. + MockUSDC otherAsset = new MockUSDC(); + MockComet mismatched = new MockComet(IERC20(address(otherAsset))); + + vm.prank(owner); + vm.expectRevert(CompoundV3StrategyFacet.CompoundAssetMismatch.selector); + CompoundV3StrategyFacet(address(vault)).compoundSetConfig(IComet(address(mismatched))); + } + + // ----------------------------------------------------------------------- + // Unconfigured behaviour + // ----------------------------------------------------------------------- + // Mirrors the Aave facet: `compoundTotalAssets` reads as empty (0) when + // unconfigured rather than reverting, so the allocator's NAV sweep is robust; + // the fund-movers revert hard. + + function test_TotalAssets_ReturnsZeroWhenUnconfigured() public view { + assertEq(CompoundV3StrategyFacet(address(vault)).compoundTotalAssets(), 0, "empty when unconfigured"); + } + + function test_Comet_ReturnsZeroWhenUnconfigured() public view { + assertEq(address(CompoundV3StrategyFacet(address(vault)).compoundComet()), address(0), "no market set"); + } + + function test_Deposit_RevertsWhenUnconfigured() public { + vm.prank(address(vault)); // satisfy onlySelf + vm.expectRevert(CompoundV3StrategyFacet.CompoundCometNotConfigured.selector); + CompoundV3StrategyFacet(address(vault)).compoundDeposit(1e6); + } + + function test_Withdraw_RevertsWhenUnconfigured() public { + vm.prank(address(vault)); + vm.expectRevert(CompoundV3StrategyFacet.CompoundCometNotConfigured.selector); + CompoundV3StrategyFacet(address(vault)).compoundWithdraw(1e6); + } + + // ----------------------------------------------------------------------- + // Access control — fund-movers are reachable only via diamond self-dispatch + // ----------------------------------------------------------------------- + + function test_Deposit_RevertsForExternalCaller() public { + _configure(); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibDiamond.NotSelf.selector, alice)); + CompoundV3StrategyFacet(address(vault)).compoundDeposit(1e6); + } + + function test_Withdraw_RevertsForExternalCaller() public { + _configure(); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibDiamond.NotSelf.selector, alice)); + CompoundV3StrategyFacet(address(vault)).compoundWithdraw(1e6); + } + + // ----------------------------------------------------------------------- + // Strategy primitives — driven directly by pranking the diamond as itself + // (msg.sender == address(this) satisfies enforceIsSelf), so each guard gets + // precise coverage independent of the allocator. + // ----------------------------------------------------------------------- + + function test_Deposit_CreditsAndReportsAssets() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + + vm.prank(address(vault)); + CompoundV3StrategyFacet(address(vault)).compoundDeposit(amount); + + assertEq(usdc.balanceOf(address(vault)), 0, "idle fully supplied"); + assertEq(comet.balanceOf(address(vault)), amount, "market credited the supply"); + assertEq(CompoundV3StrategyFacet(address(vault)).compoundTotalAssets(), amount, "position reported 1:1"); + } + + function test_Deposit_RevertsOnSupplyShortfall() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + comet.setSupplyShortfallBps(100); // credit 1% fewer base units than supplied + + vm.prank(address(vault)); + vm.expectRevert( + abi.encodeWithSelector( + CompoundV3StrategyFacet.CompoundDepositFailed.selector, amount, (amount * 9900) / 10_000 + ) + ); + CompoundV3StrategyFacet(address(vault)).compoundDeposit(amount); + } + + function test_Withdraw_ReturnsUnderlyingToDiamond() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + vm.startPrank(address(vault)); + CompoundV3StrategyFacet(address(vault)).compoundDeposit(amount); + CompoundV3StrategyFacet(address(vault)).compoundWithdraw(400 * 1e6); + vm.stopPrank(); + + assertEq(usdc.balanceOf(address(vault)), 400 * 1e6, "withdrawn underlying back to idle"); + assertEq(CompoundV3StrategyFacet(address(vault)).compoundTotalAssets(), 600 * 1e6, "remaining position"); + } + + function test_Withdraw_RevertsOnShortfall() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + vm.startPrank(address(vault)); + CompoundV3StrategyFacet(address(vault)).compoundDeposit(amount); + + comet.setWithdrawShortfallBps(100); // market returns 1% less than requested + vm.expectRevert( + abi.encodeWithSelector( + CompoundV3StrategyFacet.CompoundWithdrawFailed.selector, 400 * 1e6, (400 * 1e6 * 9900) / 10_000 + ) + ); + CompoundV3StrategyFacet(address(vault)).compoundWithdraw(400 * 1e6); + vm.stopPrank(); + } + + /// @dev Over-requesting must clamp to the position, never overdraw into a + /// borrow. Asking for 2x the balance withdraws exactly the balance. + function test_Withdraw_ClampsOverRequestToBalance() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + vm.startPrank(address(vault)); + CompoundV3StrategyFacet(address(vault)).compoundDeposit(amount); + CompoundV3StrategyFacet(address(vault)).compoundWithdraw(amount * 2); // way over the position + vm.stopPrank(); + + assertEq(usdc.balanceOf(address(vault)), amount, "clamped: only the balance came back"); + assertEq(CompoundV3StrategyFacet(address(vault)).compoundTotalAssets(), 0, "position fully drained, no borrow"); + } + + function test_TotalAssets_TracksYieldAccrual() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + vm.prank(address(vault)); + CompoundV3StrategyFacet(address(vault)).compoundDeposit(amount); + + uint256 beforeYield = CompoundV3StrategyFacet(address(vault)).compoundTotalAssets(); + comet._testAccrueYield(address(vault), 100 * 1e6); // 10% supply interest + uint256 afterYield = CompoundV3StrategyFacet(address(vault)).compoundTotalAssets(); + + assertGt(afterYield, beforeYield, "rebasing balance grew with supply interest"); + assertEq(afterYield, 1100 * 1e6, "position == principal + accrued interest"); + } + + function test_Harvest_IsNoOp() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + vm.prank(address(vault)); + CompoundV3StrategyFacet(address(vault)).compoundDeposit(amount); + + uint256 before = CompoundV3StrategyFacet(address(vault)).compoundTotalAssets(); + CompoundV3StrategyFacet(address(vault)).compoundHarvest(); // not onlySelf, moves nothing + assertEq(CompoundV3StrategyFacet(address(vault)).compoundTotalAssets(), before, "harvest is a no-op"); + } + + // ----------------------------------------------------------------------- + // End-to-end through the allocator + // ----------------------------------------------------------------------- + + function test_Rebalance_RoutesAssetsIntoCompound() public { + _configure(); + _register(); + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(COMPOUND_ID, 8000); // 80% + + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + assertEq(comet.balanceOf(address(vault)), 800 * 1e6, "80% supplied to Comet"); + assertEq(usdc.balanceOf(address(vault)), 200 * 1e6, "20% stays idle"); + assertApproxEqAbs(vault.totalAssets(), 1000 * 1e6, 1, "totalAssets unchanged across rebalance"); + } + + function test_Rebalance_PullsBackWhenAllocationDrops() public { + _configure(); + _register(); + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(COMPOUND_ID, 8000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + _setSingleAllocation(COMPOUND_ID, 0); // drop to 0 -> next rebalance drains + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + assertEq(comet.balanceOf(address(vault)), 0, "Comet position drained"); + assertApproxEqAbs(usdc.balanceOf(address(vault)), 1000 * 1e6, 1, "all assets back idle"); + } + + /// @dev A fee-on-supply makes `compoundDeposit` revert; the allocator turns it + /// into a per-strategy SKIP rather than a whole-rebalance revert, so the + /// funds stay idle and the skip is recorded. + function test_Rebalance_SkipsDepositOnSupplyShortfall() public { + _configure(); + _register(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + comet.setSupplyShortfallBps(100); + _setSingleAllocation(COMPOUND_ID, 10_000); + vm.roll(block.number + 1); + + vm.expectEmit(true, false, false, true, address(vault)); + emit AllocatorFacet.StrategyRebalanceSkipped(COMPOUND_ID, CompoundV3StrategyFacet.compoundDeposit.selector); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + assertEq(usdc.balanceOf(address(vault)), amount, "funds stayed idle; over-slippage deposit skipped"); + } + + /// @dev An illiquid/paused market makes `compoundWithdraw` revert; the + /// rebalancer skips that one strategy instead of bricking the batch. + function test_Rebalance_SkipsWithdrawWhenMarketReverts() public { + _configure(); + _register(); + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(COMPOUND_ID, 8000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); // 800 in Comet + + comet.setWithdrawReverts(true); // market can't return funds + _setSingleAllocation(COMPOUND_ID, 0); // ask to pull everything back + vm.roll(block.number + 1); + + vm.expectEmit(true, false, false, true, address(vault)); + emit AllocatorFacet.StrategyRebalanceSkipped(COMPOUND_ID, CompoundV3StrategyFacet.compoundWithdraw.selector); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + assertEq(comet.balanceOf(address(vault)), 800 * 1e6, "position untouched after skipped withdraw"); + } + + // ----------------------------------------------------------------------- + // Redeem — documents current Vault behaviour (no strategy pull-back hook yet, + // identical to the Morpho strategy test). + // ----------------------------------------------------------------------- + + function test_Redeem_SucceedsWhenCoveredByIdle() public { + _configure(); + _register(); + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(COMPOUND_ID, 8000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); // 200 idle, 800 in Comet + + uint256 redeemShares = vault.balanceOf(alice) / 10; // ~10% -> ~100 USDC <= idle + vm.prank(alice); + uint256 assetsOut = vault.redeem(redeemShares, alice, alice); + + assertGt(assetsOut, 0, "alice received underlying"); + assertEq(usdc.balanceOf(alice), assetsOut, "alice's wallet credited"); + assertEq(comet.balanceOf(address(vault)), 800 * 1e6, "Comet position untouched"); + } + + function test_Redeem_RevertsWhenExceedsIdleLiquidity() public { + _configure(); + _register(); + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(COMPOUND_ID, 8000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); // only 200 USDC idle + + uint256 allShares = vault.balanceOf(alice); + vm.prank(alice); + vm.expectRevert(); + vault.redeem(allShares, alice, alice); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + function _configure() internal { + vm.prank(owner); + CompoundV3StrategyFacet(address(vault)).compoundSetConfig(IComet(address(comet))); + } + + function _register() internal { + vm.prank(owner); + AllocatorFacet(address(vault)).registerStrategy(COMPOUND_ID, _compoundStrategyConfig()); + } + + 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 _compoundStrategyConfig() 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 // overwritten in registerStrategy + }); + } + + function _deployVault() internal returns (Vault) { + DiamondCutFacet cut = new DiamondCutFacet(); + DiamondLoupeFacet loupe = new DiamondLoupeFacet(); + OwnershipFacet ownership = new OwnershipFacet(); + AllocatorFacet allocator = new AllocatorFacet(); + CompoundV3StrategyFacet compound = new CompoundV3StrategyFacet(); + + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](5); + 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(allocator), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _allocatorSelectors() + }); + cuts[4] = IDiamond.FacetCut({ + facetAddress: address(compound), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _compoundSelectors() + }); + + return new Vault(IERC20(address(usdc)), "Vault Router", "vUSDC", owner, cuts, address(0), ""); + } + + 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 _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 _compoundSelectors() 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/unit/StorageNamespaces.t.sol b/test/unit/StorageNamespaces.t.sol index d9bb4a7..f8f8c72 100644 --- a/test/unit/StorageNamespaces.t.sol +++ b/test/unit/StorageNamespaces.t.sol @@ -12,6 +12,7 @@ import { LibWithdrawQueue } from "../../src/libraries/LibWithdrawQueue.sol"; import { AaveStrategyFacet } from "../../src/facets/strategies/AaveStrategyFacet.sol"; import { MorphoStrategyFacet } from "../../src/facets/strategies/MorphoStrategyFacet.sol"; import { PendlePtStrategyFacet } from "../../src/facets/strategies/PendlePtStrategyFacet.sol"; +import { CompoundV3StrategyFacet } from "../../src/facets/strategies/CompoundV3StrategyFacet.sol"; // The strategy facets keep their slot constant `internal` at contract scope, which is not // reachable via qualified access, so a thin heir exposes it for the invariant check. @@ -33,6 +34,12 @@ contract PendleExposer is PendlePtStrategyFacet { } } +contract CompoundExposer is CompoundV3StrategyFacet { + function exposedSlot() external pure returns (bytes32) { + return COMPOUND_STORAGE_SLOT; + } +} + /// @title Storage namespace invariant /// @notice Every precomputed storage-slot literal in the protocol must equal the ERC-7201 /// hash of its declared namespace string. This pins each named literal to its @@ -58,5 +65,6 @@ contract StorageNamespacesTest is Test { assertEq(new AaveExposer().exposedSlot(), _erc7201("vaultrouter.strategy.aave"), "aave"); assertEq(new MorphoExposer().exposedSlot(), _erc7201("vaultrouter.strategy.morpho"), "morpho"); assertEq(new PendleExposer().exposedSlot(), _erc7201("vaultrouter.strategy.pendle"), "pendle"); + assertEq(new CompoundExposer().exposedSlot(), _erc7201("vaultrouter.strategy.compound"), "compound"); } }