Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/Vault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions src/facets/FeeFacet.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
104 changes: 104 additions & 0 deletions src/facets/strategies/AaveStrategyFacet.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 23 additions & 0 deletions src/interfaces/external/IAavePool.sol
Original file line number Diff line number Diff line change
@@ -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);
}
37 changes: 37 additions & 0 deletions src/libraries/LibFees.sol
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading