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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ New strategies are added as facets and registered through the curator, no vault
- 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
- Strategy quarantine — isolate a failing strategy so it can't brick the vault

### Strategy quarantine

The vault prices itself by summing each strategy's reported position. If one
strategy's read reverts (a failing, exploited, or stuck protocol), it would
otherwise brick `totalAssets` — and with it every deposit, withdrawal, and fee
accrual vault-wide. The owner can `quarantineStrategy(id)` to isolate it:

- excluded from `totalAssets` (valued at zero — conservative over trusting a
stale or manipulable reading),
- skipped by the rebalancer and `harvestAll`, and its target allocation zeroed,

so the rest of the vault keeps operating. Funds already in the strategy stay put,
untouched, until `releaseStrategy(id)` lifts the quarantine once it is healthy
again. Failures are loud by default — a non-quarantined strategy that reverts
still halts the vault, so isolation is always a deliberate, audited owner action.

### NAV circuit breaker

Expand Down
3 changes: 3 additions & 0 deletions src/Vault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ contract Vault is Diamond, ERC4626 {
bytes32 id = $.strategyIds[i];
LibAllocator.StrategyConfig storage configs = $.configs[id];
if (!configs.active) continue;
// Isolated strategy: excluded from NAV so a single failing protocol
// cannot brick deposits, withdrawals, or fee accrual vault-wide.
if ($.quarantined[id]) continue;
(bool ok, bytes memory data) = address(this).staticcall(abi.encodeWithSelector(configs.totalAssetsSelector));
if (!ok) revert StrategyTotalAssetsCallFailed(id);
total += abi.decode(data, (uint256));
Expand Down
50 changes: 49 additions & 1 deletion src/facets/AllocatorFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ contract AllocatorFacet {
error StrategyTotalAssetsCallFailed(bytes32 strategyId);
error StrategyCallFailed(bytes32 strategyId, bytes4 selector);
error RebalanceTooSoon(uint256 lastBlock, uint256 currentBlock);
error StrategyAlreadyQuarantined(bytes32 strategyId);
error StrategyNotQuarantined(bytes32 strategyId);
error AllocationToQuarantined(bytes32 strategyId);

event StrategyRegistered(bytes32 indexed strategyId, LibAllocator.StrategyConfig config);
event StrategyRemoved(bytes32 indexed strategyId);
Expand All @@ -34,6 +37,8 @@ contract AllocatorFacet {
event StrategyCapSet(bytes32 indexed strategyId, uint16 capBps);
event GlobalStrategyCapSet(uint16 capBps);
event Rebalanced(uint256 totalAssets, uint256 idleAfter);
event StrategyQuarantined(bytes32 indexed strategyId);
event StrategyReleased(bytes32 indexed strategyId);

// -----------------------------------------------------------------------
// Owner-gated governance / risk bounds
Expand Down Expand Up @@ -103,6 +108,7 @@ contract AllocatorFacet {
bytes32 id = strategyIds[i];
uint16 b = bps[i];
if (!s.configs[id].active) revert StrategyNotRegistered(id);
if (s.quarantined[id] && b > 0) revert AllocationToQuarantined(id);
if (b > LibAllocator.BPS_DENOMINATOR) revert InvalidBps(b);
uint16 cap = _effectiveCap(s, id);
if (b > cap) revert AllocationExceedsCap(id, cap, b);
Expand Down Expand Up @@ -137,6 +143,38 @@ contract AllocatorFacet {
emit GlobalStrategyCapSet(capBps);
}

/// @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
/// harvester, so its failure can never brick deposits, withdrawals,
/// or fee accrual for the rest of the vault.
/// @dev Owner-only — it changes how vault NAV is computed. The strategy's
/// target is zeroed so the rebalancer stops funding it. Funds already in
/// the strategy stay there (untouched and unvalued) until it is released;
/// valuing them at zero is the conservative choice over trusting a stale
/// or manipulable reading.
function quarantineStrategy(bytes32 strategyId) external {
LibDiamond.enforceIsContractOwner();
LibAllocator.AllocatorStorage storage s = LibAllocator.allocatorStorage();
if (!s.configs[strategyId].active) revert StrategyNotRegistered(strategyId);
if (s.quarantined[strategyId]) revert StrategyAlreadyQuarantined(strategyId);
s.quarantined[strategyId] = true;
s.targetBps[strategyId] = 0;
emit StrategyQuarantined(strategyId);
}

/// @notice Lift quarantine once a strategy is healthy again; its position is
/// counted in NAV and it becomes rebalanceable once more.
/// @dev Owner-only. Re-funding it requires a fresh `setAllocation`, since the
/// target was zeroed on quarantine.
function releaseStrategy(bytes32 strategyId) external {
LibDiamond.enforceIsContractOwner();
LibAllocator.AllocatorStorage storage s = LibAllocator.allocatorStorage();
if (!s.quarantined[strategyId]) revert StrategyNotQuarantined(strategyId);
s.quarantined[strategyId] = false;
emit StrategyReleased(strategyId);
}

// -----------------------------------------------------------------------
// Rebalance
// -----------------------------------------------------------------------
Expand All @@ -158,14 +196,19 @@ contract AllocatorFacet {
uint256[] memory currentAssets = new uint256[](n);
uint256 totalCached = _idleAssetsInternal();
for (uint256 i; i < n; i++) {
uint256 cur = _strategyTotalAssetsInternal(s.configs[s.strategyIds[i]], s.strategyIds[i]);
bytes32 id = s.strategyIds[i];
// Isolated: never read (the read may revert) or fund a quarantined
// strategy. currentAssets[i] stays 0, so both passes skip it too.
if (s.quarantined[id]) continue;
uint256 cur = _strategyTotalAssetsInternal(s.configs[id], id);
currentAssets[i] = cur;
totalCached += cur;
}

// Pass 1: withdraw from over-target strategies.
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;
if (currentAssets[i] > target) {
uint256 delta = currentAssets[i] - target;
Expand All @@ -176,6 +219,7 @@ contract AllocatorFacet {
// Pass 2: deposit into under-target strategies.
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;
if (currentAssets[i] < target) {
uint256 delta = target - currentAssets[i];
Expand Down Expand Up @@ -233,6 +277,10 @@ contract AllocatorFacet {
return LibAllocator.allocatorStorage().lastRebalanceBlock;
}

function isQuarantined(bytes32 strategyId) external view returns (bool) {
return LibAllocator.allocatorStorage().quarantined[strategyId];
}

// -----------------------------------------------------------------------
// Internals
// -----------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions src/facets/HarvestFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ contract HarvestFacet {
bytes32 id = s.strategyIds[i];
LibAllocator.StrategyConfig memory cfg = s.configs[id];
if (!cfg.active) continue;
if (s.quarantined[id]) continue; // isolated: don't let a failing strategy break harvestAll
if (cfg.harvestSelector != bytes4(0)) {
(bool ok, bytes memory ret) = address(this).call(abi.encodeWithSelector(cfg.harvestSelector));
if (!ok) {
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 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
/// out of StrategyConfig so it is dynamic rather than static config.
mapping(bytes32 => bool) quarantined;
}

function allocatorStorage() internal pure returns (AllocatorStorage storage s) {
Expand Down
9 changes: 9 additions & 0 deletions test/mocks/MockStrategyFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ contract MockStrategyFacet {
struct MockStorage {
MockProtocol protocol;
uint256 harvestCount;
bool reverting;
}

function _ms() internal pure returns (MockStorage storage s) {
Expand All @@ -34,7 +35,14 @@ contract MockStrategyFacet {
return _ms().protocol;
}

/// @notice Test-only — when set, the strategy's totalAssets and harvest revert,
/// simulating a failing/exploited/stuck underlying protocol.
function mockSetReverting(bool v) external {
_ms().reverting = v;
}

function mockTotalAssets() external view returns (uint256) {
if (_ms().reverting) revert("mock: totalAssets reverted");
MockProtocol p = _ms().protocol;
if (address(p) == address(0)) return 0;
return p.balanceOf(address(this));
Expand All @@ -52,6 +60,7 @@ contract MockStrategyFacet {
}

function mockHarvest() external {
if (_ms().reverting) revert("mock: harvest reverted");
_ms().harvestCount += 1;
}

Expand Down
Loading