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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ All facet state is isolated using EIP-7201 namespaced storage — no storage col

| Role | Permissions |
|------|-------------|
| **Owner** | Add / remove / replace facets via `diamondCut` |
| **Curator** | Register strategies, set allocations, trigger rebalance and harvest |
| **Owner** | Add / remove / replace facets via `diamondCut`, register / remove strategies, set caps + idle floor, configure fees, appoint / revoke curators |
| **Curator** | Set allocations, trigger rebalance and harvest — **within** the bounds the owner sets |
| **User** | Deposit / withdraw underlying asset |

The curator is a deliberately low-privilege operational key: it can move capital only between owner-allow-listed strategies and only within owner-set caps and the idle floor — it can never upgrade facets, change fees, or withdraw funds. This separation is what makes it safe to delegate day-to-day rebalancing to an automated operator (e.g. an off-chain agent) without exposing the vault to it. Owners are implicitly curators, so governance can always operate the vault directly.

## Strategies

| Strategy | Protocol | Yield Source |
Expand Down
14 changes: 11 additions & 3 deletions src/facets/AllocatorFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol";

import { LibDiamond } from "../libraries/LibDiamond.sol";
import { LibRoles } from "../libraries/LibRoles.sol";
import { LibAllocator } from "../libraries/LibAllocator.sol";

/// @title AllocatorFacet
Expand Down Expand Up @@ -35,8 +36,11 @@ contract AllocatorFacet {
event Rebalanced(uint256 totalAssets, uint256 idleAfter);

// -----------------------------------------------------------------------
// Curator-gated setters
// Owner-gated governance / risk bounds
// -----------------------------------------------------------------------
// Registering strategies and setting caps / idle floor define the bounds the
// curator must operate within, so they stay owner-only. `setAllocation` (a
// policy choice within those bounds) and `rebalance` are curator-gated below.

function registerStrategy(bytes32 strategyId, LibAllocator.StrategyConfig calldata config) external {
LibDiamond.enforceIsContractOwner();
Expand Down Expand Up @@ -77,8 +81,12 @@ contract AllocatorFacet {
emit StrategyRemoved(strategyId);
}

// -----------------------------------------------------------------------
// Curator-gated operations (allocation policy within owner-set bounds)
// -----------------------------------------------------------------------

function setAllocation(bytes32[] calldata strategyIds, uint16[] calldata bps) external {
LibDiamond.enforceIsContractOwner();
LibRoles.enforceIsCurator();
if (strategyIds.length != bps.length) revert AllocationLengthMismatch(strategyIds.length, bps.length);

LibAllocator.AllocatorStorage storage s = LibAllocator.allocatorStorage();
Expand Down Expand Up @@ -139,7 +147,7 @@ contract AllocatorFacet {
/// cap is enforced upstream in `setAllocation`; the idle-reserve floor
/// follows automatically from `total + idleReserveBps ≤ 10_000`.
function rebalance() external {
LibDiamond.enforceIsContractOwner();
LibRoles.enforceIsCurator();
LibAllocator.AllocatorStorage storage s = LibAllocator.allocatorStorage();
if (block.number <= uint256(s.lastRebalanceBlock)) {
revert RebalanceTooSoon(uint256(s.lastRebalanceBlock), block.number);
Expand Down
6 changes: 3 additions & 3 deletions src/facets/HarvestFacet.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { LibDiamond } from "../libraries/LibDiamond.sol";
import { LibRoles } from "../libraries/LibRoles.sol";
import { LibAllocator } from "../libraries/LibAllocator.sol";

/// @title HarvestFacet
Expand All @@ -23,7 +23,7 @@ contract HarvestFacet {
/// `harvestSelector` — useful for protocols where rewards auto-accrue
/// (Aave aTokens, Morpho lender shares) and no explicit claim is needed.
function harvest(bytes32 strategyId) external {
LibDiamond.enforceIsContractOwner();
LibRoles.enforceIsCurator();
LibAllocator.StrategyConfig memory cfg = LibAllocator.allocatorStorage().configs[strategyId];
if (!cfg.active) revert StrategyNotRegistered(strategyId);
if (cfg.harvestSelector != bytes4(0)) {
Expand All @@ -42,7 +42,7 @@ contract HarvestFacet {

/// @notice Harvest every registered strategy in registration order.
function harvestAll() external {
LibDiamond.enforceIsContractOwner();
LibRoles.enforceIsCurator();
LibAllocator.AllocatorStorage storage s = LibAllocator.allocatorStorage();
uint256 n = s.strategyIds.length;
for (uint256 i; i < n; i++) {
Expand Down
34 changes: 34 additions & 0 deletions src/facets/RolesFacet.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

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

/// @title RolesFacet
/// @notice Owner-gated management of the curator role. The owner appoints or
/// revokes curators; curators get day-to-day operational authority
/// (allocation + rebalance + harvest) bounded by the risk parameters the
/// owner controls. Curators can never upgrade facets, change fees, or
/// move funds outside the allow-listed strategies.
contract RolesFacet {
error ZeroAddress();

event CuratorSet(address indexed account, bool enabled);

/// @notice Grant or revoke the curator role for `account`.
/// @dev Owner-only. The owner is always implicitly a curator (see
/// LibRoles.isCurator), so this controls *additional* operational keys —
/// for example an off-chain agent that autonomously rebalances.
function setCurator(address account, bool enabled) external {
LibDiamond.enforceIsContractOwner();
if (account == address(0)) revert ZeroAddress();
LibRoles.rolesStorage().isCurator[account] = enabled;
emit CuratorSet(account, enabled);
}

/// @notice True if `account` may perform curator-gated operations.
/// @dev Returns true for the owner as well, since owner ≥ curator.
function isCurator(address account) external view returns (bool) {
return LibRoles.isCurator(account);
}
}
45 changes: 45 additions & 0 deletions src/libraries/LibRoles.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

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

/// @title LibRoles
/// @notice Namespaced storage for the vault's operational role layer. Separates
/// the low-privilege **curator** seat (day-to-day allocation + harvest)
/// from the high-privilege **owner** seat (facet upgrades, risk bounds,
/// fee config). The curator key can operate the vault within bounds the
/// owner sets but can never drain it, upgrade it, or change fees — which
/// is what makes it safe to hand to an automated (e.g. AI) operator.
/// @dev Roles state lives in an EIP-7201 namespaced slot so it cannot collide
/// with the ERC-4626 surface storage on Vault.sol, LibDiamond's selector
/// table, or any other facet's namespace.
/// keccak256(abi.encode(uint256(keccak256("vaultrouter.storage.roles")) - 1)) & ~bytes32(uint256(0xff))
library LibRoles {
bytes32 internal constant ROLES_STORAGE_SLOT = 0x72812988d549c1f62ecdf8218c688f5047bef5695066f17d8d1060ecc0962300;

error NotCurator(address caller);

/// @custom:storage-location erc7201:vaultrouter.storage.roles
struct RolesStorage {
mapping(address => bool) isCurator;
}

function rolesStorage() internal pure returns (RolesStorage storage s) {
bytes32 slot = ROLES_STORAGE_SLOT;
assembly {
s.slot := slot
}
}

/// @notice True if `account` may perform curator-gated operations.
/// @dev The owner is implicitly a curator (owner ≥ curator), so governance
/// can always operate the vault even before any curator is appointed.
function isCurator(address account) internal view returns (bool) {
return account == LibDiamond.contractOwner() || rolesStorage().isCurator[account];
}

/// @notice Reverts unless `msg.sender` is the owner or an appointed curator.
function enforceIsCurator() internal view {
if (!isCurator(msg.sender)) revert NotCurator(msg.sender);
}
}
Loading