From e79014d985fa1fd96a873b8bed1b77e521b5e67f Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Wed, 12 Feb 2025 14:50:29 +0700 Subject: [PATCH] feat: add PDG integration to dashboard --- contracts/0.8.25/vaults/Dashboard.sol | 26 ++++- contracts/0.8.25/vaults/Permissions.sol | 18 ++++ contracts/0.8.25/vaults/VaultFactory.sol | 2 + .../interfaces/IPredepositGuarantee.sol | 94 +++++++++++++++++++ .../predeposit_guarantee/CLProofVerifier.sol | 11 +-- .../PredepositGuarantee.sol | 53 ++++------- .../vaults/delegation/delegation.test.ts | 1 + .../cl-proof-verifyer.test.ts | 80 ++-------------- .../vaults/predeposit-guarantee/helpers.ts | 76 +++++++++++++++ .../predeposit-guarantee.test.ts | 10 +- .../vaults-happy-path.integration.ts | 1 + 11 files changed, 252 insertions(+), 120 deletions(-) create mode 100644 contracts/0.8.25/vaults/interfaces/IPredepositGuarantee.sol create mode 100644 test/0.8.25/vaults/predeposit-guarantee/helpers.ts diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index a00923153..7ac8dce44 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -411,12 +411,30 @@ contract Dashboard is Permissions { _rebalanceVault(_ether); } + /** + * @notice withdraws ether of disputed validator from PDG + * @param _pubkey of validator that was proven invalid in PDG + * @param _recipient address to receive the `PREDEPOSIT_AMOUNT` + */ + function withdrawDisputedValidator(bytes calldata _pubkey, address _recipient) external { + _withdrawDisputedValidatorFromPDG(_pubkey, _recipient); + } + + /** + * @notice funds vault with ether of disproven validator from PDG + * @param _pubkey of validator that was proven invalid in PDG + */ + function refundDisputedValidatorToVault(bytes calldata _pubkey) external { + uint128 _amount = _withdrawDisputedValidatorFromPDG(_pubkey, address(this)); + _fund(_amount); + } + /** * @notice recovers ERC20 tokens or ether from the dashboard contract to sender * @param _token Address of the token to recover or 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee for ether * @param _recipient Address of the recovery recipient */ - function recoverERC20(address _token, address _recipient, uint256 _amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + function recoverERC20(address _token, address _recipient, uint256 _amount) external onlyRole(ASSET_RECOVERY_ROLE) { if (_token == address(0)) revert ZeroArgument("_token"); if (_recipient == address(0)) revert ZeroArgument("_recipient"); if (_amount == 0) revert ZeroArgument("_amount"); @@ -439,7 +457,11 @@ contract Dashboard is Permissions { * @param _tokenId token id to recover * @param _recipient Address of the recovery recipient */ - function recoverERC721(address _token, uint256 _tokenId, address _recipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + function recoverERC721( + address _token, + uint256 _tokenId, + address _recipient + ) external onlyRole(ASSET_RECOVERY_ROLE) { if (_token == address(0)) revert ZeroArgument("_token"); if (_recipient == address(0)) revert ZeroArgument("_recipient"); diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index d2c7b31ea..6e54077ea 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -9,6 +9,7 @@ import {AccessControlVoteable} from "contracts/0.8.25/utils/AccessControlVoteabl import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; +import {IPredepositGuarantee} from "./interfaces/IPredepositGuarantee.sol"; import {VaultHub} from "./VaultHub.sol"; /** @@ -64,6 +65,16 @@ abstract contract Permissions is AccessControlVoteable { */ bytes32 public constant VOLUNTARY_DISCONNECT_ROLE = keccak256("StakingVault.Permissions.VoluntaryDisconnect"); + /** + * @notice Permission for recover assets from Delegate contracts + */ + bytes32 public constant PDG_WITHDRAWAL_ROLE = keccak256("StakingVault.Permissions.PDGWithdrawal"); + + /** + * @notice Permission for recover assets from Delegate contracts + */ + bytes32 public constant ASSET_RECOVERY_ROLE = keccak256("StakingVault.Permissions.AssetRecovery"); + /** * @notice Address of the implementation contract * @dev Used to prevent initialization in the implementation @@ -149,6 +160,13 @@ abstract contract Permissions is AccessControlVoteable { vaultHub.voluntaryDisconnect(address(stakingVault())); } + function _withdrawDisputedValidatorFromPDG( + bytes calldata _pubkey, + address _recipient + ) internal onlyRole(PDG_WITHDRAWAL_ROLE) returns (uint128) { + return IPredepositGuarantee(stakingVault().depositGuardian()).withdrawDisprovenPredeposit(_pubkey, _recipient); + } + function _transferStakingVaultOwnership(address _newOwner) internal onlyIfVotedBy(_votingCommittee()) { OwnableUpgradeable(address(stakingVault())).transferOwnership(_newOwner); } diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index dd39d7a86..f94bea416 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -24,6 +24,7 @@ struct DelegationConfig { address curator; address nodeOperatorManager; address nodeOperatorFeeClaimer; + address assetRecoverer; uint16 curatorFeeBP; uint16 nodeOperatorFeeBP; } @@ -87,6 +88,7 @@ contract VaultFactory { delegation.grantRole(delegation.CURATOR_ROLE(), _delegationConfig.curator); delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationConfig.nodeOperatorManager); delegation.grantRole(delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE(), _delegationConfig.nodeOperatorFeeClaimer); + delegation.grantRole(delegation.ASSET_RECOVERY_ROLE(), _delegationConfig.assetRecoverer); // grant temporary roles to factory delegation.grantRole(delegation.CURATOR_ROLE(), address(this)); diff --git a/contracts/0.8.25/vaults/interfaces/IPredepositGuarantee.sol b/contracts/0.8.25/vaults/interfaces/IPredepositGuarantee.sol new file mode 100644 index 000000000..df84a62d0 --- /dev/null +++ b/contracts/0.8.25/vaults/interfaces/IPredepositGuarantee.sol @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +pragma solidity 0.8.25; + +import {IStakingVaultOwnable} from "./IStakingVault.sol"; + +/** + * @title ICLProofVerifier + * @author Lido + * @notice Interface for the internal `CLProofVerifier` contract + */ +interface ICLProofVerifier { + struct ValidatorWitness { + bytes32[] proof; + bytes pubkey; + uint256 validatorIndex; + uint64 childBlockTimestamp; + } +} + +/** + * @title IPredepositGuarantee + * @author Lido + * @notice Interface for the `PredepositGuarantee` contract + */ +interface IPredepositGuarantee is ICLProofVerifier { + enum BondStatus { + NONE, + AWAITING_PROOF, + PROVED, + PROVED_INVALID + } + + struct NodeOperatorBond { + uint128 total; + uint128 locked; + } + + struct ValidatorStatus { + BondStatus bondStatus; + IStakingVaultOwnable stakingVault; + address nodeOperator; + } + + // constructor and initializer interfaces not needed in interface definition + + function nodeOperatorBond(address _nodeOperator) external view returns (NodeOperatorBond memory); + + function nodeOperatorVoucher(address _nodeOperator) external view returns (address); + + function validatorStatus(bytes calldata _validatorPubkey) external view returns (ValidatorStatus memory); + + function topUpNodeOperatorBond(address _nodeOperator) external payable; + + function withdrawNodeOperatorBond(address _nodeOperator, uint128 _amount, address _recipient) external; + + function setNodeOperatorVoucher(address _voucher) external payable; + + function predeposit( + IStakingVaultOwnable _stakingVault, + IStakingVaultOwnable.Deposit[] calldata _deposits + ) external payable; + + function proveValidatorWC(ValidatorWitness calldata _witness) external; + + function depositToBeaconChain( + IStakingVaultOwnable _stakingVault, + IStakingVaultOwnable.Deposit[] calldata _deposits + ) external payable; + + function proveAndDeposit( + ValidatorWitness[] calldata _witnesses, + IStakingVaultOwnable.Deposit[] calldata _deposits, + IStakingVaultOwnable _stakingVault + ) external payable; + + function proveInvalidValidatorWC( + ValidatorWitness calldata _witness, + bytes32 _invalidWithdrawalCredentials + ) external; + + function withdrawDisprovenPredeposit( + bytes calldata _validatorPubkey, + address _recipient + ) external returns (uint128 amount); + + function disproveAndWithdraw( + ValidatorWitness calldata _witness, + bytes32 _invalidWithdrawalCredentials, + address _recipient + ) external returns (uint128 amount); +} diff --git a/contracts/0.8.25/vaults/predeposit_guarantee/CLProofVerifier.sol b/contracts/0.8.25/vaults/predeposit_guarantee/CLProofVerifier.sol index bcd905a9f..47613ee81 100644 --- a/contracts/0.8.25/vaults/predeposit_guarantee/CLProofVerifier.sol +++ b/contracts/0.8.25/vaults/predeposit_guarantee/CLProofVerifier.sol @@ -7,6 +7,8 @@ pragma solidity 0.8.25; import {GIndex, pack, concat} from "contracts/0.8.25/lib/GIndex.sol"; import {SSZ} from "contracts/0.8.25/lib/SSZ.sol"; +import {ICLProofVerifier} from "../interfaces/IPredepositGuarantee.sol"; + /** * @title CLProofVerifier * @author Lido @@ -22,14 +24,7 @@ import {SSZ} from "contracts/0.8.25/lib/SSZ.sol"; * (e.g. Pectra, Altair, etc.) * */ -abstract contract CLProofVerifier { - struct ValidatorWitness { - bytes32[] proof; - bytes pubkey; - uint256 validatorIndex; - uint64 childBlockTimestamp; - } - +abstract contract CLProofVerifier is ICLProofVerifier { // See `BEACON_ROOTS_ADDRESS` constant in the EIP-4788. address public constant BEACON_ROOTS = 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02; diff --git a/contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol b/contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol index 82db216c2..c433d029b 100644 --- a/contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol +++ b/contracts/0.8.25/vaults/predeposit_guarantee/PredepositGuarantee.sol @@ -10,32 +10,14 @@ import {PausableUntilWithRoles} from "contracts/0.8.25/utils/PausableUntilWithRo import {CLProofVerifier} from "./CLProofVerifier.sol"; import {IStakingVaultOwnable} from "../interfaces/IStakingVault.sol"; +import {IPredepositGuarantee} from "../interfaces/IPredepositGuarantee.sol"; /** * @title PredepositGuarantee * @author Lido * @notice */ -contract PredepositGuarantee is CLProofVerifier, PausableUntilWithRoles { - enum BondStatus { - NO_RECORD, - AWAITING_PROOF, - PROVED, - PROVED_INVALID, - WITHDRAWN - } - - struct NodeOperatorBond { - uint128 total; - uint128 locked; - } - - struct ValidatorStatus { - BondStatus bondStatus; - IStakingVaultOwnable stakingVault; - address nodeOperator; - } - +contract PredepositGuarantee is IPredepositGuarantee, CLProofVerifier, PausableUntilWithRoles { /** * @notice ERC-7201 storage namespace for the vault * @dev ERC-7201 namespace is used to prevent upgrade collisions @@ -186,7 +168,7 @@ contract PredepositGuarantee is CLProofVerifier, PausableUntilWithRoles { for (uint256 i = 0; i < _deposits.length; i++) { IStakingVaultOwnable.Deposit calldata _deposit = _deposits[i]; - if ($.validatorStatuses[_deposit.pubkey].bondStatus != BondStatus.NO_RECORD) { + if ($.validatorStatuses[_deposit.pubkey].bondStatus != BondStatus.NONE) { revert MustBeNewValidatorPubkey(_deposit.pubkey, $.validatorStatuses[_deposit.pubkey].bondStatus); } @@ -318,38 +300,41 @@ contract PredepositGuarantee is CLProofVerifier, PausableUntilWithRoles { // called by the staking vault owner if the predeposited validator was proven invalid // i.e. node operator was malicious and has stolen vault ether - function withdrawDisprovenPredeposit(bytes calldata validatorPubkey, address _recipient) public whenResumed { - ValidatorStatus storage validator = _getStorage().validatorStatuses[validatorPubkey]; + function withdrawDisprovenPredeposit( + bytes calldata _validatorPubkey, + address _recipient + ) public whenResumed returns (uint128) { + ValidatorStatus storage validator = _getStorage().validatorStatuses[_validatorPubkey]; + + IStakingVaultOwnable _stakingVault = validator.stakingVault; + address _nodeOperator = validator.nodeOperator; if (_recipient == address(0)) revert ZeroArgument("_recipient"); - if (_recipient == address(validator.stakingVault)) revert WithdrawToVaultNotAllowed(); + if (_recipient == address(_stakingVault)) revert WithdrawToVaultNotAllowed(); - if (msg.sender != validator.stakingVault.owner()) revert WithdrawSenderNotStakingVaultOwner(); + if (msg.sender != _stakingVault.owner()) revert WithdrawSenderNotStakingVaultOwner(); if (validator.bondStatus != BondStatus.PROVED_INVALID) revert ValidatorNotProvenInvalid(validator.bondStatus); - validator.bondStatus = BondStatus.WITHDRAWN; + delete _getStorage().validatorStatuses[_validatorPubkey]; (bool success, ) = _recipient.call{value: PREDEPOSIT_AMOUNT}(""); if (!success) revert WithdrawalFailed(); - emit ValidatorDisprovenWithdrawn( - validator.nodeOperator, - validatorPubkey, - address(validator.stakingVault), - _recipient - ); + emit ValidatorDisprovenWithdrawn(_nodeOperator, _validatorPubkey, address(_stakingVault), _recipient); + + return PREDEPOSIT_AMOUNT; } function disproveAndWithdraw( ValidatorWitness calldata _witness, bytes32 _invalidWithdrawalCredentials, address _recipient - ) external { + ) external returns (uint128) { proveInvalidValidatorWC(_witness, _invalidWithdrawalCredentials); - withdrawDisprovenPredeposit(_witness.pubkey, _recipient); + return withdrawDisprovenPredeposit(_witness.pubkey, _recipient); } /// Internal functions diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 7b4651a2b..7fe32938d 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -115,6 +115,7 @@ describe("Delegation.sol", () => { exitRequester, disconnecter, curator, + assetRecoverer: curator, nodeOperatorManager, nodeOperatorFeeClaimer, curatorFeeBP: 0n, diff --git a/test/0.8.25/vaults/predeposit-guarantee/cl-proof-verifyer.test.ts b/test/0.8.25/vaults/predeposit-guarantee/cl-proof-verifyer.test.ts index 7f63f7c59..9651ea048 100644 --- a/test/0.8.25/vaults/predeposit-guarantee/cl-proof-verifyer.test.ts +++ b/test/0.8.25/vaults/predeposit-guarantee/cl-proof-verifyer.test.ts @@ -1,81 +1,17 @@ import { expect } from "chai"; -import { hexlify, parseUnits, randomBytes } from "ethers"; import { ethers } from "hardhat"; -import { CLProofVerifier__Harness, IStakingVault, SSZHelpers, SSZMerkleTree } from "typechain-types"; - -import { ether, impersonate } from "lib"; +import { CLProofVerifier__Harness, SSZMerkleTree } from "typechain-types"; import { Snapshot } from "test/suite"; -const randomBytes32 = (): string => hexlify(randomBytes(32)); -const randomInt = (max: number): number => Math.floor(Math.random() * max); -const randomValidatorPubkey = (): string => hexlify(randomBytes(48)); - -export const generateValidator = (customWC?: string, customPukey?: string): SSZHelpers.ValidatorStruct => { - return { - pubkey: customPukey ?? randomValidatorPubkey(), - withdrawalCredentials: customWC ?? randomBytes32(), - effectiveBalance: parseUnits(randomInt(32).toString(), "gwei"), - slashed: false, - activationEligibilityEpoch: randomInt(343300), - activationEpoch: randomInt(343300), - exitEpoch: randomInt(343300), - withdrawableEpoch: randomInt(343300), - }; -}; - -export const generatePredeposit = (validator: SSZHelpers.ValidatorStruct): IStakingVault.DepositStruct => { - return { - pubkey: validator.pubkey, - amount: ether("1"), - signature: randomBytes(96), - depositDataRoot: randomBytes32(), - }; -}; - -export const generatePostDeposit = ( - validator: SSZHelpers.ValidatorStruct, - amount = ether("31"), -): IStakingVault.DepositStruct => { - return { - pubkey: validator.pubkey, - amount, - signature: randomBytes(96), - depositDataRoot: randomBytes32(), - }; -}; - -export const generateBeaconHeader = (stateRoot: string) => { - return { - slot: randomInt(1743359), - proposerIndex: randomInt(1337), - parentRoot: randomBytes32(), - stateRoot, - bodyRoot: randomBytes32(), - }; -}; - -export const setBeaconBlockRoot = async (root: string) => { - const systemSigner = await impersonate("0xfffffffffffffffffffffffffffffffffffffffe", 999999999999999999999999999n); - const BEACON_ROOTS = "0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02"; - const block = await systemSigner - .sendTransaction({ - to: BEACON_ROOTS, - value: 0, - data: root, - }) - .then((tx) => tx.getBlock()); - if (!block) throw new Error("ivariant"); - return block.timestamp; -}; - -export const prepareLocalMerkleTree = async () => { - const sszMerkleTree: SSZMerkleTree = await ethers.deployContract("SSZMerkleTree", {}); - await sszMerkleTree.addValidatorLeaf(generateValidator()); - const gIFirstValidator = await sszMerkleTree.getGeneralizedIndex(0n); - return { sszMerkleTree, gIFirstValidator }; -}; +import { + generateBeaconHeader, + generateValidator, + prepareLocalMerkleTree, + randomBytes32, + setBeaconBlockRoot, +} from "./helpers"; // CSM "borrowed" prefab validator object with precalculated proofs & root // allows us to be sure that core merkle proof validation is working correctly diff --git a/test/0.8.25/vaults/predeposit-guarantee/helpers.ts b/test/0.8.25/vaults/predeposit-guarantee/helpers.ts new file mode 100644 index 000000000..0db254023 --- /dev/null +++ b/test/0.8.25/vaults/predeposit-guarantee/helpers.ts @@ -0,0 +1,76 @@ +import { hexlify, parseUnits, randomBytes } from "ethers"; +import { ethers } from "hardhat"; + +import { IStakingVault, SSZHelpers, SSZMerkleTree } from "typechain-types"; + +import { ether, impersonate } from "lib"; + +export const randomBytes32 = (): string => hexlify(randomBytes(32)); +export const randomValidatorPubkey = (): string => hexlify(randomBytes(48)); + +export const randomInt = (max: number): number => Math.floor(Math.random() * max); + +export const generateValidator = (customWC?: string, customPukey?: string): SSZHelpers.ValidatorStruct => { + return { + pubkey: customPukey ?? randomValidatorPubkey(), + withdrawalCredentials: customWC ?? randomBytes32(), + effectiveBalance: parseUnits(randomInt(32).toString(), "gwei"), + slashed: false, + activationEligibilityEpoch: randomInt(343300), + activationEpoch: randomInt(343300), + exitEpoch: randomInt(343300), + withdrawableEpoch: randomInt(343300), + }; +}; + +export const generatePredeposit = (validator: SSZHelpers.ValidatorStruct): IStakingVault.DepositStruct => { + return { + pubkey: validator.pubkey, + amount: ether("1"), + signature: randomBytes(96), + depositDataRoot: randomBytes32(), + }; +}; + +export const generatePostDeposit = ( + validator: SSZHelpers.ValidatorStruct, + amount = ether("31"), +): IStakingVault.DepositStruct => { + return { + pubkey: validator.pubkey, + amount, + signature: randomBytes(96), + depositDataRoot: randomBytes32(), + }; +}; + +export const generateBeaconHeader = (stateRoot: string) => { + return { + slot: randomInt(1743359), + proposerIndex: randomInt(1337), + parentRoot: randomBytes32(), + stateRoot, + bodyRoot: randomBytes32(), + }; +}; + +export const setBeaconBlockRoot = async (root: string) => { + const systemSigner = await impersonate("0xfffffffffffffffffffffffffffffffffffffffe", 999999999999999999999999999n); + const BEACON_ROOTS = "0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02"; + const block = await systemSigner + .sendTransaction({ + to: BEACON_ROOTS, + value: 0, + data: root, + }) + .then((tx) => tx.getBlock()); + if (!block) throw new Error("ivariant"); + return block.timestamp; +}; + +export const prepareLocalMerkleTree = async () => { + const sszMerkleTree: SSZMerkleTree = await ethers.deployContract("SSZMerkleTree", {}); + await sszMerkleTree.addValidatorLeaf(generateValidator()); + const gIFirstValidator = await sszMerkleTree.getGeneralizedIndex(0n); + return { sszMerkleTree, gIFirstValidator }; +}; diff --git a/test/0.8.25/vaults/predeposit-guarantee/predeposit-guarantee.test.ts b/test/0.8.25/vaults/predeposit-guarantee/predeposit-guarantee.test.ts index e1540b91f..e0c7f6d1f 100644 --- a/test/0.8.25/vaults/predeposit-guarantee/predeposit-guarantee.test.ts +++ b/test/0.8.25/vaults/predeposit-guarantee/predeposit-guarantee.test.ts @@ -12,6 +12,7 @@ import { SSZMerkleTree, StakingVault, StakingVault__factory, + VaultFactory__MockForStakingVault, VaultHub__MockForStakingVault, } from "typechain-types"; @@ -27,7 +28,7 @@ import { generateValidator, prepareLocalMerkleTree, setBeaconBlockRoot, -} from "./cl-proof-verifyer.test"; +} from "./helpers"; describe("PredepositGuarantee.sol", () => { let deployer: HardhatEthersSigner; @@ -55,9 +56,10 @@ describe("PredepositGuarantee.sol", () => { ]); // deploying factory/beacon - const vaultFactory_ = await ethers.deployContract("VaultFactory__MockForStakingVault", [ - await stakingVaultImplementation_.getAddress(), - ]); + const vaultFactory_: VaultFactory__MockForStakingVault = await ethers.deployContract( + "VaultFactory__MockForStakingVault", + [await stakingVaultImplementation_.getAddress()], + ); // deploying beacon proxy const vaultCreation = await vaultFactory_.createVault(owner, operator, pdg).then((tx) => tx.wait()); diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 59df58cb4..556a87308 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -171,6 +171,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { disconnecter: curator, nodeOperatorManager: nodeOperator, nodeOperatorFeeClaimer: nodeOperator, + assetRecoverer: curator, curatorFeeBP: VAULT_OWNER_FEE, nodeOperatorFeeBP: VAULT_NODE_OPERATOR_FEE, },