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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ New strategies are added as facets and registered through the curator, no vault
- Performance fee gated by High Water Mark, fees only taken on net gains
- Management fee accrues linearly (capped at 10% annually)
- Slippage protection on strategy deposits
- NAV circuit breaker — bounds how far the share price may move between checkpoints

### NAV circuit breaker

The vault prices its shares on-chain (idle balance + each strategy's reported
position). To contain a bad rebalance, oracle glitch, or strategy exploit, the
owner sets `maxSharePriceDeltaBps` — the maximum share-price move tolerated
between checkpoints. Two enforcement paths:

- **Hot-path tripwire** — every deposit/withdraw re-prices the vault and reverts
(`SharePriceDeviation`) if the move exceeds the bound, so users never transact
at an anomalous NAV. Self-healing: once the price is back in band, ops resume.
- **Latching poke** — anyone (a keeper or the curator agent) can call
`guardCheckpoint()`; on a breach it latches the vault into a paused state that
persists until the owner reviews and `unpause()`s. A revert can't latch a pause
in the same call, so this dedicated poke is what makes the auto-pause stick.

The owner can also `pause()` / `unpause()` manually. With the bound set to `0` the
deviation check is disabled and only manual pause applies.

## Fees

Expand Down
48 changes: 33 additions & 15 deletions src/Vault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import { IDiamond } from "./interfaces/IDiamond.sol";
import { LibDiamond } from "./libraries/LibDiamond.sol";
import { LibAllocator } from "./libraries/LibAllocator.sol";
import { LibFees } from "./libraries/LibFees.sol";
import { LibGuard } from "./libraries/LibGuard.sol";

/// @title Vault Router modular ERC-4626 vault on the EIP-2535 Diamond pattern.
/// @title Vault Router is a modular ERC-4626 vault on the EIP-2535 Diamond pattern.
/// @notice Vault.sol owns the ERC-4626 surface (deposit/withdraw/totalAssets) and
/// acts as the Diamond proxy. Strategy logic, allocation policy, and
/// harvesting live in facets attached via diamondCut.
/// @dev Inflation attack mitigation comes from OZ ERC-4626's `_decimalsOffset`
/// virtual shares, not the literal 1 wei dead deposit pattern.
/// virtual shares.
contract Vault is ERC4626 {
error UnknownSelector(bytes4 selector);
error StrategyTotalAssetsCallFailed(bytes32 strategyId);
Expand All @@ -36,26 +37,27 @@ contract Vault is ERC4626 {
LibDiamond.diamondCut(diamondCut_, init_, initCalldata_);
}

/// @dev 6 decimals of virtual shares — OZ's recommended inflation-attack
/// mitigation for ERC-4626 vaults.
/// @dev 6 decimals of virtual shares, OZ's recommended inflation-attack
/// mitigation for ERC-4626 vaults. Sourced from LibGuard so the breaker's
/// share-price math and this offset can never drift apart.
function _decimalsOffset() internal pure override returns (uint8) {
return 6;
return LibGuard.DECIMALS_OFFSET;
}

/// @notice Total assets under management = idle vault balance + sum of every
/// registered strategy's reported position.
/// @dev Self-staticcalls each strategy's totalAssets selector via the diamond
/// fallback. When no strategies are registered the result equals the
/// vault's idle USDC balance (default ERC-4626 behaviour).
/// vault's idle USDC balance (default ERC-4626 behavior).
function totalAssets() public view override returns (uint256) {
uint256 total = IERC20(asset()).balanceOf(address(this));
LibAllocator.AllocatorStorage storage s = LibAllocator.allocatorStorage();
uint256 n = s.strategyIds.length;
for (uint256 i; i < n; i++) {
bytes32 id = s.strategyIds[i];
LibAllocator.StrategyConfig storage cfg = s.configs[id];
if (!cfg.active) continue;
(bool ok, bytes memory data) = address(this).staticcall(abi.encodeWithSelector(cfg.totalAssetsSelector));
LibAllocator.AllocatorStorage storage $ = LibAllocator.allocatorStorage();
uint256 length = $.strategyIds.length;
for (uint256 i; i < length; i++) {
bytes32 id = $.strategyIds[i];
LibAllocator.StrategyConfig storage configs = $.configs[id];
if (!configs.active) continue;
(bool ok, bytes memory data) = address(this).staticcall(abi.encodeWithSelector(configs.totalAssetsSelector));
if (!ok) revert StrategyTotalAssetsCallFailed(id);
total += abi.decode(data, (uint256));
}
Expand All @@ -67,6 +69,9 @@ contract Vault is ERC4626 {
// -----------------------------------------------------------------------

function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override {
// Circuit breaker: revert if paused, or if the current share price has
// moved beyond the configured bound since the last checkpoint.
_guard();
// 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).
Expand All @@ -87,11 +92,25 @@ contract Vault is ERC4626 {
internal
override
{
_guard();
_accrueFees();
super._withdraw(caller, receiver, owner, assets, shares);
_accrueFees();
}

/// @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`),
/// otherwise advances the checkpoint. Runs independently of fee accrual
/// so it is enforced even when no fee recipient is configured.
function _guard() internal {
LibGuard.GuardStorage storage g = LibGuard.guardStorage();
if (g.paused) revert LibGuard.EnforcedPause();
uint256 supply = totalSupply();
if (supply == 0) return; // nothing to price yet; breaker arms on the next op
LibGuard.checkpoint(g, LibGuard.sharePrice(totalAssets(), supply));
}

/// @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
Expand All @@ -111,8 +130,7 @@ contract Vault is ERC4626 {
}

uint256 ta = totalAssets();
uint256 effectiveSupply = supply + 10 ** _decimalsOffset();
uint256 sharePrice = ((ta + 1) * 1e18) / effectiveSupply;
uint256 sharePrice = LibGuard.sharePrice(ta, supply);

if (f.highWaterMark == 0) f.highWaterMark = sharePrice;
if (f.lastFeeAccrual == 0) f.lastFeeAccrual = nowTs;
Expand Down
114 changes: 114 additions & 0 deletions src/facets/GuardFacet.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol";

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

/// @title GuardFacet
/// @notice Owner controls for the NAV circuit breaker plus a permissionless
/// `guardCheckpoint` poke. The hot-path tripwire that protects every
/// deposit/withdraw lives in Vault.sol; this facet configures the bound,
/// exposes the latch, and lets a keeper or the curator agent enforce the
/// breaker continuously between user actions.
contract GuardFacet {
error InvalidBps(uint16 bps);

event MaxSharePriceDeltaSet(uint16 bps);
event Paused(address indexed by);
event Unpaused(address indexed by, uint256 baseline);
event Checkpoint(uint256 sharePrice);
event BreakerTripped(uint256 lastSharePrice, uint256 currentSharePrice);

// -----------------------------------------------------------------------
// Owner-gated configuration
// -----------------------------------------------------------------------

/// @notice Set the max allowed share-price move between checkpoints, in bps.
/// @dev 0 disables the deviation check (pause latch still works manually).
function setMaxSharePriceDelta(uint16 bps) external {
LibDiamond.enforceIsContractOwner();
if (bps > LibGuard.BPS_DENOMINATOR) revert InvalidBps(bps);
LibGuard.guardStorage().maxSharePriceDeltaBps = bps;
emit MaxSharePriceDeltaSet(bps);
}

/// @notice Manually latch the breaker, halting deposits and withdrawals.
function pause() external {
LibDiamond.enforceIsContractOwner();
LibGuard.guardStorage().paused = true;
emit Paused(msg.sender);
}

/// @notice Clear the latch and re-baseline the checkpoint to the current
/// share price, so a normalised (or owner-accepted) NAV resumes
/// cleanly without immediately re-tripping the hot-path tripwire.
function unpause() external {
LibDiamond.enforceIsContractOwner();
LibGuard.GuardStorage storage g = LibGuard.guardStorage();
g.paused = false;
uint256 baseline = _currentSharePrice();
g.lastSharePrice = baseline;
emit Unpaused(msg.sender, baseline);
}

// -----------------------------------------------------------------------
// Permissionless enforcement
// -----------------------------------------------------------------------

/// @notice Sample the current share price; if it deviates beyond the bound,
/// latch the vault into a paused state, otherwise advance the
/// checkpoint. Callable by anyone (keeper, curator agent, monitor).
/// @dev This call *commits* its state change, so the latch persists — unlike
/// the hot-path tripwire which can only revert the offending op.
function guardCheckpoint() external {
LibGuard.GuardStorage storage g = LibGuard.guardStorage();
if (g.paused) return;

uint256 supply = IERC20(address(this)).totalSupply();
if (supply == 0) return;

uint256 price = LibGuard.sharePrice(IERC4626(address(this)).totalAssets(), supply);
uint256 last = g.lastSharePrice;
if (last == 0) {
g.lastSharePrice = price;
emit Checkpoint(price);
return;
}
if (LibGuard.deviationExceeded(last, price, g.maxSharePriceDeltaBps)) {
g.paused = true;
emit BreakerTripped(last, price);
return;
}
g.lastSharePrice = price;
emit Checkpoint(price);
}

// -----------------------------------------------------------------------
// Readers
// -----------------------------------------------------------------------

function paused() external view returns (bool) {
return LibGuard.guardStorage().paused;
}

function maxSharePriceDeltaBps() external view returns (uint16) {
return LibGuard.guardStorage().maxSharePriceDeltaBps;
}

function lastSharePrice() external view returns (uint256) {
return LibGuard.guardStorage().lastSharePrice;
}

// -----------------------------------------------------------------------
// Internals
// -----------------------------------------------------------------------

function _currentSharePrice() internal view returns (uint256) {
uint256 supply = IERC20(address(this)).totalSupply();
if (supply == 0) return 0;
return LibGuard.sharePrice(IERC4626(address(this)).totalAssets(), supply);
}
}
78 changes: 78 additions & 0 deletions src/libraries/LibGuard.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @title LibGuard
/// @notice Namespaced storage and logic for the vault's NAV circuit breaker.
/// Bounds how far the ERC-4626 share price may move between checkpoints;
/// a move beyond the bound either reverts the offending operation
/// (hot-path tripwire) or latches the vault into a paused state
/// (permissionless poke), depending on entry point.
/// @dev Why two behaviours: a Solidity revert rolls back *all* state in the
/// call, so an operation that detects an anomaly and reverts cannot also
/// persist `paused = true`. The hot path therefore reverts (never transact
/// at an anomalous NAV, self-healing), while a dedicated poke that does
/// nothing *but* record the breaker state can commit the latch.
/// Storage location:
/// keccak256(abi.encode(uint256(keccak256("vaultrouter.storage.guard")) - 1)) & ~bytes32(uint256(0xff))
library LibGuard {
uint16 internal constant BPS_DENOMINATOR = 10_000;

/// @dev Virtual-share offset for ERC-4626 inflation-attack mitigation. Kept
/// here as the single source of truth so Vault._decimalsOffset and the
/// share-price math below never drift apart.
uint8 internal constant DECIMALS_OFFSET = 6;

bytes32 internal constant GUARD_STORAGE_SLOT = 0x2e670cc2b429ff4c75b2b5ce7b57521bb8c3d00aaafa77116d454e88d382a900;

/// @notice Reverted on any deposit/withdraw while the breaker is latched.
error EnforcedPause();
/// @notice Reverted on the hot path when the share price moved beyond bound.
error SharePriceDeviation(uint256 lastSharePrice, uint256 currentSharePrice, uint16 maxDeltaBps);

/// @custom:storage-location erc7201:vaultrouter.storage.guard
struct GuardStorage {
bool paused;
/// @dev Max allowed |Δ share price| between checkpoints, in bps of the
/// previous checkpoint. 0 disables the deviation check entirely.
uint16 maxSharePriceDeltaBps;
/// @dev Last accepted share price (asset units per share, scaled 1e18).
uint256 lastSharePrice;
}

function guardStorage() internal pure returns (GuardStorage storage s) {
bytes32 slot = GUARD_STORAGE_SLOT;
assembly {
s.slot := slot
}
}

/// @notice Share price = (totalAssets + 1) * 1e18 / (supply + virtual shares).
/// @dev Mirrors OZ ERC-4626's conversion with the same virtual-share offset
/// Vault uses, so the breaker measures the exact price users transact at.
function sharePrice(uint256 totalAssets_, uint256 supply) internal pure returns (uint256) {
uint256 effectiveSupply = supply + 10 ** DECIMALS_OFFSET;
return ((totalAssets_ + 1) * 1e18) / effectiveSupply;
}

/// @notice True if |current - last| exceeds `maxDeltaBps` of `last`.
/// @dev A zero bound or an unset checkpoint (`last == 0`) never trips.
function deviationExceeded(uint256 last, uint256 current, uint16 maxDeltaBps) internal pure returns (bool) {
if (maxDeltaBps == 0 || last == 0) return false;
uint256 diff = current > last ? current - last : last - current;
return diff * BPS_DENOMINATOR > uint256(maxDeltaBps) * last;
}

/// @notice Hot-path tripwire: revert if `current` deviates beyond bound,
/// otherwise advance the checkpoint. Pause is enforced by the caller.
function checkpoint(GuardStorage storage g, uint256 current) internal {
uint256 last = g.lastSharePrice;
if (last == 0) {
g.lastSharePrice = current; // first checkpoint arms the breaker
return;
}
if (deviationExceeded(last, current, g.maxSharePriceDeltaBps)) {
revert SharePriceDeviation(last, current, g.maxSharePriceDeltaBps);
}
g.lastSharePrice = current;
}
}
7 changes: 7 additions & 0 deletions test/mocks/MockProtocol.sol
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,11 @@ contract MockProtocol {
IMintable(address(asset)).mint(address(this), amount);
balanceOf[account] += amount;
}

/// @notice Test-only — debits `account`'s internal balance to simulate a
/// strategy loss (exploit, bad debt, depeg). The reported position
/// drops, dragging the vault's totalAssets and share price down.
function _testSimulateLoss(address account, uint256 amount) external {
balanceOf[account] -= amount;
}
}
Loading
Loading