Skip to content
9 changes: 2 additions & 7 deletions contracts/0.8.25/vaults/VaultFactory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,9 @@ contract VaultFactory {
vault.initialize(address(dashboard), _nodeOperator, locator.predepositGuarantee());

// initialize Dashboard with the factory address as the default admin, grant optional roles and connect to VaultHub
dashboard.initialize(address(this), address(this), _nodeOperatorManager, _nodeOperatorFeeBP, _confirmExpiry);
dashboard.initialize(address(this), _nodeOperatorManager, _nodeOperatorManager, _nodeOperatorFeeBP, _confirmExpiry);

// connection must be pre-approved by the node operator manager
dashboard.setApprovedToConnect(true);
dashboard.connectToVaultHub{value: msg.value}();

dashboard.grantRole(dashboard.NODE_OPERATOR_MANAGER_ROLE(), _nodeOperatorManager);
dashboard.revokeRole(dashboard.NODE_OPERATOR_MANAGER_ROLE(), address(this));
dashboard.connectToVaultHub{value: msg.value}(0);

// _roleAssignments can only include DEFAULT_ADMIN_ROLE's subroles,
// which is why it's important to revoke the NODE_OPERATOR_MANAGER_ROLE BEFORE granting roles
Expand Down
23 changes: 13 additions & 10 deletions contracts/0.8.25/vaults/dashboard/Dashboard.sol
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,13 @@ contract Dashboard is NodeOperatorFee {
* @notice Accepts the ownership over the disconnected StakingVault transferred from VaultHub
* and immediately passes it to a new pending owner. This new owner will have to accept the ownership
* on the StakingVault contract.
* Resets the settled growth to 0 to encourage correction before reconnection.
* @param _newOwner The address to transfer the StakingVault ownership to.
*/
function abandonDashboard(address _newOwner) external {
if (VAULT_HUB.isVaultConnected(address(_stakingVault()))) revert ConnectedToVaultHub();
if (_newOwner == address(this)) revert DashboardNotAllowed();
if (settledGrowth != 0) _setSettledGrowth(0);

_acceptOwnership();
_transferOwnership(_newOwner);
Expand All @@ -275,33 +277,34 @@ contract Dashboard is NodeOperatorFee {
/**
* @notice Accepts the ownership over the StakingVault and connects to VaultHub. Can be called to reconnect
* to the hub after voluntaryDisconnect()
* @param _currentSettledGrowth The current settled growth value to verify against the stored one
*/
function reconnectToVaultHub() external {
function reconnectToVaultHub(uint256 _currentSettledGrowth) external {
_acceptOwnership();
connectToVaultHub();
connectToVaultHub(_currentSettledGrowth);
}

/**
* @notice Connects to VaultHub, transferring underlying StakingVault ownership to VaultHub.
* @param _currentSettledGrowth The current settled growth value to verify against the stored one
*/
function connectToVaultHub() public payable {
if (!isApprovedToConnect) revert ForbiddenToConnectByNodeOperator();

function connectToVaultHub(uint256 _currentSettledGrowth) public payable {
if (settledGrowth != int256(_currentSettledGrowth)) {
revert SettledGrowthMismatch();
}
if (msg.value > 0) _stakingVault().fund{value: msg.value}();
_transferOwnership(address(VAULT_HUB));
VAULT_HUB.connectVault(address(_stakingVault()));

// node operator approval is one time only and is reset after connect
_setApprovedToConnect(false);
}

/**
* @notice Changes the tier of the vault and connects to VaultHub
* @param _tierId The tier to change to
* @param _requestedShareLimit The requested share limit
* @param _currentSettledGrowth The current settled growth value to verify against the stored one
*/
function connectAndAcceptTier(uint256 _tierId, uint256 _requestedShareLimit) external payable {
connectToVaultHub();
function connectAndAcceptTier(uint256 _tierId, uint256 _requestedShareLimit, uint256 _currentSettledGrowth) external payable {
connectToVaultHub(_currentSettledGrowth);
if (!_changeTier(_tierId, _requestedShareLimit)) {
revert TierChangeNotConfirmed();
}
Expand Down
94 changes: 54 additions & 40 deletions contracts/0.8.25/vaults/dashboard/NodeOperatorFee.sol
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ contract NodeOperatorFee is Permissions {
*/
bytes32 public constant NODE_OPERATOR_PROVE_UNKNOWN_VALIDATOR_ROLE =
keccak256("vaults.NodeOperatorFee.ProveUnknownValidatorsRole");

/**
* @notice If the accrued fee exceeds this BP of the total value, it is considered abnormally high.
* An abnormally high fee can only be disbursed by `DEFAULT_ADMIN_ROLE`.
* This threshold is to prevent accidental overpayment due to outdated settled growth.
*
* Why 1% threshold?
*
* - Assume a very generous annual staking APR of ~5% (3% CL + 2% EL).
* - A very high node operator fee rate of 10% translates to a 0.5% annual fee.
* - Thus, a 1% fee threshold would therefore be reached in 2 years.
* - Meaning: as long as the operator disburses fees at least once every 2 years,
* the threshold will never be hit.
*
* Since these assumptions are highly conservative, in practice the operator
* would need to disburse even less frequently before approaching the threshold.
*/
uint256 constant internal ABNORMALLY_HIGH_FEE_THRESHOLD_BP = 1_00;

// ==================== Packed Storage Slot 1 ====================
/**
Expand Down Expand Up @@ -93,13 +111,6 @@ contract NodeOperatorFee is Permissions {
*/
uint64 public latestCorrectionTimestamp;

/**
* @notice Flag indicating whether the vault is approved by the node operator to connect to VaultHub.
* The node operator's approval is needed to confirm the validity of fee calculations,
* particularly the settled growth.
*/
bool public isApprovedToConnect;

/**
* @notice Passes the address of the vault hub up the inheritance chain.
* @param _vaultHub The address of the vault hub.
Expand Down Expand Up @@ -167,20 +178,12 @@ contract NodeOperatorFee is Permissions {
* @return fee The amount of ETH accrued as fee
*/
function accruedFee() public view returns (uint256 fee) {
(fee, ) = _calculateFee();
}

/**
* @notice Approves/forbids connection to VaultHub. Approval implies that the node operator agrees
* with the current fee parameters, particularly the settled growth used as baseline for fee calculations.
* @param _isApproved True to approve, False to forbid
*/
function setApprovedToConnect(bool _isApproved) external onlyRoleMemberOrAdmin(NODE_OPERATOR_MANAGER_ROLE) {
_setApprovedToConnect(_isApproved);
(fee,, ) = _calculateFee();
}

/**
* @notice Permissionless function to disburse node operator fees.
* @notice Disburses node operator fees permissionlessly.
* Can be called by anyone as long as fee is not abnormally high.
*
* Fee disbursement steps:
* 1. Calculate current vault growth from latest report
Expand All @@ -189,15 +192,20 @@ contract NodeOperatorFee is Permissions {
* 4. Withdraws fee amount from vault to node operator recipient
*/
function disburseFee() public {
(uint256 fee, int128 growth) = _calculateFee();

// it's important not to revert here so as not to block disconnect
if (fee == 0) return;
(uint256 fee, int128 growth, uint256 abnormallyHighFeeThreshold) = _calculateFee();
if (fee > abnormallyHighFeeThreshold) revert AbnormallyHighFee();

_setSettledGrowth(growth);
_disburseFee(fee, growth);
}

VAULT_HUB.withdraw(address(_stakingVault()), feeRecipient, fee);
emit FeeDisbursed(msg.sender, fee);
/**
* @notice Disburses an abnormally high fee as `DEFAULT_ADMIN_ROLE`.
* Before calling this function, the caller must ensure that the high fee is expected,
* and the settled growth (used as baseline for fee) is set correctly.
*/
function disburseAbnormallyHighFee() external onlyRoleMemberOrAdmin(DEFAULT_ADMIN_ROLE) {
(uint256 fee, int128 growth,) = _calculateFee();
_disburseFee(fee, growth);
}

/**
Expand Down Expand Up @@ -284,13 +292,17 @@ contract NodeOperatorFee is Permissions {
return LazyOracle(LIDO_LOCATOR.lazyOracle());
}

function _setApprovedToConnect(bool _isApproved) internal {
isApprovedToConnect = _isApproved;
function _disburseFee(uint256 fee, int128 growth) internal {
// it's important not to revert here so as not to block disconnect
if (fee == 0) return;

_setSettledGrowth(growth);

emit ApprovedToConnectSet(_isApproved);
VAULT_HUB.withdraw(address(_stakingVault()), feeRecipient, fee);
emit FeeDisbursed(msg.sender, fee);
}

function _setSettledGrowth(int256 _newSettledGrowth) private {
function _setSettledGrowth(int256 _newSettledGrowth) internal {
int128 oldSettledGrowth = settledGrowth;
if (oldSettledGrowth == _newSettledGrowth) revert SameSettledGrowth();

Expand Down Expand Up @@ -323,14 +335,16 @@ contract NodeOperatorFee is Permissions {
_correctSettledGrowth(settledGrowth + int256(_amount));
}

function _calculateFee() internal view returns (uint256 fee, int128 growth) {
function _calculateFee() internal view returns (uint256 fee, int128 growth, uint256 abnormallyHighFeeThreshold) {
VaultHub.Report memory report = latestReport();
growth = int128(uint128(report.totalValue)) - int128(report.inOutDelta);
int256 unsettledGrowth = growth - settledGrowth;

if (unsettledGrowth > 0) {
fee = (uint256(unsettledGrowth) * feeRate) / TOTAL_BASIS_POINTS;
}

abnormallyHighFeeThreshold = (report.totalValue * ABNORMALLY_HIGH_FEE_THRESHOLD_BP) / TOTAL_BASIS_POINTS;
}

function _setFeeRate(uint256 _newFeeRate) internal {
Expand Down Expand Up @@ -389,18 +403,18 @@ contract NodeOperatorFee is Permissions {
*/
event CorrectionTimestampUpdated(uint64 timestamp);

/**
* @dev Emitted when the node operator approves/forbids to connect to VaultHub.
*/
event ApprovedToConnectSet(bool isApproved);

// ==================== Errors ====================

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

/**
* @dev Error emitted when trying to disburse an abnormally high fee.
*/
error AbnormallyHighFee();

/**
* @dev Error emitted when trying to set same value for recipient
*/
Expand All @@ -411,6 +425,11 @@ contract NodeOperatorFee is Permissions {
*/
error SameSettledGrowth();

/**
* @dev Error emitted when the settled growth does not match the expected value during connection.
*/
error SettledGrowthMismatch();

/**
* @dev Error emitted when the report is stale.
*/
Expand All @@ -431,11 +450,6 @@ contract NodeOperatorFee is Permissions {
*/
error UnexpectedFeeExemptionAmount();

/**
* @dev Error emitted when the settled growth is pending manual adjustment.
*/
error ForbiddenToConnectByNodeOperator();

/**
* @dev Error emitted when the vault is quarantined.
*/
Expand Down
1 change: 1 addition & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const EMPTY_SIGNATURE = "0x".padEnd(SIGNATURE_LENGTH_HEX + 2, "0");
export const ONE_GWEI = 1_000_000_000n;

export const TOTAL_BASIS_POINTS = 100_00n;
export const ABNORMALLY_HIGH_FEE_THRESHOLD_BP = 1_00n;

export const MAX_FEE_BP = 65_535n;
export const MAX_RESERVE_RATIO_BP = 99_99n;
Expand Down
Loading
Loading