From 3ed08f5abf2d241b397c2ae4bcc1c99364c7d295 Mon Sep 17 00:00:00 2001 From: Jayesh Yadav Date: Mon, 11 May 2026 23:48:47 +0530 Subject: [PATCH 1/4] add fee facet and storage --- src/facets/FeeFacet.sol | 72 +++++++++++++++++++++++++++++++++++++++ src/libraries/LibFees.sol | 37 ++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/facets/FeeFacet.sol create mode 100644 src/libraries/LibFees.sol diff --git a/src/facets/FeeFacet.sol b/src/facets/FeeFacet.sol new file mode 100644 index 0000000..72b5ede --- /dev/null +++ b/src/facets/FeeFacet.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {LibDiamond} from "../libraries/LibDiamond.sol"; +import {LibFees} from "../libraries/LibFees.sol"; + +/// @title FeeFacet +/// @notice Curator-gated setters and public readers for the vault's fee parameters. +/// The actual accrual logic lives in `Vault.sol` (it needs `_mint` access), +/// while this facet owns the configuration surface. +contract FeeFacet { + error InvalidFeeRecipient(); + error PerformanceFeeTooHigh(uint16 attemptedBps, uint16 maxBps); + error ManagementFeeTooHigh(uint16 attemptedBps, uint16 maxBps); + + event FeeRecipientSet(address indexed recipient); + event PerformanceFeeSet(uint16 bps); + event ManagementFeeSet(uint16 bps); + + // ----------------------------------------------------------------------- + // Curator-gated setters + // ----------------------------------------------------------------------- + + function setFeeRecipient(address recipient) external { + LibDiamond.enforceIsContractOwner(); + if (recipient == address(0)) revert InvalidFeeRecipient(); + LibFees.feeStorage().feeRecipient = recipient; + emit FeeRecipientSet(recipient); + } + + function setPerformanceFee(uint16 bps) external { + LibDiamond.enforceIsContractOwner(); + if (bps > LibFees.MAX_PERFORMANCE_FEE_BPS) { + revert PerformanceFeeTooHigh(bps, LibFees.MAX_PERFORMANCE_FEE_BPS); + } + LibFees.feeStorage().performanceFeeBps = bps; + emit PerformanceFeeSet(bps); + } + + function setManagementFee(uint16 bps) external { + LibDiamond.enforceIsContractOwner(); + if (bps > LibFees.MAX_MANAGEMENT_FEE_BPS) { + revert ManagementFeeTooHigh(bps, LibFees.MAX_MANAGEMENT_FEE_BPS); + } + LibFees.feeStorage().managementFeeBps = bps; + emit ManagementFeeSet(bps); + } + + // ----------------------------------------------------------------------- + // Readers + // ----------------------------------------------------------------------- + + function feeRecipient() external view returns (address) { + return LibFees.feeStorage().feeRecipient; + } + + function performanceFeeBps() external view returns (uint16) { + return LibFees.feeStorage().performanceFeeBps; + } + + function managementFeeBps() external view returns (uint16) { + return LibFees.feeStorage().managementFeeBps; + } + + function highWaterMark() external view returns (uint256) { + return LibFees.feeStorage().highWaterMark; + } + + function lastFeeAccrual() external view returns (uint64) { + return LibFees.feeStorage().lastFeeAccrual; + } +} diff --git a/src/libraries/LibFees.sol b/src/libraries/LibFees.sol new file mode 100644 index 0000000..d03bf93 --- /dev/null +++ b/src/libraries/LibFees.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title LibFees +/// @notice Namespaced storage and constants for the vault's fee accrual logic. +/// Tracks the fee recipient, performance + management fee rates, the +/// share-price high-water mark used by the performance fee, and the +/// timestamp of the last accrual used by the management fee. +/// @dev Storage location: +/// keccak256(abi.encode(uint256(keccak256("vaultrouter.storage.fees")) - 1)) & ~bytes32(uint256(0xff)) +library LibFees { + uint16 internal constant BPS_DENOMINATOR = 10_000; + uint16 internal constant MAX_PERFORMANCE_FEE_BPS = 5_000; // 50% sanity ceiling + uint16 internal constant MAX_MANAGEMENT_FEE_BPS = 1_000; // 10% / year sanity ceiling + uint256 internal constant SECONDS_PER_YEAR = 365 days; + + /// @dev erc7201:vaultrouter.storage.fees + bytes32 internal constant FEE_STORAGE_SLOT = + 0xd8263cd2923de1a73423e53eeb7d7ffc12f7b4ef6a8eadaee1bbca5e38dbe600; + + struct FeeStorage { + address feeRecipient; + uint16 performanceFeeBps; + uint16 managementFeeBps; + /// @dev Share price (asset units per share, scaled by 1e18) at the most + /// recent accrual. Performance fee is taken on any increase above this. + uint256 highWaterMark; + uint64 lastFeeAccrual; + } + + function feeStorage() internal pure returns (FeeStorage storage s) { + bytes32 slot = FEE_STORAGE_SLOT; + assembly { + s.slot := slot + } + } +} From 6413e2bb5d56489ddfec712ef6e4e322b200aa6f Mon Sep 17 00:00:00 2001 From: Jayesh Yadav Date: Mon, 11 May 2026 23:56:10 +0530 Subject: [PATCH 2/4] accrue performance and management fees --- src/Vault.sol | 80 ++++++++++ test/mocks/MockProtocol.sol | 15 +- test/unit/Fees.t.sol | 296 ++++++++++++++++++++++++++++++++++++ 3 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 test/unit/Fees.t.sol diff --git a/src/Vault.sol b/src/Vault.sol index 15c3e91..ada8102 100644 --- a/src/Vault.sol +++ b/src/Vault.sol @@ -8,6 +8,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IDiamond} from "./interfaces/IDiamond.sol"; import {LibDiamond} from "./libraries/LibDiamond.sol"; import {LibAllocator} from "./libraries/LibAllocator.sol"; +import {LibFees} from "./libraries/LibFees.sol"; /// @title Vault Router — modular ERC-4626 vault on the EIP-2535 Diamond pattern. /// @notice Vault.sol owns the ERC-4626 surface (deposit/withdraw/totalAssets) and @@ -59,6 +60,85 @@ contract Vault is ERC4626 { return total; } + // ----------------------------------------------------------------------- + // Fee-accrual hooks + // ----------------------------------------------------------------------- + + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal override { + // Pre-accrue so the new depositor doesn't dilute the perf-fee owed on + // yield earned by existing holders. No-op on the very first deposit + // (supply == 0 → early return). + _accrueFees(); + super._deposit(caller, receiver, assets, shares); + // Post-accrue handles the first-deposit bootstrap: now that supply > 0, + // initialise HWM and the accrual timestamp. No-op for subsequent deposits. + _accrueFees(); + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal override { + _accrueFees(); + super._withdraw(caller, receiver, owner, assets, shares); + _accrueFees(); + } + + /// @dev Mints performance + management fee shares to the configured recipient. + /// Performance fee is taken on any increase in share price above the HWM + /// since the last accrual. Management fee accrues linearly over elapsed + /// time. Both use linear approximations valid for small fees; an exact + /// "no-self-dilution" form would mint slightly fewer shares. + function _accrueFees() internal { + LibFees.FeeStorage storage f = LibFees.feeStorage(); + if (f.feeRecipient == address(0)) return; + + uint256 supply = totalSupply(); + uint64 nowTs = uint64(block.timestamp); + + if (supply == 0) { + f.lastFeeAccrual = nowTs; + f.highWaterMark = 0; + return; + } + + uint256 ta = totalAssets(); + uint256 effectiveSupply = supply + 10 ** _decimalsOffset(); + uint256 sharePrice = ((ta + 1) * 1e18) / effectiveSupply; + + if (f.highWaterMark == 0) f.highWaterMark = sharePrice; + if (f.lastFeeAccrual == 0) f.lastFeeAccrual = nowTs; + + uint256 feeShares; + + // Management fee — linear over elapsed seconds. + if (f.managementFeeBps > 0 && nowTs > f.lastFeeAccrual) { + uint256 elapsed = nowTs - f.lastFeeAccrual; + feeShares += (supply * uint256(f.managementFeeBps) * elapsed) + / (uint256(LibFees.BPS_DENOMINATOR) * LibFees.SECONDS_PER_YEAR); + } + + // Performance fee — proportional to share-price gain above HWM. + if (f.performanceFeeBps > 0 && sharePrice > f.highWaterMark) { + uint256 profitPerShare = sharePrice - f.highWaterMark; + uint256 profitValue = (profitPerShare * supply) / 1e18; + uint256 feeValue = (profitValue * uint256(f.performanceFeeBps)) / LibFees.BPS_DENOMINATOR; + if (ta > 0) feeShares += (feeValue * supply) / ta; + f.highWaterMark = sharePrice; + } + + f.lastFeeAccrual = nowTs; + if (feeShares > 0) _mint(f.feeRecipient, feeShares); + } + fallback() external payable { LibDiamond.DiamondStorage storage ds; bytes32 position = LibDiamond.DIAMOND_STORAGE_POSITION; diff --git a/test/mocks/MockProtocol.sol b/test/mocks/MockProtocol.sol index a7b0867..de78f90 100644 --- a/test/mocks/MockProtocol.sol +++ b/test/mocks/MockProtocol.sol @@ -3,10 +3,13 @@ pragma solidity ^0.8.24; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +interface IMintable { + function mint(address to, uint256 amount) external; +} + /// @notice Test-only stand-in for an external yield protocol (Morpho/Aave style). /// Holds the underlying asset on behalf of depositors and tracks per-account -/// balances. No yield accrual — yield is added by the test harness when -/// needed by minting tokens directly to this contract. +/// balances. Includes a test-only `_testAccrueYield` to simulate yield growth. contract MockProtocol { IERC20 public immutable asset; mapping(address => uint256) public balanceOf; @@ -24,4 +27,12 @@ contract MockProtocol { balanceOf[msg.sender] -= amount; asset.transfer(msg.sender, amount); } + + /// @notice Test-only — mints `amount` of the underlying to this contract and + /// credits `account`'s internal balance. Lets tests simulate yield + /// accrual without modelling a real interest curve. + function _testAccrueYield(address account, uint256 amount) external { + IMintable(address(asset)).mint(address(this), amount); + balanceOf[account] += amount; + } } diff --git a/test/unit/Fees.t.sol b/test/unit/Fees.t.sol new file mode 100644 index 0000000..aaf5d09 --- /dev/null +++ b/test/unit/Fees.t.sol @@ -0,0 +1,296 @@ +// 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 {FeeFacet} from "../../src/facets/FeeFacet.sol"; +import {LibAllocator} from "../../src/libraries/LibAllocator.sol"; +import {LibFees} from "../../src/libraries/LibFees.sol"; + +import {MockProtocol} from "../mocks/MockProtocol.sol"; +import {MockStrategyFacet} from "../mocks/MockStrategyFacet.sol"; + +contract MockUSDC is ERC20 { + constructor() ERC20("USD Coin", "USDC") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function decimals() public pure override returns (uint8) { + return 6; + } +} + +contract FeesTest is Test { + MockUSDC internal usdc; + Vault internal vault; + MockProtocol internal mockProtocol; + + address internal owner = makeAddr("owner"); + address internal alice = makeAddr("alice"); + address internal feeRx = makeAddr("feeRecipient"); + + bytes32 internal constant MOCK_ID = bytes32("mock"); + + function setUp() public { + usdc = new MockUSDC(); + mockProtocol = new MockProtocol(IERC20(address(usdc))); + vault = _deployVault(); + + vm.startPrank(owner); + MockStrategyFacet(address(vault)).mockSetProtocol(mockProtocol); + AllocatorFacet(address(vault)).registerStrategy(MOCK_ID, _mockStrategyConfig()); + vm.stopPrank(); + } + + // ----------------------------------------------------------------------- + // No fees when not configured + // ----------------------------------------------------------------------- + + function test_NoFee_WhenRecipientUnset() public { + // Even with non-zero rates set, no recipient → no fee shares. + // (Setters reject 0-address recipient, so we just leave it default.) + _depositToVault(alice, 1_000 * 1e6); + mockProtocol._testAccrueYield(address(vault), 100 * 1e6); // 10% yield + _depositToVault(alice, 1_000 * 1e6); // triggers _accrueFees + + assertEq(vault.balanceOf(feeRx), 0, "no fee minted without recipient"); + } + + // ----------------------------------------------------------------------- + // Performance fee + // ----------------------------------------------------------------------- + + function test_PerformanceFee_BootstrapsHwmOnFirstAccrual() public { + _configureFees(2_000, 0); // 20% perf, 0% mgmt + _depositToVault(alice, 1_000 * 1e6); + + // First deposit just initialised HWM to the current share price. + // No fee should have been charged. + assertEq(vault.balanceOf(feeRx), 0, "no fee on the bootstrap deposit"); + assertGt(FeeFacet(address(vault)).highWaterMark(), 0, "HWM initialised"); + } + + function test_PerformanceFee_ChargedOnShareGrowth() public { + _configureFees(2_000, 0); // 20% perf + _depositToVault(alice, 1_000 * 1e6); + _setSingleAllocation(MOCK_ID, 8_000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + // Yield: 100 USDC profit (10% of TVL) accrues to the mock-protocol position. + mockProtocol._testAccrueYield(address(vault), 100 * 1e6); + + // Trigger accrual via a fresh deposit. + _depositToVault(alice, 1 * 1e6); + + // 20% of 100 USDC profit ≈ 20 USDC worth of shares minted to feeRx. + // Recipient's share of TVL after the mint ≈ 20 / 1101 ≈ 1.82%. + uint256 supplyTotal = vault.totalSupply(); + uint256 recipientShare = (vault.balanceOf(feeRx) * 10_000) / supplyTotal; + assertApproxEqAbs(recipientShare, 182, 10, "feeRx owns ~1.82% of supply"); + } + + function test_PerformanceFee_NotChargedBelowHWM() public { + _configureFees(2_000, 0); + _depositToVault(alice, 1_000 * 1e6); + _setSingleAllocation(MOCK_ID, 8_000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + // Bump then trigger an accrual to lock HWM at the high. + mockProtocol._testAccrueYield(address(vault), 100 * 1e6); + _depositToVault(alice, 1 * 1e6); + + uint256 sharesBefore = vault.balanceOf(feeRx); + + // Burn yield (simulate loss by withdrawing from mock at a discount). + // Easier: manipulate the mock balance directly. + vm.prank(address(vault)); + mockProtocol.withdraw(50 * 1e6); // pulls 50 back, both protocol-side balance and vault USDC change + + // Trigger another accrual. Share price now BELOW the previous HWM + // (we lost value). Perf fee must NOT trigger. + _depositToVault(alice, 1 * 1e6); + + assertEq(vault.balanceOf(feeRx), sharesBefore, "no extra perf fee below HWM"); + } + + // ----------------------------------------------------------------------- + // Management fee + // ----------------------------------------------------------------------- + + function test_ManagementFee_AccruedOverTime() public { + _configureFees(0, 200); // 0% perf, 2%/year mgmt + _depositToVault(alice, 1_000 * 1e6); + + uint256 supplyBefore = vault.totalSupply(); + + // Warp one full year forward. + vm.warp(block.timestamp + 365 days); + + // Trigger accrual via a fresh deposit. + _depositToVault(alice, 1 * 1e6); + + // After 1 year at 2%, fee shares should be ~2% of the pre-warp supply. + // The exact share count uses our linear approximation; allow ±5% drift. + uint256 expected = (supplyBefore * 200) / 10_000; + assertApproxEqRel(vault.balanceOf(feeRx), expected, 0.05e18, "mgmt fee ~ 2% of supply after 1 yr"); + } + + // ----------------------------------------------------------------------- + // setUp helpers + // ----------------------------------------------------------------------- + + function _configureFees(uint16 perfBps, uint16 mgmtBps) internal { + vm.startPrank(owner); + FeeFacet(address(vault)).setFeeRecipient(feeRx); + if (perfBps > 0) FeeFacet(address(vault)).setPerformanceFee(perfBps); + if (mgmtBps > 0) FeeFacet(address(vault)).setManagementFee(mgmtBps); + vm.stopPrank(); + } + + 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 _deployVault() internal returns (Vault) { + DiamondCutFacet cut = new DiamondCutFacet(); + DiamondLoupeFacet loupe = new DiamondLoupeFacet(); + OwnershipFacet ownership = new OwnershipFacet(); + AllocatorFacet allocator = new AllocatorFacet(); + FeeFacet feeFacet = new FeeFacet(); + MockStrategyFacet mock = new MockStrategyFacet(); + + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](6); + 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(feeFacet), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _feeSelectors() + }); + cuts[5] = IDiamond.FacetCut({ + facetAddress: address(mock), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _mockSelectors() + }); + + return new Vault(IERC20(address(usdc)), "Vault Router", "vUSDC", owner, cuts, address(0), ""); + } + + function _mockStrategyConfig() 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 _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 _feeSelectors() 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 _mockSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](7); + 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.mockHarvestCount.selector; + } +} From fde7a23b20b79cea00792c9cff1c92bae2ebba21 Mon Sep 17 00:00:00 2001 From: Jayesh Yadav Date: Tue, 12 May 2026 21:33:00 +0530 Subject: [PATCH 3/4] add aave strategy facet --- src/facets/strategies/AaveStrategyFacet.sol | 104 ++++++++++++++++++++ src/interfaces/external/IAavePool.sol | 23 +++++ 2 files changed, 127 insertions(+) create mode 100644 src/facets/strategies/AaveStrategyFacet.sol create mode 100644 src/interfaces/external/IAavePool.sol diff --git a/src/facets/strategies/AaveStrategyFacet.sol b/src/facets/strategies/AaveStrategyFacet.sol new file mode 100644 index 0000000..42ba12e --- /dev/null +++ b/src/facets/strategies/AaveStrategyFacet.sol @@ -0,0 +1,104 @@ +// 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 {IAavePool} from "../../interfaces/external/IAavePool.sol"; + +/// @title AaveStrategyFacet +/// @notice Strategy facet that supplies the vault's asset to Aave V3 and +/// reports its position via the corresponding aToken. +/// @dev Selectors are prefixed with `aave*` so the facet coexists with other +/// strategy facets in the same Diamond without selector collisions. +/// State lives at EIP-7201 slot `vaultrouter.strategy.aave`. +contract AaveStrategyFacet { + using SafeERC20 for IERC20; + + error AavePoolNotConfigured(); + error AaveATokenNotConfigured(); + + event AaveConfigSet(IAavePool indexed pool, IERC20 indexed aToken); + + /// @dev erc7201:vaultrouter.strategy.aave + bytes32 internal constant AAVE_STORAGE_SLOT = + 0x340080245a7d3e67835fb5055646777827d09fc7212fda4d8d724367e1215700; + + struct AaveStorage { + IAavePool pool; + IERC20 aToken; + } + + function _as() internal pure returns (AaveStorage storage s) { + bytes32 slot = AAVE_STORAGE_SLOT; + assembly { + s.slot := slot + } + } + + // ----------------------------------------------------------------------- + // Curator-gated setup + // ----------------------------------------------------------------------- + + /// @notice Set the Aave V3 pool and the aToken that corresponds to the + /// vault's underlying asset. Must be called once before the strategy + /// is registered with the allocator. + function aaveSetConfig(IAavePool pool, IERC20 aToken) external { + LibDiamond.enforceIsContractOwner(); + if (address(pool) == address(0)) revert AavePoolNotConfigured(); + if (address(aToken) == address(0)) revert AaveATokenNotConfigured(); + AaveStorage storage s = _as(); + s.pool = pool; + s.aToken = aToken; + emit AaveConfigSet(pool, aToken); + } + + // ----------------------------------------------------------------------- + // IStrategy surface (prefixed) + // ----------------------------------------------------------------------- + + /// @notice Current asset value held by the strategy. aTokens rebase upward + /// as borrow interest accrues, so `balanceOf` of the vault is the + /// exact current position in underlying units. + function aaveTotalAssets() external view returns (uint256) { + IERC20 aToken = _as().aToken; + if (address(aToken) == address(0)) return 0; + return aToken.balanceOf(address(this)); + } + + /// @notice Pulls `amount` of the underlying from idle and supplies it to Aave V3. + /// @dev Called via diamond fallback by the AllocatorFacet during rebalance. + function aaveDeposit(uint256 amount) external { + AaveStorage storage s = _as(); + if (address(s.pool) == address(0)) revert AavePoolNotConfigured(); + IERC20 underlying = IERC20(IERC4626(address(this)).asset()); + underlying.forceApprove(address(s.pool), amount); + s.pool.supply(address(underlying), amount, address(this), 0); + } + + /// @notice Withdraws `amount` of the underlying from Aave V3 back to idle. + function aaveWithdraw(uint256 amount) external { + AaveStorage storage s = _as(); + if (address(s.pool) == address(0)) revert AavePoolNotConfigured(); + IERC20 underlying = IERC20(IERC4626(address(this)).asset()); + s.pool.withdraw(address(underlying), amount, address(this)); + } + + /// @notice No-op for Aave V3 — supply yield auto-accrues into the aToken's + /// rebasing balance, so there's nothing to claim. + function aaveHarvest() external pure {} + + // ----------------------------------------------------------------------- + // Readers + // ----------------------------------------------------------------------- + + function aavePool() external view returns (IAavePool) { + return _as().pool; + } + + function aaveAToken() external view returns (IERC20) { + return _as().aToken; + } +} diff --git a/src/interfaces/external/IAavePool.sol b/src/interfaces/external/IAavePool.sol new file mode 100644 index 0000000..3d2dc21 --- /dev/null +++ b/src/interfaces/external/IAavePool.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IAavePool +/// @notice Minimal interface for the Aave V3 `Pool` contract — only the methods +/// the strategy facet calls. Full pool ABI is much larger; trimmed here +/// to keep the dependency surface small and the audit diff narrow. +/// @dev Reference: https://aave.com/docs/developers/smart-contracts/pool +interface IAavePool { + /// @notice Supplies `amount` of `asset` to the pool, receiving aTokens (rebasing) in return. + /// @param asset The underlying asset address (e.g. USDC). + /// @param amount The amount of `asset` to supply. + /// @param onBehalfOf The address that will receive the aTokens. + /// @param referralCode Reserved for backwards compatibility; pass 0. + function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; + + /// @notice Withdraws `amount` of `asset` from the pool to `to`, burning the corresponding aTokens. + /// @param asset The underlying asset address. + /// @param amount The amount to withdraw. Use `type(uint256).max` to withdraw all. + /// @param to The address receiving the underlying. + /// @return The amount of `asset` actually withdrawn. + function withdraw(address asset, uint256 amount, address to) external returns (uint256); +} From d5834e5fa3f12d6adb02b45662425a756dd9b27c Mon Sep 17 00:00:00 2001 From: Jayesh Yadav Date: Tue, 12 May 2026 21:52:20 +0530 Subject: [PATCH 4/4] add aave fork integration test --- test/integration/AaveStrategy.fork.t.sol | 239 +++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 test/integration/AaveStrategy.fork.t.sol diff --git a/test/integration/AaveStrategy.fork.t.sol b/test/integration/AaveStrategy.fork.t.sol new file mode 100644 index 0000000..94ba98b --- /dev/null +++ b/test/integration/AaveStrategy.fork.t.sol @@ -0,0 +1,239 @@ +// 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 {AaveStrategyFacet} from "../../src/facets/strategies/AaveStrategyFacet.sol"; +import {IAavePool} from "../../src/interfaces/external/IAavePool.sol"; +import {LibAllocator} from "../../src/libraries/LibAllocator.sol"; + +/// @title AaveStrategyForkTest +/// @notice Exercises the AaveStrategyFacet end-to-end against the real Aave V3 +/// deployment on Base mainnet. Skipped automatically when no Base RPC +/// is available; defaults to the public `mainnet.base.org` endpoint. +contract AaveStrategyForkTest is Test { + // ----------------------------------------------------------------------- + // Base mainnet Aave V3 — addresses sourced from Aave's address book. + // ----------------------------------------------------------------------- + address internal constant BASE_AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; + address internal constant BASE_USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + address internal constant BASE_AUSDC = 0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB; + + bytes32 internal constant AAVE_ID = bytes32("aave"); + + Vault internal vault; + address internal owner = makeAddr("owner"); + address internal alice = makeAddr("alice"); + + function setUp() public { + // Fork tests require a dedicated Base RPC (the public endpoint times out + // on the storage-introspection calls forge-std's `deal` cheat makes). + // Set BASE_RPC_URL in your shell or .env to opt in. + string memory rpc = vm.envOr("BASE_RPC_URL", string("")); + if (bytes(rpc).length == 0) { + vm.skip(true); + return; + } + vm.createSelectFork(rpc, 25_000_000); + + vault = _deployVault(); + + vm.startPrank(owner); + AaveStrategyFacet(address(vault)).aaveSetConfig(IAavePool(BASE_AAVE_POOL), IERC20(BASE_AUSDC)); + AllocatorFacet(address(vault)).registerStrategy(AAVE_ID, _aaveStrategyConfig()); + _setSingleAllocation(AAVE_ID, 8_000); // 80% to Aave + vm.stopPrank(); + } + + // ----------------------------------------------------------------------- + // Tests + // ----------------------------------------------------------------------- + + function test_DepositRebalanceDeploysToAUsdc() public { + _seedAndDeposit(alice, 1_000 * 1e6); + + assertEq(IERC20(BASE_USDC).balanceOf(address(vault)), 1_000 * 1e6, "USDC sits idle pre-rebalance"); + assertEq(IERC20(BASE_AUSDC).balanceOf(address(vault)), 0, "no aUSDC yet"); + + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + // 80% routed to Aave, 20% stays idle. aUSDC starts at 1:1 with USDC. + assertEq(IERC20(BASE_USDC).balanceOf(address(vault)), 200 * 1e6, "20% idle"); + assertApproxEqAbs( + IERC20(BASE_AUSDC).balanceOf(address(vault)), + 800 * 1e6, + 1, // 1 wei tolerance for aave's internal rounding + "80% deployed to aave as aUSDC" + ); + assertApproxEqAbs(vault.totalAssets(), 1_000 * 1e6, 1, "totalAssets unchanged"); + } + + function test_InterestAccruesIntoATokenBalance() public { + _seedAndDeposit(alice, 1_000 * 1e6); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + uint256 aBefore = IERC20(BASE_AUSDC).balanceOf(address(vault)); + + // Roll forward ~30 days. Block time on Base is ~2s; 30 days ≈ 1_296_000 blocks. + vm.warp(block.timestamp + 30 days); + vm.roll(block.number + 1_296_000); + + uint256 aAfter = IERC20(BASE_AUSDC).balanceOf(address(vault)); + assertGt(aAfter, aBefore, "aUSDC balance grew from supply interest"); + + // totalAssets reflects the gain. + assertGt(vault.totalAssets(), 1_000 * 1e6, "vault TVL grew"); + } + + function test_RedeemWithdrawsFromAaveAndReturnsAssets() public { + _seedAndDeposit(alice, 1_000 * 1e6); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + // Accrue some interest. + vm.warp(block.timestamp + 30 days); + vm.roll(block.number + 1_296_000); + + uint256 aliceShares = vault.balanceOf(alice); + vm.prank(alice); + uint256 assetsReturned = vault.redeem(aliceShares, alice, alice); + + assertGe(assetsReturned, 1_000 * 1e6, "alice gets back at least her principal"); + assertEq(IERC20(BASE_USDC).balanceOf(alice), assetsReturned, "alice's wallet credited"); + assertApproxEqAbs( + IERC20(BASE_AUSDC).balanceOf(address(vault)), + 0, + 1, + "aUSDC drained back to idle on redeem" + ); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + function _seedAndDeposit(address from, uint256 amount) internal { + deal(BASE_USDC, from, amount); + vm.startPrank(from); + IERC20(BASE_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 _deployVault() internal returns (Vault) { + DiamondCutFacet cut = new DiamondCutFacet(); + DiamondLoupeFacet loupe = new DiamondLoupeFacet(); + OwnershipFacet ownership = new OwnershipFacet(); + AllocatorFacet allocator = new AllocatorFacet(); + AaveStrategyFacet aave = new AaveStrategyFacet(); + + 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(aave), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _aaveSelectors() + }); + + return new Vault(IERC20(BASE_USDC), "Vault Router", "vUSDC", owner, cuts, address(0), ""); + } + + function _aaveStrategyConfig() internal pure returns (LibAllocator.StrategyConfig memory) { + return LibAllocator.StrategyConfig({ + totalAssetsSelector: AaveStrategyFacet.aaveTotalAssets.selector, + depositSelector: AaveStrategyFacet.aaveDeposit.selector, + withdrawSelector: AaveStrategyFacet.aaveWithdraw.selector, + harvestSelector: AaveStrategyFacet.aaveHarvest.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 _aaveSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](6); + s[0] = AaveStrategyFacet.aaveSetConfig.selector; + s[1] = AaveStrategyFacet.aaveTotalAssets.selector; + s[2] = AaveStrategyFacet.aaveDeposit.selector; + s[3] = AaveStrategyFacet.aaveWithdraw.selector; + s[4] = AaveStrategyFacet.aaveHarvest.selector; + s[5] = AaveStrategyFacet.aavePool.selector; + } +}