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
22 changes: 22 additions & 0 deletions src/Vault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Diamond } from "./Diamond.sol";
import { LibAllocator } from "./libraries/LibAllocator.sol";
import { LibFees } from "./libraries/LibFees.sol";
import { LibGuard } from "./libraries/LibGuard.sol";
import { LibLock } from "./libraries/LibLock.sol";

/// @title Vault Router is a modular ERC-4626 vault on the EIP-2535 Diamond pattern.
/// @notice Vault owns the ERC-4626 surface (deposit/withdraw/totalAssets) plus the
Expand Down Expand Up @@ -84,6 +85,13 @@ contract Vault is Diamond, ERC4626, ReentrancyGuard {
// Post-accrue handles the first-deposit bootstrap: now that supply > 0,
// initialise HWM and the accrual timestamp. No-op for subsequent deposits.
_accrueFees();
// Lock the freshly minted shares for the configured window so an attacker
// cannot deposit, manipulate, and withdraw within the same block. Skipped
// when the period is 0 (disabled). Re-deposits refresh the window.
LibLock.LockStorage storage l = LibLock.lockStorage();
if (l.shareLockPeriod > 0) {
l.lockedUntil[receiver] = block.timestamp + uint256(l.shareLockPeriod);
}
}

function _withdraw(
Expand All @@ -103,6 +111,20 @@ contract Vault is Diamond, ERC4626, ReentrancyGuard {
_accrueFees();
}

/// @dev Single enforcement point for the share lock. Runs on every ERC20
/// mutation: mints (`from == 0`) are exempt — that is how the lock is
/// armed in `_deposit` — while transfers AND burns of still-locked
/// shares revert. Catching burns here covers withdraw/redeem, and
/// catching transfers stops the transfer-then-withdraw bypass, so no
/// separate check is needed in `_withdraw`.
function _update(address from, address to, uint256 value) internal override {
if (from != address(0)) {
uint256 unlockAt = LibLock.lockStorage().lockedUntil[from];
if (block.timestamp < unlockAt) revert LibLock.SharesLocked(from, unlockAt);
}
super._update(from, to, value);
}

/// @dev NAV circuit-breaker tripwire run on every deposit/withdraw. Reverts
/// when the breaker is latched (`EnforcedPause`) or when the live share
/// price deviates beyond the owner-set bound (`SharePriceDeviation`),
Expand Down
56 changes: 55 additions & 1 deletion src/facets/AllocatorFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ contract AllocatorFacet {
error StrategyAlreadyQuarantined(bytes32 strategyId);
error StrategyNotQuarantined(bytes32 strategyId);
error AllocationToQuarantined(bytes32 strategyId);
error RebalanceDeltaTooLarge(uint256 movementBps, uint16 maxBps);
error IdleReserveBreached(uint256 idleAfter, uint256 requiredIdle);

event StrategyRegistered(bytes32 indexed strategyId, LibAllocator.StrategyConfig config);
event StrategyRemoved(bytes32 indexed strategyId);
Expand All @@ -39,6 +41,7 @@ contract AllocatorFacet {
event Rebalanced(uint256 totalAssets, uint256 idleAfter);
event StrategyQuarantined(bytes32 indexed strategyId);
event StrategyReleased(bytes32 indexed strategyId);
event MaxRebalanceDeltaSet(uint16 bps);

// -----------------------------------------------------------------------
// Owner-gated governance / risk bounds
Expand Down Expand Up @@ -143,6 +146,22 @@ contract AllocatorFacet {
emit GlobalStrategyCapSet(capBps);
}

/// @notice Cap the total churn a single `rebalance` may move — the sum of
/// |delta| across all strategies — as bps of NAV. This bounds *how
/// much* a rebalance can relocate, complementing the role gate
/// (*who*) and the one-per-block throttle (*how often*).
/// @dev Owner-gated risk bound. `0` disables the check. Because a full
/// reshuffle moves each relocated dollar twice (out of one strategy and
/// into another), movement can reach 2x NAV (20_000 bps); the setter
/// therefore admits values up to 2 * BPS_DENOMINATOR.
/// @param bps Max movement in basis points of NAV (0 = disabled).
function setMaxRebalanceDelta(uint16 bps) external {
LibDiamond.enforceIsContractOwner();
if (bps > 2 * LibAllocator.BPS_DENOMINATOR) revert InvalidBps(bps);
LibAllocator.allocatorStorage().maxRebalanceDeltaBps = bps;
emit MaxRebalanceDeltaSet(bps);
}

/// @notice Isolate a strategy whose accounting can no longer be trusted (a
/// failing, exploited, or stuck protocol). A quarantined strategy is
/// excluded from `totalAssets` and skipped by the rebalancer and
Expand Down Expand Up @@ -205,6 +224,28 @@ contract AllocatorFacet {
totalCached += cur;
}

// Per-call churn bound: sum |delta| across strategies and reject the
// rebalance if it exceeds maxRebalanceDeltaBps of NAV. Evaluated before
// any funds move, so an over-large reshuffle never partially executes.
// 0 disables the bound.
uint16 maxDelta = s.maxRebalanceDeltaBps;
if (maxDelta != 0) {
uint256 totalMovement;
for (uint256 i; i < n; i++) {
bytes32 id = s.strategyIds[i];
if (s.quarantined[id]) continue;
uint256 target = (totalCached * uint256(s.targetBps[id])) / LibAllocator.BPS_DENOMINATOR;
uint256 cur = currentAssets[i];
totalMovement += cur > target ? cur - target : target - cur;
}
// Cross-multiply to avoid division and the totalCached == 0 case.
if (totalMovement * LibAllocator.BPS_DENOMINATOR > totalCached * uint256(maxDelta)) {
uint256 movementBps =
totalCached == 0 ? 0 : (totalMovement * LibAllocator.BPS_DENOMINATOR) / totalCached;
revert RebalanceDeltaTooLarge(movementBps, maxDelta);
}
}

// Pass 1: withdraw from over-target strategies.
for (uint256 i; i < n; i++) {
bytes32 id = s.strategyIds[i];
Expand All @@ -227,7 +268,16 @@ contract AllocatorFacet {
}
}

emit Rebalanced(totalCached, _idleAssetsInternal());
// Defense-in-depth: the idle reserve floor follows from
// `total + idleReserveBps <= 10_000` in setAllocation, but assert it on
// realized balances too so any accounting/slippage drift that would dip
// idle below the floor reverts the whole rebalance rather than silently
// under-reserving.
uint256 idleAfter = _idleAssetsInternal();
uint256 requiredIdle = (totalCached * uint256(s.idleReserveBps)) / LibAllocator.BPS_DENOMINATOR;
if (idleAfter < requiredIdle) revert IdleReserveBreached(idleAfter, requiredIdle);

emit Rebalanced(totalCached, idleAfter);
}

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -277,6 +327,10 @@ contract AllocatorFacet {
return LibAllocator.allocatorStorage().lastRebalanceBlock;
}

function maxRebalanceDelta() external view returns (uint16) {
return LibAllocator.allocatorStorage().maxRebalanceDeltaBps;
}

function isQuarantined(bytes32 strategyId) external view returns (bool) {
return LibAllocator.allocatorStorage().quarantined[strategyId];
}
Expand Down
42 changes: 42 additions & 0 deletions src/facets/LockFacet.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { LibDiamond } from "../libraries/LibDiamond.sol";
import { LibLock } from "../libraries/LibLock.sol";

/// @title LockFacet
/// @notice Owner-gated configuration and public readers for the share-lock
/// period. The enforcement itself lives in `Vault._update`/`_deposit`
/// (the native ERC-4626 surface needs the ERC20 mutation hooks); this
/// facet owns the configuration surface.
contract LockFacet {
error ShareLockPeriodTooLong(uint64 attempted, uint64 maxPeriod);

event ShareLockPeriodSet(uint64 period);

/// @notice Set the share-lock window (seconds). Freshly minted shares from a
/// deposit cannot be moved until this window elapses.
/// @dev Owner-gated risk bound. Capped at `MAX_SHARE_LOCK_PERIOD` so it
/// stays an anti-MEV measure rather than a withdrawal freeze. `0`
/// disables the lock for future deposits.
/// @param period Lock duration in seconds (0 = disabled).
function setShareLockPeriod(uint64 period) external {
LibDiamond.enforceIsContractOwner();
if (period > LibLock.MAX_SHARE_LOCK_PERIOD) {
revert ShareLockPeriodTooLong(period, LibLock.MAX_SHARE_LOCK_PERIOD);
}
LibLock.lockStorage().shareLockPeriod = period;
emit ShareLockPeriodSet(period);
}

/// @notice The configured share-lock window in seconds (0 = disabled).
function shareLockPeriod() external view returns (uint64) {
return LibLock.lockStorage().shareLockPeriod;
}

/// @notice Timestamp until which `account`'s shares are locked. A move is
/// blocked while `block.timestamp` is below this value.
function lockedUntil(address account) external view returns (uint256) {
return LibLock.lockStorage().lockedUntil[account];
}
}
2 changes: 2 additions & 0 deletions src/facets/strategies/AaveStrategyFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ contract AaveStrategyFacet {
/// @dev Called via diamond fallback by the AllocatorFacet during rebalance.
/// Verifies aToken balance increased by at least `amount` after supply.
function aaveDeposit(uint256 amount) external {
LibDiamond.enforceIsSelf();
AaveStorage storage s = _as();
if (address(s.pool) == address(0)) revert AavePoolNotConfigured();
IERC20 underlying = IERC20(IERC4626(address(this)).asset());
Expand All @@ -87,6 +88,7 @@ contract AaveStrategyFacet {
/// @dev Checks the actual amount returned by pool.withdraw() — Aave can return
/// less than requested if liquidity is insufficient.
function aaveWithdraw(uint256 amount) external {
LibDiamond.enforceIsSelf();
AaveStorage storage s = _as();
if (address(s.pool) == address(0)) revert AavePoolNotConfigured();
IERC20 underlying = IERC20(IERC4626(address(this)).asset());
Expand Down
2 changes: 2 additions & 0 deletions src/facets/strategies/MorphoStrategyFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ contract MorphoStrategyFacet {
/// `previewDeposit(amount)` predicted.
/// @param amount Quantity of underlying asset to allocate to Morpho.
function morphoDeposit(uint256 amount) external {
LibDiamond.enforceIsSelf();
MorphoStorage storage s = _ms();
if (address(s.vault) == address(0)) revert MorphoVaultNotConfigured();
IERC20 underlying = IERC20(IERC4626(address(this)).asset());
Expand All @@ -112,6 +113,7 @@ contract MorphoStrategyFacet {
/// of the user-facing redeem path that also burns shares.
/// @param amount Quantity of underlying asset to pull out of Morpho.
function morphoWithdraw(uint256 amount) external {
LibDiamond.enforceIsSelf();
MorphoStorage storage s = _ms();
if (address(s.vault) == address(0)) revert MorphoVaultNotConfigured();
// Bound the shares burned: burning more than previewWithdraw predicted
Expand Down
53 changes: 27 additions & 26 deletions src/facets/strategies/PendlePtStrategyFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ contract PendlePtStrategyFacet {
/// @notice Thrown when a configured slippage tolerance exceeds 100%.
error PendleInvalidSlippage(uint16 bps);

/// @notice Thrown when an AMM swap is attempted with no usable oracle mark
/// (oracle unset or reporting a zero rate). Swapping unpriced would
/// force minOut = 0 and leave the trade fully sandwichable.
error PendleOracleRequired();

// -----------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -214,6 +219,7 @@ contract PendlePtStrategyFacet {
/// or if zero PT is received.
/// @param amount Quantity of underlying asset to spend.
function pendleDeposit(uint256 amount) external {
LibDiamond.enforceIsSelf();
PendleStorage storage s = _ps();
if (address(s.router) == address(0)) revert PendleNotConfigured();
if (s.pt.isExpired()) revert PendleMarketExpired();
Expand Down Expand Up @@ -242,20 +248,16 @@ contract PendlePtStrategyFacet {
// Empty limit order, strategy does not participate in the limit book.
IPendleRouter.LimitOrderData memory limit;

// Derive an on-chain minimum from the oracle mark when available: invert
// the PT->asset rate to value `amount` in PT, then haircut by the
// slippage tolerance. The router reverts if it cannot meet minPtOut.
// Without an oracle there is no mark to bound against, so we fall back to
// 0 (unchanged behaviour for unpriced markets) and rely on the post-call
// zero check.
uint256 minPtOut;
if (address(s.oracle) != address(0)) {
uint256 rate = s.oracle.getPtToAssetRate(s.market, s.twapDuration);
if (rate > 0) {
uint256 expectedPt = amount * 1e18 / rate;
minPtOut = expectedPt * (PENDLE_BPS - _maxSlippageBps(s)) / PENDLE_BPS;
}
}
// Mandatory oracle: refuse to swap unpriced. The oracle mark is the only
// on-chain reference for bounding minPtOut; without it the trade would
// run with minPtOut = 0 and be fully sandwichable. Invert the PT->asset
// rate to value `amount` in PT, then haircut by the slippage tolerance.
// The router reverts if it cannot meet minPtOut.
if (address(s.oracle) == address(0)) revert PendleOracleRequired();
uint256 rate = s.oracle.getPtToAssetRate(s.market, s.twapDuration);
if (rate == 0) revert PendleOracleRequired();
uint256 expectedPt = amount * 1e18 / rate;
uint256 minPtOut = expectedPt * (PENDLE_BPS - _maxSlippageBps(s)) / PENDLE_BPS;

(uint256 netPtOut,,) = s.router
.swapExactTokenForPt(
Expand All @@ -280,6 +282,7 @@ contract PendlePtStrategyFacet {
/// the AMM discount; post-maturity it is 1:1.
/// @param amount PT quantity to liquidate (denominated in underlying units).
function pendleWithdraw(uint256 amount) external {
LibDiamond.enforceIsSelf();
PendleStorage storage s = _ps();
if (address(s.router) == address(0)) revert PendleNotConfigured();

Expand Down Expand Up @@ -307,18 +310,16 @@ contract PendlePtStrategyFacet {
// redeemPyToToken burns PT (YT is implicitly 0 post-maturity).
(received,) = s.router.redeemPyToToken(address(this), s.pt.YT(), amount, output);
} else {
// Pre-maturity: sell PT on the Pendle AMM. Derive minTokenOut from the
// oracle mark when available (PT->asset rate, haircut by slippage) so
// the router itself enforces the bound; fall back to 0 only when no
// oracle is configured (unchanged behaviour for unpriced markets).
uint256 minTokenOut;
if (address(s.oracle) != address(0)) {
uint256 rate = s.oracle.getPtToAssetRate(s.market, s.twapDuration);
if (rate > 0) {
uint256 expected = amount * rate / 1e18;
minTokenOut = expected * (PENDLE_BPS - _maxSlippageBps(s)) / PENDLE_BPS;
}
}
// Pre-maturity: sell PT on the Pendle AMM. Mandatory oracle — derive
// minTokenOut from the mark (PT->asset rate, haircut by slippage) so
// the router enforces the bound. Refuse to sell unpriced rather than
// run with minTokenOut = 0 (fully sandwichable). Post-maturity redeem
// below needs no oracle: it is 1:1 with a hard 99% dust bound.
if (address(s.oracle) == address(0)) revert PendleOracleRequired();
uint256 rate = s.oracle.getPtToAssetRate(s.market, s.twapDuration);
if (rate == 0) revert PendleOracleRequired();
uint256 expected = amount * rate / 1e18;
uint256 minTokenOut = expected * (PENDLE_BPS - _maxSlippageBps(s)) / PENDLE_BPS;

IPendleRouter.TokenOutput memory output = IPendleRouter.TokenOutput({
tokenOut: address(underlying),
Expand Down
5 changes: 5 additions & 0 deletions src/libraries/LibAllocator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ library LibAllocator {
uint16 idleReserveBps;
uint16 globalMaxStrategyCapBps;
uint64 lastRebalanceBlock;
/// @dev Per-call rebalance churn bound: caps the sum of |delta| moved
/// across all strategies in a single rebalance, as bps of NAV. So
/// even a compromised curator/AI key can only relocate a bounded,
/// throttled fraction of the vault per block. 0 disables the bound.
uint16 maxRebalanceDeltaBps;
/// @dev Strategies flagged here are isolated: excluded from NAV and
/// skipped by the rebalancer/harvester, so a single failing protocol
/// cannot brick the whole vault. Owner-controlled risk state, kept
Expand Down
15 changes: 15 additions & 0 deletions src/libraries/LibDiamond.sol
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ library LibDiamond {
}

error NotContractOwner(address caller, address expected);
error NotSelf(address caller);
error NoSelectorsProvided(address facetAddress);
error CannotAddSelectorsToZeroAddress(bytes4[] selectors);
error NoBytecodeAtAddress(address facetAddress, string message);
Expand Down Expand Up @@ -66,6 +67,20 @@ library LibDiamond {
}
}

/// @notice Restrict a function to internal diamond dispatch only — callable
/// solely via `address(this).call(...)` from another facet (e.g. the
/// allocator's rebalance or the harvester), never by an external
/// caller. Used to gate strategy fund-movers so they cannot be
/// invoked directly, which would bypass the curator gate, allocation
/// caps, the per-rebalance churn bound, and the rebalance cooldown.
/// @dev Inside a facet running via the diamond's delegatecall, `address(this)`
/// is the diamond; a legitimate self-dispatch arrives with
/// `msg.sender == address(this)`, while any external (EOA/contract) call
/// arrives with its own address preserved through the delegatecall.
function enforceIsSelf() internal view {
if (msg.sender != address(this)) revert NotSelf(msg.sender);
}

event DiamondCut(IDiamond.FacetCut[] _diamondCut, address _init, bytes _calldata);

function diamondCut(IDiamond.FacetCut[] memory _diamondCut, address _init, bytes memory _calldata) internal {
Expand Down
Loading