Skip to content

Commit

Permalink
feat: dynamic fees
Browse files Browse the repository at this point in the history
  • Loading branch information
failingtwice committed Feb 11, 2025
1 parent fce5c97 commit a3c4a13
Showing 1 changed file with 83 additions and 228 deletions.
311 changes: 83 additions & 228 deletions contracts/0.8.25/vaults/Delegation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,75 +11,26 @@ import {Dashboard} from "./Dashboard.sol";
* @title Delegation
* @notice This contract is a contract-owner of StakingVault and includes an additional delegation layer.
*
* The delegation hierarchy is as follows:
* - DEFAULT_ADMIN_ROLE is the underlying owner of StakingVault;
* - NODE_OPERATOR_MANAGER_ROLE is the node operator manager of StakingVault; and itself is the role admin,
* and the DEFAULT_ADMIN_ROLE cannot assign NODE_OPERATOR_MANAGER_ROLE;
* - NODE_OPERATOR_FEE_CLAIMER_ROLE is the role that can claim node operator fee; is assigned by NODE_OPERATOR_MANAGER_ROLE;
*
* Additionally, the following roles are assigned by DEFAULT_ADMIN_ROLE:
* - CURATOR_ROLE is the curator of StakingVault and perfoms some operations on behalf of DEFAULT_ADMIN_ROLE;
* - FUND_WITHDRAW_ROLE funds and withdraws from the StakingVault;
* - MINT_BURN_ROLE mints and burns shares of stETH backed by the StakingVault;
*
* The curator and node operator have their respective fees.
* The feeBP is the percentage (in basis points) of the StakingVault rewards.
* The unclaimed fee is the amount of ether that is owed to the curator or node operator based on the feeBP.
*/
contract Delegation is Dashboard {
/**
* @notice Maximum combined feeBP value; equals to 100%.
*/
uint256 private constant MAX_FEE_BP = TOTAL_BASIS_POINTS;

/**
* @notice Curator role:
* - sets curator fee;
* - claims curator fee;
* - confirms confirm lifetime;
* - confirms node operator fee;
* - confirms ownership transfer;
* - pauses deposits to beacon chain;
* - resumes deposits to beacon chain.
*/
bytes32 public constant CURATOR_ROLE = keccak256("vaults.Delegation.CuratorRole");

/**
* @notice Node operator manager role:
* - confirms confirm lifetime;
* - confirms node operator fee;
* - confirms ownership transfer;
* - assigns NODE_OPERATOR_FEE_CLAIMER_ROLE.
*/
bytes32 public constant NODE_OPERATOR_MANAGER_ROLE = keccak256("vaults.Delegation.NodeOperatorManagerRole");

/**
* @notice Node operator fee claimer role:
* - claims node operator fee.
*/
bytes32 public constant NODE_OPERATOR_FEE_CLAIMER_ROLE = keccak256("vaults.Delegation.NodeOperatorFeeClaimerRole");

/**
* @notice Curator fee in basis points; combined with node operator fee cannot exceed 100%.
* The curator's unclaimed fee in ether is returned by `curatorUnclaimedFee()`.
*/
uint256 public curatorFeeBP;
struct Fee {
uint256 share;
IStakingVault.Report lastClaimedReport;
}

/**
* @notice The last report for which curator fee was claimed. Updated on each claim.
*/
IStakingVault.Report public curatorFeeClaimedReport;
// A signal value to indicate that a fee is proposed.
IStakingVault.Report private constant PROPOSED_FEE_REPORT = IStakingVault.Report(type(uint128).max, 0);

/**
* @notice Node operator fee in basis points; combined with curator fee cannot exceed 100%, or 10,000 basis points.
* The node operator's unclaimed fee in ether is returned by `nodeOperatorUnclaimedFee()`.
*/
uint256 public nodeOperatorFeeBP;
mapping(address claimer => Fee fee) public fees;

/**
* @notice The last report for which node operator fee was claimed. Updated on each claim.
*/
IStakingVault.Report public nodeOperatorFeeClaimedReport;
// the list of claimers, needed to calculate the total unclaimed fees to account for reserved amount.
// so that withdrawer cannot withdraw more than the unreserved amount.
address[] public claimers;

/**
* @notice Constructs the contract.
Expand All @@ -89,53 +40,6 @@ contract Delegation is Dashboard {
*/
constructor(address _weth, address _lidoLocator) Dashboard(_weth, _lidoLocator) {}

/**
* @notice Initializes the contract:
* - sets up the roles;
* - sets the confirm lifetime to 7 days (can be changed later by CURATOR_ROLE and NODE_OPERATOR_MANAGER_ROLE).
* @dev The msg.sender here is VaultFactory. The VaultFactory is temporarily granted
* DEFAULT_ADMIN_ROLE AND NODE_OPERATOR_MANAGER_ROLE to be able to set initial fees and roles in VaultFactory.
* All the roles are revoked from VaultFactory by the end of the initialization.
*/
function initialize(address _defaultAdmin, uint256 _confirmLifetime) external override {
_initialize(_defaultAdmin, _confirmLifetime);

// the next line implies that the msg.sender is an operator
// however, the msg.sender is the VaultFactory, and the role will be revoked
// at the end of the initialization
_grantRole(NODE_OPERATOR_MANAGER_ROLE, _defaultAdmin);
_setRoleAdmin(NODE_OPERATOR_MANAGER_ROLE, NODE_OPERATOR_MANAGER_ROLE);
_setRoleAdmin(NODE_OPERATOR_FEE_CLAIMER_ROLE, NODE_OPERATOR_MANAGER_ROLE);
}

/**
* @notice Returns the accumulated unclaimed curator fee in ether,
* calculated as: U = (R * F) / T
* where:
* - U is the curator unclaimed fee;
* - R is the StakingVault rewards accrued since the last curator fee claim;
* - F is `curatorFeeBP`;
* - T is the total basis points, 10,000.
* @return uint256: the amount of unclaimed fee in ether.
*/
function curatorUnclaimedFee() public view returns (uint256) {
return _calculateFee(curatorFeeBP, curatorFeeClaimedReport);
}

/**
* @notice Returns the accumulated unclaimed node operator fee in ether,
* calculated as: U = (R * F) / T
* where:
* - U is the node operator unclaimed fee;
* - R is the StakingVault rewards accrued since the last node operator fee claim;
* - F is `nodeOperatorFeeBP`;
* - T is the total basis points, 10,000.
* @return uint256: the amount of unclaimed fee in ether.
*/
function nodeOperatorUnclaimedFee() public view returns (uint256) {
return _calculateFee(nodeOperatorFeeBP, nodeOperatorFeeClaimedReport);
}

/**
* @notice Returns the unreserved amount of ether,
* i.e. the amount of ether that is not locked in the StakingVault
Expand All @@ -145,75 +49,84 @@ contract Delegation is Dashboard {
* @return uint256: the amount of unreserved ether.
*/
function unreserved() public view returns (uint256) {
uint256 reserved = stakingVault().locked() + curatorUnclaimedFee() + nodeOperatorUnclaimedFee();
uint256 reserved = stakingVault().locked();

for (uint256 i = 0; i < claimers.length; i++) {
reserved += accruedFee(claimers[i]);
}

uint256 valuation = stakingVault().valuation();

return reserved > valuation ? 0 : valuation - reserved;
}

/**
* @notice Sets the confirm lifetime.
* Confirm lifetime is a period during which the confirm is counted. Once the period is over,
* the confirm is considered expired, no longer counts and must be recasted.
* @param _newConfirmLifetime The new confirm lifetime in seconds.
*/
function setConfirmLifetime(uint256 _newConfirmLifetime) external onlyMutuallyConfirmed(_confirmingRoles()) {
_setConfirmLifetime(_newConfirmLifetime);
}
function accruedFee(address _claimer) public view returns (uint256) {
Fee fee = fees[_claimer];

/**
* @notice Sets the curator fee.
* The curator fee is the percentage (in basis points) of curator's share of the StakingVault rewards.
* The curator and node operator fees combined cannot exceed 100%, or 10,000 basis points.
* The function will revert if the curator fee is unclaimed.
* @param _newCuratorFeeBP The new curator fee in basis points.
*/
function setCuratorFeeBP(uint256 _newCuratorFeeBP) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (_newCuratorFeeBP + nodeOperatorFeeBP > MAX_FEE_BP) revert CombinedFeesExceed100Percent();
if (curatorUnclaimedFee() > 0) revert CuratorFeeUnclaimed();
uint256 oldCuratorFeeBP = curatorFeeBP;
curatorFeeBP = _newCuratorFeeBP;
IStakingVault.Report memory latestReport = stakingVault().latestReport();

emit CuratorFeeBPSet(msg.sender, oldCuratorFeeBP, _newCuratorFeeBP);
int128 rewardsAccrued = int128(latestReport.valuation - fee.lastClaimedReport.valuation) -
(latestReport.inOutDelta - fee.lastClaimedReport.inOutDelta);

return rewardsAccrued > 0 ? (uint256(uint128(rewardsAccrued)) * fee.share) / TOTAL_BASIS_POINTS : 0;
}

/**
* @notice Sets the node operator fee.
* The node operator fee is the percentage (in basis points) of node operator's share of the StakingVault rewards.
* The node operator fee combined with the curator fee cannot exceed 100%.
* Note that the function reverts if the node operator fee is unclaimed and all the confirms must be recasted to execute it again,
* which is why the deciding confirm must make sure that `nodeOperatorUnclaimedFee()` is 0 before calling this function.
* @param _newNodeOperatorFeeBP The new node operator fee in basis points.
*/
function setNodeOperatorFeeBP(uint256 _newNodeOperatorFeeBP) external onlyMutuallyConfirmed(_confirmingRoles()) {
if (_newNodeOperatorFeeBP + curatorFeeBP > MAX_FEE_BP) revert CombinedFeesExceed100Percent();
if (nodeOperatorUnclaimedFee() > 0) revert NodeOperatorFeeUnclaimed();
uint256 oldNodeOperatorFeeBP = nodeOperatorFeeBP;
nodeOperatorFeeBP = _newNodeOperatorFeeBP;
function claimFee(address _recipient) external {
if (_recipient == address(0)) revert ZeroArgument("_recipient");
if (fees[msg.sender].share == 0) revert NotEligible();

uint256 fee = accruedFee(msg.sender);
if (fee == 0) revert NoFeeToClaim();

emit NodeOperatorFeeBPSet(msg.sender, oldNodeOperatorFeeBP, _newNodeOperatorFeeBP);
fees[msg.sender].lastClaimedReport = stakingVault().latestReport();

stakingVault().withdraw(_recipient, fee);
}

/**
* @notice Claims the curator fee.
* @param _recipient The address to which the curator fee will be sent.
*/
function claimCuratorFee(address _recipient) external onlyRole(CURATOR_ROLE) {
uint256 fee = curatorUnclaimedFee();
curatorFeeClaimedReport = stakingVault().latestReport();
_claimFee(_recipient, fee);
// propose:
// - adding a new claimer,
// - updating the share of an existing claimer,
// - deleting a claimer.
// the report valuation is set to max uint128 value so that when calculating the fee,
// the result is 0.
function proposeFee(uint256 _share) external {
if (accruedFee(msg.sender) > 0) revert MustBeClaimed();

uint256 totalShare = 0;
for (uint256 i = 0; i < claimers.length; i++) {
totalShare += fees[claimers[i]].share;
}

if (totalShare + _share > TOTAL_BASIS_POINTS) revert CombinedFeesExceed100Percent();

fees[msg.sender] = Fee(msg.sender, _share, PROPOSED_FEE_REPORT);
}

/**
* @notice Claims the node operator fee.
* Note that the authorized role is NODE_OPERATOR_FEE_CLAIMER_ROLE, not NODE_OPERATOR_MANAGER_ROLE,
* although NODE_OPERATOR_MANAGER_ROLE is the admin role for NODE_OPERATOR_FEE_CLAIMER_ROLE.
* @param _recipient The address to which the node operator fee will be sent.
*/
function claimNodeOperatorFee(address _recipient) external onlyRole(NODE_OPERATOR_FEE_CLAIMER_ROLE) {
uint256 fee = nodeOperatorUnclaimedFee();
nodeOperatorFeeClaimedReport = stakingVault().latestReport();
_claimFee(_recipient, fee);
// called by the admin to confirm:
// - a proposed fee,
// - an update to an existing claimer's share,
// - a deletion of a claimer.
// the report valuation is set to the latest report valuation,
// so that the fee is calculated correctly.
function confirmFee(address _claimer, uint256 _share) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (fees[_claimer].share != _share) revert ShareMismatch();
if (fees[_claimer].lastClaimedReport != PROPOSED_FEE_REPORT) revert MustBeProposed();

fees[_claimer] = Fee(_claimer, _share, stakingVault().latestReport());

// if the share is 0, the claimer is deleted.
if (_share == 0) {
for (uint256 i = 0; i < claimers.length; i++) {
if (claimers[i] == _claimer) {
claimers[i] = claimers[claimers.length - 1];
claimers.pop();
break;
}
}
} else {
// if the share is not 0, the claimer is added.
claimers.push(_claimer);
}
}

/**
Expand All @@ -226,51 +139,6 @@ contract Delegation is Dashboard {
_;
}

/**
* @dev Calculates the curator/node operator fee amount based on the fee and the last claimed report.
* @param _feeBP The fee in basis points.
* @param _lastClaimedReport The last claimed report.
* @return The accrued fee amount.
*/
function _calculateFee(
uint256 _feeBP,
IStakingVault.Report memory _lastClaimedReport
) internal view returns (uint256) {
IStakingVault.Report memory latestReport = stakingVault().latestReport();

int128 rewardsAccrued = int128(latestReport.valuation - _lastClaimedReport.valuation) -
(latestReport.inOutDelta - _lastClaimedReport.inOutDelta);

return rewardsAccrued > 0 ? (uint256(uint128(rewardsAccrued)) * _feeBP) / TOTAL_BASIS_POINTS : 0;
}

/**
* @dev Claims the curator/node operator fee amount.
* @param _recipient The address to which the fee will be sent.
* @param _fee The accrued fee amount.
* @dev Use `Permissions._unsafeWithdraw()` to avoid the `WITHDRAW_ROLE` check.
*/
function _claimFee(address _recipient, uint256 _fee) internal onlyIfUnreserved(_fee) {
if (_recipient == address(0)) revert ZeroArgument("_recipient");
if (_fee == 0) revert ZeroArgument("_fee");

stakingVault().withdraw(_recipient, _fee);
}

/**
* @notice Returns the roles that can:
* - change the confirm lifetime;
* - set the curator fee;
* - set the node operator fee;
* - transfer the ownership of the StakingVault.
* @return roles is an array of roles that form the confirming roles.
*/
function _confirmingRoles() internal pure override returns (bytes32[] memory roles) {
roles = new bytes32[](2);
roles[0] = CURATOR_ROLE;
roles[1] = NODE_OPERATOR_MANAGER_ROLE;
}

/**
* @dev Overrides the Permissions' internal withdraw function to add a check for the unreserved amount.
* Cannot withdraw more than the unreserved amount: which is the amount of ether
Expand All @@ -285,36 +153,23 @@ contract Delegation is Dashboard {
}

/**
* @dev Emitted when the curator fee is set.
* @param oldCuratorFeeBP The old curator fee.
* @param newCuratorFeeBP The new curator fee.
* @dev Error emitted when the combined feeBPs exceed 100%.
*/
event CuratorFeeBPSet(address indexed sender, uint256 oldCuratorFeeBP, uint256 newCuratorFeeBP);
error CombinedFeesExceed100Percent();

/**
* @dev Emitted when the node operator fee is set.
* @param oldNodeOperatorFeeBP The old node operator fee.
* @param newNodeOperatorFeeBP The new node operator fee.
* @dev Error emitted when the requested amount exceeds the unreserved amount.
*/
event NodeOperatorFeeBPSet(address indexed sender, uint256 oldNodeOperatorFeeBP, uint256 newNodeOperatorFeeBP);
error RequestedAmountExceedsUnreserved();

/**
* @dev Error emitted when the curator fee is unclaimed.
* @dev Error emitted when the share is 0.
*/
error CuratorFeeUnclaimed();
error NotEligible();

/**
* @dev Error emitted when the node operator fee is unclaimed.
*/
error NodeOperatorFeeUnclaimed();
error NoFeeToClaim();

/**
* @dev Error emitted when the combined feeBPs exceed 100%.
*/
error CombinedFeesExceed100Percent();
error MustBeProposed();

/**
* @dev Error emitted when the requested amount exceeds the unreserved amount.
*/
error RequestedAmountExceedsUnreserved();
error ShareMismatch();
}

0 comments on commit a3c4a13

Please sign in to comment.