From 72f8a2690898aac84a0516c608c2d9bbadef383b Mon Sep 17 00:00:00 2001 From: Eladio Date: Wed, 22 Oct 2025 14:38:31 +0200 Subject: [PATCH 01/55] Implemented triggerValidatorExit in PM and PMM --- mainnet-contracts/src/PufferModule.sol | 22 +- mainnet-contracts/src/PufferModuleManager.sol | 15 ++ .../Eigenlayer-Slashing/IEigenPod.sol | 227 +++++++++++++++--- .../Eigenlayer-Slashing/ISemVerMixin.sol | 11 + .../src/interface/IPufferModuleManager.sol | 8 + 5 files changed, 255 insertions(+), 28 deletions(-) create mode 100644 mainnet-contracts/src/interface/Eigenlayer-Slashing/ISemVerMixin.sol diff --git a/mainnet-contracts/src/PufferModule.sol b/mainnet-contracts/src/PufferModule.sol index 7c2e57cb..e8b675a3 100644 --- a/mainnet-contracts/src/PufferModule.sol +++ b/mainnet-contracts/src/PufferModule.sol @@ -8,7 +8,7 @@ import { IEigenPodManager } from "../src/interface/Eigenlayer-Slashing/IEigenPod import { ISignatureUtils } from "../src/interface/Eigenlayer-Slashing/ISignatureUtils.sol"; import { IStrategy } from "../src/interface/Eigenlayer-Slashing/IStrategy.sol"; import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; -import { IEigenPod } from "../src/interface/Eigenlayer-Slashing/IEigenPod.sol"; +import { IEigenPod, IEigenPodTypes } from "../src/interface/Eigenlayer-Slashing/IEigenPod.sol"; import { PufferModuleManager } from "./PufferModuleManager.sol"; import { Unauthorized } from "./Errors.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -195,6 +195,26 @@ contract PufferModule is Initializable, AccessManagedUpgradeable { return EIGEN_DELEGATION_MANAGER.undelegate(address(this)); } + /** + * @notice Triggers the validators exit for the given pubkeys + * @param pubkeys The pubkeys of the validators to exit + * @dev Only callable by the PufferModuleManager + * @dev According to EIP-7002 there is a fee for each validator exit request (See https://eips.ethereum.org/assets/eip-7002/fee_analysis) + * The fee is paid in the msg.value of this function. Since the fee is not fixed and might change, the excess amount will be kept in the PufferModule + */ + function triggerValidatorsExit(bytes[] calldata pubkeys) external virtual payable onlyPufferModuleManager { + ModuleStorage storage $ = _getPufferModuleStorage(); + + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](pubkeys.length); + for (uint256 i = 0; i < pubkeys.length; i++) { + requests[i] = IEigenPodTypes.WithdrawalRequest({ + pubkey: pubkeys[i], + amountGwei: 0 // This means full exit. Only value supported for 0x01 validators + }); + } + $.eigenPod.requestWithdrawal{value: msg.value}(requests); + } + /** * @notice Sets the rewards claimer to `claimer` for the PufferModule */ diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index f7d6f619..ab7730e1 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -239,6 +239,21 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, emit PufferModuleUndelegated(moduleName); } + /** + * @notice Triggers the validators exit for the given pubkeys + * @param moduleName The name of the Puffer module + * @param pubkeys The pubkeys of the validators to exit + * @dev Restricted to the DAO + * @dev According to EIP-7002 there is a fee for each validator exit request (See https://eips.ethereum.org/assets/eip-7002/fee_analysis) + * The fee is paid in the msg.value of this function. Since the fee is not fixed and might change, the excess amount will be kept in the PufferModule + */ + function triggerValidatorsExit(bytes32 moduleName, bytes[] calldata pubkeys) external virtual payable restricted { + address moduleAddress = IPufferProtocol(PUFFER_PROTOCOL).getModuleAddress(moduleName); + PufferModule(payable(moduleAddress)).triggerValidatorsExit{value: msg.value}(pubkeys); + + emit ValidatorsExitTriggered(moduleName, pubkeys); + } + /** * @notice Calls the callRegisterOperatorToAVS function on the target restaking operator * @param restakingOperator is the address of the restaking operator diff --git a/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol b/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol index b465f711..03943b0f 100644 --- a/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol +++ b/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol @@ -4,6 +4,7 @@ pragma solidity >=0.5.0; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "../libraries/BeaconChainProofs.sol"; +import "./ISemVerMixin.sol"; import "./IEigenPodManager.sol"; interface IEigenPodErrors { @@ -42,8 +43,6 @@ interface IEigenPodErrors { /// @dev Thrown when amount exceeds `restakedExecutionLayerGwei`. error InsufficientWithdrawableBalance(); - /// @dev Thrown when provided `amountGwei` is not a multiple of gwei. - error AmountMustBeMultipleOfGwei(); /// Validator Status @@ -60,6 +59,17 @@ interface IEigenPodErrors { /// @dev Thrown when a validator has not been slashed on the beacon chain. error ValidatorNotSlashedOnBeaconChain(); + /// Consolidation and Withdrawal Requests + + /// @dev Thrown when a predeploy request is initiated with insufficient msg.value + error InsufficientFunds(); + /// @dev Thrown when refunding excess fees from a predeploy fails + error RefundFailed(); + /// @dev Thrown when calling the predeploy fails + error PredeployFailed(); + /// @dev Thrown when querying a predeploy for its current fee fails + error FeeQueryFailed(); + /// Misc /// @dev Thrown when an invalid block root is returned by the EIP-4788 oracle. @@ -68,24 +78,28 @@ interface IEigenPodErrors { error MsgValueNot32ETH(); /// @dev Thrown when provided `beaconTimestamp` is too far in the past. error BeaconTimestampTooFarInPast(); + /// @dev Thrown when the pectraForkTimestamp returned from the EigenPodManager is zero + error ForkTimestampZero(); } interface IEigenPodTypes { enum VALIDATOR_STATUS { - INACTIVE, // doesn't exist + INACTIVE, // doesnt exist ACTIVE, // staked on ethpos and withdrawal credentials are pointed to the EigenPod WITHDRAWN // withdrawn from the Beacon Chain } + /** + * @param validatorIndex index of the validator on the beacon chain + * @param restakedBalanceGwei amount of beacon chain ETH restaked on EigenLayer in gwei + * @param lastCheckpointedAt timestamp of the validator's most recent balance update + * @param status last recorded status of the validator + */ struct ValidatorInfo { - // index of the validator in the beacon chain uint64 validatorIndex; - // amount of beacon chain ETH restaked on EigenLayer in gwei uint64 restakedBalanceGwei; - //timestamp of the validator's most recent balance update uint64 lastCheckpointedAt; - // status of the validator VALIDATOR_STATUS status; } @@ -96,6 +110,30 @@ interface IEigenPodTypes { int64 balanceDeltasGwei; uint64 prevBeaconBalanceGwei; } + + /** + * @param srcPubkey the pubkey of the source validator for the consolidation + * @param targetPubkey the pubkey of the target validator for the consolidation + * @dev Note that if srcPubkey == targetPubkey, this is a "switch request," and will + * change the validator's withdrawal credential type from 0x01 to 0x02. + * For more notes on usage, see `requestConsolidation` + */ + struct ConsolidationRequest { + bytes srcPubkey; + bytes targetPubkey; + } + + /** + * @param pubkey the pubkey of the validator to withdraw from + * @param amountGwei the amount (in gwei) to withdraw from the beacon chain to the pod + * @dev Note that if amountGwei == 0, this is a "full exit request," and will fully exit + * the validator to the pod. + * For more notes on usage, see `requestWithdrawal` + */ + struct WithdrawalRequest { + bytes pubkey; + uint64 amountGwei; + } } interface IEigenPodEvents is IEigenPodTypes { @@ -131,6 +169,18 @@ interface IEigenPodEvents is IEigenPodTypes { /// @notice Emitted when a validaor is proven to have 0 balance at a given checkpoint event ValidatorWithdrawn(uint64 indexed checkpointTimestamp, uint40 indexed validatorIndex); + + /// @notice Emitted when a consolidation request is initiated where source == target + event SwitchToCompoundingRequested(bytes32 indexed validatorPubkeyHash); + + /// @notice Emitted when a standard consolidation request is initiated + event ConsolidationRequested(bytes32 indexed sourcePubkeyHash, bytes32 indexed targetPubkeyHash); + + /// @notice Emitted when a withdrawal request is initiated where request.amountGwei == 0 + event ExitRequested(bytes32 indexed validatorPubkeyHash); + + /// @notice Emitted when a partial withdrawal request is initiated + event WithdrawalRequested(bytes32 indexed validatorPubkeyHash, uint64 withdrawalAmountGwei); } /** @@ -140,19 +190,20 @@ interface IEigenPodEvents is IEigenPodTypes { * @dev Note that all beacon chain balances are stored as gwei within the beacon chain datastructures. We choose * to account balances in terms of gwei in the EigenPod contract and convert to wei when making calls to other contracts */ -interface IEigenPod is IEigenPodErrors, IEigenPodEvents { +interface IEigenPod is IEigenPodErrors, IEigenPodEvents, ISemVerMixin { /// @notice Used to initialize the pointers to contracts crucial to the pod's functionality, in beacon proxy construction from EigenPodManager - function initialize(address owner) external; + function initialize( + address owner + ) external; /// @notice Called by EigenPodManager when the owner wants to create another ETH validator. + /// @dev This function only supports staking to a 0x01 validator. For compounding validators, please interact directly with the deposit contract. function stake(bytes calldata pubkey, bytes calldata signature, bytes32 depositDataRoot) external payable; /** - * @notice Transfers `amountWei` in ether from this contract to the specified `recipient` address - * @notice Called by EigenPodManager to withdrawBeaconChainETH that has been added to the EigenPod's balance due to a withdrawal from the beacon chain. - * @dev The podOwner must have already proved sufficient withdrawals, so that this pod's `restakedExecutionLayerGwei` exceeds the - * `amountWei` input (when converted to GWEI). - * @dev Reverts if `amountWei` is not a whole Gwei amount + * @notice Transfers `amountWei` from this contract to the `recipient`. Only callable by the EigenPodManager as part + * of the DelegationManager's withdrawal flow. + * @dev `amountWei` is not required to be a whole Gwei amount. Amounts less than a Gwei multiple may be unrecoverable due to Gwei conversion. */ function withdrawRestakedBeaconChainETH(address recipient, uint256 amount) external; @@ -168,7 +219,9 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents { * @param revertIfNoBalance Forces a revert if the pod ETH balance is 0. This allows the pod owner * to prevent accidentally starting a checkpoint that will not increase their shares */ - function startCheckpoint(bool revertIfNoBalance) external; + function startCheckpoint( + bool revertIfNoBalance + ) external; /** * @dev Progress the current checkpoint towards completion by submitting one or more validator @@ -243,17 +296,112 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents { BeaconChainProofs.ValidatorProof calldata proof ) external; + /// @notice Allows the owner or proof submitter to initiate one or more requests to + /// consolidate their validators on the beacon chain. + /// @param requests An array of requests consisting of the source and target pubkeys + /// of the validators to be consolidated + /// @dev Both the source and target validator MUST have active withdrawal credentials + /// pointed at the pod + /// @dev The consolidation request predeploy requires a fee is sent with each request; + /// this is pulled from msg.value. After submitting all requests, any remaining fee is + /// refunded to the caller by calling its fallback function. + /// @dev This contract exposes `getConsolidationRequestFee` to query the current fee for + /// a single request. If submitting multiple requests in a single block, the total fee + /// is equal to (fee * requests.length). This fee is updated at the end of each block. + /// + /// (See https://eips.ethereum.org/EIPS/eip-7251#fee-calculation for details) + /// + /// @dev Note on beacon chain behavior: + /// - If request.srcPubkey == request.targetPubkey, this is a "switch" consolidation. Once + /// processed on the beacon chain, the validator's withdrawal credentials will be changed + /// to compounding (0x02). + /// - The rest of the notes assume src != target. + /// - The target validator MUST already have 0x02 credentials. The source validator can have either. + /// - Consoldiation sets the source validator's exit_epoch and withdrawable_epoch, similar to an exit. + /// When the exit epoch is reached, an epoch sweep will process the consolidation and transfer balance + /// from the source to the target validator. + /// - Consolidation transfers min(srcValidator.effective_balance, state.balance[srcIndex]) to the target. + /// This may not be the entirety of the source validator's balance; any remainder will be moved to the + /// pod when hit by a subsequent withdrawal sweep. + /// + /// @dev Note that consolidation requests CAN FAIL for a variety of reasons. Failures occur when the request + /// is processed on the beacon chain, and are invisible to the pod. The pod and predeploy cannot guarantee + /// a request will succeed; it's up to the pod owner to determine this for themselves. If your request fails, + /// you can retry by initiating another request via this method. + /// + /// Some requirements that are NOT checked by the pod: + /// - If request.srcPubkey == request.targetPubkey, the validator MUST have 0x01 credentials + /// - If request.srcPubkey != request.targetPubkey, the target validator MUST have 0x02 credentials + /// - Both the source and target validators MUST be active and MUST NOT have initiated exits + /// - The source validator MUST NOT have pending partial withdrawal requests (via `requestWithdrawal`) + /// - If the source validator is slashed after requesting consolidation (but before processing), + /// the consolidation will be skipped. + /// + /// For further reference, see consolidation processing at block and epoch boundaries: + /// - Block: https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-process_consolidation_request + /// - Epoch: https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-process_pending_consolidations + function requestConsolidation( + ConsolidationRequest[] calldata requests + ) external payable; + + /// @notice Allows the owner or proof submitter to initiate one or more requests to + /// withdraw funds from validators on the beacon chain. + /// @param requests An array of requests consisting of the source validator and an + /// amount to withdraw + /// @dev The withdrawal request predeploy requires a fee is sent with each request; + /// this is pulled from msg.value. After submitting all requests, any remaining fee is + /// refunded to the caller by calling its fallback function. + /// @dev This contract exposes `getWithdrawalRequestFee` to query the current fee for + /// a single request. If submitting multiple requests in a single block, the total fee + /// is equal to (fee * requests.length). This fee is updated at the end of each block. + /// + /// (See https://eips.ethereum.org/EIPS/eip-7002#fee-update-rule for details) + /// + /// @dev Note on beacon chain behavior: + /// - Withdrawal requests have two types: full exit requests, and partial exit requests. + /// Partial exit requests will be skipped if the validator has 0x01 withdrawal credentials. + /// If you want your validators to have access to partial exits, use `requestConsolidation` + /// to change their withdrawal credentials to compounding (0x02). + /// - If request.amount == 0, this is a FULL exit request. A full exit request initiates a + /// standard validator exit. + /// - Other amounts are treated as PARTIAL exit requests. A partial exit request will NOT result + /// in a validator with less than 32 ETH balance. Any requested amount above this is ignored. + /// - The actual amount withdrawn for a partial exit is given by the formula: + /// min(request.amount, state.balances[vIdx] - 32 ETH - pending_balance_to_withdraw) + /// (where `pending_balance_to_withdraw` is the sum of any outstanding partial exit requests) + /// (Note that this means you may request more than is actually withdrawn!) + /// + /// @dev Note that withdrawal requests CAN FAIL for a variety of reasons. Failures occur when the request + /// is processed on the beacon chain, and are invisible to the pod. The pod and predeploy cannot guarantee + /// a request will succeed; it's up to the pod owner to determine this for themselves. If your request fails, + /// you can retry by initiating another request via this method. + /// + /// Some requirements that are NOT checked by the pod: + /// - request.pubkey MUST be a valid validator pubkey + /// - request.pubkey MUST belong to a validator whose withdrawal credentials are this pod + /// - If request.amount is for a partial exit, the validator MUST have 0x02 withdrawal credentials + /// - If request.amount is for a full exit, the validator MUST NOT have any pending partial exits + /// - The validator MUST be active and MUST NOT have initiated exit + /// + /// For further reference: https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-process_withdrawal_request + function requestWithdrawal( + WithdrawalRequest[] calldata requests + ) external payable; + /// @notice called by owner of a pod to remove any ERC20s deposited in the pod function recoverTokens(IERC20[] memory tokenList, uint256[] memory amountsToWithdraw, address recipient) external; /// @notice Allows the owner of a pod to update the proof submitter, a permissioned - /// address that can call `startCheckpoint` and `verifyWithdrawalCredentials`. + /// address that can call various EigenPod methods, but cannot trigger asset withdrawals + /// from the DelegationManager. /// @dev Note that EITHER the podOwner OR proofSubmitter can access these methods, /// so it's fine to set your proofSubmitter to 0 if you want the podOwner to be the /// only address that can call these methods. /// @param newProofSubmitter The new proof submitter address. If set to 0, only the - /// pod owner will be able to call `startCheckpoint` and `verifyWithdrawalCredentials` - function setProofSubmitter(address newProofSubmitter) external; + /// pod owner will be able to call EigenPod methods. + function setProofSubmitter( + address newProofSubmitter + ) external; /** * @@ -267,7 +415,8 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents { /// @dev If this address is NOT set, only the podOwner can call `startCheckpoint` and `verifyWithdrawalCredentials` function proofSubmitter() external view returns (address); - /// @notice the amount of execution layer ETH in this contract that is staked in EigenLayer (i.e. withdrawn from beaconchain but not EigenLayer), + /// @notice Native ETH in the pod that has been accounted for in a checkpoint (denominated in gwei). + /// This amount is withdrawable from the pod via the DelegationManager withdrawal flow. function withdrawableRestakedExecutionLayerGwei() external view returns (uint64); /// @notice The single EigenPodManager for EigenLayer @@ -277,16 +426,24 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents { function podOwner() external view returns (address); /// @notice Returns the validatorInfo struct for the provided pubkeyHash - function validatorPubkeyHashToInfo(bytes32 validatorPubkeyHash) external view returns (ValidatorInfo memory); + function validatorPubkeyHashToInfo( + bytes32 validatorPubkeyHash + ) external view returns (ValidatorInfo memory); /// @notice Returns the validatorInfo struct for the provided pubkey - function validatorPubkeyToInfo(bytes calldata validatorPubkey) external view returns (ValidatorInfo memory); + function validatorPubkeyToInfo( + bytes calldata validatorPubkey + ) external view returns (ValidatorInfo memory); - /// @notice This returns the status of a given validator - function validatorStatus(bytes32 pubkeyHash) external view returns (VALIDATOR_STATUS); + /// @notice Returns the validator status for a given validator pubkey hash + function validatorStatus( + bytes32 pubkeyHash + ) external view returns (VALIDATOR_STATUS); - /// @notice This returns the status of a given validator pubkey - function validatorStatus(bytes calldata validatorPubkey) external view returns (VALIDATOR_STATUS); + /// @notice Returns the validator status for a given validator pubkey + function validatorStatus( + bytes calldata validatorPubkey + ) external view returns (VALIDATOR_STATUS); /// @notice Number of validators with proven withdrawal credentials, who do not have proven full withdrawals function activeValidatorCount() external view returns (uint256); @@ -298,6 +455,8 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents { function currentCheckpointTimestamp() external view returns (uint64); /// @notice Returns the currently-active checkpoint + /// To save gas on checkpoint creation, we don't delete checkpoints when they're completed. + /// If there's not an active checkpoint, this method returns an empty Checkpoint. function currentCheckpoint() external view returns (Checkpoint memory); /// @notice For each checkpoint, the total balance attributed to exited validators, in gwei @@ -328,11 +487,25 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents { /// - The final partial withdrawal for an exited validator will be likely be included in this mapping. /// i.e. if a validator was last checkpointed at 32.1 ETH before exiting, the next checkpoint will calculate their /// "exited" amount to be 32.1 ETH rather than 32 ETH. - function checkpointBalanceExitedGwei(uint64) external view returns (uint64); + function checkpointBalanceExitedGwei( + uint64 + ) external view returns (uint64); /// @notice Query the 4788 oracle to get the parent block root of the slot with the given `timestamp` /// @param timestamp of the block for which the parent block root will be returned. MUST correspond /// to an existing slot within the last 24 hours. If the slot at `timestamp` was skipped, this method /// will revert. - function getParentBlockRoot(uint64 timestamp) external view returns (bytes32); + function getParentBlockRoot( + uint64 timestamp + ) external view returns (bytes32); + + /// @notice Returns the fee required to add a consolidation request to the EIP-7251 predeploy this block. + /// @dev Note that the predeploy updates its fee every block according to https://eips.ethereum.org/EIPS/eip-7251#fee-calculation + /// Consider overestimating the amount sent to ensure the fee does not update before your transaction. + function getConsolidationRequestFee() external view returns (uint256); + + /// @notice Returns the current fee required to add a withdrawal request to the EIP-7002 predeploy. + /// @dev Note that the predeploy updates its fee every block according to https://eips.ethereum.org/EIPS/eip-7002#fee-update-rule + /// Consider overestimating the amount sent to ensure the fee does not update before your transaction. + function getWithdrawalRequestFee() external view returns (uint256); } diff --git a/mainnet-contracts/src/interface/Eigenlayer-Slashing/ISemVerMixin.sol b/mainnet-contracts/src/interface/Eigenlayer-Slashing/ISemVerMixin.sol new file mode 100644 index 00000000..206cf38d --- /dev/null +++ b/mainnet-contracts/src/interface/Eigenlayer-Slashing/ISemVerMixin.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/// @title ISemVerMixin +/// @notice A mixin interface that provides semantic versioning functionality. +/// @dev Follows SemVer 2.0.0 specification (https://semver.org/) +interface ISemVerMixin { + /// @notice Returns the semantic version string of the contract. + /// @return The version string in SemVer format (e.g., "v1.1.1") + function version() external view returns (string memory); +} diff --git a/mainnet-contracts/src/interface/IPufferModuleManager.sol b/mainnet-contracts/src/interface/IPufferModuleManager.sol index fa5b754f..2df5a0bf 100644 --- a/mainnet-contracts/src/interface/IPufferModuleManager.sol +++ b/mainnet-contracts/src/interface/IPufferModuleManager.sol @@ -73,6 +73,14 @@ interface IPufferModuleManager { */ event PufferModuleUndelegated(bytes32 indexed moduleName); + /** + * @notice Emitted when the validators exit is triggered + * @param moduleName the module name to be exited + * @param pubkeys the pubkeys of the validators to exit + * @dev Signature "0x456e0aba5f7f36ec541f2f550d3f5895eb7d1ae057f45e8683952ac182254e5d" + */ + event ValidatorsExitTriggered(bytes32 indexed moduleName, bytes[] pubkeys); + /** * @notice Emitted when the restaking operator avs signature proof is updated * @param restakingOperator is the address of the restaking operator From 34a17640d78b54a5e2b2a8de2272fc39278f190a Mon Sep 17 00:00:00 2001 From: Eladio Date: Wed, 22 Oct 2025 16:24:33 +0200 Subject: [PATCH 02/55] Added function to exit validator from PufferProtocol, and more checks --- mainnet-contracts/script/Roles.sol | 1 + mainnet-contracts/src/PufferModuleManager.sol | 3 ++- mainnet-contracts/src/PufferProtocol.sol | 20 +++++++++++++++++++ .../src/interface/IPufferModuleManager.sol | 5 +++++ .../src/interface/IPufferProtocol.sol | 19 ++++++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/mainnet-contracts/script/Roles.sol b/mainnet-contracts/script/Roles.sol index f969b0b8..dfcd3df1 100644 --- a/mainnet-contracts/script/Roles.sol +++ b/mainnet-contracts/script/Roles.sol @@ -13,6 +13,7 @@ uint64 constant ROLE_ID_OPERATIONS_PAYMASTER = 23; uint64 constant ROLE_ID_OPERATIONS_COORDINATOR = 24; uint64 constant ROLE_ID_WITHDRAWAL_FINALIZER = 25; uint64 constant ROLE_ID_REVENUE_DEPOSITOR = 26; +uint64 constant ROLE_ID_VALIDATOR_EXITOR = 27; // Role assigned to validator ticket price setter uint64 constant ROLE_ID_VT_PRICER = 25; diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index ab7730e1..40f45411 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -243,11 +243,12 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, * @notice Triggers the validators exit for the given pubkeys * @param moduleName The name of the Puffer module * @param pubkeys The pubkeys of the validators to exit - * @dev Restricted to the DAO + * @dev Restricted to the VALIDATOR_EXITOR and PUFFER_PROTOCOL * @dev According to EIP-7002 there is a fee for each validator exit request (See https://eips.ethereum.org/assets/eip-7002/fee_analysis) * The fee is paid in the msg.value of this function. Since the fee is not fixed and might change, the excess amount will be kept in the PufferModule */ function triggerValidatorsExit(bytes32 moduleName, bytes[] calldata pubkeys) external virtual payable restricted { + require(pubkeys.length > 0, InputArrayLengthZero()); address moduleAddress = IPufferProtocol(PUFFER_PROTOCOL).getModuleAddress(moduleName); PufferModule(payable(moduleAddress)).triggerValidatorsExit{value: msg.value}(pubkeys); diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index d910547e..2cc20553 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -288,6 +288,26 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad $.validators[moduleName][index].status = Status.ACTIVE; } + /** + * @inheritdoc IPufferProtocol + * @dev Restricted to Node Operators + */ + function triggerValidatorsExit( + bytes32 moduleName, + uint256[] calldata indices + ) external restricted payable { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + bytes[] memory pubkeys = new bytes[](indices.length); + + for (uint256 i = 0; i < indices.length; ++i) { + Validator memory validator = $.validators[moduleName][indices[i]]; + require(validator.node == msg.sender, InvalidValidator()); + pubkeys[i] = validator.pubKey; + } + + PUFFER_MODULE_MANAGER.triggerValidatorsExit{value: msg.value}(moduleName, pubkeys); + } + /** * @inheritdoc IPufferProtocol * @dev Restricted to Puffer Paymaster diff --git a/mainnet-contracts/src/interface/IPufferModuleManager.sol b/mainnet-contracts/src/interface/IPufferModuleManager.sol index 2df5a0bf..32f3cd1a 100644 --- a/mainnet-contracts/src/interface/IPufferModuleManager.sol +++ b/mainnet-contracts/src/interface/IPufferModuleManager.sol @@ -14,6 +14,11 @@ interface IPufferModuleManager { */ error ForbiddenModuleName(); + /** + * @notice Thrown if the input array length is zero + */ + error InputArrayLengthZero(); + /** * @notice Emitted when the Custom Call from the restakingOperator is successful * @dev Signature "0x80b240e4b7a31d61bdee28b97592a7c0ad486cb27d11ee5c6b90530db4e949ff" diff --git a/mainnet-contracts/src/interface/IPufferProtocol.sol b/mainnet-contracts/src/interface/IPufferProtocol.sol index f6a87a69..de37030d 100644 --- a/mainnet-contracts/src/interface/IPufferProtocol.sol +++ b/mainnet-contracts/src/interface/IPufferProtocol.sol @@ -68,6 +68,12 @@ interface IPufferProtocol { */ error InvalidValidatorState(Status status); + /** + * @notice Thrown when the validator is not owned by the sender + * @dev Signature "682a6e7c" + */ + error InvalidValidator(); + /** * @notice Thrown if the sender did not send enough ETH in the transaction * @dev Signature "0x242b035c" @@ -210,6 +216,19 @@ interface IPufferProtocol { */ function withdrawValidatorTickets(uint96 amount, address recipient) external; + /** + * @notice Triggers the validators exit for the given indices + * @param moduleName The name of the Puffer module + * @param indices The indices of the validators to exit + * @dev Restricted to Node Operators + * @dev According to EIP-7002 there is a fee for each validator exit request (See https://eips.ethereum.org/assets/eip-7002/fee_analysis) + * The fee is paid in the msg.value of this function. Since the fee is not fixed and might change, the excess amount will be kept in the PufferModule + */ + function triggerValidatorsExit( + bytes32 moduleName, + uint256[] calldata indices + ) external payable; + /** * @notice Batch settling of validator withdrawals * From 9a6652d535bd5b252280e66ed4195862b0789524 Mon Sep 17 00:00:00 2001 From: Eladio Date: Mon, 1 Dec 2025 13:39:22 +0100 Subject: [PATCH 03/55] Reverted old fmt, improved natspec and removed role --- mainnet-contracts/script/Roles.sol | 1 - mainnet-contracts/src/PufferModule.sol | 6 +-- mainnet-contracts/src/PufferModuleManager.sol | 6 +-- mainnet-contracts/src/PufferProtocol.sol | 10 ++--- .../Eigenlayer-Slashing/IEigenPod.sol | 44 +++++-------------- .../src/interface/IPufferProtocol.sol | 5 +-- 6 files changed, 22 insertions(+), 50 deletions(-) diff --git a/mainnet-contracts/script/Roles.sol b/mainnet-contracts/script/Roles.sol index dfcd3df1..f969b0b8 100644 --- a/mainnet-contracts/script/Roles.sol +++ b/mainnet-contracts/script/Roles.sol @@ -13,7 +13,6 @@ uint64 constant ROLE_ID_OPERATIONS_PAYMASTER = 23; uint64 constant ROLE_ID_OPERATIONS_COORDINATOR = 24; uint64 constant ROLE_ID_WITHDRAWAL_FINALIZER = 25; uint64 constant ROLE_ID_REVENUE_DEPOSITOR = 26; -uint64 constant ROLE_ID_VALIDATOR_EXITOR = 27; // Role assigned to validator ticket price setter uint64 constant ROLE_ID_VT_PRICER = 25; diff --git a/mainnet-contracts/src/PufferModule.sol b/mainnet-contracts/src/PufferModule.sol index e8b675a3..a72d4cb8 100644 --- a/mainnet-contracts/src/PufferModule.sol +++ b/mainnet-contracts/src/PufferModule.sol @@ -202,7 +202,7 @@ contract PufferModule is Initializable, AccessManagedUpgradeable { * @dev According to EIP-7002 there is a fee for each validator exit request (See https://eips.ethereum.org/assets/eip-7002/fee_analysis) * The fee is paid in the msg.value of this function. Since the fee is not fixed and might change, the excess amount will be kept in the PufferModule */ - function triggerValidatorsExit(bytes[] calldata pubkeys) external virtual payable onlyPufferModuleManager { + function triggerValidatorsExit(bytes[] calldata pubkeys) external payable virtual onlyPufferModuleManager { ModuleStorage storage $ = _getPufferModuleStorage(); IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](pubkeys.length); @@ -210,9 +210,9 @@ contract PufferModule is Initializable, AccessManagedUpgradeable { requests[i] = IEigenPodTypes.WithdrawalRequest({ pubkey: pubkeys[i], amountGwei: 0 // This means full exit. Only value supported for 0x01 validators - }); + }); } - $.eigenPod.requestWithdrawal{value: msg.value}(requests); + $.eigenPod.requestWithdrawal{ value: msg.value }(requests); } /** diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index 40f45411..9001ce3c 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -243,14 +243,14 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, * @notice Triggers the validators exit for the given pubkeys * @param moduleName The name of the Puffer module * @param pubkeys The pubkeys of the validators to exit - * @dev Restricted to the VALIDATOR_EXITOR and PUFFER_PROTOCOL + * @dev Restricted to the Puffer Paymaster and PUFFER_PROTOCOL * @dev According to EIP-7002 there is a fee for each validator exit request (See https://eips.ethereum.org/assets/eip-7002/fee_analysis) * The fee is paid in the msg.value of this function. Since the fee is not fixed and might change, the excess amount will be kept in the PufferModule */ - function triggerValidatorsExit(bytes32 moduleName, bytes[] calldata pubkeys) external virtual payable restricted { + function triggerValidatorsExit(bytes32 moduleName, bytes[] calldata pubkeys) external payable virtual restricted { require(pubkeys.length > 0, InputArrayLengthZero()); address moduleAddress = IPufferProtocol(PUFFER_PROTOCOL).getModuleAddress(moduleName); - PufferModule(payable(moduleAddress)).triggerValidatorsExit{value: msg.value}(pubkeys); + PufferModule(payable(moduleAddress)).triggerValidatorsExit{ value: msg.value }(pubkeys); emit ValidatorsExitTriggered(moduleName, pubkeys); } diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index 2cc20553..76c0bc60 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -290,12 +290,10 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad /** * @inheritdoc IPufferProtocol - * @dev Restricted to Node Operators + * @dev Restricted in this context is like `whenNotPaused` modifier from Pausable.sol + * @dev Only the node operators that own the indicated validators can call this function */ - function triggerValidatorsExit( - bytes32 moduleName, - uint256[] calldata indices - ) external restricted payable { + function triggerValidatorsExit(bytes32 moduleName, uint256[] calldata indices) external payable restricted { ProtocolStorage storage $ = _getPufferProtocolStorage(); bytes[] memory pubkeys = new bytes[](indices.length); @@ -305,7 +303,7 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad pubkeys[i] = validator.pubKey; } - PUFFER_MODULE_MANAGER.triggerValidatorsExit{value: msg.value}(moduleName, pubkeys); + PUFFER_MODULE_MANAGER.triggerValidatorsExit{ value: msg.value }(moduleName, pubkeys); } /** diff --git a/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol b/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol index 03943b0f..f4288fdf 100644 --- a/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol +++ b/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol @@ -192,9 +192,7 @@ interface IEigenPodEvents is IEigenPodTypes { */ interface IEigenPod is IEigenPodErrors, IEigenPodEvents, ISemVerMixin { /// @notice Used to initialize the pointers to contracts crucial to the pod's functionality, in beacon proxy construction from EigenPodManager - function initialize( - address owner - ) external; + function initialize(address owner) external; /// @notice Called by EigenPodManager when the owner wants to create another ETH validator. /// @dev This function only supports staking to a 0x01 validator. For compounding validators, please interact directly with the deposit contract. @@ -219,9 +217,7 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents, ISemVerMixin { * @param revertIfNoBalance Forces a revert if the pod ETH balance is 0. This allows the pod owner * to prevent accidentally starting a checkpoint that will not increase their shares */ - function startCheckpoint( - bool revertIfNoBalance - ) external; + function startCheckpoint(bool revertIfNoBalance) external; /** * @dev Progress the current checkpoint towards completion by submitting one or more validator @@ -340,9 +336,7 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents, ISemVerMixin { /// For further reference, see consolidation processing at block and epoch boundaries: /// - Block: https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-process_consolidation_request /// - Epoch: https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-process_pending_consolidations - function requestConsolidation( - ConsolidationRequest[] calldata requests - ) external payable; + function requestConsolidation(ConsolidationRequest[] calldata requests) external payable; /// @notice Allows the owner or proof submitter to initiate one or more requests to /// withdraw funds from validators on the beacon chain. @@ -384,9 +378,7 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents, ISemVerMixin { /// - The validator MUST be active and MUST NOT have initiated exit /// /// For further reference: https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-process_withdrawal_request - function requestWithdrawal( - WithdrawalRequest[] calldata requests - ) external payable; + function requestWithdrawal(WithdrawalRequest[] calldata requests) external payable; /// @notice called by owner of a pod to remove any ERC20s deposited in the pod function recoverTokens(IERC20[] memory tokenList, uint256[] memory amountsToWithdraw, address recipient) external; @@ -399,9 +391,7 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents, ISemVerMixin { /// only address that can call these methods. /// @param newProofSubmitter The new proof submitter address. If set to 0, only the /// pod owner will be able to call EigenPod methods. - function setProofSubmitter( - address newProofSubmitter - ) external; + function setProofSubmitter(address newProofSubmitter) external; /** * @@ -426,24 +416,16 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents, ISemVerMixin { function podOwner() external view returns (address); /// @notice Returns the validatorInfo struct for the provided pubkeyHash - function validatorPubkeyHashToInfo( - bytes32 validatorPubkeyHash - ) external view returns (ValidatorInfo memory); + function validatorPubkeyHashToInfo(bytes32 validatorPubkeyHash) external view returns (ValidatorInfo memory); /// @notice Returns the validatorInfo struct for the provided pubkey - function validatorPubkeyToInfo( - bytes calldata validatorPubkey - ) external view returns (ValidatorInfo memory); + function validatorPubkeyToInfo(bytes calldata validatorPubkey) external view returns (ValidatorInfo memory); /// @notice Returns the validator status for a given validator pubkey hash - function validatorStatus( - bytes32 pubkeyHash - ) external view returns (VALIDATOR_STATUS); + function validatorStatus(bytes32 pubkeyHash) external view returns (VALIDATOR_STATUS); /// @notice Returns the validator status for a given validator pubkey - function validatorStatus( - bytes calldata validatorPubkey - ) external view returns (VALIDATOR_STATUS); + function validatorStatus(bytes calldata validatorPubkey) external view returns (VALIDATOR_STATUS); /// @notice Number of validators with proven withdrawal credentials, who do not have proven full withdrawals function activeValidatorCount() external view returns (uint256); @@ -487,17 +469,13 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents, ISemVerMixin { /// - The final partial withdrawal for an exited validator will be likely be included in this mapping. /// i.e. if a validator was last checkpointed at 32.1 ETH before exiting, the next checkpoint will calculate their /// "exited" amount to be 32.1 ETH rather than 32 ETH. - function checkpointBalanceExitedGwei( - uint64 - ) external view returns (uint64); + function checkpointBalanceExitedGwei(uint64) external view returns (uint64); /// @notice Query the 4788 oracle to get the parent block root of the slot with the given `timestamp` /// @param timestamp of the block for which the parent block root will be returned. MUST correspond /// to an existing slot within the last 24 hours. If the slot at `timestamp` was skipped, this method /// will revert. - function getParentBlockRoot( - uint64 timestamp - ) external view returns (bytes32); + function getParentBlockRoot(uint64 timestamp) external view returns (bytes32); /// @notice Returns the fee required to add a consolidation request to the EIP-7251 predeploy this block. /// @dev Note that the predeploy updates its fee every block according to https://eips.ethereum.org/EIPS/eip-7251#fee-calculation diff --git a/mainnet-contracts/src/interface/IPufferProtocol.sol b/mainnet-contracts/src/interface/IPufferProtocol.sol index de37030d..8f88f5d3 100644 --- a/mainnet-contracts/src/interface/IPufferProtocol.sol +++ b/mainnet-contracts/src/interface/IPufferProtocol.sol @@ -224,10 +224,7 @@ interface IPufferProtocol { * @dev According to EIP-7002 there is a fee for each validator exit request (See https://eips.ethereum.org/assets/eip-7002/fee_analysis) * The fee is paid in the msg.value of this function. Since the fee is not fixed and might change, the excess amount will be kept in the PufferModule */ - function triggerValidatorsExit( - bytes32 moduleName, - uint256[] calldata indices - ) external payable; + function triggerValidatorsExit(bytes32 moduleName, uint256[] calldata indices) external payable; /** * @notice Batch settling of validator withdrawals From 737d3905b468670e490842e43bc46e942b615eca Mon Sep 17 00:00:00 2001 From: Eladio Date: Mon, 1 Dec 2025 14:18:37 +0100 Subject: [PATCH 04/55] Fixed codespell in dependecy --- .../src/interface/Eigenlayer-Slashing/IEigenPod.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol b/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol index f4288fdf..a6bd714e 100644 --- a/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol +++ b/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol @@ -84,7 +84,7 @@ interface IEigenPodErrors { interface IEigenPodTypes { enum VALIDATOR_STATUS { - INACTIVE, // doesnt exist + INACTIVE, // doesn't exist ACTIVE, // staked on ethpos and withdrawal credentials are pointed to the EigenPod WITHDRAWN // withdrawn from the Beacon Chain From 57c4cdc3f0f65547347dd3f1230f37daeaf5b20d Mon Sep 17 00:00:00 2001 From: Eladio Date: Mon, 1 Dec 2025 17:44:41 +0100 Subject: [PATCH 05/55] Fixed edge case in testing where caller is broadcaster --- mainnet-contracts/test/unit/Timelock.t.sol | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mainnet-contracts/test/unit/Timelock.t.sol b/mainnet-contracts/test/unit/Timelock.t.sol index ce6fc788..a7bab6c7 100644 --- a/mainnet-contracts/test/unit/Timelock.t.sol +++ b/mainnet-contracts/test/unit/Timelock.t.sol @@ -18,6 +18,8 @@ contract TimelockTest is Test { stETHMock public stETH; Timelock public timelock; + address public constant BROADCASTER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + function setUp() public { PufferDeployment memory deployment = new DeployPufETH().run(); @@ -33,6 +35,7 @@ contract TimelockTest is Test { vm.assume(caller != timelock.OPERATIONS_MULTISIG()); vm.assume(caller != address(timelock)); vm.assume(caller != address(accessManager)); + vm.assume(caller != BROADCASTER); // Upgrades are forbidden (bool canCall, uint32 delay) = @@ -236,10 +239,11 @@ contract TimelockTest is Test { assertTrue(!canCall, "should not be able to call"); } - function test_pause_depositor_slectors(address caller) public { + function test_pause_depositor_selectors(address caller) public { vm.startPrank(timelock.pauserMultisig()); vm.assume(caller != address(timelock)); vm.assume(caller != address(accessManager)); + vm.assume(caller != BROADCASTER); address[] memory targets = new address[](1); targets[0] = address(pufferDepositor); From 2d9089b40cd461c1e6e082c9d904d7c0e11e2125 Mon Sep 17 00:00:00 2001 From: Eladio Date: Tue, 2 Dec 2025 16:16:28 +0100 Subject: [PATCH 06/55] Implemented PMM tests --- .../test/mocks/EigenPodManagerMock.sol | 12 ++ .../test/unit/PufferModuleManager.t.sol | 114 ++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/mainnet-contracts/test/mocks/EigenPodManagerMock.sol b/mainnet-contracts/test/mocks/EigenPodManagerMock.sol index 67312086..6ea5e424 100644 --- a/mainnet-contracts/test/mocks/EigenPodManagerMock.sol +++ b/mainnet-contracts/test/mocks/EigenPodManagerMock.sol @@ -6,9 +6,21 @@ import "src/interface/Eigenlayer-Slashing/IEigenPodManager.sol"; import "src/interface/Eigenlayer-Slashing/IAllocationManager.sol"; contract EigenPodMock { + + uint256 private constant WITHDRAWAL_FEE = 0.0001 ether; + + struct WithdrawalRequest { + bytes pubkey; + uint64 amountGwei; + } + function startCheckpoint(bool) external { } function setProofSubmitter(address) external { } + + function requestWithdrawal(WithdrawalRequest[] calldata requests) external payable { + payable(msg.sender).transfer(msg.value - requests.length * WITHDRAWAL_FEE); + } } contract EigenPodManagerMock is IEigenPodManager, Test { diff --git a/mainnet-contracts/test/unit/PufferModuleManager.t.sol b/mainnet-contracts/test/unit/PufferModuleManager.t.sol index afd284a2..9bafd54c 100644 --- a/mainnet-contracts/test/unit/PufferModuleManager.t.sol +++ b/mainnet-contracts/test/unit/PufferModuleManager.t.sol @@ -5,6 +5,8 @@ import { UnitTestHelper } from "../helpers/UnitTestHelper.sol"; import { PufferModule } from "../../src/PufferModule.sol"; import { PufferProtocol } from "../../src/PufferProtocol.sol"; import { IPufferModuleManager } from "../../src/interface/IPufferModuleManager.sol"; +import { PufferModuleManager } from "../../src/PufferModuleManager.sol"; +import { IAccessManaged } from "@openzeppelin/contracts/access/manager/IAccessManaged.sol"; import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { Merkle } from "murky/Merkle.sol"; @@ -37,6 +39,10 @@ contract PufferModuleManagerTest is UnitTestHelper { bytes32 CRAZY_GAINS = bytes32("CRAZY_GAINS"); + bytes32 MOCK_MODULE = bytes32("MOCK_MODULE"); + + uint256 EXIT_FEE = 0.0001 ether; + function setUp() public override { super.setUp(); @@ -49,6 +55,11 @@ contract PufferModuleManagerTest is UnitTestHelper { (bool success,) = address(accessManager).call(cd); assertTrue(success, "should succeed"); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = PufferModuleManager.triggerValidatorsExit.selector; + accessManager.setTargetFunctionRole(address(pufferModuleManager), selectors, ROLE_ID_OPERATIONS_PAYMASTER); + + vm.stopPrank(); _skipDefaultFuzzAddresses(); @@ -335,6 +346,109 @@ contract PufferModuleManagerTest is UnitTestHelper { vm.stopPrank(); } + function test_requestWithdrawalExactFee1() public { + _createPufferModule(MOCK_MODULE); + + bytes[] memory pubkeys = new bytes[](1); + pubkeys[0] = bytes("0x1234"); + + vm.expectEmit(true, true, true, true); + emit IPufferModuleManager.ValidatorsExitTriggered(MOCK_MODULE, pubkeys); + + pufferModuleManager.triggerValidatorsExit{ value: EXIT_FEE }(MOCK_MODULE, pubkeys); + } + + function test_requestWithdrawalExactFee2() public { + _createPufferModule(MOCK_MODULE); + + bytes[] memory pubkeys = new bytes[](2); + pubkeys[0] = bytes("0x1234"); + pubkeys[1] = bytes("0x4321"); + + vm.expectEmit(true, true, true, true); + emit IPufferModuleManager.ValidatorsExitTriggered(MOCK_MODULE, pubkeys); + + pufferModuleManager.triggerValidatorsExit{ value: 2 * EXIT_FEE }(MOCK_MODULE, pubkeys); + } + + function test_requestWithdrawalExcessFee() public { + address moduleAddress = _createPufferModule(MOCK_MODULE); + + bytes[] memory pubkeys = new bytes[](1); + pubkeys[0] = bytes("0x1234"); + + uint256 initialBalance = moduleAddress.balance; + + vm.expectEmit(true, true, true, true); + emit IPufferModuleManager.ValidatorsExitTriggered(MOCK_MODULE, pubkeys); + + pufferModuleManager.triggerValidatorsExit{ value: 1 ether }(MOCK_MODULE, pubkeys); + + // Calculate expected balance: initial + amount sent - fee + uint256 expectedBalance = initialBalance + 1 ether - EXIT_FEE; + + // Verify the balance change accounting for gas + assertEq(moduleAddress.balance, expectedBalance, "Module should get the fee back minus gas costs"); + + } + + function test_requestWithdrawalExcessFee2() public { + address moduleAddress = _createPufferModule(MOCK_MODULE); + + bytes[] memory pubkeys = new bytes[](2); + pubkeys[0] = bytes("0x1234"); + pubkeys[1] = bytes("0x4321"); + + uint256 initialBalance = moduleAddress.balance; + + vm.expectEmit(true, true, true, true); + emit IPufferModuleManager.ValidatorsExitTriggered(MOCK_MODULE, pubkeys); + + pufferModuleManager.triggerValidatorsExit{ value: 1 ether }(MOCK_MODULE, pubkeys); + + // Calculate expected balance: initial + amount sent - fee + uint256 expectedBalance = initialBalance + 1 ether - 2 * EXIT_FEE; + + // Verify the balance change accounting for gas + assertEq(moduleAddress.balance, expectedBalance, "Module should get the fee back minus gas costs"); + + } + + function test_requestWithdrawalNoFee() public { + _createPufferModule(MOCK_MODULE); + + bytes[] memory pubkeys = new bytes[](1); + pubkeys[0] = bytes("0x1234"); + + vm.expectRevert(); // panic underflow when subtracting fee + pufferModuleManager.triggerValidatorsExit(MOCK_MODULE, pubkeys); + } + + function test_requestWithdrawalUnauthorized() public { + _createPufferModule(MOCK_MODULE); + + bytes[] memory pubkeys = new bytes[](1); + pubkeys[0] = bytes("0x1234"); + + vm.startPrank(bob); + + vm.expectRevert(abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, bob)); + pufferModuleManager.triggerValidatorsExit(MOCK_MODULE, pubkeys); + + vm.stopPrank(); + + } + + function test_requestWithdrawalInputArrayLengthZero() public { + _createPufferModule(MOCK_MODULE); + + bytes[] memory pubkeys = new bytes[](0); + + vm.expectRevert(abi.encodeWithSelector(IPufferModuleManager.InputArrayLengthZero.selector)); + pufferModuleManager.triggerValidatorsExit(MOCK_MODULE, pubkeys); + + } + function _createPufferModule(bytes32 moduleName) internal returns (address module) { vm.assume(pufferProtocol.getModuleAddress(moduleName) == address(0)); vm.assume(bytes32("NO_VALIDATORS") != moduleName); From 957b7d126f2bbf0d4cd05ac08669f0ef6442d1ef Mon Sep 17 00:00:00 2001 From: Eladio Date: Tue, 2 Dec 2025 16:50:43 +0100 Subject: [PATCH 07/55] Added access control to the new flows --- mainnet-contracts/script/SetupAccess.s.sol | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mainnet-contracts/script/SetupAccess.s.sol b/mainnet-contracts/script/SetupAccess.s.sol index e0349cc1..8cf03387 100644 --- a/mainnet-contracts/script/SetupAccess.s.sol +++ b/mainnet-contracts/script/SetupAccess.s.sol @@ -175,9 +175,10 @@ contract SetupAccess is BaseScript { ); // Bot selectors - bytes4[] memory botSelectors = new bytes4[](2); + bytes4[] memory botSelectors = new bytes4[](3); botSelectors[0] = PufferModuleManager.callQueueWithdrawals.selector; botSelectors[1] = PufferModuleManager.callCompleteQueuedWithdrawals.selector; + botSelectors[2] = PufferModuleManager.triggerValidatorsExit.selector; calldatas[1] = abi.encodeWithSelector( AccessManager.setTargetFunctionRole.selector, @@ -319,11 +320,12 @@ contract SetupAccess is BaseScript { ROLE_ID_OPERATIONS_PAYMASTER ); - bytes4[] memory publicSelectors = new bytes4[](4); + bytes4[] memory publicSelectors = new bytes4[](5); publicSelectors[0] = PufferProtocol.registerValidatorKey.selector; publicSelectors[1] = PufferProtocol.depositValidatorTickets.selector; publicSelectors[2] = PufferProtocol.withdrawValidatorTickets.selector; publicSelectors[3] = PufferProtocol.revertIfPaused.selector; + publicSelectors[4] = PufferProtocol.triggerValidatorsExit.selector; calldatas[2] = abi.encodeWithSelector( AccessManager.setTargetFunctionRole.selector, From 9f70b7ebfa7892fec01c740de54d8e0336d1f2c1 Mon Sep 17 00:00:00 2001 From: Eladio Date: Tue, 2 Dec 2025 17:29:30 +0100 Subject: [PATCH 08/55] Fixes access control, added tests for PufferProtocol --- mainnet-contracts/script/SetupAccess.s.sol | 36 ++++++++----- .../test/unit/PufferModuleManager.t.sol | 14 ++--- .../test/unit/PufferProtocol.t.sol | 54 ++++++++++++++++++- 3 files changed, 84 insertions(+), 20 deletions(-) diff --git a/mainnet-contracts/script/SetupAccess.s.sol b/mainnet-contracts/script/SetupAccess.s.sol index 8cf03387..a76dc58a 100644 --- a/mainnet-contracts/script/SetupAccess.s.sol +++ b/mainnet-contracts/script/SetupAccess.s.sol @@ -98,7 +98,7 @@ contract SetupAccess is BaseScript { bytes[] memory coordinatorAccess, bytes[] memory validatorTicketAccess ) internal view returns (bytes[] memory calldatas) { - calldatas = new bytes[](30); + calldatas = new bytes[](31); calldatas[0] = _setupGuardianModuleRoles(); calldatas[1] = _setupEnclaveVerifierRoles(); calldatas[2] = rolesCalldatas[0]; @@ -124,19 +124,20 @@ contract SetupAccess is BaseScript { calldatas[18] = moduleManagerAccess[0]; calldatas[19] = moduleManagerAccess[1]; + calldatas[20] = moduleManagerAccess[2]; - calldatas[20] = roleLabels[0]; - calldatas[21] = roleLabels[1]; - calldatas[22] = roleLabels[2]; - calldatas[23] = roleLabels[3]; + calldatas[21] = roleLabels[0]; + calldatas[22] = roleLabels[1]; + calldatas[23] = roleLabels[2]; + calldatas[24] = roleLabels[3]; - calldatas[24] = coordinatorAccess[0]; - calldatas[25] = coordinatorAccess[1]; + calldatas[25] = coordinatorAccess[0]; + calldatas[26] = coordinatorAccess[1]; - calldatas[26] = validatorTicketAccess[0]; - calldatas[27] = validatorTicketAccess[1]; - calldatas[28] = validatorTicketAccess[2]; - calldatas[29] = validatorTicketAccess[3]; + calldatas[27] = validatorTicketAccess[0]; + calldatas[28] = validatorTicketAccess[1]; + calldatas[29] = validatorTicketAccess[2]; + calldatas[30] = validatorTicketAccess[3]; } function _labelRoles() internal pure returns (bytes[] memory) { @@ -158,7 +159,7 @@ contract SetupAccess is BaseScript { } function _setupPufferModuleManagerAccess() internal view returns (bytes[] memory) { - bytes[] memory calldatas = new bytes[](2); + bytes[] memory calldatas = new bytes[](3); // Dao selectors bytes4[] memory selectors = new bytes4[](7); @@ -187,6 +188,17 @@ contract SetupAccess is BaseScript { ROLE_ID_OPERATIONS_PAYMASTER ); + // PufferProtocol selectors + bytes4[] memory pufferProtocolSelectors = new bytes4[](1); + pufferProtocolSelectors[0] = PufferModuleManager.triggerValidatorsExit.selector; + + calldatas[2] = abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, + pufferDeployment.moduleManager, + pufferProtocolSelectors, + ROLE_ID_PUFFER_PROTOCOL + ); + return calldatas; } diff --git a/mainnet-contracts/test/unit/PufferModuleManager.t.sol b/mainnet-contracts/test/unit/PufferModuleManager.t.sol index 9bafd54c..2854a611 100644 --- a/mainnet-contracts/test/unit/PufferModuleManager.t.sol +++ b/mainnet-contracts/test/unit/PufferModuleManager.t.sol @@ -346,7 +346,7 @@ contract PufferModuleManagerTest is UnitTestHelper { vm.stopPrank(); } - function test_requestWithdrawalExactFee1() public { + function test_triggerValidatorsExitExactFee1() public { _createPufferModule(MOCK_MODULE); bytes[] memory pubkeys = new bytes[](1); @@ -358,7 +358,7 @@ contract PufferModuleManagerTest is UnitTestHelper { pufferModuleManager.triggerValidatorsExit{ value: EXIT_FEE }(MOCK_MODULE, pubkeys); } - function test_requestWithdrawalExactFee2() public { + function test_triggerValidatorsExitExactFee2() public { _createPufferModule(MOCK_MODULE); bytes[] memory pubkeys = new bytes[](2); @@ -371,7 +371,7 @@ contract PufferModuleManagerTest is UnitTestHelper { pufferModuleManager.triggerValidatorsExit{ value: 2 * EXIT_FEE }(MOCK_MODULE, pubkeys); } - function test_requestWithdrawalExcessFee() public { + function test_triggerValidatorsExitExcessFee() public { address moduleAddress = _createPufferModule(MOCK_MODULE); bytes[] memory pubkeys = new bytes[](1); @@ -392,7 +392,7 @@ contract PufferModuleManagerTest is UnitTestHelper { } - function test_requestWithdrawalExcessFee2() public { + function test_triggerValidatorsExitExcessFee2() public { address moduleAddress = _createPufferModule(MOCK_MODULE); bytes[] memory pubkeys = new bytes[](2); @@ -414,7 +414,7 @@ contract PufferModuleManagerTest is UnitTestHelper { } - function test_requestWithdrawalNoFee() public { + function test_triggerValidatorsExitNoFee() public { _createPufferModule(MOCK_MODULE); bytes[] memory pubkeys = new bytes[](1); @@ -424,7 +424,7 @@ contract PufferModuleManagerTest is UnitTestHelper { pufferModuleManager.triggerValidatorsExit(MOCK_MODULE, pubkeys); } - function test_requestWithdrawalUnauthorized() public { + function test_triggerValidatorsExitUnauthorized() public { _createPufferModule(MOCK_MODULE); bytes[] memory pubkeys = new bytes[](1); @@ -439,7 +439,7 @@ contract PufferModuleManagerTest is UnitTestHelper { } - function test_requestWithdrawalInputArrayLengthZero() public { + function test_triggerValidatorsExitInputArrayLengthZero() public { _createPufferModule(MOCK_MODULE); bytes[] memory pubkeys = new bytes[](0); diff --git a/mainnet-contracts/test/unit/PufferProtocol.t.sol b/mainnet-contracts/test/unit/PufferProtocol.t.sol index bff105a3..8b6a55c2 100644 --- a/mainnet-contracts/test/unit/PufferProtocol.t.sol +++ b/mainnet-contracts/test/unit/PufferProtocol.t.sol @@ -5,12 +5,13 @@ import { PufferProtocolMockUpgrade } from "../mocks/PufferProtocolMockUpgrade.so import { UnitTestHelper } from "../helpers/UnitTestHelper.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { IPufferProtocol } from "../../src/interface/IPufferProtocol.sol"; +import { IPufferModuleManager } from "../../src/interface/IPufferModuleManager.sol"; import { ValidatorKeyData } from "../../src/struct/ValidatorKeyData.sol"; import { Status } from "../../src/struct/Status.sol"; import { Validator } from "../../src/struct/Validator.sol"; import { PufferProtocol } from "../../src/PufferProtocol.sol"; import { PufferModule } from "../../src/PufferModule.sol"; -import { ROLE_ID_DAO, ROLE_ID_OPERATIONS_PAYMASTER, ROLE_ID_OPERATIONS_MULTISIG } from "../../script/Roles.sol"; +import { ROLE_ID_DAO, ROLE_ID_OPERATIONS_PAYMASTER, ROLE_ID_OPERATIONS_MULTISIG, ROLE_ID_PUFFER_PROTOCOL } from "../../script/Roles.sol"; import { Unauthorized } from "../../src/Errors.sol"; import { LibGuardianMessages } from "../../src/LibGuardianMessages.sol"; import { Permit } from "../../src/structs/Permit.sol"; @@ -30,6 +31,7 @@ contract PufferProtocolTest is UnitTestHelper { bytes32 constant EIGEN_DA = bytes32("EIGEN_DA"); bytes32 constant CRAZY_GAINS = bytes32("CRAZY_GAINS"); bytes32 constant DEFAULT_DEPOSIT_ROOT = bytes32("depositRoot"); + uint256 EXIT_FEE = 0.0001 ether; Permit emptyPermit; @@ -65,6 +67,7 @@ contract PufferProtocolTest is UnitTestHelper { accessManager.grantRole(ROLE_ID_OPERATIONS_MULTISIG, address(this), 0); accessManager.grantRole(ROLE_ID_OPERATIONS_PAYMASTER, address(this), 0); accessManager.grantRole(ROLE_ID_OPERATIONS_MULTISIG, address(this), 0); + accessManager.grantRole(ROLE_ID_PUFFER_PROTOCOL, address(pufferProtocol), 0); vm.stopPrank(); _skipDefaultFuzzAddresses(); @@ -1854,6 +1857,55 @@ contract PufferProtocolTest is UnitTestHelper { assertEq(validatorTicket.balanceOf(bob), 50 ether, "bob got the VT"); } + function test_triggerValidatorsExit_InvalidValidator() public { + bytes32 pubKeyPart = bytes32("alice"); + vm.deal(alice, 10 ether); + + vm.startPrank(alice); + _registerValidatorKey(pubKeyPart, PUFFER_MODULE_0); + vm.stopPrank(); + + (, uint256 index) = pufferProtocol.getNextValidatorToProvision(); + + pufferProtocol.provisionNode( + _getGuardianSignatures(_getPubKey(pubKeyPart)), _validatorSignature(), DEFAULT_DEPOSIT_ROOT + ); + + uint256[] memory indices = new uint256[](1); + indices[0] = index; + vm.startPrank(bob); + vm.expectRevert(abi.encodeWithSelector(IPufferProtocol.InvalidValidator.selector)); + pufferProtocol.triggerValidatorsExit(PUFFER_MODULE_0, indices); + vm.stopPrank(); + + } + + function test_triggerValidatorsExit_1validator() public { + bytes32 pubKeyPart = bytes32("alice"); + bytes memory pubKey = _getPubKey(pubKeyPart); + bytes[] memory pubKeys = new bytes[](1); + pubKeys[0] = pubKey; + vm.deal(alice, 10 ether); + + vm.startPrank(alice); + _registerValidatorKey(pubKeyPart, PUFFER_MODULE_0); + vm.stopPrank(); + + (, uint256 index) = pufferProtocol.getNextValidatorToProvision(); + + pufferProtocol.provisionNode( + _getGuardianSignatures(pubKey), _validatorSignature(), DEFAULT_DEPOSIT_ROOT + ); + + uint256[] memory indices = new uint256[](1); + indices[0] = index; + vm.startPrank(alice); + emit IPufferModuleManager.ValidatorsExitTriggered(PUFFER_MODULE_0, pubKeys); + pufferProtocol.triggerValidatorsExit{value: EXIT_FEE}(PUFFER_MODULE_0, indices); + vm.stopPrank(); + + } + function _getGuardianSignatures(bytes memory pubKey) internal view returns (bytes[] memory) { (bytes32 moduleName, uint256 pendingIdx) = pufferProtocol.getNextValidatorToProvision(); Validator memory validator = pufferProtocol.getValidatorInfo(moduleName, pendingIdx); From efe3eb2fe67a22019d68c9f5c980388bb9d7aa06 Mon Sep 17 00:00:00 2001 From: Eladio Date: Wed, 3 Dec 2025 09:42:17 +0100 Subject: [PATCH 09/55] Implemented extra tests --- .../test/mocks/EigenPodManagerMock.sol | 1 - .../test/unit/PufferModuleManager.t.sol | 5 -- .../test/unit/PufferProtocol.t.sol | 88 +++++++++++++++++-- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/mainnet-contracts/test/mocks/EigenPodManagerMock.sol b/mainnet-contracts/test/mocks/EigenPodManagerMock.sol index 6ea5e424..054c9ef7 100644 --- a/mainnet-contracts/test/mocks/EigenPodManagerMock.sol +++ b/mainnet-contracts/test/mocks/EigenPodManagerMock.sol @@ -6,7 +6,6 @@ import "src/interface/Eigenlayer-Slashing/IEigenPodManager.sol"; import "src/interface/Eigenlayer-Slashing/IAllocationManager.sol"; contract EigenPodMock { - uint256 private constant WITHDRAWAL_FEE = 0.0001 ether; struct WithdrawalRequest { diff --git a/mainnet-contracts/test/unit/PufferModuleManager.t.sol b/mainnet-contracts/test/unit/PufferModuleManager.t.sol index 2854a611..1f63aeed 100644 --- a/mainnet-contracts/test/unit/PufferModuleManager.t.sol +++ b/mainnet-contracts/test/unit/PufferModuleManager.t.sol @@ -59,7 +59,6 @@ contract PufferModuleManagerTest is UnitTestHelper { selectors[0] = PufferModuleManager.triggerValidatorsExit.selector; accessManager.setTargetFunctionRole(address(pufferModuleManager), selectors, ROLE_ID_OPERATIONS_PAYMASTER); - vm.stopPrank(); _skipDefaultFuzzAddresses(); @@ -389,7 +388,6 @@ contract PufferModuleManagerTest is UnitTestHelper { // Verify the balance change accounting for gas assertEq(moduleAddress.balance, expectedBalance, "Module should get the fee back minus gas costs"); - } function test_triggerValidatorsExitExcessFee2() public { @@ -411,7 +409,6 @@ contract PufferModuleManagerTest is UnitTestHelper { // Verify the balance change accounting for gas assertEq(moduleAddress.balance, expectedBalance, "Module should get the fee back minus gas costs"); - } function test_triggerValidatorsExitNoFee() public { @@ -436,7 +433,6 @@ contract PufferModuleManagerTest is UnitTestHelper { pufferModuleManager.triggerValidatorsExit(MOCK_MODULE, pubkeys); vm.stopPrank(); - } function test_triggerValidatorsExitInputArrayLengthZero() public { @@ -446,7 +442,6 @@ contract PufferModuleManagerTest is UnitTestHelper { vm.expectRevert(abi.encodeWithSelector(IPufferModuleManager.InputArrayLengthZero.selector)); pufferModuleManager.triggerValidatorsExit(MOCK_MODULE, pubkeys); - } function _createPufferModule(bytes32 moduleName) internal returns (address module) { diff --git a/mainnet-contracts/test/unit/PufferProtocol.t.sol b/mainnet-contracts/test/unit/PufferProtocol.t.sol index 8b6a55c2..db6b8844 100644 --- a/mainnet-contracts/test/unit/PufferProtocol.t.sol +++ b/mainnet-contracts/test/unit/PufferProtocol.t.sol @@ -11,7 +11,12 @@ import { Status } from "../../src/struct/Status.sol"; import { Validator } from "../../src/struct/Validator.sol"; import { PufferProtocol } from "../../src/PufferProtocol.sol"; import { PufferModule } from "../../src/PufferModule.sol"; -import { ROLE_ID_DAO, ROLE_ID_OPERATIONS_PAYMASTER, ROLE_ID_OPERATIONS_MULTISIG, ROLE_ID_PUFFER_PROTOCOL } from "../../script/Roles.sol"; +import { + ROLE_ID_DAO, + ROLE_ID_OPERATIONS_PAYMASTER, + ROLE_ID_OPERATIONS_MULTISIG, + ROLE_ID_PUFFER_PROTOCOL +} from "../../script/Roles.sol"; import { Unauthorized } from "../../src/Errors.sol"; import { LibGuardianMessages } from "../../src/LibGuardianMessages.sol"; import { Permit } from "../../src/structs/Permit.sol"; @@ -1877,7 +1882,6 @@ contract PufferProtocolTest is UnitTestHelper { vm.expectRevert(abi.encodeWithSelector(IPufferProtocol.InvalidValidator.selector)); pufferProtocol.triggerValidatorsExit(PUFFER_MODULE_0, indices); vm.stopPrank(); - } function test_triggerValidatorsExit_1validator() public { @@ -1893,17 +1897,89 @@ contract PufferProtocolTest is UnitTestHelper { (, uint256 index) = pufferProtocol.getNextValidatorToProvision(); - pufferProtocol.provisionNode( - _getGuardianSignatures(pubKey), _validatorSignature(), DEFAULT_DEPOSIT_ROOT - ); + pufferProtocol.provisionNode(_getGuardianSignatures(pubKey), _validatorSignature(), DEFAULT_DEPOSIT_ROOT); uint256[] memory indices = new uint256[](1); indices[0] = index; vm.startPrank(alice); emit IPufferModuleManager.ValidatorsExitTriggered(PUFFER_MODULE_0, pubKeys); - pufferProtocol.triggerValidatorsExit{value: EXIT_FEE}(PUFFER_MODULE_0, indices); + pufferProtocol.triggerValidatorsExit{ value: EXIT_FEE }(PUFFER_MODULE_0, indices); + vm.stopPrank(); + } + + function test_triggerValidatorsExit_2validators() public { + bytes32 pubKeyPart1 = bytes32("alice"); + bytes memory pubKey1 = _getPubKey(pubKeyPart1); + bytes32 pubKeyPart2 = bytes32("alice2"); + bytes memory pubKey2 = _getPubKey(pubKeyPart2); + bytes[] memory pubKeys = new bytes[](2); + pubKeys[0] = pubKey1; + pubKeys[1] = pubKey2; + + vm.deal(alice, 10 ether); + + vm.startPrank(alice); + _registerValidatorKey(pubKeyPart1, PUFFER_MODULE_0); + (, uint256 index1) = pufferProtocol.getNextValidatorToProvision(); + _registerValidatorKey(pubKeyPart2, PUFFER_MODULE_0); + (, uint256 index2) = pufferProtocol.getNextValidatorToProvision(); + vm.stopPrank(); + + pufferProtocol.provisionNode(_getGuardianSignatures(pubKey1), _validatorSignature(), DEFAULT_DEPOSIT_ROOT); + + pufferProtocol.provisionNode(_getGuardianSignatures(pubKey2), _validatorSignature(), DEFAULT_DEPOSIT_ROOT); + + uint256[] memory indices = new uint256[](2); + indices[0] = index1; + indices[0] = index2; + vm.startPrank(alice); + emit IPufferModuleManager.ValidatorsExitTriggered(PUFFER_MODULE_0, pubKeys); + pufferProtocol.triggerValidatorsExit{ value: 2 * EXIT_FEE }(PUFFER_MODULE_0, indices); vm.stopPrank(); + } + function test_triggerValidatorsExit_InputArrayLengthZero() public { + bytes32 pubKeyPart = bytes32("alice"); + bytes memory pubKey = _getPubKey(pubKeyPart); + bytes[] memory pubKeys = new bytes[](1); + pubKeys[0] = pubKey; + vm.deal(alice, 10 ether); + + vm.startPrank(alice); + _registerValidatorKey(pubKeyPart, PUFFER_MODULE_0); + vm.stopPrank(); + + pufferProtocol.provisionNode(_getGuardianSignatures(pubKey), _validatorSignature(), DEFAULT_DEPOSIT_ROOT); + + uint256[] memory indices = new uint256[](0); + vm.startPrank(alice); + vm.expectRevert(abi.encodeWithSelector(IPufferModuleManager.InputArrayLengthZero.selector)); + pufferProtocol.triggerValidatorsExit{ value: EXIT_FEE }(PUFFER_MODULE_0, indices); + vm.stopPrank(); + } + + function test_triggerValidators_ExitNoFee() public { + bytes32 pubKeyPart = bytes32("alice"); + bytes memory pubKey = _getPubKey(pubKeyPart); + bytes[] memory pubKeys = new bytes[](1); + pubKeys[0] = pubKey; + vm.deal(alice, 10 ether); + + vm.startPrank(alice); + _registerValidatorKey(pubKeyPart, PUFFER_MODULE_0); + vm.stopPrank(); + + (, uint256 index) = pufferProtocol.getNextValidatorToProvision(); + + pufferProtocol.provisionNode(_getGuardianSignatures(pubKey), _validatorSignature(), DEFAULT_DEPOSIT_ROOT); + + uint256[] memory indices = new uint256[](1); + indices[0] = index; + vm.startPrank(alice); + + vm.expectRevert(); // panic underflow when subtracting fee + pufferProtocol.triggerValidatorsExit(PUFFER_MODULE_0, indices); + vm.stopPrank(); } function _getGuardianSignatures(bytes memory pubKey) internal view returns (bytes[] memory) { From 6aa3c9a3ef8c13899a7120f970f1ad226b05b626 Mon Sep 17 00:00:00 2001 From: eladio Date: Tue, 9 Dec 2025 12:45:54 +0100 Subject: [PATCH 10/55] Added new role for validator ejection --- mainnet-contracts/script/Roles.sol | 1 + mainnet-contracts/script/SetupAccess.s.sol | 16 ++++++++-------- .../test/unit/PufferModuleManager.t.sol | 7 ++----- mainnet-contracts/test/unit/PufferProtocol.t.sol | 6 +++--- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/mainnet-contracts/script/Roles.sol b/mainnet-contracts/script/Roles.sol index f969b0b8..ca1548c3 100644 --- a/mainnet-contracts/script/Roles.sol +++ b/mainnet-contracts/script/Roles.sol @@ -13,6 +13,7 @@ uint64 constant ROLE_ID_OPERATIONS_PAYMASTER = 23; uint64 constant ROLE_ID_OPERATIONS_COORDINATOR = 24; uint64 constant ROLE_ID_WITHDRAWAL_FINALIZER = 25; uint64 constant ROLE_ID_REVENUE_DEPOSITOR = 26; +uint64 constant ROLE_ID_VALIDATOR_EJECTOR = 28; // Role assigned to validator ticket price setter uint64 constant ROLE_ID_VT_PRICER = 25; diff --git a/mainnet-contracts/script/SetupAccess.s.sol b/mainnet-contracts/script/SetupAccess.s.sol index a76dc58a..502ab976 100644 --- a/mainnet-contracts/script/SetupAccess.s.sol +++ b/mainnet-contracts/script/SetupAccess.s.sol @@ -29,7 +29,8 @@ import { ROLE_ID_PUFFER_PROTOCOL, ROLE_ID_DAO, ROLE_ID_OPERATIONS_COORDINATOR, - ROLE_ID_VT_PRICER + ROLE_ID_VT_PRICER, + ROLE_ID_VALIDATOR_EJECTOR } from "../script/Roles.sol"; contract SetupAccess is BaseScript { @@ -176,10 +177,9 @@ contract SetupAccess is BaseScript { ); // Bot selectors - bytes4[] memory botSelectors = new bytes4[](3); + bytes4[] memory botSelectors = new bytes4[](2); botSelectors[0] = PufferModuleManager.callQueueWithdrawals.selector; botSelectors[1] = PufferModuleManager.callCompleteQueuedWithdrawals.selector; - botSelectors[2] = PufferModuleManager.triggerValidatorsExit.selector; calldatas[1] = abi.encodeWithSelector( AccessManager.setTargetFunctionRole.selector, @@ -188,15 +188,15 @@ contract SetupAccess is BaseScript { ROLE_ID_OPERATIONS_PAYMASTER ); - // PufferProtocol selectors - bytes4[] memory pufferProtocolSelectors = new bytes4[](1); - pufferProtocolSelectors[0] = PufferModuleManager.triggerValidatorsExit.selector; + // Validator Ejector selectors + bytes4[] memory validatorEjectorSelectors = new bytes4[](1); + validatorEjectorSelectors[0] = PufferModuleManager.triggerValidatorsExit.selector; calldatas[2] = abi.encodeWithSelector( AccessManager.setTargetFunctionRole.selector, pufferDeployment.moduleManager, - pufferProtocolSelectors, - ROLE_ID_PUFFER_PROTOCOL + validatorEjectorSelectors, + ROLE_ID_VALIDATOR_EJECTOR ); return calldatas; diff --git a/mainnet-contracts/test/unit/PufferModuleManager.t.sol b/mainnet-contracts/test/unit/PufferModuleManager.t.sol index 1f63aeed..0f72ac02 100644 --- a/mainnet-contracts/test/unit/PufferModuleManager.t.sol +++ b/mainnet-contracts/test/unit/PufferModuleManager.t.sol @@ -12,7 +12,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { Merkle } from "murky/Merkle.sol"; import { ISignatureUtils } from "src/interface/Eigenlayer-Slashing/ISignatureUtils.sol"; import { Unauthorized } from "../../src/Errors.sol"; -import { ROLE_ID_OPERATIONS_PAYMASTER } from "../../script/Roles.sol"; +import { ROLE_ID_OPERATIONS_PAYMASTER, ROLE_ID_VALIDATOR_EJECTOR } from "../../script/Roles.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IDelegationManager } from "src/interface/Eigenlayer-Slashing/IDelegationManager.sol"; import { IDelegationManagerTypes } from "src/interface/Eigenlayer-Slashing/IDelegationManager.sol"; @@ -52,13 +52,10 @@ contract PufferModuleManagerTest is UnitTestHelper { vm.startPrank(timelock); accessManager.grantRole(ROLE_ID_OPERATIONS_PAYMASTER, address(this), 0); + accessManager.grantRole(ROLE_ID_VALIDATOR_EJECTOR, address(this), 0); (bool success,) = address(accessManager).call(cd); assertTrue(success, "should succeed"); - bytes4[] memory selectors = new bytes4[](1); - selectors[0] = PufferModuleManager.triggerValidatorsExit.selector; - accessManager.setTargetFunctionRole(address(pufferModuleManager), selectors, ROLE_ID_OPERATIONS_PAYMASTER); - vm.stopPrank(); _skipDefaultFuzzAddresses(); diff --git a/mainnet-contracts/test/unit/PufferProtocol.t.sol b/mainnet-contracts/test/unit/PufferProtocol.t.sol index db6b8844..9747c71c 100644 --- a/mainnet-contracts/test/unit/PufferProtocol.t.sol +++ b/mainnet-contracts/test/unit/PufferProtocol.t.sol @@ -15,7 +15,8 @@ import { ROLE_ID_DAO, ROLE_ID_OPERATIONS_PAYMASTER, ROLE_ID_OPERATIONS_MULTISIG, - ROLE_ID_PUFFER_PROTOCOL + ROLE_ID_PUFFER_PROTOCOL, + ROLE_ID_VALIDATOR_EJECTOR } from "../../script/Roles.sol"; import { Unauthorized } from "../../src/Errors.sol"; import { LibGuardianMessages } from "../../src/LibGuardianMessages.sol"; @@ -71,8 +72,7 @@ contract PufferProtocolTest is UnitTestHelper { accessManager.grantRole(ROLE_ID_DAO, address(this), 0); accessManager.grantRole(ROLE_ID_OPERATIONS_MULTISIG, address(this), 0); accessManager.grantRole(ROLE_ID_OPERATIONS_PAYMASTER, address(this), 0); - accessManager.grantRole(ROLE_ID_OPERATIONS_MULTISIG, address(this), 0); - accessManager.grantRole(ROLE_ID_PUFFER_PROTOCOL, address(pufferProtocol), 0); + accessManager.grantRole(ROLE_ID_VALIDATOR_EJECTOR, address(pufferProtocol), 0); vm.stopPrank(); _skipDefaultFuzzAddresses(); From 6fed931aabb873be2ebd15a45d6a8c36cb8dd3fb Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 20 Jan 2026 13:20:21 +0530 Subject: [PATCH 11/55] feat: init designing of permissioned validator --- mainnet-contracts/src/PufferProtocol.sol | 43 ++++++++++++++++++- .../src/struct/ProtocolStorage.sol | 9 +++- mainnet-contracts/src/struct/Validator.sol | 8 ++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index 76c0bc60..3c42cfe0 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -11,7 +11,7 @@ import { IPufferOracleV2 } from "./interface/IPufferOracleV2.sol"; import { IGuardianModule } from "./interface/IGuardianModule.sol"; import { IBeaconDepositContract } from "./interface/IBeaconDepositContract.sol"; import { ValidatorKeyData } from "./struct/ValidatorKeyData.sol"; -import { Validator } from "./struct/Validator.sol"; +import { Validator, PermissionedValidator } from "./struct/Validator.sol"; import { Permit } from "./structs/Permit.sol"; import { Status } from "./struct/Status.sol"; import { ProtocolStorage, NodeInfo, ModuleLimit } from "./struct/ProtocolStorage.sol"; @@ -247,6 +247,32 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad }); } + /** + * @notice restricted to new role for permissioned validator registration + **/ + function registerPermissionedValidatorKey( + ValidatorKeyData calldata data, + bytes32 moduleName, + bool isNonRestaked + ) external restricted returns (uint256 index) { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + + index = $.pendingPermissionedValidatorIndices[moduleName]; + + $.permissionedValidators[moduleName][index] = PermissionedValidator({ + pubKey: data.blsPubKey, + status: Status.PENDING, + module: address($.modules[moduleName]), //@todo: check if this is correct + node: msg.sender, + isNonRestaked: isNonRestaked + }); + unchecked { + ++$.pendingPermissionedValidatorIndices[moduleName]; + } + + // emit PermissionedValidatorRegistered(data.blsPubKey, index, moduleName, isNonRestaked); + } + /** * @inheritdoc IPufferProtocol * @dev Restricted to Puffer Paymaster @@ -288,6 +314,21 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad $.validators[moduleName][index].status = Status.ACTIVE; } + function provisionPermissionedValidator( + bytes[] calldata guardianEnclaveSignatures, + bytes calldata validatorSignature, + bytes32 depositRootHash + )external restricted{ + if (depositRootHash != BEACON_DEPOSIT_CONTRACT.get_deposit_root()) { + revert InvalidDepositRootHash(); + } + + ProtocolStorage storage $ = _getPufferProtocolStorage(); + + + + } + /** * @inheritdoc IPufferProtocol * @dev Restricted in this context is like `whenNotPaused` modifier from Pausable.sol diff --git a/mainnet-contracts/src/struct/ProtocolStorage.sol b/mainnet-contracts/src/struct/ProtocolStorage.sol index c87d18e2..75c1e2ec 100644 --- a/mainnet-contracts/src/struct/ProtocolStorage.sol +++ b/mainnet-contracts/src/struct/ProtocolStorage.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; -import { Validator } from "../struct/Validator.sol"; +import { Validator, PermissionedValidator } from "../struct/Validator.sol"; import { NodeInfo } from "../struct/NodeInfo.sol"; import { PufferModule } from "../PufferModule.sol"; /** @@ -67,6 +67,13 @@ struct ProtocolStorage { * Slot 9 */ uint256 vtPenalty; + + mapping(bytes32 moduleName => mapping(uint256 index => PermissionedValidator validator)) permissionedValidators; + + mapping(bytes32 moduleName => uint256 pendingPermissionedValidatorIndex) pendingPermissionedValidatorIndices; + mapping(bytes32 moduleName => uint256 nextPermissionedValidatorToBeProvisionedIndex) nextPermissionedValidatorToBeProvisionedIndices; + + } struct ModuleLimit { diff --git a/mainnet-contracts/src/struct/Validator.sol b/mainnet-contracts/src/struct/Validator.sol index f1bddf25..8f1d8c88 100644 --- a/mainnet-contracts/src/struct/Validator.sol +++ b/mainnet-contracts/src/struct/Validator.sol @@ -13,3 +13,11 @@ struct Validator { Status status; // Validator status bytes pubKey; // Validator public key } + +struct PermissionedValidator { + address node; // Address of the Node operator + address module; // In which module is the Validator participating + Status status; // Validator status + bytes pubKey; // Validator public key + bool isNonRestaked; +} From e99bdcec112429f6aa8e69305a88f15e7f50bda5 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 20 Jan 2026 13:43:33 +0530 Subject: [PATCH 12/55] feat: add NonRestakingWithdrawalCredentials contract --- .../src/NonRestakingWithdrawalCredentials.sol | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol diff --git a/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol b/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol new file mode 100644 index 00000000..535235d2 --- /dev/null +++ b/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { IEigenPodTypes } from "./interface/Eigenlayer-Slashing/IEigenPod.sol"; +import { AccessManaged } from "@openzeppelin/contracts/access/manager/AccessManaged.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { Unauthorized } from "./Errors.sol"; + +/** + * @title NonRestakingWithdrawalCredentials + * @author Puffer Finance + * @notice Non-restaked validators should point the withdrawal credentials to this contract + * @custom:security-contact security@puffer.fi + */ +contract NonRestakingWithdrawalCredentials is AccessManaged { + using Address for address payable; + + /** + * @notice Event emitted when a validator is requested to be switched to compounding withdrawal credentials + * @param pubkey The public key of the validator + */ + event SwitchToCompoundingWithdrawalCredentials(bytes pubkey); + + /** + * @notice Event emitted when a withdrawal request is made + * @param pubkey The public key of the validator + * @param amountGwei The amount of ETH to withdraw (in Gwei) + */ + event WithdrawalRequested(bytes pubkey, uint256 indexed amountGwei); + + /** + * @notice Event emitted when a consolidation request is made + * @param srcPubkey The public key of the source validator + * @param targetPubkey The public key of the target validator + */ + event ConsolidationRequested(bytes srcPubkey, bytes targetPubkey); + + /** + * @notice Thrown if the sender did not send enough ETH to cover the fee + */ + error NotEnoughETH(); + + /** + * @notice Thrown if the withdrawal request fails + */ + error WithdrawalRequestFailed(); + + /** + * @notice Thrown if the consolidation request fails + */ + error ConsolidationRequestFailed(); + + /** + * @notice Thrown if the fee query fails + */ + error FeeQueryFailed(); + + // https://eips.ethereum.org/EIPS/eip-7002 + address internal constant WITHDRAWAL_REQUEST_ADDRESS = 0x00000961Ef480Eb55e80D19ad83579A64c007002; + // https://eips.ethereum.org/EIPS/eip-7251 + address internal constant CONSOLIDATION_REQUEST_ADDRESS = 0x0000BBdDc7CE488642fb579F8B00f3a590007251; + + /** + * @notice The address of the PermissionedModule that owns this contract + */ + address public immutable PERMISSIONED_MODULE; + + constructor(address permissionedModule, address accessManager) AccessManaged(accessManager) { + PERMISSIONED_MODULE = permissionedModule; + } + + /** + * @notice Allow contract to receive ETH from Beacon Chain withdrawals + */ + receive() external payable { } + + /** + * @notice Withdraw accumulated ETH to the PermissionedModule + * @dev Only callable by the PermissionedModule + */ + function withdrawETH() external { + if (msg.sender != PERMISSIONED_MODULE) { + revert Unauthorized(); + } + payable(PERMISSIONED_MODULE).sendValue(address(this).balance); + } + + /** + * @notice Request a withdrawal of validators via EIP-7002 + * @param requests The requests to withdraw + * @dev Restricted to authorized callers via AccessManager + */ + function requestWithdrawal(IEigenPodTypes.WithdrawalRequest[] calldata requests) external payable restricted { + uint256 fee = getWithdrawalRequestFee(); + // The remainder is donated and not refunded to the caller + if (msg.value < fee * requests.length) { + revert NotEnoughETH(); + } + + for (uint256 i = 0; i < requests.length; ++i) { + // We don't need to validate the length of the pubkeys as the precompile will revert if the pubkeys are of invalid length + bytes memory callData = abi.encodePacked(requests[i].pubkey, requests[i].amountGwei); + (bool ok,) = WITHDRAWAL_REQUEST_ADDRESS.call{ value: fee }(callData); + if (!ok) { + revert WithdrawalRequestFailed(); + } + emit WithdrawalRequested(requests[i].pubkey, requests[i].amountGwei); + } + } + + /** + * @notice Request consolidation of validators via EIP-7251 + * It is possible to consolidate a validator to itself, which will switch the withdrawal credentials to compounding withdrawal credentials (0x01 -> 0x02) + * It is also possible to consolidate a validator from this withdrawal credentials to another withdrawal credentials + * @dev We do not validate if the source validator belongs to this contract + * @param requests The requests to consolidate + */ + function requestConsolidation(IEigenPodTypes.ConsolidationRequest[] calldata requests) + external + payable + restricted + { + uint256 fee = getConsolidationRequestFee(); + // The remainder is donated and not refunded to the caller + if (msg.value < fee * requests.length) { + revert NotEnoughETH(); + } + + for (uint256 i = 0; i < requests.length; ++i) { + IEigenPodTypes.ConsolidationRequest calldata request = requests[i]; + // We don't need to validate the length of the pubkeys as the precompile will revert if the pubkeys are invalid + // The precompile just checks for the keys length, it doesn't check if it is an active validator + + bytes memory callData = bytes.concat(request.srcPubkey, request.targetPubkey); + (bool ok,) = CONSOLIDATION_REQUEST_ADDRESS.call{ value: fee }(callData); + if (!ok) { + revert ConsolidationRequestFailed(); + } + + // Emit event depending on whether this is a switch to 0x02, or a regular consolidation + if (keccak256(request.srcPubkey) == keccak256(request.targetPubkey)) { + emit SwitchToCompoundingWithdrawalCredentials(request.srcPubkey); + } else { + emit ConsolidationRequested(request.srcPubkey, request.targetPubkey); + } + } + } + + /** + * @notice Get the fee for a consolidation request + * @return The fee for a consolidation request + */ + function getConsolidationRequestFee() public view returns (uint256) { + return _getFee(CONSOLIDATION_REQUEST_ADDRESS); + } + + /** + * @notice Get the fee for a withdrawal request + * @return The fee for a withdrawal request + */ + function getWithdrawalRequestFee() public view returns (uint256) { + return _getFee(WITHDRAWAL_REQUEST_ADDRESS); + } + + /** + * @notice Get the fee for a request + * @param predeploy The address of the predeploy + * @return The fee for a request + */ + function _getFee(address predeploy) internal view returns (uint256) { + (bool success, bytes memory result) = predeploy.staticcall(""); + if (!success || result.length != 32) { + revert FeeQueryFailed(); + } + return uint256(bytes32(result)); + } +} From 9fd53a52f922a776c5021b6eb86df5e1e3eee19d Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 20 Jan 2026 13:44:13 +0530 Subject: [PATCH 13/55] feat: add IPermissionedModule interface --- .../src/interface/IPermissionedModule.sol | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 mainnet-contracts/src/interface/IPermissionedModule.sol diff --git a/mainnet-contracts/src/interface/IPermissionedModule.sol b/mainnet-contracts/src/interface/IPermissionedModule.sol new file mode 100644 index 00000000..210f6a12 --- /dev/null +++ b/mainnet-contracts/src/interface/IPermissionedModule.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { ISignatureUtils } from "./Eigenlayer-Slashing/ISignatureUtils.sol"; +import { IDelegationManagerTypes } from "./Eigenlayer-Slashing/IDelegationManager.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title IPermissionedModule + * @author Puffer Finance + * @notice Interface for the PermissionedModule contract that supports both restaked and non-restaked validators + * @custom:security-contact security@puffer.fi + */ +interface IPermissionedModule { + /** + * @notice Emitted when the non-restaking withdrawal credentials contract is set + */ + event NonRestakingWithdrawalCredentialsSet(address indexed withdrawalCredentials); + + /** + * @notice Stakes a validator via EigenLayer (restaked path) + * @param pubKey The validator's public key + * @param signature The validator's signature + * @param depositDataRoot The deposit data root + */ + function callStakeRestaked(bytes calldata pubKey, bytes calldata signature, bytes32 depositDataRoot) + external + payable; + + /** + * @notice Stakes a validator directly to Beacon Chain (non-restaked path) + * @param pubKey The validator's public key + * @param signature The validator's signature + * @param depositDataRoot The deposit data root + */ + function callStakeNonRestaked(bytes calldata pubKey, bytes calldata signature, bytes32 depositDataRoot) + external + payable; + + /** + * @notice Returns the withdrawal credentials for restaked validators (EigenPod) + * @return The withdrawal credentials bytes + */ + function getRestakingWithdrawalCredentials() external view returns (bytes memory); + + /** + * @notice Returns the withdrawal credentials for non-restaked validators + * @return The withdrawal credentials bytes + */ + function getNonRestakingWithdrawalCredentials() external view returns (bytes memory); + + /** + * @notice Returns the EigenPod address owned by the module + * @return The EigenPod address + */ + function getEigenPod() external view returns (address); + + /** + * @notice Returns the non-restaking withdrawal credentials contract address + * @return The NonRestakingWithdrawalCredentials contract address + */ + function getNonRestakingWithdrawalCredentialsContract() external view returns (address); + + /** + * @notice Returns the module name + * @return The module name as bytes32 + */ + function NAME() external view returns (bytes32); + + /** + * @notice Queues the withdrawal from EigenLayer for the Beacon Chain strategy + * @param shareAmount The amount of shares to withdraw + * @return The withdrawal roots + */ + function queueWithdrawals(uint256 shareAmount) external returns (bytes32[] memory); + + /** + * @notice Completes the queued withdrawals from EigenLayer + * @param withdrawals The withdrawals to complete + * @param tokens The tokens to receive + * @param receiveAsTokens Whether to receive as tokens + */ + function completeQueuedWithdrawals( + IDelegationManagerTypes.Withdrawal[] calldata withdrawals, + IERC20[][] calldata tokens, + bool[] calldata receiveAsTokens + ) external; + + /** + * @notice Delegates to an EigenLayer operator + * @param operator The operator address + * @param approverSignatureAndExpiry The approver signature and expiry + * @param approverSalt The approver salt + */ + function callDelegateTo( + address operator, + ISignatureUtils.SignatureWithExpiry calldata approverSignatureAndExpiry, + bytes32 approverSalt + ) external; + + /** + * @notice Undelegates from the current EigenLayer operator + * @return The withdrawal roots + */ + function callUndelegate() external returns (bytes32[] memory); + + /** + * @notice Triggers the validators exit for the given pubkeys (restaked validators via EigenPod) + * @param pubkeys The pubkeys of the validators to exit + */ + function triggerRestakedValidatorsExit(bytes[] calldata pubkeys) external payable; + + /** + * @notice Withdraws accumulated ETH from non-restaking withdrawal credentials to this module + */ + function withdrawNonRestakedETH() external; + + /** + * @notice Sets the proof submitter on the EigenPod + * @param proofSubmitter The address of the proof submitter + */ + function setProofSubmitter(address proofSubmitter) external; + + /** + * @notice Sets the rewards claimer for EigenLayer rewards + * @param claimer The address of the claimer + */ + function callSetClaimerFor(address claimer) external; + + /** + * @notice Executes a custom call from the module + * @param to The target address + * @param amount The ETH amount to send + * @param data The call data + * @return success Whether the call succeeded + * @return returnData The return data from the call + */ + function call(address to, uint256 amount, bytes calldata data) external returns (bool success, bytes memory); +} From 051c2ff76203ba3a674f7d87c2153f35ca4dc8d1 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 20 Jan 2026 13:47:23 +0530 Subject: [PATCH 14/55] feat: add PermissionedModule contract --- mainnet-contracts/src/PermissionedModule.sol | 309 +++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 mainnet-contracts/src/PermissionedModule.sol diff --git a/mainnet-contracts/src/PermissionedModule.sol b/mainnet-contracts/src/PermissionedModule.sol new file mode 100644 index 00000000..1f7469a9 --- /dev/null +++ b/mainnet-contracts/src/PermissionedModule.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { AccessManagedUpgradeable } from + "@openzeppelin/contracts-upgradeable/access/manager/AccessManagedUpgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { IDelegationManager, IDelegationManagerTypes } from "./interface/Eigenlayer-Slashing/IDelegationManager.sol"; +import { IEigenPodManager } from "./interface/Eigenlayer-Slashing/IEigenPodManager.sol"; +import { ISignatureUtils } from "./interface/Eigenlayer-Slashing/ISignatureUtils.sol"; +import { IStrategy } from "./interface/Eigenlayer-Slashing/IStrategy.sol"; +import { IEigenPod, IEigenPodTypes } from "./interface/Eigenlayer-Slashing/IEigenPod.sol"; +import { IRewardsCoordinator } from "./interface/Eigenlayer-Slashing/IRewardsCoordinator.sol"; +import { IBeaconDepositContract } from "./interface/IBeaconDepositContract.sol"; +import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; +import { IPermissionedModule } from "./interface/IPermissionedModule.sol"; +import { PufferModuleManager } from "./PufferModuleManager.sol"; +import { NonRestakingWithdrawalCredentials } from "./NonRestakingWithdrawalCredentials.sol"; +import { Unauthorized } from "./Errors.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title PermissionedModule + * @author Puffer Finance + * @notice Module that supports both restaked and non-restaked permissioned validators + * @custom:security-contact security@puffer.fi + */ +contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermissionedModule { + using Address for address; + using Address for address payable; + + /** + * @dev Represents the Beacon Chain strategy in EigenLayer + */ + address internal constant _BEACON_CHAIN_STRATEGY = 0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0; + + /** + * @dev Storage struct for PermissionedModule + * @custom:storage-location erc7201:PermissionedModule.storage + */ + struct PermissionedModuleStorage { + bytes32 moduleName; + IEigenPod eigenPod; + NonRestakingWithdrawalCredentials nonRestakingWithdrawalCredentials; + } + + /** + * keccak256(abi.encode(uint256(keccak256("PermissionedModule.storage")) - 1)) & ~bytes32(uint256(0xff)) + */ + bytes32 private constant _PERMISSIONED_MODULE_STORAGE = + 0x2784f76ce9c1e210747909ec29cc0ceef82df4aa8f3bfcd656a8d65758b79900; + + IEigenPodManager public immutable EIGEN_POD_MANAGER; + IRewardsCoordinator public immutable EIGEN_REWARDS_COORDINATOR; + IDelegationManager public immutable EIGEN_DELEGATION_MANAGER; + IBeaconDepositContract public immutable BEACON_DEPOSIT_CONTRACT; + IPufferProtocol public immutable PUFFER_PROTOCOL; + PufferModuleManager public immutable PUFFER_MODULE_MANAGER; + + constructor( + IPufferProtocol protocol, + address eigenPodManager, + IDelegationManager delegationManager, + PufferModuleManager moduleManager, + IRewardsCoordinator rewardsCoordinator, + IBeaconDepositContract beaconDepositContract + ) payable { + EIGEN_POD_MANAGER = IEigenPodManager(eigenPodManager); + EIGEN_DELEGATION_MANAGER = delegationManager; + PUFFER_PROTOCOL = protocol; + PUFFER_MODULE_MANAGER = moduleManager; + EIGEN_REWARDS_COORDINATOR = rewardsCoordinator; + BEACON_DEPOSIT_CONTRACT = beaconDepositContract; + _disableInitializers(); + } + + /** + * @notice Initializes the module, creates EigenPod and NonRestakingWithdrawalCredentials + * @param moduleName The name of this module + * @param initialAuthority The access manager address + */ + function initialize(bytes32 moduleName, address initialAuthority) external initializer { + __AccessManaged_init(initialAuthority); + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + $.moduleName = moduleName; + // Create EigenPod for restaked validators + $.eigenPod = IEigenPod(address(EIGEN_POD_MANAGER.createPod())); + // Deploy NonRestakingWithdrawalCredentials for non-restaked validators + $.nonRestakingWithdrawalCredentials = + new NonRestakingWithdrawalCredentials(address(this), initialAuthority); + + emit NonRestakingWithdrawalCredentialsSet(address($.nonRestakingWithdrawalCredentials)); + } + + /** + * @dev Calls PufferProtocol to check if it is paused + */ + modifier whenNotPaused() { + PUFFER_PROTOCOL.revertIfPaused(); + _; + } + + modifier onlyPufferProtocol() { + if (msg.sender != address(PUFFER_PROTOCOL)) { + revert Unauthorized(); + } + _; + } + + modifier onlyPufferModuleManager() { + if (msg.sender != address(PUFFER_MODULE_MANAGER)) { + revert Unauthorized(); + } + _; + } + + modifier onlyPufferProtocolOrPufferModuleManager() { + if (msg.sender != address(PUFFER_MODULE_MANAGER) && msg.sender != address(PUFFER_PROTOCOL)) { + revert Unauthorized(); + } + _; + } + + receive() external payable { } + + /** + * @inheritdoc IPermissionedModule + */ + function callStakeRestaked(bytes calldata pubKey, bytes calldata signature, bytes32 depositDataRoot) + external + payable + onlyPufferProtocol + { + EIGEN_POD_MANAGER.stake{ value: 32 ether }(pubKey, signature, depositDataRoot); + } + + /** + * @inheritdoc IPermissionedModule + */ + function callStakeNonRestaked(bytes calldata pubKey, bytes calldata signature, bytes32 depositDataRoot) + external + payable + onlyPufferProtocol + { + BEACON_DEPOSIT_CONTRACT.deposit{ value: 32 ether }( + pubKey, getNonRestakingWithdrawalCredentials(), signature, depositDataRoot + ); + } + + /** + * @inheritdoc IPermissionedModule + */ + function setProofSubmitter(address proofSubmitter) external onlyPufferModuleManager { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + $.eigenPod.setProofSubmitter(proofSubmitter); + } + + /** + * @inheritdoc IPermissionedModule + */ + function queueWithdrawals(uint256 shareAmount) + external + virtual + onlyPufferModuleManager + returns (bytes32[] memory) + { + IDelegationManagerTypes.QueuedWithdrawalParams[] memory withdrawals = + new IDelegationManagerTypes.QueuedWithdrawalParams[](1); + + uint256[] memory shares = new uint256[](1); + shares[0] = shareAmount; + + IStrategy[] memory strategies = new IStrategy[](1); + strategies[0] = IStrategy(_BEACON_CHAIN_STRATEGY); + + withdrawals[0] = IDelegationManagerTypes.QueuedWithdrawalParams({ + strategies: strategies, + depositShares: shares, + withdrawer: address(this) + }); + + return EIGEN_DELEGATION_MANAGER.queueWithdrawals(withdrawals); + } + + /** + * @inheritdoc IPermissionedModule + */ + function completeQueuedWithdrawals( + IDelegationManagerTypes.Withdrawal[] calldata withdrawals, + IERC20[][] calldata tokens, + bool[] calldata receiveAsTokens + ) external virtual whenNotPaused onlyPufferModuleManager { + EIGEN_DELEGATION_MANAGER.completeQueuedWithdrawals({ + withdrawals: withdrawals, + tokens: tokens, + receiveAsTokens: receiveAsTokens + }); + } + + /** + * @inheritdoc IPermissionedModule + */ + function call(address to, uint256 amount, bytes calldata data) + external + onlyPufferProtocolOrPufferModuleManager + returns (bool success, bytes memory) + { + // slither-disable-next-line arbitrary-send-eth + // nosemgrep arbitrary-low-level-call + return to.call{ value: amount }(data); + } + + /** + * @inheritdoc IPermissionedModule + */ + function callDelegateTo( + address operator, + ISignatureUtils.SignatureWithExpiry calldata approverSignatureAndExpiry, + bytes32 approverSalt + ) external virtual onlyPufferModuleManager { + EIGEN_DELEGATION_MANAGER.delegateTo(operator, approverSignatureAndExpiry, approverSalt); + } + + /** + * @inheritdoc IPermissionedModule + */ + function callUndelegate() external virtual onlyPufferModuleManager returns (bytes32[] memory withdrawalRoot) { + return EIGEN_DELEGATION_MANAGER.undelegate(address(this)); + } + + /** + * @inheritdoc IPermissionedModule + */ + function triggerRestakedValidatorsExit(bytes[] calldata pubkeys) external payable virtual onlyPufferModuleManager { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](pubkeys.length); + for (uint256 i = 0; i < pubkeys.length; i++) { + requests[i] = IEigenPodTypes.WithdrawalRequest({ + pubkey: pubkeys[i], + amountGwei: 0 // Full exit + }); + } + $.eigenPod.requestWithdrawal{ value: msg.value }(requests); + } + + /** + * @inheritdoc IPermissionedModule + */ + function withdrawNonRestakedETH() external onlyPufferModuleManager { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + $.nonRestakingWithdrawalCredentials.withdrawETH(); + } + + /** + * @inheritdoc IPermissionedModule + */ + function callSetClaimerFor(address claimer) external virtual onlyPufferModuleManager { + EIGEN_REWARDS_COORDINATOR.setClaimerFor(claimer); + } + + /** + * @inheritdoc IPermissionedModule + */ + function getRestakingWithdrawalCredentials() public view returns (bytes memory) { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + return abi.encodePacked(bytes1(uint8(1)), bytes11(0), $.eigenPod); + } + + /** + * @inheritdoc IPermissionedModule + */ + function getNonRestakingWithdrawalCredentials() public view returns (bytes memory) { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + return abi.encodePacked(bytes1(uint8(2)), bytes11(0), $.nonRestakingWithdrawalCredentials); + } + + /** + * @inheritdoc IPermissionedModule + */ + function getEigenPod() external view returns (address) { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + return address($.eigenPod); + } + + /** + * @inheritdoc IPermissionedModule + */ + function getNonRestakingWithdrawalCredentialsContract() external view returns (address) { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + return address($.nonRestakingWithdrawalCredentials); + } + + /** + * @inheritdoc IPermissionedModule + */ + // solhint-disable-next-line func-name-mixedcase + function NAME() external view returns (bytes32) { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + return $.moduleName; + } + + function _getPermissionedModuleStorage() internal pure returns (PermissionedModuleStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _PERMISSIONED_MODULE_STORAGE + } + } +} From 3ee87edd3ec947199936b7bed847d6724a65502d Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 21 Jan 2026 23:56:19 +0530 Subject: [PATCH 15/55] feat: add permissioned module and oracle --- .../src/LibBeaconchainContract.sol | 50 ++++ mainnet-contracts/src/PermissionedModule.sol | 13 +- mainnet-contracts/src/PermissionedOracle.sol | 57 +++++ mainnet-contracts/src/PufferProtocol.sol | 224 ++++++++++++++++-- .../src/struct/ProtocolStorage.sol | 21 +- mainnet-contracts/src/struct/Validator.sol | 3 +- 6 files changed, 343 insertions(+), 25 deletions(-) create mode 100644 mainnet-contracts/src/PermissionedOracle.sol diff --git a/mainnet-contracts/src/LibBeaconchainContract.sol b/mainnet-contracts/src/LibBeaconchainContract.sol index 3673fb4d..9e2a1fe6 100644 --- a/mainnet-contracts/src/LibBeaconchainContract.sol +++ b/mainnet-contracts/src/LibBeaconchainContract.sol @@ -38,4 +38,54 @@ library LibBeaconchainContract { ) ); } + + /** + * @notice Returns the deposit data root for variable ETH amounts (Pectra support) + * @param pubKey The validator public key + * @param signature The validator signature + * @param withdrawalCredentials The withdrawal credentials + * @param amount The deposit amount in wei (must be 32-2048 ETH in 1 gwei increments) + * @return The deposit data root + */ + function getDepositDataRootWithAmount( + bytes calldata pubKey, + bytes calldata signature, + bytes calldata withdrawalCredentials, + uint256 amount + ) external pure returns (bytes32) { + bytes32 pubKeyRoot = sha256(abi.encodePacked(pubKey, bytes16(0))); + bytes32 signatureRoot = sha256( + abi.encodePacked( + sha256(abi.encodePacked(signature[:64])), sha256(abi.encodePacked(signature[64:], bytes32(0))) + ) + ); + + // Convert amount to little-endian Gwei bytes + bytes memory amountBytes = _toLittleEndianGwei(amount); + + return sha256( + abi.encodePacked( + sha256(abi.encodePacked(pubKeyRoot, withdrawalCredentials)), + sha256(abi.encodePacked(amountBytes, signatureRoot)) + ) + ); + } + + /** + * @dev Converts wei amount to 32-byte little-endian Gwei representation + * @param amountWei The amount in wei + * @return result 32-byte little-endian representation + */ + function _toLittleEndianGwei(uint256 amountWei) internal pure returns (bytes memory) { + uint64 amountGwei = uint64(amountWei / 1 gwei); + bytes memory result = new bytes(32); + + // Write as little-endian (least significant byte first) + for (uint256 i = 0; i < 8; i++) { + result[i] = bytes1(uint8(amountGwei >> (i * 8))); + } + // Remaining 24 bytes are already zero + + return result; + } } diff --git a/mainnet-contracts/src/PermissionedModule.sol b/mainnet-contracts/src/PermissionedModule.sol index 1f7469a9..83d04f0e 100644 --- a/mainnet-contracts/src/PermissionedModule.sol +++ b/mainnet-contracts/src/PermissionedModule.sol @@ -137,12 +137,13 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss /** * @inheritdoc IPermissionedModule */ - function callStakeNonRestaked(bytes calldata pubKey, bytes calldata signature, bytes32 depositDataRoot) - external - payable - onlyPufferProtocol - { - BEACON_DEPOSIT_CONTRACT.deposit{ value: 32 ether }( + function callStakeNonRestaked( + bytes calldata pubKey, + bytes calldata signature, + bytes32 depositDataRoot, + uint256 amount + ) external payable onlyPufferProtocol { + BEACON_DEPOSIT_CONTRACT.deposit{ value: amount }( pubKey, getNonRestakingWithdrawalCredentials(), signature, depositDataRoot ); } diff --git a/mainnet-contracts/src/PermissionedOracle.sol b/mainnet-contracts/src/PermissionedOracle.sol new file mode 100644 index 00000000..237a928c --- /dev/null +++ b/mainnet-contracts/src/PermissionedOracle.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { IPermissionedOracle } from "./interface/IPermissionedOracle.sol"; +import { AccessManaged } from "@openzeppelin/contracts/access/manager/AccessManaged.sol"; + +/** + * @title PermissionedOracle + * @notice Oracle for tracking ETH locked by permissioned validators + * @dev Tracks actual ETH amounts per module to support Pectra variable stake (32-2048 ETH) + * @custom:security-contact security@puffer.fi + */ +contract PermissionedOracle is IPermissionedOracle, AccessManaged { + /** + * @notice Locked ETH per module + */ + mapping(bytes32 moduleName => uint256 lockedEth) public moduleLockedEth; + + /** + * @notice Total locked ETH across all permissioned validators + */ + uint256 public totalLockedEth; + + constructor(address accessManager) AccessManaged(accessManager) { } + + /** + * @inheritdoc IPermissionedOracle + */ + function getLockedEthAmount() external view returns (uint256) { + return totalLockedEth; + } + + /** + * @inheritdoc IPermissionedOracle + */ + function getModuleLockedEth(bytes32 moduleName) external view returns (uint256) { + return moduleLockedEth[moduleName]; + } + + /** + * @inheritdoc IPermissionedOracle + */ + function provisionValidator(bytes32 moduleName, uint256 amount) external restricted { + moduleLockedEth[moduleName] += amount; + totalLockedEth += amount; + emit PermissionedValidatorProvisioned(moduleName, amount); + } + + /** + * @inheritdoc IPermissionedOracle + */ + function exitValidator(bytes32 moduleName, uint256 amount) external restricted { + moduleLockedEth[moduleName] -= amount; + totalLockedEth -= amount; + emit PermissionedValidatorExited(moduleName, amount); + } +} diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index 3c42cfe0..8aa0d646 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -23,6 +23,8 @@ import { ValidatorTicket } from "./ValidatorTicket.sol"; import { InvalidAddress } from "./Errors.sol"; import { StoppedValidatorInfo } from "./struct/StoppedValidatorInfo.sol"; import { PufferModule } from "./PufferModule.sol"; +import { PermissionedModule } from "./PermissionedModule.sol"; +import { IPermissionedOracle } from "./interface/IPermissionedOracle.sol"; /** * @title PufferProtocol @@ -100,13 +102,19 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad */ IBeaconDepositContract public immutable override BEACON_DEPOSIT_CONTRACT; + /** + * @notice Oracle for tracking permissioned validator ETH (supports variable stake amounts) + */ + IPermissionedOracle public immutable PUFFER_PERMISSIONED_ORACLE; + constructor( PufferVaultV5 pufferVault, IGuardianModule guardianModule, address moduleManager, ValidatorTicket validatorTicket, IPufferOracleV2 oracle, - address beaconDepositContract + address beaconDepositContract, + IPermissionedOracle permissionedOracle ) { GUARDIAN_MODULE = guardianModule; PUFFER_VAULT = PufferVaultV5(payable(address(pufferVault))); @@ -114,6 +122,7 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad VALIDATOR_TICKET = validatorTicket; PUFFER_ORACLE = oracle; BEACON_DEPOSIT_CONTRACT = IBeaconDepositContract(beaconDepositContract); + PUFFER_PERMISSIONED_ORACLE = permissionedOracle; _disableInitializers(); } @@ -248,29 +257,68 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad } /** - * @notice restricted to new role for permissioned validator registration - **/ + * @notice Registers a permissioned validator key (no bond, no VT required) + * @param blsPubKey The BLS public key of the validator + * @param moduleName The name of the permissioned module + * @param isNonRestaked true = direct Beacon Chain, false = EigenLayer restaking + * @param stakeAmount The stake amount in wei (32-2048 ETH for non-restaked, must be 32 ETH for restaked) + * @return index The index of the registered validator + * @dev Restricted to permissioned operators + */ function registerPermissionedValidatorKey( - ValidatorKeyData calldata data, + bytes calldata blsPubKey, bytes32 moduleName, - bool isNonRestaked + bool isNonRestaked, + uint256 stakeAmount ) external restricted returns (uint256 index) { ProtocolStorage storage $ = _getPufferProtocolStorage(); + // Validate BLS public key length + if (blsPubKey.length != _BLS_PUB_KEY_LENGTH) { + revert InvalidBLSPubKey(); + } + + // Get the permissioned module + PermissionedModule module = $.permissionedModules[moduleName]; + if (address(module) == address(0)) { + revert InvalidAddress(); + } + + // Validate stake amount + uint64 stakeAmountGwei; + if (isNonRestaked) { + // Non-restaked: variable 32-2048 ETH (Pectra MaxEB) + if (stakeAmount < 32 ether || stakeAmount > 2048 ether) { + revert InvalidETHAmount(); + } + if (stakeAmount % 1 gwei != 0) { + revert InvalidETHAmount(); // Must be in gwei increments + } + stakeAmountGwei = uint64(stakeAmount / 1 gwei); + } else { + // Restaked (EigenLayer): fixed 32 ETH due to EigenPod limitation + if (stakeAmount != 32 ether) { + revert InvalidETHAmount(); + } + stakeAmountGwei = uint64(32 ether / 1 gwei); + } + index = $.pendingPermissionedValidatorIndices[moduleName]; $.permissionedValidators[moduleName][index] = PermissionedValidator({ - pubKey: data.blsPubKey, + pubKey: blsPubKey, status: Status.PENDING, - module: address($.modules[moduleName]), //@todo: check if this is correct + module: address(module), node: msg.sender, - isNonRestaked: isNonRestaked + isNonRestaked: isNonRestaked, + stakeAmountGwei: stakeAmountGwei }); + unchecked { ++$.pendingPermissionedValidatorIndices[moduleName]; } - - // emit PermissionedValidatorRegistered(data.blsPubKey, index, moduleName, isNonRestaked); + + emit PermissionedValidatorKeyRegistered(blsPubKey, index, moduleName, isNonRestaked, stakeAmount); } /** @@ -314,19 +362,135 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad $.validators[moduleName][index].status = Status.ACTIVE; } + /** + * @notice Provisions a permissioned validator (no bond, no VT, no guardian signatures) + * @param moduleName The name of the permissioned module + * @param validatorIndex The index of the validator to provision + * @param validatorSignature The validator's BLS signature + * @param expectedDepositDataRoot Expected deposit data root (for reorg protection) + * @dev Restricted to multisig/provisioner role. Guardian signatures removed since + * permissioned validators are provisioned by trusted multisig and deposit data + * is verified on-chain. + */ function provisionPermissionedValidator( - bytes[] calldata guardianEnclaveSignatures, + bytes32 moduleName, + uint256 validatorIndex, bytes calldata validatorSignature, - bytes32 depositRootHash - )external restricted{ - if (depositRootHash != BEACON_DEPOSIT_CONTRACT.get_deposit_root()) { + bytes32 expectedDepositDataRoot + ) external restricted { + // Verify deposit root matches (protects against reorgs) + if (expectedDepositDataRoot != BEACON_DEPOSIT_CONTRACT.get_deposit_root()) { revert InvalidDepositRootHash(); } ProtocolStorage storage $ = _getPufferProtocolStorage(); + PermissionedValidator storage validator = $.permissionedValidators[moduleName][validatorIndex]; + + if (validator.status != Status.PENDING) { + revert InvalidValidatorState(validator.status); + } - + _provisionPermissionedValidatorInternal({ + $: $, + moduleName: moduleName, + validatorIndex: validatorIndex, + validator: validator, + validatorSignature: validatorSignature + }); + } + + /** + * @dev Internal function to provision permissioned validator + */ + function _provisionPermissionedValidatorInternal( + ProtocolStorage storage $, + bytes32 moduleName, + uint256 validatorIndex, + PermissionedValidator storage validator, + bytes calldata validatorSignature + ) internal { + PermissionedModule module = $.permissionedModules[moduleName]; + + // Get stake amount from validator record + uint256 stakeAmount = uint256(validator.stakeAmountGwei) * 1 gwei; + + // Get withdrawal credentials based on restaking preference + bytes memory withdrawalCredentials = validator.isNonRestaked + ? module.getNonRestakingWithdrawalCredentials() + : module.getRestakingWithdrawalCredentials(); + + // Calculate deposit data root ON-CHAIN (no guardian needed) + bytes32 depositDataRoot; + if (validator.isNonRestaked && stakeAmount != 32 ether) { + // Variable amount for non-restaked (Pectra) + depositDataRoot = LibBeaconchainContract.getDepositDataRootWithAmount({ + pubKey: validator.pubKey, + signature: validatorSignature, + withdrawalCredentials: withdrawalCredentials, + amount: stakeAmount + }); + } else { + // Standard 32 ETH (restaked or non-restaked with 32 ETH) + depositDataRoot = LibBeaconchainContract.getDepositDataRoot({ + pubKey: validator.pubKey, + signature: validatorSignature, + withdrawalCredentials: withdrawalCredentials + }); + } + + // Transfer ETH from vault to module + PUFFER_VAULT.transferETH(address(module), stakeAmount); + + // Stake based on restaking preference + if (validator.isNonRestaked) { + module.callStakeNonRestaked(validator.pubKey, validatorSignature, depositDataRoot, stakeAmount); + } else { + module.callStakeRestaked(validator.pubKey, validatorSignature, depositDataRoot); + } + + // Update permissioned oracle with actual amount + PUFFER_PERMISSIONED_ORACLE.provisionValidator(moduleName, stakeAmount); + + // Mark validator as active + validator.status = Status.ACTIVE; + + // Update next to be provisioned index + $.nextPermissionedValidatorToBeProvisionedIndices[moduleName] = validatorIndex + 1; + + emit PermissionedValidatorProvisioned( + validator.pubKey, validatorIndex, moduleName, validator.isNonRestaked, stakeAmount + ); + } + + /** + * @notice Handles the exit of a permissioned validator + * @param moduleName The name of the permissioned module + * @param validatorIndex The index of the validator + * @param withdrawalAmount The amount of ETH withdrawn from the validator + * @dev Restricted to authorized roles. Updates oracle and marks validator as exited. + */ + function handlePermissionedValidatorExit( + bytes32 moduleName, + uint256 validatorIndex, + uint256 withdrawalAmount + ) external restricted { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + PermissionedValidator storage validator = $.permissionedValidators[moduleName][validatorIndex]; + + if (validator.status != Status.ACTIVE) { + revert InvalidValidatorState(validator.status); + } + + uint256 stakeAmount = uint256(validator.stakeAmountGwei) * 1 gwei; + + // Update oracle + PUFFER_PERMISSIONED_ORACLE.exitValidator(moduleName, stakeAmount); + + // Mark as exited + validator.status = Status.EXITED; + + emit PermissionedValidatorExited(validator.pubKey, validatorIndex, moduleName, withdrawalAmount); } /** @@ -496,6 +660,36 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad return _createPufferModule(moduleName); } + /** + * @notice Creates a new permissioned module + * @param moduleName The name of the permissioned module + * @return The address of the newly created module + * @dev Restricted to the DAO + */ + function createPermissionedModule(bytes32 moduleName) external restricted returns (address) { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + + if (address($.permissionedModules[moduleName]) != address(0)) { + revert ModuleAlreadyExists(); + } + + PermissionedModule module = PUFFER_MODULE_MANAGER.createNewPermissionedModule(moduleName); + $.permissionedModules[moduleName] = module; + + emit NewPermissionedModuleCreated(address(module), moduleName); + return address(module); + } + + /** + * @notice Returns the address of a permissioned module + * @param moduleName The name of the permissioned module + * @return The address of the permissioned module + */ + function getPermissionedModuleAddress(bytes32 moduleName) external view returns (address) { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + return address($.permissionedModules[moduleName]); + } + /** * @dev Restricted to the DAO */ diff --git a/mainnet-contracts/src/struct/ProtocolStorage.sol b/mainnet-contracts/src/struct/ProtocolStorage.sol index 75c1e2ec..21db573b 100644 --- a/mainnet-contracts/src/struct/ProtocolStorage.sol +++ b/mainnet-contracts/src/struct/ProtocolStorage.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.0 <0.9.0; import { Validator, PermissionedValidator } from "../struct/Validator.sol"; import { NodeInfo } from "../struct/NodeInfo.sol"; import { PufferModule } from "../PufferModule.sol"; +import { PermissionedModule } from "../PermissionedModule.sol"; /** * @custom:storage-location erc7201:PufferProtocol.storage * @dev +-----------------------------------------------------------+ @@ -68,12 +69,26 @@ struct ProtocolStorage { */ uint256 vtPenalty; + /** + * @dev Mapping of Module name => idx => PermissionedValidator + * Slot 10 + */ mapping(bytes32 moduleName => mapping(uint256 index => PermissionedValidator validator)) permissionedValidators; - + /** + * @dev Mapping of module name to pending permissioned validator index + * Slot 11 + */ mapping(bytes32 moduleName => uint256 pendingPermissionedValidatorIndex) pendingPermissionedValidatorIndices; + /** + * @dev Mapping of module name to next permissioned validator to be provisioned index + * Slot 12 + */ mapping(bytes32 moduleName => uint256 nextPermissionedValidatorToBeProvisionedIndex) nextPermissionedValidatorToBeProvisionedIndices; - - + /** + * @dev Mapping between module name and a permissioned module + * Slot 13 + */ + mapping(bytes32 moduleName => PermissionedModule moduleAddress) permissionedModules; } struct ModuleLimit { diff --git a/mainnet-contracts/src/struct/Validator.sol b/mainnet-contracts/src/struct/Validator.sol index 8f1d8c88..edb9eb01 100644 --- a/mainnet-contracts/src/struct/Validator.sol +++ b/mainnet-contracts/src/struct/Validator.sol @@ -19,5 +19,6 @@ struct PermissionedValidator { address module; // In which module is the Validator participating Status status; // Validator status bytes pubKey; // Validator public key - bool isNonRestaked; + bool isNonRestaked; // true = non-restaked (Beacon Chain), false = restaked (EigenLayer) + uint64 stakeAmountGwei; // Stake amount in Gwei (32-2048 ETH for non-restaked, always 32 ETH for restaked) } From 9d15c5d8a7aa9cef4e92416760e3fab4a91fbb46 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Mon, 26 Jan 2026 18:49:34 +0530 Subject: [PATCH 16/55] feat: remove consolidation --- .../src/NonRestakingWithdrawalCredentials.sol | 77 +------------------ mainnet-contracts/src/PufferVaultV5.sol | 8 +- mainnet-contracts/src/struct/Status.sol | 3 +- 3 files changed, 9 insertions(+), 79 deletions(-) diff --git a/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol b/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol index 535235d2..a4cb185b 100644 --- a/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol +++ b/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol @@ -15,12 +15,6 @@ import { Unauthorized } from "./Errors.sol"; contract NonRestakingWithdrawalCredentials is AccessManaged { using Address for address payable; - /** - * @notice Event emitted when a validator is requested to be switched to compounding withdrawal credentials - * @param pubkey The public key of the validator - */ - event SwitchToCompoundingWithdrawalCredentials(bytes pubkey); - /** * @notice Event emitted when a withdrawal request is made * @param pubkey The public key of the validator @@ -28,13 +22,6 @@ contract NonRestakingWithdrawalCredentials is AccessManaged { */ event WithdrawalRequested(bytes pubkey, uint256 indexed amountGwei); - /** - * @notice Event emitted when a consolidation request is made - * @param srcPubkey The public key of the source validator - * @param targetPubkey The public key of the target validator - */ - event ConsolidationRequested(bytes srcPubkey, bytes targetPubkey); - /** * @notice Thrown if the sender did not send enough ETH to cover the fee */ @@ -45,11 +32,6 @@ contract NonRestakingWithdrawalCredentials is AccessManaged { */ error WithdrawalRequestFailed(); - /** - * @notice Thrown if the consolidation request fails - */ - error ConsolidationRequestFailed(); - /** * @notice Thrown if the fee query fails */ @@ -57,8 +39,6 @@ contract NonRestakingWithdrawalCredentials is AccessManaged { // https://eips.ethereum.org/EIPS/eip-7002 address internal constant WITHDRAWAL_REQUEST_ADDRESS = 0x00000961Ef480Eb55e80D19ad83579A64c007002; - // https://eips.ethereum.org/EIPS/eip-7251 - address internal constant CONSOLIDATION_REQUEST_ADDRESS = 0x0000BBdDc7CE488642fb579F8B00f3a590007251; /** * @notice The address of the PermissionedModule that owns this contract @@ -108,67 +88,12 @@ contract NonRestakingWithdrawalCredentials is AccessManaged { } } - /** - * @notice Request consolidation of validators via EIP-7251 - * It is possible to consolidate a validator to itself, which will switch the withdrawal credentials to compounding withdrawal credentials (0x01 -> 0x02) - * It is also possible to consolidate a validator from this withdrawal credentials to another withdrawal credentials - * @dev We do not validate if the source validator belongs to this contract - * @param requests The requests to consolidate - */ - function requestConsolidation(IEigenPodTypes.ConsolidationRequest[] calldata requests) - external - payable - restricted - { - uint256 fee = getConsolidationRequestFee(); - // The remainder is donated and not refunded to the caller - if (msg.value < fee * requests.length) { - revert NotEnoughETH(); - } - - for (uint256 i = 0; i < requests.length; ++i) { - IEigenPodTypes.ConsolidationRequest calldata request = requests[i]; - // We don't need to validate the length of the pubkeys as the precompile will revert if the pubkeys are invalid - // The precompile just checks for the keys length, it doesn't check if it is an active validator - - bytes memory callData = bytes.concat(request.srcPubkey, request.targetPubkey); - (bool ok,) = CONSOLIDATION_REQUEST_ADDRESS.call{ value: fee }(callData); - if (!ok) { - revert ConsolidationRequestFailed(); - } - - // Emit event depending on whether this is a switch to 0x02, or a regular consolidation - if (keccak256(request.srcPubkey) == keccak256(request.targetPubkey)) { - emit SwitchToCompoundingWithdrawalCredentials(request.srcPubkey); - } else { - emit ConsolidationRequested(request.srcPubkey, request.targetPubkey); - } - } - } - - /** - * @notice Get the fee for a consolidation request - * @return The fee for a consolidation request - */ - function getConsolidationRequestFee() public view returns (uint256) { - return _getFee(CONSOLIDATION_REQUEST_ADDRESS); - } - /** * @notice Get the fee for a withdrawal request * @return The fee for a withdrawal request */ function getWithdrawalRequestFee() public view returns (uint256) { - return _getFee(WITHDRAWAL_REQUEST_ADDRESS); - } - - /** - * @notice Get the fee for a request - * @param predeploy The address of the predeploy - * @return The fee for a request - */ - function _getFee(address predeploy) internal view returns (uint256) { - (bool success, bytes memory result) = predeploy.staticcall(""); + (bool success, bytes memory result) = WITHDRAWAL_REQUEST_ADDRESS.staticcall(""); if (!success || result.length != 32) { revert FeeQueryFailed(); } diff --git a/mainnet-contracts/src/PufferVaultV5.sol b/mainnet-contracts/src/PufferVaultV5.sol index 3adfce95..f60249de 100644 --- a/mainnet-contracts/src/PufferVaultV5.sol +++ b/mainnet-contracts/src/PufferVaultV5.sol @@ -19,6 +19,7 @@ import { EnumerableMap } from "@openzeppelin/contracts/utils/structs/EnumerableM import { IPufferVaultV5 } from "./interface/IPufferVaultV5.sol"; import { IPufferOracleV2 } from "./interface/IPufferOracleV2.sol"; import { IPufferRevenueDepositor } from "./interface/IPufferRevenueDepositor.sol"; +import { IPermissionedOracle } from "./interface/IPermissionedOracle.sol"; import { InvalidAddress } from "./Errors.sol"; /** @@ -46,19 +47,22 @@ contract PufferVaultV5 is IWETH internal immutable _WETH; IPufferOracleV2 public immutable PUFFER_ORACLE; IPufferRevenueDepositor public immutable RESTAKING_REWARDS_DEPOSITOR; + IPermissionedOracle public immutable PUFFER_PERMISSIONED_ORACLE; constructor( IStETH stETH, ILidoWithdrawalQueue lidoWithdrawalQueue, IWETH weth, IPufferOracleV2 pufferOracle, - IPufferRevenueDepositor revenueDepositor + IPufferRevenueDepositor revenueDepositor, + IPermissionedOracle permissionedOracle ) { _ST_ETH = stETH; _LIDO_WITHDRAWAL_QUEUE = lidoWithdrawalQueue; _WETH = weth; PUFFER_ORACLE = pufferOracle; RESTAKING_REWARDS_DEPOSITOR = revenueDepositor; + PUFFER_PERMISSIONED_ORACLE = permissionedOracle; _disableInitializers(); } @@ -127,7 +131,7 @@ contract PufferVaultV5 is callValue := callvalue() } return _ST_ETH.balanceOf(address(this)) + getPendingLidoETHAmount() + _WETH.balanceOf(address(this)) - + (address(this).balance - callValue) + PUFFER_ORACLE.getLockedEthAmount() + getTotalRewardMintAmount() + + (address(this).balance - callValue) + PUFFER_ORACLE.getLockedEthAmount() + PUFFER_PERMISSIONED_ORACLE.getLockedEthAmount() + getTotalRewardMintAmount() - getTotalRewardDepositAmount() - RESTAKING_REWARDS_DEPOSITOR.getPendingDistributionAmount(); } diff --git a/mainnet-contracts/src/struct/Status.sol b/mainnet-contracts/src/struct/Status.sol index 89be9879..1bf224bd 100644 --- a/mainnet-contracts/src/struct/Status.sol +++ b/mainnet-contracts/src/struct/Status.sol @@ -9,5 +9,6 @@ enum Status { PENDING, SKIPPED, ACTIVE, - FROZEN + FROZEN, + EXITED } From 42ac37d8e9fcefdfdb38d46d565ad0e9f637b012 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 27 Jan 2026 20:11:32 +0530 Subject: [PATCH 17/55] feat: partial withdrawals, skip provisioning and permissionedModule support --- mainnet-contracts/src/PermissionedModule.sol | 13 + mainnet-contracts/src/PermissionedOracle.sol | 19 +- mainnet-contracts/src/PufferModuleManager.sol | 242 ++++++++++++++++++ mainnet-contracts/src/PufferProtocol.sol | 104 +++++++- mainnet-contracts/src/struct/Status.sol | 3 +- mainnet-contracts/src/struct/Validator.sol | 7 +- 6 files changed, 373 insertions(+), 15 deletions(-) diff --git a/mainnet-contracts/src/PermissionedModule.sol b/mainnet-contracts/src/PermissionedModule.sol index 83d04f0e..f70c7e4b 100644 --- a/mainnet-contracts/src/PermissionedModule.sol +++ b/mainnet-contracts/src/PermissionedModule.sol @@ -253,6 +253,19 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss $.nonRestakingWithdrawalCredentials.withdrawETH(); } + /** + * @inheritdoc IPermissionedModule + */ + function triggerNonRestakedValidatorWithdrawals(IEigenPodTypes.WithdrawalRequest[] calldata requests) + external + payable + virtual + onlyPufferModuleManager + { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + $.nonRestakingWithdrawalCredentials.requestWithdrawal{ value: msg.value }(requests); + } + /** * @inheritdoc IPermissionedModule */ diff --git a/mainnet-contracts/src/PermissionedOracle.sol b/mainnet-contracts/src/PermissionedOracle.sol index 237a928c..f16c7cc6 100644 --- a/mainnet-contracts/src/PermissionedOracle.sol +++ b/mainnet-contracts/src/PermissionedOracle.sol @@ -50,8 +50,25 @@ contract PermissionedOracle is IPermissionedOracle, AccessManaged { * @inheritdoc IPermissionedOracle */ function exitValidator(bytes32 moduleName, uint256 amount) external restricted { - moduleLockedEth[moduleName] -= amount; + uint256 moduleAmount = moduleLockedEth[moduleName]; + if (amount > moduleAmount) { + revert InsufficientLockedEth(moduleName, moduleAmount, amount); + } + moduleLockedEth[moduleName] = moduleAmount - amount; totalLockedEth -= amount; emit PermissionedValidatorExited(moduleName, amount); } + + /** + * @inheritdoc IPermissionedOracle + */ + function adjustLockedEth(bytes32 moduleName, uint256 reductionAmount) external restricted { + uint256 moduleAmount = moduleLockedEth[moduleName]; + if (reductionAmount > moduleAmount) { + revert InsufficientLockedEth(moduleName, moduleAmount, reductionAmount); + } + moduleLockedEth[moduleName] = moduleAmount - reductionAmount; + totalLockedEth -= reductionAmount; + emit LockedEthAdjusted(moduleName, reductionAmount); + } } diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index 9001ce3c..39395434 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -5,6 +5,7 @@ import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; import { Unauthorized, InvalidAmount } from "./Errors.sol"; import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; import { PufferModule } from "./PufferModule.sol"; +import { PermissionedModule } from "./PermissionedModule.sol"; import { PufferVaultV5 } from "./PufferVaultV5.sol"; import { RestakingOperator } from "./RestakingOperator.sol"; import { IPufferModuleManager } from "./interface/IPufferModuleManager.sol"; @@ -18,6 +19,7 @@ import { ISignatureUtils } from "../src/interface/Eigenlayer-Slashing/ISignature import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { RestakingOperator } from "./RestakingOperator.sol"; import { IAllocationManager } from "../src/interface/Eigenlayer-Slashing/IAllocationManager.sol"; +import { IEigenPodTypes } from "../src/interface/Eigenlayer-Slashing/IEigenPod.sol"; import { PufferModule } from "./PufferModule.sol"; /** @@ -327,4 +329,244 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, } function _authorizeUpgrade(address newImplementation) internal virtual override restricted { } + + // ============ Permissioned Module Support ============ + + /** + * @dev Permissioned module beacon address (stored in contract storage for upgradeability) + * keccak256(abi.encode(uint256(keccak256("PufferModuleManager.permissionedModuleBeacon")) - 1)) & ~bytes32(uint256(0xff)) + */ + bytes32 private constant _PERMISSIONED_MODULE_BEACON_SLOT = + 0x12ddf963a4f129d061806b3796c3f91a43d3f59a05b31d1b6ef212e44874cf00; + + /** + * @notice Sets the permissioned module beacon address + * @param beacon The address of the permissioned module beacon + * @dev Restricted to the DAO + */ + function setPermissionedModuleBeacon(address beacon) external virtual restricted { + assembly { + sstore(_PERMISSIONED_MODULE_BEACON_SLOT, beacon) + } + emit PermissionedModuleBeaconSet(beacon); + } + + /** + * @notice Returns the permissioned module beacon address + * @return beacon The address of the permissioned module beacon + */ + function getPermissionedModuleBeacon() public view returns (address beacon) { + assembly { + beacon := sload(_PERMISSIONED_MODULE_BEACON_SLOT) + } + } + + /** + * @notice Create a new Permissioned module + * @dev This function creates a new Permissioned module with the given module name + * @param moduleName The name of the module + * @return module The newly created Permissioned module + * @dev Restricted to Puffer Protocol + */ + function createNewPermissionedModule(bytes32 moduleName) + external + virtual + onlyPufferProtocol + returns (PermissionedModule) + { + if (moduleName == bytes32("NO_VALIDATORS")) { + revert ForbiddenModuleName(); + } + + address beacon = getPermissionedModuleBeacon(); + if (beacon == address(0)) { + revert InvalidAmount(); // Beacon not set + } + + // This called from the PufferProtocol and the event is emitted there + return PermissionedModule( + payable( + Create2.deploy({ + amount: 0, + salt: keccak256(abi.encodePacked("PERMISSIONED_", moduleName)), + bytecode: abi.encodePacked( + type(BeaconProxy).creationCode, + abi.encode(beacon, abi.encodeCall(PermissionedModule.initialize, (moduleName, authority()))) + ) + }) + ) + ); + } + + /** + * @notice Completes queued withdrawals for a permissioned module + * @param permissionedModule The address of the permissioned module + * @param withdrawals The list of withdrawals to complete + * @param tokens The list of tokens to withdraw + * @param receiveAsTokens Whether to receive the tokens as ERC20 tokens + * @dev Restricted to Puffer Paymaster + */ + function callCompleteQueuedWithdrawalsPermissioned( + address permissionedModule, + IDelegationManagerTypes.Withdrawal[] calldata withdrawals, + IERC20[][] calldata tokens, + bool[] calldata receiveAsTokens + ) external virtual restricted { + PermissionedModule(payable(permissionedModule)).completeQueuedWithdrawals({ + withdrawals: withdrawals, + tokens: tokens, + receiveAsTokens: receiveAsTokens + }); + + uint256 sharesWithdrawn; + for (uint256 i = 0; i < withdrawals.length; ++i) { + for (uint256 j = 0; j < withdrawals[i].scaledShares.length; ++j) { + sharesWithdrawn += withdrawals[i].scaledShares[j]; + } + } + + emit PermissionedModuleCompletedQueuedWithdrawals(permissionedModule, sharesWithdrawn); + } + + /** + * @notice Queues the withdrawals for a permissioned module + * @param permissionedModule The address of the permissioned module + * @param sharesAmount The amount of shares to withdraw + * @dev Restricted to Puffer Paymaster + */ + function callQueueWithdrawalsPermissioned(address permissionedModule, uint256 sharesAmount) + external + virtual + restricted + { + bytes32[] memory withdrawalRoots = PermissionedModule(payable(permissionedModule)).queueWithdrawals(sharesAmount); + emit PermissionedModuleWithdrawalsQueued(permissionedModule, sharesAmount, withdrawalRoots[0]); + } + + /** + * @notice Calls the callDelegateTo function on the permissioned module + * @param permissionedModule The address of the permissioned module + * @param operator The address of the restaking operator + * @param approverSignatureAndExpiry The signature of the delegation approver + * @param approverSalt Salt for the signature + * @dev Restricted to the DAO + */ + function callDelegateToPermissioned( + address permissionedModule, + address operator, + ISignatureUtils.SignatureWithExpiry calldata approverSignatureAndExpiry, + bytes32 approverSalt + ) external virtual restricted { + PermissionedModule(payable(permissionedModule)).callDelegateTo(operator, approverSignatureAndExpiry, approverSalt); + emit PermissionedModuleDelegated(permissionedModule, operator); + } + + /** + * @notice Calls the callUndelegate function on the permissioned module + * @param permissionedModule The address of the permissioned module + * @dev Restricted to the DAO + */ + function callUndelegatePermissioned(address permissionedModule) + external + virtual + restricted + returns (bytes32[] memory withdrawalRoot) + { + withdrawalRoot = PermissionedModule(payable(permissionedModule)).callUndelegate(); + emit PermissionedModuleUndelegated(permissionedModule); + } + + /** + * @notice Triggers the restaked validators exit for a permissioned module + * @param permissionedModule The address of the permissioned module + * @param pubkeys The pubkeys of the validators to exit + * @dev Restricted to Puffer Paymaster + */ + function triggerRestakedValidatorsExit(address permissionedModule, bytes[] calldata pubkeys) + external + payable + virtual + restricted + { + require(pubkeys.length > 0, InputArrayLengthZero()); + PermissionedModule(payable(permissionedModule)).triggerRestakedValidatorsExit{ value: msg.value }(pubkeys); + emit PermissionedRestakedValidatorsExitTriggered(permissionedModule, pubkeys); + } + + /** + * @notice Withdraws ETH from the NonRestakingWithdrawalCredentials to the permissioned module + * @param permissionedModule The address of the permissioned module + * @dev Restricted to Puffer Paymaster + */ + function withdrawNonRestakedETH(address permissionedModule) external virtual restricted { + PermissionedModule(payable(permissionedModule)).withdrawNonRestakedETH(); + emit PermissionedNonRestakedETHWithdrawn(permissionedModule); + } + + /** + * @notice Transfers ETH from the permissioned module to the vault + * @param permissionedModules The addresses of the permissioned modules + * @param amounts The amounts of ETH to transfer + * @dev Restricted to Puffer Paymaster + */ + function transferPermissionedModuleETHToVault(address[] calldata permissionedModules, uint256[] calldata amounts) + external + virtual + restricted + { + uint256 totalAmount; + for (uint256 i = 0; i < permissionedModules.length; ++i) { + (bool success,) = PermissionedModule(payable(permissionedModules[i])).call(address(this), amounts[i], ""); + if (!success) { + revert InvalidAmount(); + } + totalAmount += amounts[i]; + } + PufferVaultV5(PUFFER_VAULT).depositRewards{ value: totalAmount }(); + } + + /** + * @notice Sets proof submitter on a permissioned module + * @param permissionedModule The address of the permissioned module + * @param proofSubmitter The address of the proof submitter + * @dev Restricted to the DAO + */ + function callSetProofSubmitterPermissioned(address permissionedModule, address proofSubmitter) + external + virtual + restricted + { + PermissionedModule(payable(permissionedModule)).setProofSubmitter(proofSubmitter); + emit PermissionedProofSubmitterSet(permissionedModule, proofSubmitter); + } + + /** + * @notice Sets claimer for a permissioned module + * @param permissionedModule The address of the permissioned module + * @param claimer The address of the claimer + * @dev Restricted to the DAO + */ + function callSetClaimerForPermissioned(address permissionedModule, address claimer) external virtual restricted { + PermissionedModule(payable(permissionedModule)).callSetClaimerFor(claimer); + emit PermissionedClaimerSet(permissionedModule, claimer); + } + + /** + * @notice Triggers withdrawal requests for non-restaked validators via EIP-7002 + * @param permissionedModule The address of the permissioned module + * @param requests The withdrawal requests with pubkey and amountGwei + * @dev Restricted to Puffer Paymaster. Calls EIP-7002 via NonRestakingWithdrawalCredentials. + * - amountGwei == 0: Full validator exit + * - amountGwei > 0: Partial withdrawal (Pectra feature, requires 0x02 credentials) + */ + function triggerNonRestakedValidatorWithdrawals( + address permissionedModule, + IEigenPodTypes.WithdrawalRequest[] calldata requests + ) external payable virtual restricted { + require(requests.length > 0, InputArrayLengthZero()); + PermissionedModule(payable(permissionedModule)).triggerNonRestakedValidatorWithdrawals{ value: msg.value }( + requests + ); + emit PermissionedNonRestakedValidatorWithdrawalsTriggered(permissionedModule, requests); + } } diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index 8aa0d646..f9b9efa8 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -306,12 +306,12 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad index = $.pendingPermissionedValidatorIndices[moduleName]; $.permissionedValidators[moduleName][index] = PermissionedValidator({ - pubKey: blsPubKey, - status: Status.PENDING, - module: address(module), node: msg.sender, + status: Status.PENDING, isNonRestaked: isNonRestaked, - stakeAmountGwei: stakeAmountGwei + stakeAmountGwei: stakeAmountGwei, + module: address(module), + pubKey: blsPubKey }); unchecked { @@ -385,6 +385,11 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad ProtocolStorage storage $ = _getPufferProtocolStorage(); + // Bounds check: validatorIndex must be less than the number of registered validators + if (validatorIndex >= $.pendingPermissionedValidatorIndices[moduleName]) { + revert InvalidValidatorIndex(); + } + PermissionedValidator storage validator = $.permissionedValidators[moduleName][validatorIndex]; if (validator.status != Status.PENDING) { @@ -421,9 +426,12 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad : module.getRestakingWithdrawalCredentials(); // Calculate deposit data root ON-CHAIN (no guardian needed) + // Note: We use getDepositDataRootWithAmount for ALL non-restaked validators because + // they use 0x02 withdrawal credentials, regardless of stake amount. + // getDepositDataRoot is only for restaked (0x01) with exactly 32 ETH. bytes32 depositDataRoot; - if (validator.isNonRestaked && stakeAmount != 32 ether) { - // Variable amount for non-restaked (Pectra) + if (validator.isNonRestaked) { + // Non-restaked: uses 0x02 credentials and variable amount (32-2048 ETH) depositDataRoot = LibBeaconchainContract.getDepositDataRootWithAmount({ pubKey: validator.pubKey, signature: validatorSignature, @@ -431,7 +439,7 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad amount: stakeAmount }); } else { - // Standard 32 ETH (restaked or non-restaked with 32 ETH) + // Restaked: uses 0x01 credentials and fixed 32 ETH depositDataRoot = LibBeaconchainContract.getDepositDataRoot({ pubKey: validator.pubKey, signature: validatorSignature, @@ -476,6 +484,12 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad uint256 withdrawalAmount ) external restricted { ProtocolStorage storage $ = _getPufferProtocolStorage(); + + // Bounds check: validatorIndex must be less than the number of registered validators + if (validatorIndex >= $.pendingPermissionedValidatorIndices[moduleName]) { + revert InvalidValidatorIndex(); + } + PermissionedValidator storage validator = $.permissionedValidators[moduleName][validatorIndex]; if (validator.status != Status.ACTIVE) { @@ -483,14 +497,49 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad } uint256 stakeAmount = uint256(validator.stakeAmountGwei) * 1 gwei; + bytes memory pubKey = validator.pubKey; // Update oracle PUFFER_PERMISSIONED_ORACLE.exitValidator(moduleName, stakeAmount); - // Mark as exited - validator.status = Status.EXITED; + // Delete validator data (same as batchHandleWithdrawals for external validators) + delete $.permissionedValidators[moduleName][validatorIndex]; - emit PermissionedValidatorExited(validator.pubKey, validatorIndex, moduleName, withdrawalAmount); + emit PermissionedValidatorExited(pubKey, validatorIndex, moduleName, withdrawalAmount); + } + + /** + * @notice Skips provisioning of a permissioned validator (for invalid/unwanted registrations) + * @param moduleName The name of the permissioned module + * @param validatorIndex The index of the validator to skip + * @dev Restricted to authorized roles. Only PENDING validators can be skipped. + * Unlike external validators, no VT penalty since permissioned validators don't pay VT. + */ + function skipPermissionedProvisioning(bytes32 moduleName, uint256 validatorIndex) external restricted { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + + // Bounds check + if (validatorIndex >= $.pendingPermissionedValidatorIndices[moduleName]) { + revert InvalidValidatorIndex(); + } + + PermissionedValidator storage validator = $.permissionedValidators[moduleName][validatorIndex]; + + if (validator.status != Status.PENDING) { + revert InvalidValidatorState(validator.status); + } + + bytes memory pubKey = validator.pubKey; + + // Delete validator data + delete $.permissionedValidators[moduleName][validatorIndex]; + + // Update next to be provisioned index if this was the next in line + if ($.nextPermissionedValidatorToBeProvisionedIndices[moduleName] == validatorIndex) { + $.nextPermissionedValidatorToBeProvisionedIndices[moduleName] = validatorIndex + 1; + } + + emit PermissionedValidatorSkipped(pubKey, validatorIndex, moduleName); } /** @@ -806,6 +855,41 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad return $.validators[moduleName][pufferModuleIndex]; } + /** + * @notice Returns information about a permissioned validator + * @param moduleName The name of the permissioned module + * @param validatorIndex The index of the validator + * @return The permissioned validator information + */ + function getPermissionedValidatorInfo(bytes32 moduleName, uint256 validatorIndex) + external + view + returns (PermissionedValidator memory) + { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + return $.permissionedValidators[moduleName][validatorIndex]; + } + + /** + * @notice Returns the pending validator index for a permissioned module + * @param moduleName The name of the permissioned module + * @return The pending validator index (total registered validators) + */ + function getPendingPermissionedValidatorIndex(bytes32 moduleName) external view returns (uint256) { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + return $.pendingPermissionedValidatorIndices[moduleName]; + } + + /** + * @notice Returns the next permissioned validator index to be provisioned + * @param moduleName The name of the permissioned module + * @return The next validator index to provision + */ + function getNextPermissionedValidatorToBeProvisionedIndex(bytes32 moduleName) external view returns (uint256) { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + return $.nextPermissionedValidatorToBeProvisionedIndices[moduleName]; + } + /** * @inheritdoc IPufferProtocol */ diff --git a/mainnet-contracts/src/struct/Status.sol b/mainnet-contracts/src/struct/Status.sol index 1bf224bd..dd948d40 100644 --- a/mainnet-contracts/src/struct/Status.sol +++ b/mainnet-contracts/src/struct/Status.sol @@ -9,6 +9,5 @@ enum Status { PENDING, SKIPPED, ACTIVE, - FROZEN, - EXITED + FROZEN } diff --git a/mainnet-contracts/src/struct/Validator.sol b/mainnet-contracts/src/struct/Validator.sol index edb9eb01..29879bc2 100644 --- a/mainnet-contracts/src/struct/Validator.sol +++ b/mainnet-contracts/src/struct/Validator.sol @@ -15,10 +15,13 @@ struct Validator { } struct PermissionedValidator { + // Slot 1: node (20) + status (1) + isNonRestaked (1) + stakeAmountGwei (8) = 30 bytes address node; // Address of the Node operator - address module; // In which module is the Validator participating Status status; // Validator status - bytes pubKey; // Validator public key bool isNonRestaked; // true = non-restaked (Beacon Chain), false = restaked (EigenLayer) uint64 stakeAmountGwei; // Stake amount in Gwei (32-2048 ETH for non-restaked, always 32 ETH for restaked) + // Slot 2: module (20 bytes) + address module; // In which module is the Validator participating + // Slot 3: pubKey reference (dynamic bytes) + bytes pubKey; // Validator public key } From 2f76042ba53723db9e8f496126671ad25f157330 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 28 Jan 2026 14:27:46 +0530 Subject: [PATCH 18/55] fix: storage keccak --- mainnet-contracts/src/PermissionedModule.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mainnet-contracts/src/PermissionedModule.sol b/mainnet-contracts/src/PermissionedModule.sol index f70c7e4b..0937bbe6 100644 --- a/mainnet-contracts/src/PermissionedModule.sol +++ b/mainnet-contracts/src/PermissionedModule.sol @@ -48,7 +48,7 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss * keccak256(abi.encode(uint256(keccak256("PermissionedModule.storage")) - 1)) & ~bytes32(uint256(0xff)) */ bytes32 private constant _PERMISSIONED_MODULE_STORAGE = - 0x2784f76ce9c1e210747909ec29cc0ceef82df4aa8f3bfcd656a8d65758b79900; + 0x7410446085c160ccc4c2b0e41801f8ac5004a5bf87d0402533c18d1e95927d00; IEigenPodManager public immutable EIGEN_POD_MANAGER; IRewardsCoordinator public immutable EIGEN_REWARDS_COORDINATOR; From 7267bf68ea44855bef4b9645ba122669eddcbcef Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 28 Jan 2026 14:29:58 +0530 Subject: [PATCH 19/55] feat: add interfaces --- .../src/interface/IPermissionedModule.sol | 22 +++++- .../src/interface/IPermissionedOracle.sol | 75 +++++++++++++++++++ .../src/interface/IPufferModuleManager.sol | 73 ++++++++++++++++++ .../src/interface/IPufferProtocol.sol | 63 ++++++++++++++++ 4 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 mainnet-contracts/src/interface/IPermissionedOracle.sol diff --git a/mainnet-contracts/src/interface/IPermissionedModule.sol b/mainnet-contracts/src/interface/IPermissionedModule.sol index 210f6a12..b94de0b5 100644 --- a/mainnet-contracts/src/interface/IPermissionedModule.sol +++ b/mainnet-contracts/src/interface/IPermissionedModule.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.0 <0.9.0; import { ISignatureUtils } from "./Eigenlayer-Slashing/ISignatureUtils.sol"; import { IDelegationManagerTypes } from "./Eigenlayer-Slashing/IDelegationManager.sol"; +import { IEigenPodTypes } from "./Eigenlayer-Slashing/IEigenPod.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /** @@ -32,10 +33,14 @@ interface IPermissionedModule { * @param pubKey The validator's public key * @param signature The validator's signature * @param depositDataRoot The deposit data root + * @param amount The stake amount in wei (32-2048 ETH for Pectra support) */ - function callStakeNonRestaked(bytes calldata pubKey, bytes calldata signature, bytes32 depositDataRoot) - external - payable; + function callStakeNonRestaked( + bytes calldata pubKey, + bytes calldata signature, + bytes32 depositDataRoot, + uint256 amount + ) external payable; /** * @notice Returns the withdrawal credentials for restaked validators (EigenPod) @@ -110,6 +115,17 @@ interface IPermissionedModule { */ function triggerRestakedValidatorsExit(bytes[] calldata pubkeys) external payable; + /** + * @notice Triggers withdrawal requests for non-restaked validators via EIP-7002 + * @param requests The withdrawal requests with pubkey and amountGwei + * @dev Uses NonRestakingWithdrawalCredentials contract. + * - amountGwei == 0: Full validator exit + * - amountGwei > 0: Partial withdrawal (Pectra feature, requires 0x02 credentials) + */ + function triggerNonRestakedValidatorWithdrawals(IEigenPodTypes.WithdrawalRequest[] calldata requests) + external + payable; + /** * @notice Withdraws accumulated ETH from non-restaking withdrawal credentials to this module */ diff --git a/mainnet-contracts/src/interface/IPermissionedOracle.sol b/mainnet-contracts/src/interface/IPermissionedOracle.sol new file mode 100644 index 00000000..650e4b62 --- /dev/null +++ b/mainnet-contracts/src/interface/IPermissionedOracle.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +/** + * @title IPermissionedOracle + * @notice Oracle for tracking ETH locked by permissioned validators + * @dev Unlike PufferOracleV2 which uses (count * 32 ETH), this tracks actual amounts + * to support Pectra variable stake (32-2048 ETH) for non-restaked validators + * @custom:security-contact security@puffer.fi + */ +interface IPermissionedOracle { + /** + * @notice Emitted when a permissioned validator is provisioned + * @param moduleName The module name + * @param amount The staked ETH amount + */ + event PermissionedValidatorProvisioned(bytes32 indexed moduleName, uint256 amount); + + /** + * @notice Emitted when a permissioned validator exits + * @param moduleName The module name + * @param amount The exited ETH amount + */ + event PermissionedValidatorExited(bytes32 indexed moduleName, uint256 amount); + + /** + * @notice Emitted when locked ETH is adjusted due to slashing/inactivity + * @param moduleName The module name + * @param reductionAmount The amount reduced + */ + event LockedEthAdjusted(bytes32 indexed moduleName, uint256 reductionAmount); + + /** + * @notice Thrown when trying to exit more ETH than locked + * @param moduleName The module name + * @param lockedAmount The current locked amount + * @param requestedAmount The requested exit amount + */ + error InsufficientLockedEth(bytes32 moduleName, uint256 lockedAmount, uint256 requestedAmount); + + /** + * @notice Returns total locked ETH across all permissioned validators + * @return The total locked ETH amount + */ + function getLockedEthAmount() external view returns (uint256); + + /** + * @notice Returns locked ETH for a specific module + * @param moduleName The module name + * @return The locked ETH amount for the module + */ + function getModuleLockedEth(bytes32 moduleName) external view returns (uint256); + + /** + * @notice Called when a permissioned validator is provisioned + * @param moduleName The module name + * @param amount The staked ETH amount (32-2048 ETH) + */ + function provisionValidator(bytes32 moduleName, uint256 amount) external; + + /** + * @notice Called when a permissioned validator exits + * @param moduleName The module name + * @param amount The exited ETH amount + */ + function exitValidator(bytes32 moduleName, uint256 amount) external; + + /** + * @notice Adjusts locked ETH amount due to slashing or inactivity penalties + * @param moduleName The module name + * @param reductionAmount The amount to reduce from locked ETH + * @dev This should be called when validator balance decreases due to slashing + */ + function adjustLockedEth(bytes32 moduleName, uint256 reductionAmount) external; +} diff --git a/mainnet-contracts/src/interface/IPufferModuleManager.sol b/mainnet-contracts/src/interface/IPufferModuleManager.sol index 32f3cd1a..50603841 100644 --- a/mainnet-contracts/src/interface/IPufferModuleManager.sol +++ b/mainnet-contracts/src/interface/IPufferModuleManager.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.0 <0.9.0; import { RestakingOperator } from "../RestakingOperator.sol"; +import { IEigenPodTypes } from "./Eigenlayer-Slashing/IEigenPod.sol"; /** * @title IPufferModuleManager @@ -116,4 +117,76 @@ interface IPufferModuleManager { * @dev Signature "0x4925eafc82d0c4d67889898eeed64b18488ab19811e61620f387026dec126a28" */ event ClaimerSet(address indexed rewardsReceiver, address indexed claimer); + + /** + * @notice Emitted when the permissioned module beacon is set + * @param beacon The address of the permissioned module beacon + */ + event PermissionedModuleBeaconSet(address indexed beacon); + + /** + * @notice Emitted when queued withdrawals are completed for a permissioned module + * @param permissionedModule The address of the permissioned module + * @param sharesWithdrawn The amount of shares withdrawn + */ + event PermissionedModuleCompletedQueuedWithdrawals(address indexed permissionedModule, uint256 sharesWithdrawn); + + /** + * @notice Emitted when withdrawals are queued for a permissioned module + * @param permissionedModule The address of the permissioned module + * @param shareAmount The amount of shares queued + * @param withdrawalRoot The withdrawal root + */ + event PermissionedModuleWithdrawalsQueued( + address indexed permissionedModule, uint256 shareAmount, bytes32 withdrawalRoot + ); + + /** + * @notice Emitted when a permissioned module is delegated + * @param permissionedModule The address of the permissioned module + * @param operator The operator address + */ + event PermissionedModuleDelegated(address indexed permissionedModule, address indexed operator); + + /** + * @notice Emitted when a permissioned module is undelegated + * @param permissionedModule The address of the permissioned module + */ + event PermissionedModuleUndelegated(address indexed permissionedModule); + + /** + * @notice Emitted when restaked validators exit is triggered for a permissioned module + * @param permissionedModule The address of the permissioned module + * @param pubkeys The pubkeys of the validators + */ + event PermissionedRestakedValidatorsExitTriggered(address indexed permissionedModule, bytes[] pubkeys); + + /** + * @notice Emitted when non-restaked ETH is withdrawn from a permissioned module + * @param permissionedModule The address of the permissioned module + */ + event PermissionedNonRestakedETHWithdrawn(address indexed permissionedModule); + + /** + * @notice Emitted when proof submitter is set for a permissioned module + * @param permissionedModule The address of the permissioned module + * @param proofSubmitter The proof submitter address + */ + event PermissionedProofSubmitterSet(address indexed permissionedModule, address indexed proofSubmitter); + + /** + * @notice Emitted when claimer is set for a permissioned module + * @param permissionedModule The address of the permissioned module + * @param claimer The claimer address + */ + event PermissionedClaimerSet(address indexed permissionedModule, address indexed claimer); + + /** + * @notice Emitted when withdrawal requests are triggered for non-restaked validators + * @param permissionedModule The address of the permissioned module + * @param requests The withdrawal requests (amountGwei == 0 for full exit, > 0 for partial) + */ + event PermissionedNonRestakedValidatorWithdrawalsTriggered( + address indexed permissionedModule, IEigenPodTypes.WithdrawalRequest[] requests + ); } diff --git a/mainnet-contracts/src/interface/IPufferProtocol.sol b/mainnet-contracts/src/interface/IPufferProtocol.sol index 8f88f5d3..33d28534 100644 --- a/mainnet-contracts/src/interface/IPufferProtocol.sol +++ b/mainnet-contracts/src/interface/IPufferProtocol.sol @@ -92,6 +92,11 @@ interface IPufferProtocol { */ error Failed(); + /** + * @notice Thrown when an invalid validator index is provided + */ + error InvalidValidatorIndex(); + /** * @notice Emitted when the number of active validators changes * @dev Signature "0xc06afc2b3c88873a9be580de9bbbcc7fea3027ef0c25fd75d5411ed3195abcec" @@ -183,6 +188,64 @@ interface IPufferProtocol { */ event SuccessfullyProvisioned(bytes pubKey, uint256 indexed pufferModuleIndex, bytes32 indexed moduleName); + /** + * @notice Emitted when a new permissioned module is created + * @param module is the address of the new permissioned module + * @param moduleName is the name of the module + */ + event NewPermissionedModuleCreated(address indexed module, bytes32 indexed moduleName); + + /** + * @notice Emitted when a permissioned validator key is registered + * @param pubKey is the validator public key + * @param pufferModuleIndex is the internal validator index + * @param moduleName is the permissioned module name + * @param isNonRestaked indicates if the validator is non-restaked (direct Beacon Chain) + * @param stakeAmount is the stake amount in wei (32-2048 ETH for non-restaked, always 32 ETH for restaked) + */ + event PermissionedValidatorKeyRegistered( + bytes pubKey, + uint256 indexed pufferModuleIndex, + bytes32 indexed moduleName, + bool isNonRestaked, + uint256 stakeAmount + ); + + /** + * @notice Emitted when a permissioned validator is provisioned + * @param pubKey is the validator public key + * @param pufferModuleIndex is the internal validator index + * @param moduleName is the permissioned module name + * @param isNonRestaked indicates if the validator is non-restaked (direct Beacon Chain) + * @param stakeAmount is the stake amount in wei + */ + event PermissionedValidatorProvisioned( + bytes pubKey, + uint256 indexed pufferModuleIndex, + bytes32 indexed moduleName, + bool isNonRestaked, + uint256 stakeAmount + ); + + /** + * @notice Emitted when a permissioned validator exits + * @param pubKey is the validator public key + * @param pufferModuleIndex is the internal validator index + * @param moduleName is the permissioned module name + * @param withdrawalAmount is the amount withdrawn + */ + event PermissionedValidatorExited( + bytes pubKey, uint256 indexed pufferModuleIndex, bytes32 indexed moduleName, uint256 withdrawalAmount + ); + + /** + * @notice Emitted when a permissioned validator provisioning is skipped + * @param pubKey is the validator public key + * @param pufferModuleIndex is the internal validator index + * @param moduleName is the permissioned module name + */ + event PermissionedValidatorSkipped(bytes pubKey, uint256 indexed pufferModuleIndex, bytes32 indexed moduleName); + /** * @notice Returns validator information * @param moduleName is the staking Module From 6e7ce6345872fbd548b980177cb52de5a30b23ab Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 28 Jan 2026 16:17:12 +0530 Subject: [PATCH 20/55] fix: compile issue due to constructor arg --- mainnet-contracts/script/DeployPufETH.s.sol | 4 +++- mainnet-contracts/script/DeployPuffer.s.sol | 4 +++- .../script/DeployPufferProtocolImplementation.s.sol | 4 +++- mainnet-contracts/script/DeployPufferVault.s.sol | 4 +++- mainnet-contracts/script/Roles.sol | 3 +++ mainnet-contracts/script/UpgradePufETH.s.sol | 4 +++- mainnet-contracts/test/MainnetForkTestHelper.sol | 7 +++++-- .../test/fork-tests/PufferVaultForkTest.t.sol | 4 +++- mainnet-contracts/test/mocks/PufferProtocolMockUpgrade.sol | 4 +++- mainnet-contracts/test/mocks/PufferVaultV5Liq.sol | 6 ++++-- mainnet-contracts/test/mocks/PufferVaultV5Tests.sol | 6 ++++-- mainnet-contracts/test/unit/PufETH.t.sol | 4 +++- mainnet-contracts/test/unit/PufferVault.t.sol | 6 ++++-- mainnet-contracts/test/unit/xPufETHTest.t.sol | 4 +++- 14 files changed, 47 insertions(+), 17 deletions(-) diff --git a/mainnet-contracts/script/DeployPufETH.s.sol b/mainnet-contracts/script/DeployPufETH.s.sol index 6309d523..df505005 100644 --- a/mainnet-contracts/script/DeployPufETH.s.sol +++ b/mainnet-contracts/script/DeployPufETH.s.sol @@ -25,6 +25,7 @@ import { IWETH } from "../src/interface/Other/IWETH.sol"; import { WETH9 } from "../test/mocks/WETH9.sol"; import { ROLE_ID_UPGRADER, ROLE_ID_OPERATIONS_MULTISIG } from "./Roles.sol"; import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import { IPermissionedOracle } from "../src/interface/IPermissionedOracle.sol"; /** * @title DeployPuffer * @author Puffer Finance @@ -117,7 +118,8 @@ contract DeployPufETH is BaseScript { lidoWithdrawalQueue, weth, IPufferOracleV2(address(0)), // Will be set in the upgrade - IPufferRevenueDepositor(address(0)) // Will be set in the upgrade + IPufferRevenueDepositor(address(0)), // Will be set in the upgrade + IPermissionedOracle(address(0)) // Will be set in the upgrade ); vm.label(address(pufferVaultImplementation), "PufferVaultOriginalImplementation"); pufferDepositorImplementation = diff --git a/mainnet-contracts/script/DeployPuffer.s.sol b/mainnet-contracts/script/DeployPuffer.s.sol index ea294c78..cdd95c47 100644 --- a/mainnet-contracts/script/DeployPuffer.s.sol +++ b/mainnet-contracts/script/DeployPuffer.s.sol @@ -30,6 +30,7 @@ import { RewardsCoordinatorMock } from "../test/mocks/RewardsCoordinatorMock.sol import { EigenAllocationManagerMock } from "../test/mocks/EigenAllocationManagerMock.sol"; import { RestakingOperatorController } from "../src/RestakingOperatorController.sol"; import { RestakingOperatorController } from "../src/RestakingOperatorController.sol"; +import { IPermissionedOracle } from "../src/interface/IPermissionedOracle.sol"; /** * @title DeployPuffer * @author Puffer Finance @@ -161,7 +162,8 @@ contract DeployPuffer is BaseScript { guardianModule: GuardianModule(payable(guardiansDeployment.guardianModule)), moduleManager: address(moduleManagerProxy), oracle: IPufferOracleV2(oracle), - beaconDepositContract: getStakingContract() + beaconDepositContract: getStakingContract(), + permissionedOracle: IPermissionedOracle(address(0)) // Will be set in upgrade }); } diff --git a/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol b/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol index d7fba15d..48dcf241 100644 --- a/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol +++ b/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol @@ -14,6 +14,7 @@ import { stdJson } from "forge-std/StdJson.sol"; import { IPufferOracleV2 } from "../src/interface/IPufferOracleV2.sol"; import { GuardianModule } from "../src/GuardianModule.sol"; import { DeployerHelper } from "./DeployerHelper.s.sol"; +import { IPermissionedOracle } from "../src/interface/IPermissionedOracle.sol"; /** * forge script script/DeployPufferProtocolImplementation.s.sol:DeployPufferProtocolImplementation --rpc-url=$RPC_URL --private-key $PK @@ -29,7 +30,8 @@ contract DeployPufferProtocolImplementation is DeployerHelper { guardianModule: GuardianModule(payable(_getGuardianModule())), moduleManager: _getPufferModuleManager(), oracle: IPufferOracleV2(_getPufferOracle()), - beaconDepositContract: _getBeaconDepositContract() + beaconDepositContract: _getBeaconDepositContract(), + permissionedOracle: IPermissionedOracle(address(0)) // TODO: set actual address }) ); diff --git a/mainnet-contracts/script/DeployPufferVault.s.sol b/mainnet-contracts/script/DeployPufferVault.s.sol index a9b96e7a..2d4873f0 100644 --- a/mainnet-contracts/script/DeployPufferVault.s.sol +++ b/mainnet-contracts/script/DeployPufferVault.s.sol @@ -13,6 +13,7 @@ import { IEigenLayer } from "../src/interface/Eigenlayer-Slashing/IEigenLayer.so import { IPufferOracleV2 } from "../src/interface/IPufferOracleV2.sol"; import { IDelegationManager } from "../src/interface/Eigenlayer-Slashing/IDelegationManager.sol"; import { IPufferRevenueDepositor } from "../src/interface/IPufferRevenueDepositor.sol"; +import { IPermissionedOracle } from "../src/interface/IPermissionedOracle.sol"; /** * @title DeployPufferVault @@ -35,7 +36,8 @@ contract DeployPufferVault is DeployerHelper { lidoWithdrawalQueue: ILidoWithdrawalQueue(_getLidoWithdrawalQueue()), weth: IWETH(_getWETH()), pufferOracle: IPufferOracleV2(_getPufferOracle()), - revenueDepositor: IPufferRevenueDepositor(_getRevenueDepositor()) + revenueDepositor: IPufferRevenueDepositor(_getRevenueDepositor()), + permissionedOracle: IPermissionedOracle(address(0)) // TODO: set actual address }); //@todo Double check reinitialization diff --git a/mainnet-contracts/script/Roles.sol b/mainnet-contracts/script/Roles.sol index ca1548c3..9a83c3d4 100644 --- a/mainnet-contracts/script/Roles.sol +++ b/mainnet-contracts/script/Roles.sol @@ -15,6 +15,9 @@ uint64 constant ROLE_ID_WITHDRAWAL_FINALIZER = 25; uint64 constant ROLE_ID_REVENUE_DEPOSITOR = 26; uint64 constant ROLE_ID_VALIDATOR_EJECTOR = 28; +// Role assigned to permissioned validator operators (no bond, no VT) +uint64 constant ROLE_ID_PERMISSIONED_OPERATOR = 29; + // Role assigned to validator ticket price setter uint64 constant ROLE_ID_VT_PRICER = 25; diff --git a/mainnet-contracts/script/UpgradePufETH.s.sol b/mainnet-contracts/script/UpgradePufETH.s.sol index 77c0640b..3ca5c831 100644 --- a/mainnet-contracts/script/UpgradePufETH.s.sol +++ b/mainnet-contracts/script/UpgradePufETH.s.sol @@ -19,6 +19,7 @@ import { PufferDeployment } from "../src/structs/PufferDeployment.sol"; import { BridgingDeployment } from "./DeploymentStructs.sol"; import { IPufferRevenueDepositor } from "../src/interface/IPufferRevenueDepositor.sol"; import { IPufferOracle } from "../src/interface/IPufferOracle.sol"; +import { IPermissionedOracle } from "../src/interface/IPermissionedOracle.sol"; /** * @title UpgradePufETH @@ -58,7 +59,8 @@ contract UpgradePufETH is BaseScript { IWETH(deployment.weth), ILidoWithdrawalQueue(deployment.lidoWithdrawalQueueMock), IPufferOracleV2(pufferOracle), - IPufferRevenueDepositor(revenueDepositor) + IPufferRevenueDepositor(revenueDepositor), + IPermissionedOracle(address(0)) ); vm.label(address(newImplementation), "PufferVaultV5Implementation"); diff --git a/mainnet-contracts/test/MainnetForkTestHelper.sol b/mainnet-contracts/test/MainnetForkTestHelper.sol index 5e5e2f89..34e1d4c3 100644 --- a/mainnet-contracts/test/MainnetForkTestHelper.sol +++ b/mainnet-contracts/test/MainnetForkTestHelper.sol @@ -23,6 +23,7 @@ import { Permit } from "../src/structs/Permit.sol"; import { ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; import { DeployerHelper } from "../script/DeployerHelper.s.sol"; import { IPufferRevenueDepositor } from "../src/interface/IPufferRevenueDepositor.sol"; +import { IPermissionedOracle } from "../src/interface/IPermissionedOracle.sol"; contract MainnetForkTestHelper is Test, DeployerHelper { /** @@ -148,7 +149,8 @@ contract MainnetForkTestHelper is Test, DeployerHelper { lidoWithdrawalQueue: ILidoWithdrawalQueue(_getLidoWithdrawalQueue()), weth: IWETH(_getWETH()), oracle: mockOracle, - revenueDepositor: IPufferRevenueDepositor(address(0)) + revenueDepositor: IPufferRevenueDepositor(address(0)), + permissionedOracle: IPermissionedOracle(address(0)) }); // Simulate that our deployed oracle becomes active and starts posting results of Puffer staking @@ -160,7 +162,8 @@ contract MainnetForkTestHelper is Test, DeployerHelper { lidoWithdrawalQueue: ILidoWithdrawalQueue(_getLidoWithdrawalQueue()), weth: IWETH(_getWETH()), pufferOracle: mockOracle, - revenueDepositor: IPufferRevenueDepositor(address(0)) + revenueDepositor: IPufferRevenueDepositor(address(0)), + permissionedOracle: IPermissionedOracle(address(0)) }); // Community multisig can do thing instantly diff --git a/mainnet-contracts/test/fork-tests/PufferVaultForkTest.t.sol b/mainnet-contracts/test/fork-tests/PufferVaultForkTest.t.sol index 2b1e0aa6..260f37ea 100644 --- a/mainnet-contracts/test/fork-tests/PufferVaultForkTest.t.sol +++ b/mainnet-contracts/test/fork-tests/PufferVaultForkTest.t.sol @@ -14,6 +14,7 @@ import { ILidoWithdrawalQueue } from "../../src/interface/Lido/ILidoWithdrawalQu import { IPufferOracleV2 } from "../../src/interface/IPufferOracleV2.sol"; import { IPufferRevenueDepositor } from "../../src/interface/IPufferRevenueDepositor.sol"; import { MockPufferOracle } from "../mocks/MockPufferOracle.sol"; +import { IPermissionedOracle } from "../../src/interface/IPermissionedOracle.sol"; using Math for uint256; @@ -203,7 +204,8 @@ contract PufferVaultForkTest is MainnetForkTestHelper { lidoWithdrawalQueue: ILidoWithdrawalQueue(_getLidoWithdrawalQueue()), weth: IWETH(_getWETH()), pufferOracle: IPufferOracleV2(address(mockOracle)), - revenueDepositor: IPufferRevenueDepositor(address(0x21660F4681aD5B6039007f7006b5ab0EF9dE7882)) + revenueDepositor: IPufferRevenueDepositor(address(0x21660F4681aD5B6039007f7006b5ab0EF9dE7882)), + permissionedOracle: IPermissionedOracle(address(0)) }); vm.prank(address(timelock)); pufferVault.upgradeToAndCall(address(v5Impl), ""); diff --git a/mainnet-contracts/test/mocks/PufferProtocolMockUpgrade.sol b/mainnet-contracts/test/mocks/PufferProtocolMockUpgrade.sol index 94e11a2b..48ba762e 100644 --- a/mainnet-contracts/test/mocks/PufferProtocolMockUpgrade.sol +++ b/mainnet-contracts/test/mocks/PufferProtocolMockUpgrade.sol @@ -6,6 +6,7 @@ import { GuardianModule } from "../../src/GuardianModule.sol"; import { PufferVaultV5 } from "../../src/PufferVaultV5.sol"; import { ValidatorTicket } from "../../src/ValidatorTicket.sol"; import { IPufferOracleV2 } from "../../src/interface/IPufferOracleV2.sol"; +import { IPermissionedOracle } from "../../src/interface/IPermissionedOracle.sol"; contract PufferProtocolMockUpgrade is PufferProtocol { function returnSomething() external pure returns (uint256) { @@ -19,7 +20,8 @@ contract PufferProtocolMockUpgrade is PufferProtocol { address(0), ValidatorTicket(address(0)), IPufferOracleV2(address(0)), - address(0) + address(0), + IPermissionedOracle(address(0)) ) { } } diff --git a/mainnet-contracts/test/mocks/PufferVaultV5Liq.sol b/mainnet-contracts/test/mocks/PufferVaultV5Liq.sol index cc4a2dae..36c9de95 100644 --- a/mainnet-contracts/test/mocks/PufferVaultV5Liq.sol +++ b/mainnet-contracts/test/mocks/PufferVaultV5Liq.sol @@ -7,6 +7,7 @@ import { ILidoWithdrawalQueue } from "src/interface/Lido/ILidoWithdrawalQueue.so import { IWETH } from "src/interface/Other/IWETH.sol"; import { IPufferOracleV2 } from "src/interface/IPufferOracleV2.sol"; import { IPufferRevenueDepositor } from "src/interface/IPufferRevenueDepositor.sol"; +import { IPermissionedOracle } from "src/interface/IPermissionedOracle.sol"; contract PufferVaultV5Liq is PufferVaultV5 { uint256 private _lockedLiquidity; @@ -16,8 +17,9 @@ contract PufferVaultV5Liq is PufferVaultV5 { IWETH weth, ILidoWithdrawalQueue lidoWithdrawalQueue, IPufferOracleV2 oracle, - IPufferRevenueDepositor revenueDepositor - ) PufferVaultV5(stETH, lidoWithdrawalQueue, weth, oracle, revenueDepositor) { + IPufferRevenueDepositor revenueDepositor, + IPermissionedOracle permissionedOracle + ) PufferVaultV5(stETH, lidoWithdrawalQueue, weth, oracle, revenueDepositor, permissionedOracle) { _disableInitializers(); } diff --git a/mainnet-contracts/test/mocks/PufferVaultV5Tests.sol b/mainnet-contracts/test/mocks/PufferVaultV5Tests.sol index 1e60e908..3742f8ed 100644 --- a/mainnet-contracts/test/mocks/PufferVaultV5Tests.sol +++ b/mainnet-contracts/test/mocks/PufferVaultV5Tests.sol @@ -7,6 +7,7 @@ import { ILidoWithdrawalQueue } from "src/interface/Lido/ILidoWithdrawalQueue.so import { IWETH } from "src/interface/Other/IWETH.sol"; import { IPufferOracleV2 } from "src/interface/IPufferOracleV2.sol"; import { IPufferRevenueDepositor } from "src/interface/IPufferRevenueDepositor.sol"; +import { IPermissionedOracle } from "src/interface/IPermissionedOracle.sol"; contract PufferVaultV5Tests is PufferVaultV5 { constructor( @@ -14,8 +15,9 @@ contract PufferVaultV5Tests is PufferVaultV5 { IWETH weth, ILidoWithdrawalQueue lidoWithdrawalQueue, IPufferOracleV2 oracle, - IPufferRevenueDepositor revenueDepositor - ) PufferVaultV5(stETH, lidoWithdrawalQueue, weth, oracle, revenueDepositor) { + IPufferRevenueDepositor revenueDepositor, + IPermissionedOracle permissionedOracle + ) PufferVaultV5(stETH, lidoWithdrawalQueue, weth, oracle, revenueDepositor, permissionedOracle) { _disableInitializers(); } diff --git a/mainnet-contracts/test/unit/PufETH.t.sol b/mainnet-contracts/test/unit/PufETH.t.sol index 2c874221..5a38baa7 100644 --- a/mainnet-contracts/test/unit/PufETH.t.sol +++ b/mainnet-contracts/test/unit/PufETH.t.sol @@ -20,6 +20,7 @@ import { UUPSUpgradeable } from "@openzeppelin-contracts-upgradeable/proxy/utils import { PufferRevenueDepositorMock } from "../mocks/PufferRevenueDepositorMock.sol"; import { Timelock } from "../../src/Timelock.sol"; import { ROLE_ID_DAO } from "script/Roles.sol"; +import { IPermissionedOracle } from "../../src/interface/IPermissionedOracle.sol"; contract PufETHTest is ERC4626Test { PufferDepositor public pufferDepositor; @@ -120,7 +121,8 @@ contract PufETHTest is ERC4626Test { lidoWithdrawalQueue: ILidoWithdrawalQueue(deployment.lidoWithdrawalQueueMock), weth: IWETH(deployment.weth), oracle: mockOracle, - revenueDepositor: revenueDepositor + revenueDepositor: revenueDepositor, + permissionedOracle: IPermissionedOracle(address(0)) }); vm.startPrank(communityMultisig); diff --git a/mainnet-contracts/test/unit/PufferVault.t.sol b/mainnet-contracts/test/unit/PufferVault.t.sol index 7f9ea095..3dba415b 100644 --- a/mainnet-contracts/test/unit/PufferVault.t.sol +++ b/mainnet-contracts/test/unit/PufferVault.t.sol @@ -8,6 +8,7 @@ import { InvalidAddress } from "src/Errors.sol"; import { PufferVaultV5Liq } from "../mocks/PufferVaultV5Liq.sol"; import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import { LidoWithdrawalQueueMock } from "../mocks/LidoWithdrawalQueueMock.sol"; +import { IPermissionedOracle } from "src/interface/IPermissionedOracle.sol"; contract PufferVaultTest is UnitTestHelper { uint256 pointZeroZeroOne = 0.0001e18; @@ -1040,8 +1041,9 @@ contract PufferVaultTest is UnitTestHelper { accessManager.setTargetFunctionRole(address(pufferVault), selectors, tempRole); accessManager.grantRole(tempRole, address(timelock), 0); - PufferVaultV5Liq newImplementation = - new PufferVaultV5Liq(stETH, weth, new LidoWithdrawalQueueMock(), pufferOracle, revenueDepositor); + PufferVaultV5Liq newImplementation = new PufferVaultV5Liq( + stETH, weth, new LidoWithdrawalQueueMock(), pufferOracle, revenueDepositor, IPermissionedOracle(address(0)) + ); UUPSUpgradeable(address(pufferVault)).upgradeToAndCall(address(newImplementation), ""); vm.stopPrank(); diff --git a/mainnet-contracts/test/unit/xPufETHTest.t.sol b/mainnet-contracts/test/unit/xPufETHTest.t.sol index 02a62513..a189baf8 100644 --- a/mainnet-contracts/test/unit/xPufETHTest.t.sol +++ b/mainnet-contracts/test/unit/xPufETHTest.t.sol @@ -20,6 +20,7 @@ import { MockPufferOracle } from "test/mocks/MockPufferOracle.sol"; import { PufferVaultV5Tests } from "test/mocks/PufferVaultV5Tests.sol"; import { ILidoWithdrawalQueue } from "src/interface/Lido/ILidoWithdrawalQueue.sol"; import { IWETH } from "src/interface/Other/IWETH.sol"; +import { IPermissionedOracle } from "src/interface/IPermissionedOracle.sol"; contract xPufETHTest is Test { PufferDepositor public pufferDepositor; @@ -154,7 +155,8 @@ contract xPufETHTest is Test { lidoWithdrawalQueue: ILidoWithdrawalQueue(deployment.lidoWithdrawalQueueMock), weth: IWETH(deployment.weth), oracle: mockOracle, - revenueDepositor: revenueDepositor + revenueDepositor: revenueDepositor, + permissionedOracle: IPermissionedOracle(address(0)) }); vm.startPrank(communityMultisig); From a09ca8593dc839c1980803d16c661d305e1a60cd Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 28 Jan 2026 18:31:16 +0530 Subject: [PATCH 21/55] feat: add permissionedModule test --- .../unit/PermissionedModuleStandalone.t.sol | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol diff --git a/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol new file mode 100644 index 00000000..ca196bc3 --- /dev/null +++ b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import "forge-std/Test.sol"; +import { PermissionedModule } from "../../src/PermissionedModule.sol"; +import { PufferModuleManager } from "../../src/PufferModuleManager.sol"; +import { NonRestakingWithdrawalCredentials } from "../../src/NonRestakingWithdrawalCredentials.sol"; +import { IEigenPodTypes } from "src/interface/Eigenlayer-Slashing/IEigenPod.sol"; +import { IDelegationManager } from "src/interface/Eigenlayer-Slashing/IDelegationManager.sol"; +import { IRewardsCoordinator } from "src/interface/Eigenlayer-Slashing/IRewardsCoordinator.sol"; +import { IBeaconDepositContract } from "src/interface/IBeaconDepositContract.sol"; +import { IPufferProtocol } from "src/interface/IPufferProtocol.sol"; +import { EigenPodManagerMock } from "../mocks/EigenPodManagerMock.sol"; +import { DelegationManagerMock } from "../mocks/DelegationManagerMock.sol"; +import { RewardsCoordinatorMock } from "../mocks/RewardsCoordinatorMock.sol"; +import { BeaconMock } from "../mocks/BeaconMock.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import { Unauthorized } from "../../src/Errors.sol"; +import { AccessManager } from "@openzeppelin/contracts/access/manager/AccessManager.sol"; + +/** + * @title PermissionedModuleStandaloneTest + * @notice Standalone tests for PermissionedModule that don't require full deployment infrastructure + * @dev Tests the triggerNonRestakedValidatorWithdrawals functionality and related flows + */ +contract PermissionedModuleStandaloneTest is Test { + bytes32 public constant MODULE_NAME = bytes32("TEST_PERM_MODULE"); + uint256 constant EXIT_FEE = 0.0001 ether; + + PermissionedModule public permissionedModule; + address public eigenPodManagerMock; + address public delegationManagerMock; + address public rewardsCoordinatorMock; + address public beaconDepositMock; + AccessManager public accessManager; + + // Mock addresses + address public pufferProtocolAddr; + address public pufferModuleManagerAddr; + address public owner; + + function setUp() public { + owner = makeAddr("owner"); + pufferProtocolAddr = makeAddr("pufferProtocol"); + pufferModuleManagerAddr = makeAddr("pufferModuleManager"); + + vm.deal(owner, 1000 ether); + vm.deal(pufferModuleManagerAddr, 1000 ether); + + // Deploy AccessManager for NonRestakingWithdrawalCredentials + accessManager = new AccessManager(owner); + + // Deploy mocks + eigenPodManagerMock = address(new EigenPodManagerMock()); + delegationManagerMock = address(new DelegationManagerMock()); + rewardsCoordinatorMock = address(new RewardsCoordinatorMock()); + beaconDepositMock = address(new BeaconMock()); + + // Deploy implementation + PermissionedModule impl = new PermissionedModule( + IPufferProtocol(pufferProtocolAddr), + eigenPodManagerMock, + IDelegationManager(delegationManagerMock), + PufferModuleManager(payable(pufferModuleManagerAddr)), + IRewardsCoordinator(rewardsCoordinatorMock), + IBeaconDepositContract(beaconDepositMock) + ); + + // Deploy beacon + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), owner); + + // Deploy proxy - use accessManager as the initialAuthority + bytes memory initData = + abi.encodeWithSelector(PermissionedModule.initialize.selector, MODULE_NAME, address(accessManager)); + BeaconProxy proxy = new BeaconProxy(address(beacon), initData); + permissionedModule = PermissionedModule(payable(address(proxy))); + + // Grant permissions for NonRestakingWithdrawalCredentials.requestWithdrawal + // In production, this would be restricted to the PermissionedModule only + // For testing, we set it to PUBLIC_ROLE so any authorized caller can test the flow + address nrwc = permissionedModule.getNonRestakingWithdrawalCredentialsContract(); + bytes4 requestWithdrawalSelector = NonRestakingWithdrawalCredentials.requestWithdrawal.selector; + + vm.startPrank(owner); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = requestWithdrawalSelector; + accessManager.setTargetFunctionRole(nrwc, selectors, accessManager.PUBLIC_ROLE()); + vm.stopPrank(); + + // Mock the EIP-7002 withdrawal request precompile + _mockWithdrawalRequestPrecompile(); + } + + function _mockWithdrawalRequestPrecompile() internal { + // Mock the withdrawal request address to return a fee and accept calls + address WITHDRAWAL_REQUEST_ADDRESS = 0x00000961Ef480Eb55e80D19ad83579A64c007002; + + // Mock getWithdrawalRequestFee - returns fee in bytes32 format + vm.mockCall(WITHDRAWAL_REQUEST_ADDRESS, bytes(""), abi.encode(EXIT_FEE)); + } + + // ============ Module Initialization Tests ============ + + function test_moduleInitialization() public view { + assertEq(permissionedModule.NAME(), MODULE_NAME, "Module name mismatch"); + assertTrue(permissionedModule.getEigenPod() != address(0), "EigenPod not created"); + assertTrue( + permissionedModule.getNonRestakingWithdrawalCredentialsContract() != address(0), + "NonRestakingWithdrawalCredentials not created" + ); + } + + function test_withdrawalCredentialsFormat() public view { + // Restaking credentials should start with 0x01 (EigenPod) + bytes memory restakingCreds = permissionedModule.getRestakingWithdrawalCredentials(); + assertEq(restakingCreds[0], bytes1(0x01), "Restaking credentials should start with 0x01"); + assertEq(restakingCreds.length, 32, "Restaking credentials should be 32 bytes"); + + // Non-restaking credentials should start with 0x02 (compounding) + bytes memory nonRestakingCreds = permissionedModule.getNonRestakingWithdrawalCredentials(); + assertEq(nonRestakingCreds[0], bytes1(0x02), "Non-restaking credentials should start with 0x02"); + assertEq(nonRestakingCreds.length, 32, "Non-restaking credentials should be 32 bytes"); + } + + function test_immutableAddresses() public view { + assertEq(address(permissionedModule.PUFFER_PROTOCOL()), pufferProtocolAddr, "PUFFER_PROTOCOL mismatch"); + assertEq( + address(permissionedModule.PUFFER_MODULE_MANAGER()), pufferModuleManagerAddr, "PUFFER_MODULE_MANAGER mismatch" + ); + assertEq(address(permissionedModule.EIGEN_POD_MANAGER()), eigenPodManagerMock, "EIGEN_POD_MANAGER mismatch"); + assertEq( + address(permissionedModule.EIGEN_DELEGATION_MANAGER()), + delegationManagerMock, + "EIGEN_DELEGATION_MANAGER mismatch" + ); + } + + // ============ triggerNonRestakedValidatorWithdrawals Tests ============ + + function test_triggerNonRestakedValidatorWithdrawals_fullExit() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ + pubkey: _generatePubkey(1), + amountGwei: 0 // Full exit + }); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); + } + + function test_triggerNonRestakedValidatorWithdrawals_partialWithdrawal() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ + pubkey: _generatePubkey(1), + amountGwei: 1_000_000_000 // 1 ETH partial withdrawal + }); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); + } + + function test_triggerNonRestakedValidatorWithdrawals_multipleRequests() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](3); + + // Mix of full exits and partial withdrawals + requests[0] = IEigenPodTypes.WithdrawalRequest({ + pubkey: _generatePubkey(1), + amountGwei: 0 // Full exit + }); + requests[1] = IEigenPodTypes.WithdrawalRequest({ + pubkey: _generatePubkey(2), + amountGwei: 5_000_000_000 // 5 ETH partial + }); + requests[2] = IEigenPodTypes.WithdrawalRequest({ + pubkey: _generatePubkey(3), + amountGwei: 10_000_000_000 // 10 ETH partial + }); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: 3 * EXIT_FEE }(requests); + } + + function test_triggerNonRestakedValidatorWithdrawals_maxUint64Amount() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + + // Max uint64 amount in gwei + requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(1), amountGwei: type(uint64).max }); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); + } + + // ============ Access Control Tests ============ + + function test_triggerNonRestakedValidatorWithdrawals_unauthorized() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(1), amountGwei: 0 }); + + address randomUser = makeAddr("randomUser"); + vm.deal(randomUser, 1 ether); + + vm.prank(randomUser); + vm.expectRevert(Unauthorized.selector); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); + } + + function test_triggerNonRestakedValidatorWithdrawals_fromOwner_unauthorized() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(1), amountGwei: 0 }); + + // Even owner cannot call directly - only pufferModuleManager + vm.prank(owner); + vm.expectRevert(Unauthorized.selector); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); + } + + function test_withdrawNonRestakedETH_unauthorized() public { + address randomUser = makeAddr("randomUser"); + + vm.prank(randomUser); + vm.expectRevert(Unauthorized.selector); + permissionedModule.withdrawNonRestakedETH(); + } + + // ============ Fuzz Tests ============ + + function testFuzz_triggerNonRestakedValidatorWithdrawals_partialAmount(uint64 amountGwei) public { + vm.assume(amountGwei > 0); // Skip zero as that's a full exit + + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(1), amountGwei: amountGwei }); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); + } + + function testFuzz_triggerNonRestakedValidatorWithdrawals_multipleValidators(uint8 numValidators) public { + numValidators = uint8(bound(numValidators, 1, 20)); + + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](numValidators); + + for (uint256 i = 0; i < numValidators; i++) { + requests[i] = IEigenPodTypes.WithdrawalRequest({ + pubkey: _generatePubkey(i), + amountGwei: uint64(i * 1_000_000_000) // 0, 1 ETH, 2 ETH, etc. + }); + } + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: uint256(numValidators) * EXIT_FEE }(requests); + } + + function testFuzz_triggerNonRestakedValidatorWithdrawals_anyAmount(uint64 amount1, uint64 amount2, uint64 amount3) + public + { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](3); + + requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(1), amountGwei: amount1 }); + requests[1] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(2), amountGwei: amount2 }); + requests[2] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(3), amountGwei: amount3 }); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: 3 * EXIT_FEE }(requests); + } + + // ============ Edge Cases ============ + + function test_triggerNonRestakedValidatorWithdrawals_singleGwei() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ + pubkey: _generatePubkey(1), + amountGwei: 1 // Minimum possible partial withdrawal (1 gwei) + }); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); + } + + function test_triggerNonRestakedValidatorWithdrawals_32EthInGwei() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ + pubkey: _generatePubkey(1), + amountGwei: 32_000_000_000 // 32 ETH in gwei + }); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); + } + + function test_triggerNonRestakedValidatorWithdrawals_2048EthInGwei() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ + pubkey: _generatePubkey(1), + amountGwei: 2048_000_000_000 // 2048 ETH in gwei (Pectra MaxEB) + }); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); + } + + function test_triggerNonRestakedValidatorWithdrawals_emptyArray() public { + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](0); + + // Should not revert at module level - validation is in PufferModuleManager + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: 0 }(requests); + } + + // ============ NonRestakingWithdrawalCredentials Tests ============ + + function test_nonRestakingWithdrawalCredentials_withdrawETH() public { + address nrwc = permissionedModule.getNonRestakingWithdrawalCredentialsContract(); + + // Send some ETH to simulate beacon chain withdrawal + vm.deal(nrwc, 10 ether); + + uint256 moduleBalanceBefore = address(permissionedModule).balance; + + // Call withdrawNonRestakedETH + vm.prank(pufferModuleManagerAddr); + permissionedModule.withdrawNonRestakedETH(); + + assertEq(address(permissionedModule).balance, moduleBalanceBefore + 10 ether, "ETH should be withdrawn"); + assertEq(nrwc.balance, 0, "NRWC balance should be zero"); + } + + function test_nonRestakingWithdrawalCredentials_withdrawETH_unauthorized() public { + // Get the NRWC address first (separate from the expectRevert) + address nrwc = permissionedModule.getNonRestakingWithdrawalCredentialsContract(); + + // Direct call should fail - only PermissionedModule can call + vm.expectRevert(Unauthorized.selector); + NonRestakingWithdrawalCredentials(payable(nrwc)).withdrawETH(); + } + + function test_nonRestakingWithdrawalCredentials_receiveETH() public { + address nrwc = permissionedModule.getNonRestakingWithdrawalCredentialsContract(); + + // NRWC should be able to receive ETH (from beacon chain withdrawals) + vm.deal(address(this), 10 ether); + (bool success,) = nrwc.call{ value: 10 ether }(""); + assertTrue(success, "NRWC should receive ETH"); + assertEq(nrwc.balance, 10 ether, "NRWC balance should be 10 ether"); + } + + function testFuzz_nonRestakingWithdrawalCredentials_withdrawETH(uint256 amount) public { + amount = bound(amount, 0, 1000 ether); + + address nrwc = permissionedModule.getNonRestakingWithdrawalCredentialsContract(); + vm.deal(nrwc, amount); + + uint256 moduleBalanceBefore = address(permissionedModule).balance; + + vm.prank(pufferModuleManagerAddr); + permissionedModule.withdrawNonRestakedETH(); + + assertEq(address(permissionedModule).balance, moduleBalanceBefore + amount, "ETH should be withdrawn"); + assertEq(nrwc.balance, 0, "NRWC balance should be zero"); + } + + // ============ triggerRestakedValidatorsExit Tests ============ + + function test_triggerRestakedValidatorsExit() public { + bytes[] memory pubkeys = new bytes[](1); + pubkeys[0] = _generatePubkey(1); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerRestakedValidatorsExit{ value: EXIT_FEE }(pubkeys); + } + + function test_triggerRestakedValidatorsExit_multiplePubkeys() public { + bytes[] memory pubkeys = new bytes[](3); + pubkeys[0] = _generatePubkey(1); + pubkeys[1] = _generatePubkey(2); + pubkeys[2] = _generatePubkey(3); + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerRestakedValidatorsExit{ value: 3 * EXIT_FEE }(pubkeys); + } + + function test_triggerRestakedValidatorsExit_unauthorized() public { + bytes[] memory pubkeys = new bytes[](1); + pubkeys[0] = _generatePubkey(1); + + address randomUser = makeAddr("randomUser"); + vm.deal(randomUser, 1 ether); + + vm.prank(randomUser); + vm.expectRevert(Unauthorized.selector); + permissionedModule.triggerRestakedValidatorsExit{ value: EXIT_FEE }(pubkeys); + } + + function testFuzz_triggerRestakedValidatorsExit(uint8 numPubkeys) public { + numPubkeys = uint8(bound(numPubkeys, 1, 20)); + + bytes[] memory pubkeys = new bytes[](numPubkeys); + for (uint256 i = 0; i < numPubkeys; i++) { + pubkeys[i] = _generatePubkey(i); + } + + vm.prank(pufferModuleManagerAddr); + permissionedModule.triggerRestakedValidatorsExit{ value: uint256(numPubkeys) * EXIT_FEE }(pubkeys); + } + + // ============ Module can receive ETH ============ + + function test_moduleCanReceiveETH() public { + vm.deal(address(this), 10 ether); + (bool success,) = address(permissionedModule).call{ value: 10 ether }(""); + assertTrue(success, "Module should receive ETH"); + assertEq(address(permissionedModule).balance, 10 ether, "Module balance should be 10 ether"); + } + + // ============ Helper Functions ============ + + function _generatePubkey(uint256 seed) internal pure returns (bytes memory) { + bytes memory pubkey = new bytes(48); + for (uint256 i = 0; i < 48; i++) { + pubkey[i] = bytes1(uint8(uint256(keccak256(abi.encode(seed, i))) % 256)); + } + return pubkey; + } +} From 8f657407d588cc8aaac31928fee1ec9a392f9ac1 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 3 Feb 2026 20:56:50 +0530 Subject: [PATCH 22/55] fix: oracle handling and index issue --- mainnet-contracts/src/PufferProtocol.sol | 39 ++++++++++++++++--- .../src/interface/IPufferProtocol.sol | 23 +++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index f9b9efa8..e2c4bc8d 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -477,6 +477,9 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad * @param validatorIndex The index of the validator * @param withdrawalAmount The amount of ETH withdrawn from the validator * @dev Restricted to authorized roles. Updates oracle and marks validator as exited. + * Oracle is updated based on actual withdrawal amount to ensure accurate totalAssets() accounting. + * If withdrawalAmount < stakeAmount, a slashing event is emitted for transparency. + * If withdrawalAmount > stakeAmount, extra is considered rewards (oracle only deducts stake). */ function handlePermissionedValidatorExit( bytes32 moduleName, @@ -499,8 +502,25 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad uint256 stakeAmount = uint256(validator.stakeAmountGwei) * 1 gwei; bytes memory pubKey = validator.pubKey; - // Update oracle - PUFFER_PERMISSIONED_ORACLE.exitValidator(moduleName, stakeAmount); + // Proper oracle accounting based on actual withdrawal amount + // If slashing occurred (withdrawalAmount < stakeAmount): + // - First adjust for slashing loss, then exit with remaining amount + // If rewards accrued (withdrawalAmount >= stakeAmount): + // - Exit with original stake amount only (rewards are extra) + if (withdrawalAmount < stakeAmount) { + // Slashing detected - emit event for transparency and tracking + uint256 slashingLoss = stakeAmount - withdrawalAmount; + emit PermissionedValidatorSlashingDetected( + moduleName, validatorIndex, stakeAmount, withdrawalAmount, slashingLoss + ); + // Adjust for slashing loss first, then exit with actual withdrawal + PUFFER_PERMISSIONED_ORACLE.adjustLockedEth(moduleName, slashingLoss); + PUFFER_PERMISSIONED_ORACLE.exitValidator(moduleName, withdrawalAmount); + } else { + // No slashing (withdrawalAmount >= stakeAmount) - exit with original stake + // Any extra is rewards and will be reflected in module/vault balance + PUFFER_PERMISSIONED_ORACLE.exitValidator(moduleName, stakeAmount); + } // Delete validator data (same as batchHandleWithdrawals for external validators) delete $.permissionedValidators[moduleName][validatorIndex]; @@ -514,6 +534,8 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad * @param validatorIndex The index of the validator to skip * @dev Restricted to authorized roles. Only PENDING validators can be skipped. * Unlike external validators, no VT penalty since permissioned validators don't pay VT. + * Only the next validator in line can be skipped (FIFO ordering enforced). + * This ensures consistent index tracking and prevents skipped validator tracking issues. */ function skipPermissionedProvisioning(bytes32 moduleName, uint256 validatorIndex) external restricted { ProtocolStorage storage $ = _getPufferProtocolStorage(); @@ -523,6 +545,13 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad revert InvalidValidatorIndex(); } + // Enforce FIFO ordering - only allow skipping the next validator in line + // This ensures nextPermissionedValidatorToBeProvisionedIndices stays consistent + uint256 nextToProvision = $.nextPermissionedValidatorToBeProvisionedIndices[moduleName]; + if (validatorIndex != nextToProvision) { + revert MustSkipNextValidator(nextToProvision, validatorIndex); + } + PermissionedValidator storage validator = $.permissionedValidators[moduleName][validatorIndex]; if (validator.status != Status.PENDING) { @@ -534,10 +563,8 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad // Delete validator data delete $.permissionedValidators[moduleName][validatorIndex]; - // Update next to be provisioned index if this was the next in line - if ($.nextPermissionedValidatorToBeProvisionedIndices[moduleName] == validatorIndex) { - $.nextPermissionedValidatorToBeProvisionedIndices[moduleName] = validatorIndex + 1; - } + // Always update next to be provisioned index (guaranteed to be the skipped one due to FIFO check) + $.nextPermissionedValidatorToBeProvisionedIndices[moduleName] = validatorIndex + 1; emit PermissionedValidatorSkipped(pubKey, validatorIndex, moduleName); } diff --git a/mainnet-contracts/src/interface/IPufferProtocol.sol b/mainnet-contracts/src/interface/IPufferProtocol.sol index 33d28534..bdf7c680 100644 --- a/mainnet-contracts/src/interface/IPufferProtocol.sol +++ b/mainnet-contracts/src/interface/IPufferProtocol.sol @@ -97,6 +97,29 @@ interface IPufferProtocol { */ error InvalidValidatorIndex(); + /** + * @notice Thrown when trying to skip a validator that is not next in line for provisioning + * @param expected The expected validator index (next in line) + * @param actual The actual validator index that was provided + */ + error MustSkipNextValidator(uint256 expected, uint256 actual); + + /** + * @notice Emitted when a permissioned validator experiences slashing loss + * @param moduleName The module name + * @param validatorIndex The validator index + * @param stakeAmount The original stake amount + * @param withdrawalAmount The actual withdrawal amount + * @param slashingLoss The slashing loss (stakeAmount - withdrawalAmount) + */ + event PermissionedValidatorSlashingDetected( + bytes32 indexed moduleName, + uint256 indexed validatorIndex, + uint256 stakeAmount, + uint256 withdrawalAmount, + uint256 slashingLoss + ); + /** * @notice Emitted when the number of active validators changes * @dev Signature "0xc06afc2b3c88873a9be580de9bbbcc7fea3027ef0c25fd75d5411ed3195abcec" From 4a2c8ab7763c53a4d1ccabe137392c6b44e5aaab Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 3 Feb 2026 22:47:21 +0530 Subject: [PATCH 23/55] feat: add fork test for permissioned validator flow --- .../PermissionedValidatorFork.t.sol | 812 ++++++++++++++++++ 1 file changed, 812 insertions(+) create mode 100644 mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol new file mode 100644 index 00000000..92342fc2 --- /dev/null +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol @@ -0,0 +1,812 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { console } from "forge-std/console.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import { MainnetForkTestHelper } from "../MainnetForkTestHelper.sol"; +import { PufferProtocol } from "../../src/PufferProtocol.sol"; +import { PufferModuleManager } from "../../src/PufferModuleManager.sol"; +import { PermissionedModule } from "../../src/PermissionedModule.sol"; +import { PermissionedOracle } from "../../src/PermissionedOracle.sol"; +import { NonRestakingWithdrawalCredentials } from "../../src/NonRestakingWithdrawalCredentials.sol"; +import { Timelock } from "../../src/Timelock.sol"; +import { IEigenPod, IEigenPodTypes } from "../../src/interface/Eigenlayer-Slashing/IEigenPod.sol"; +import { IDelegationManager } from "../../src/interface/Eigenlayer-Slashing/IDelegationManager.sol"; +import { IBeaconDepositContract } from "../../src/interface/IBeaconDepositContract.sol"; +import { IRewardsCoordinator } from "../../src/interface/Eigenlayer-Slashing/IRewardsCoordinator.sol"; +import { IGuardianModule } from "../../src/interface/IGuardianModule.sol"; +import { ValidatorTicket } from "../../src/ValidatorTicket.sol"; +import { IPufferOracleV2 } from "../../src/interface/IPufferOracleV2.sol"; +import { IPermissionedOracle } from "../../src/interface/IPermissionedOracle.sol"; +import { PermissionedValidator } from "../../src/struct/Validator.sol"; +import { Status } from "../../src/struct/Status.sol"; + +import { + ROLE_ID_DAO, + ROLE_ID_PERMISSIONED_OPERATOR, + ROLE_ID_OPERATIONS_PAYMASTER, + ROLE_ID_PUFFER_PROTOCOL +} from "../../script/Roles.sol"; + +/** + * @title PermissionedValidatorForkTest + * @notice Fork tests for permissioned validator flow using mainnet fork + * @dev Tests the complete permissioned validator lifecycle mimicking production flow + * Uses real EIP-7002 precompile since Pectra is live on mainnet (activated May 7, 2025) + */ +contract PermissionedValidatorForkTest is MainnetForkTestHelper { + // Mainnet fork block - post-Pectra block (Pectra activated May 7, 2025 at epoch 364032) + uint256 constant FORK_BLOCK = 24_333_965; + + // EIP-7002 Withdrawal Request Precompile (live on mainnet since Pectra) + address internal constant WITHDRAWAL_REQUEST_ADDRESS = 0x00000961Ef480Eb55e80D19ad83579A64c007002; + + // Contract instances (in addition to inherited ones) + PufferProtocol public pufferProtocol; + PufferModuleManager public pufferModuleManager; + PermissionedOracle public permissionedOracle; + UpgradeableBeacon public permissionedModuleBeacon; + + // Test actors + address permissionedOperator = makeAddr("permissionedOperator"); + address paymaster; + address dao; + + // Test constants + bytes32 constant TEST_MODULE_NAME = bytes32("TEST_PERM_MODULE"); + // BLS public key must be exactly 48 bytes = 96 hex characters + bytes constant TEST_PUBKEY = + hex"aabbccddee0011223344556677889900aabbccddee0011223344556677889900aabbccddee00112233445566778899aa"; + // BLS signature must be exactly 96 bytes = 192 hex characters + bytes constant TEST_SIGNATURE = + hex"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + + function setUp() public override { + // Create mainnet fork at specific block + // Try to use MAINNET_RPC_URL environment variable first, fall back to public RPC + string memory rpcUrl; + try vm.rpcUrl("mainnet") returns (string memory url) { + rpcUrl = url; + } catch { + // Fallback to PublicNode free RPC (has archive support) + rpcUrl = "https://ethereum-rpc.publicnode.com"; + } + vm.createSelectFork(rpcUrl, FORK_BLOCK); + + // Setup live contracts using inherited helper + _setupLiveContracts(); + + // Setup additional contracts from DeployerHelper + pufferProtocol = PufferProtocol(payable(_getPufferProtocol())); + pufferModuleManager = PufferModuleManager(payable(_getPufferModuleManager())); + paymaster = _getPaymaster(); + dao = _getDAO(); + + vm.label(address(pufferProtocol), "PufferProtocol"); + vm.label(address(pufferModuleManager), "PufferModuleManager"); + + // Deploy and setup permissioned infrastructure + _deployPermissionedInfrastructure(); + + // Setup access control + _setupAccessControl(); + + // Verify EIP-7002 precompile is live + _verifyWithdrawalRequestPrecompile(); + } + + function _deployPermissionedInfrastructure() internal { + // Deploy PermissionedOracle (anyone can deploy, access control is set separately) + permissionedOracle = new PermissionedOracle(_getAccessManager()); + vm.label(address(permissionedOracle), "PermissionedOracle"); + + // Deploy PermissionedModule implementation + PermissionedModule permissionedModuleImpl = new PermissionedModule( + pufferProtocol, + _getEigenPodManager(), + IDelegationManager(_getDelegationManager()), + pufferModuleManager, + IRewardsCoordinator(_getRewardsCoordinator()), + IBeaconDepositContract(_getBeaconDepositContract()) + ); + vm.label(address(permissionedModuleImpl), "PermissionedModuleImpl"); + + // Deploy UpgradeableBeacon for PermissionedModule with COMMUNITY_MULTISIG as owner + vm.prank(COMMUNITY_MULTISIG); + permissionedModuleBeacon = new UpgradeableBeacon(address(permissionedModuleImpl), COMMUNITY_MULTISIG); + vm.label(address(permissionedModuleBeacon), "PermissionedModuleBeacon"); + + // Deploy new PufferProtocol implementation with PermissionedOracle + PufferProtocol newProtocolImpl = new PufferProtocol( + pufferVault, + IGuardianModule(_getGuardianModule()), + address(pufferModuleManager), + ValidatorTicket(_getValidatorTicket()), + IPufferOracleV2(_getPufferOracle()), + _getBeaconDepositContract(), + IPermissionedOracle(address(permissionedOracle)) + ); + vm.label(address(newProtocolImpl), "PufferProtocolNewImpl"); + + // Deploy new PufferModuleManager implementation + PufferModuleManager newModuleManagerImpl = new PufferModuleManager( + _getPufferModuleBeacon(), _getRestakingOperatorBeacon(), _getPufferProtocol() + ); + vm.label(address(newModuleManagerImpl), "PufferModuleManagerNewImpl"); + + // Execute upgrades through Timelock as COMMUNITY_MULTISIG (instant execution, no delay) + vm.startPrank(COMMUNITY_MULTISIG); + + bool success; + + // 1. Upgrade PufferProtocol via Timelock + bytes memory protocolUpgradeCalldata = abi.encodeCall( + UUPSUpgradeable.upgradeToAndCall, + (address(newProtocolImpl), "") + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (_getPufferProtocol(), protocolUpgradeCalldata, 1)) + ); + require(success, "PufferProtocol upgrade failed"); + + // 2. Upgrade PufferModuleManager via Timelock + bytes memory moduleManagerUpgradeCalldata = abi.encodeCall( + UUPSUpgradeable.upgradeToAndCall, + (address(newModuleManagerImpl), "") + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (_getPufferModuleManager(), moduleManagerUpgradeCalldata, 2)) + ); + require(success, "PufferModuleManager upgrade failed"); + + // 3. Set permissioned module beacon via Timelock -> AccessManager -> PufferModuleManager + bytes memory setBeaconCalldata = abi.encodeCall( + PufferModuleManager.setPermissionedModuleBeacon, + (address(permissionedModuleBeacon)) + ); + // First, grant the DAO role permission to call setPermissionedModuleBeacon + bytes4[] memory beaconSelectors = new bytes4[](1); + beaconSelectors[0] = PufferModuleManager.setPermissionedModuleBeacon.selector; + bytes memory grantBeaconRoleCalldata = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), grantBeaconRoleCalldata, 3)) + ); + require(success, "Grant beacon role failed"); + + vm.stopPrank(); + + // Now execute setPermissionedModuleBeacon as dao (who has ROLE_ID_DAO) + vm.prank(dao); + accessManager.execute(_getPufferModuleManager(), setBeaconCalldata); + } + + function _setupAccessControl() internal { + // Execute access control changes through Timelock as COMMUNITY_MULTISIG + // Community multisig can execute instantly without delay + vm.startPrank(COMMUNITY_MULTISIG); + + bool success; + uint256 operationId = 100; // Start from 100 to avoid conflicts with upgrade operations + + bytes4[] memory selectors; + + // Grant ROLE_ID_DAO to dao address for createPermissionedModule + selectors = new bytes4[](1); + selectors[0] = PufferProtocol.createPermissionedModule.selector; + bytes memory callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferProtocol(), selectors, ROLE_ID_DAO) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "setTargetFunctionRole for createPermissionedModule failed"); + + // Grant dao the DAO role + callData = abi.encodeCall(accessManager.grantRole, (ROLE_ID_DAO, dao, 0)); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "grantRole DAO failed"); + + // Grant ROLE_ID_PERMISSIONED_OPERATOR to permissionedOperator + selectors = new bytes4[](1); + selectors[0] = PufferProtocol.registerPermissionedValidatorKey.selector; + callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferProtocol(), selectors, ROLE_ID_PERMISSIONED_OPERATOR) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "setTargetFunctionRole for registerPermissionedValidatorKey failed"); + + callData = abi.encodeCall(accessManager.grantRole, (ROLE_ID_PERMISSIONED_OPERATOR, permissionedOperator, 0)); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "grantRole PERMISSIONED_OPERATOR failed"); + + // Grant ROLE_ID_OPERATIONS_PAYMASTER to paymaster for PufferProtocol functions + selectors = new bytes4[](3); + selectors[0] = PufferProtocol.provisionPermissionedValidator.selector; + selectors[1] = PufferProtocol.handlePermissionedValidatorExit.selector; + selectors[2] = PufferProtocol.skipPermissionedProvisioning.selector; + callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferProtocol(), selectors, ROLE_ID_OPERATIONS_PAYMASTER) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "setTargetFunctionRole for paymaster protocol functions failed"); + + callData = abi.encodeCall(accessManager.grantRole, (ROLE_ID_OPERATIONS_PAYMASTER, paymaster, 0)); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "grantRole OPERATIONS_PAYMASTER failed"); + + // Grant PufferModuleManager functions to paymaster + bytes4[] memory moduleManagerSelectors = new bytes4[](4); + moduleManagerSelectors[0] = PufferModuleManager.triggerRestakedValidatorsExit.selector; + moduleManagerSelectors[1] = PufferModuleManager.triggerNonRestakedValidatorWithdrawals.selector; + moduleManagerSelectors[2] = PufferModuleManager.withdrawNonRestakedETH.selector; + moduleManagerSelectors[3] = PufferModuleManager.transferPermissionedModuleETHToVault.selector; + callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferModuleManager(), moduleManagerSelectors, ROLE_ID_OPERATIONS_PAYMASTER) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "setTargetFunctionRole for paymaster module manager functions failed"); + + // Grant ROLE_ID_PUFFER_PROTOCOL to PufferProtocol for oracle updates + selectors = new bytes4[](3); + selectors[0] = PermissionedOracle.provisionValidator.selector; + selectors[1] = PermissionedOracle.exitValidator.selector; + selectors[2] = PermissionedOracle.adjustLockedEth.selector; + callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (address(permissionedOracle), selectors, ROLE_ID_PUFFER_PROTOCOL) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "setTargetFunctionRole for oracle failed"); + + callData = abi.encodeCall(accessManager.grantRole, (ROLE_ID_PUFFER_PROTOCOL, _getPufferProtocol(), 0)); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "grantRole PUFFER_PROTOCOL failed"); + + vm.stopPrank(); + } + + function _verifyWithdrawalRequestPrecompile() internal view { + // EIP-7002 Withdrawal Request Precompile is live on mainnet since Pectra (May 7, 2025) + // Verify the precompile exists at the fork block + require( + WITHDRAWAL_REQUEST_ADDRESS.code.length > 0, "EIP-7002 precompile not found - fork block may be pre-Pectra" + ); + + // Log the precompile fee for debugging + uint256 fee = _getWithdrawalRequestFee(); + console.log("EIP-7002 withdrawal request fee:", fee); + } + + /** + * @notice Get the withdrawal request fee from EIP-7002 precompile + * @return fee The fee per withdrawal request + */ + function _getWithdrawalRequestFee() internal view returns (uint256 fee) { + (bool success, bytes memory result) = WITHDRAWAL_REQUEST_ADDRESS.staticcall(""); + require(success && result.length == 32, "Fee query failed"); + return abi.decode(result, (uint256)); + } + + // ============ Test: Module Creation ============ + + function test_createPermissionedModule() public { + vm.prank(dao); + address moduleAddress = pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + assertTrue(moduleAddress != address(0), "Module should be created"); + + // Verify module is stored + address storedModule = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + assertEq(storedModule, moduleAddress, "Module address should match"); + + // Verify EigenPod was created + PermissionedModule module = PermissionedModule(payable(moduleAddress)); + address eigenPod = module.getEigenPod(); + assertTrue(eigenPod != address(0), "EigenPod should be created"); + + // Verify NonRestakingWithdrawalCredentials was created + address nrwc = module.getNonRestakingWithdrawalCredentialsContract(); + assertTrue(nrwc != address(0), "NRWC should be created"); + + // Verify withdrawal credentials formats + bytes memory restakingCreds = module.getRestakingWithdrawalCredentials(); + assertEq(restakingCreds[0], bytes1(0x01), "Restaking creds should start with 0x01"); + + bytes memory nonRestakingCreds = module.getNonRestakingWithdrawalCredentials(); + assertEq(nonRestakingCreds[0], bytes1(0x02), "Non-restaking creds should start with 0x02"); + } + + function test_createPermissionedModule_revertIfExists() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.prank(dao); + vm.expectRevert(); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + } + + // ============ Test: Validator Registration ============ + + function test_registerNonRestakedValidator() public { + // Create module first + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + // Register non-restaked validator with 100 ETH + vm.prank(permissionedOperator); + uint256 index = pufferProtocol.registerPermissionedValidatorKey( + TEST_PUBKEY, + TEST_MODULE_NAME, + true, // isNonRestaked + 100 ether + ); + + assertEq(index, 0, "First validator index should be 0"); + + // Verify validator is stored + PermissionedValidator memory validator = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, index); + assertEq(validator.node, permissionedOperator, "Node should be operator"); + assertTrue(validator.isNonRestaked, "Should be non-restaked"); + assertEq(validator.stakeAmountGwei, uint64(100 ether / 1 gwei), "Stake amount should be 100 ETH in gwei"); + assertEq(uint8(validator.status), uint8(Status.PENDING), "Status should be PENDING"); + + // Verify index incremented + uint256 pendingIndex = pufferProtocol.getPendingPermissionedValidatorIndex(TEST_MODULE_NAME); + assertEq(pendingIndex, 1, "Pending index should be 1"); + } + + function test_registerRestakedValidator() public { + // Create module first + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + // Register restaked validator with 32 ETH + vm.prank(permissionedOperator); + uint256 index = pufferProtocol.registerPermissionedValidatorKey( + TEST_PUBKEY, + TEST_MODULE_NAME, + false, // isNonRestaked (restaked) + 32 ether + ); + + assertEq(index, 0, "First validator index should be 0"); + + // Verify validator is stored + PermissionedValidator memory validator = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, index); + assertEq(validator.node, permissionedOperator, "Node should be operator"); + assertFalse(validator.isNonRestaked, "Should be restaked"); + assertEq(validator.stakeAmountGwei, uint64(32 ether / 1 gwei), "Stake amount should be 32 ETH in gwei"); + } + + function test_registerNonRestakedValidator_variableStakes() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + // Test minimum (32 ETH) + bytes memory pubkey1 = abi.encodePacked(bytes32(uint256(1)), bytes16(0)); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey1, TEST_MODULE_NAME, true, 32 ether); + + // Test maximum (2048 ETH) + bytes memory pubkey2 = abi.encodePacked(bytes32(uint256(2)), bytes16(0)); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey2, TEST_MODULE_NAME, true, 2048 ether); + + // Test mid-range (512 ETH) + bytes memory pubkey3 = abi.encodePacked(bytes32(uint256(3)), bytes16(0)); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey3, TEST_MODULE_NAME, true, 512 ether); + + // Verify all registered + assertEq(pufferProtocol.getPendingPermissionedValidatorIndex(TEST_MODULE_NAME), 3); + } + + function test_registerValidator_revertInvalidStake() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + // Test below minimum + vm.prank(permissionedOperator); + vm.expectRevert(); + pufferProtocol.registerPermissionedValidatorKey(TEST_PUBKEY, TEST_MODULE_NAME, true, 31 ether); + + // Test above maximum for non-restaked + bytes memory pubkey2 = abi.encodePacked(bytes32(uint256(2)), bytes16(0)); + vm.prank(permissionedOperator); + vm.expectRevert(); + pufferProtocol.registerPermissionedValidatorKey(pubkey2, TEST_MODULE_NAME, true, 2049 ether); + + // Test non-32 ETH for restaked + bytes memory pubkey3 = abi.encodePacked(bytes32(uint256(3)), bytes16(0)); + vm.prank(permissionedOperator); + vm.expectRevert(); + pufferProtocol.registerPermissionedValidatorKey(pubkey3, TEST_MODULE_NAME, false, 64 ether); + } + + // ============ Test: Validator Provisioning ============ + + function test_provisionNonRestakedValidator() public { + // Setup: Create module and register validator + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.prank(permissionedOperator); + uint256 index = pufferProtocol.registerPermissionedValidatorKey( + TEST_PUBKEY, + TEST_MODULE_NAME, + true, // isNonRestaked + 100 ether + ); + + // Fund the vault + vm.deal(address(pufferVault), 200 ether); + + // Get deposit root + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Provision validator + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, index, TEST_SIGNATURE, depositRoot); + + // Verify status changed to ACTIVE + PermissionedValidator memory validator = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, index); + assertEq(uint8(validator.status), uint8(Status.ACTIVE), "Status should be ACTIVE"); + + // Verify oracle updated + uint256 lockedEth = permissionedOracle.getModuleLockedEth(TEST_MODULE_NAME); + assertEq(lockedEth, 100 ether, "Oracle should track 100 ETH"); + } + + function test_provisionRestakedValidator() public { + // Setup: Create module and register restaked validator + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.prank(permissionedOperator); + uint256 index = pufferProtocol.registerPermissionedValidatorKey( + TEST_PUBKEY, + TEST_MODULE_NAME, + false, // restaked + 32 ether + ); + + // Fund the vault + vm.deal(address(pufferVault), 100 ether); + + // Get deposit root + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Provision validator + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, index, TEST_SIGNATURE, depositRoot); + + // Verify status changed to ACTIVE + PermissionedValidator memory validator = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, index); + assertEq(uint8(validator.status), uint8(Status.ACTIVE), "Status should be ACTIVE"); + + // Verify oracle updated + uint256 lockedEth = permissionedOracle.getModuleLockedEth(TEST_MODULE_NAME); + assertEq(lockedEth, 32 ether, "Oracle should track 32 ETH"); + } + + // ============ Test: Non-Restaked Validator Withdrawals ============ + + function test_triggerNonRestakedValidatorWithdrawals_fullExit() public { + // Setup: Create module, register and provision validator + _setupProvisionedNonRestakedValidator(100 ether); + + address moduleAddress = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + PermissionedModule module = PermissionedModule(payable(moduleAddress)); + address nrwc = module.getNonRestakingWithdrawalCredentialsContract(); + + // Setup NonRestakingWithdrawalCredentials access + _grantNRWCAccess(nrwc, moduleAddress); + + // Trigger full exit (amountGwei = 0) + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ + pubkey: TEST_PUBKEY, + amountGwei: 0 // Full exit + }); + + uint256 fee = _getWithdrawalRequestFee() * requests.length; + vm.deal(paymaster, fee); + + vm.prank(paymaster); + pufferModuleManager.triggerNonRestakedValidatorWithdrawals{ value: fee }(moduleAddress, requests); + } + + function test_triggerNonRestakedValidatorWithdrawals_partialWithdrawal() public { + // Setup: Create module, register and provision validator with 100 ETH + _setupProvisionedNonRestakedValidator(100 ether); + + address moduleAddress = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + PermissionedModule module = PermissionedModule(payable(moduleAddress)); + address nrwc = module.getNonRestakingWithdrawalCredentialsContract(); + + // Setup NonRestakingWithdrawalCredentials access + _grantNRWCAccess(nrwc, moduleAddress); + + // Trigger partial withdrawal of 5 ETH (Pectra feature) + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ + pubkey: TEST_PUBKEY, + amountGwei: uint64(5 ether / 1 gwei) // 5 ETH partial withdrawal + }); + + uint256 fee = _getWithdrawalRequestFee() * requests.length; + vm.deal(paymaster, fee); + + vm.prank(paymaster); + pufferModuleManager.triggerNonRestakedValidatorWithdrawals{ value: fee }(moduleAddress, requests); + } + + // ============ Test: Restaked Validator Exit ============ + + function test_triggerRestakedValidatorsExit() public { + // Setup: Create module, register and provision restaked validator + _setupProvisionedRestakedValidator(); + + address moduleAddress = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + PermissionedModule module = PermissionedModule(payable(moduleAddress)); + + // Mock EigenPod withdrawal request + address eigenPod = module.getEigenPod(); + vm.mockCall(eigenPod, abi.encodeWithSelector(IEigenPod.requestWithdrawal.selector), ""); + + bytes[] memory pubkeys = new bytes[](1); + pubkeys[0] = TEST_PUBKEY; + + uint256 fee = _getWithdrawalRequestFee() * pubkeys.length; + vm.deal(paymaster, fee); + + vm.prank(paymaster); + pufferModuleManager.triggerRestakedValidatorsExit{ value: fee }(moduleAddress, pubkeys); + } + + // ============ Test: Withdraw Non-Restaked ETH ============ + + function test_withdrawNonRestakedETH() public { + // Setup: Create module + vm.prank(dao); + address moduleAddress = pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + PermissionedModule module = PermissionedModule(payable(moduleAddress)); + address nrwc = module.getNonRestakingWithdrawalCredentialsContract(); + + // Simulate beacon chain withdrawal to NRWC + vm.deal(nrwc, 32 ether); + + uint256 moduleBalanceBefore = moduleAddress.balance; + + // Withdraw ETH from NRWC to module + vm.prank(paymaster); + pufferModuleManager.withdrawNonRestakedETH(moduleAddress); + + uint256 moduleBalanceAfter = moduleAddress.balance; + assertEq(moduleBalanceAfter - moduleBalanceBefore, 32 ether, "Module should receive 32 ETH"); + assertEq(nrwc.balance, 0, "NRWC should be empty"); + } + + // ============ Test: Handle Validator Exit ============ + + function test_handlePermissionedValidatorExit() public { + // Setup: Create module, register and provision validator + _setupProvisionedNonRestakedValidator(100 ether); + + uint256 oracleLockedBefore = permissionedOracle.totalLockedEth(); + + // Handle exit + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 0, 100 ether); + + // Verify validator data deleted + PermissionedValidator memory validator = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 0); + assertEq(validator.node, address(0), "Validator should be deleted"); + + // Verify oracle updated + uint256 oracleLockedAfter = permissionedOracle.totalLockedEth(); + assertEq(oracleLockedBefore - oracleLockedAfter, 100 ether, "Oracle should decrease by 100 ETH"); + } + + // ============ Test: Skip Provisioning ============ + + function test_skipPermissionedProvisioning() public { + // Setup: Create module and register validator + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(TEST_PUBKEY, TEST_MODULE_NAME, true, 100 ether); + + // Skip provisioning + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); + + // Verify validator data deleted + PermissionedValidator memory validator = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 0); + assertEq(validator.node, address(0), "Validator should be deleted"); + + // Verify next to be provisioned index updated + uint256 nextIndex = pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME); + assertEq(nextIndex, 1, "Next index should be updated"); + } + + // ============ Test: Access Control ============ + + function test_accessControl_unauthorized() public { + address unauthorized = makeAddr("unauthorized"); + + // Create module (only DAO) + vm.prank(unauthorized); + vm.expectRevert(); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + // First create module with authorized user + vm.prank(dao); + address moduleAddress = pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + // Register validator (only permissioned operator) + vm.prank(unauthorized); + vm.expectRevert(); + pufferProtocol.registerPermissionedValidatorKey(TEST_PUBKEY, TEST_MODULE_NAME, true, 100 ether); + + // Provision validator (only paymaster) + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(TEST_PUBKEY, TEST_MODULE_NAME, true, 100 ether); + + vm.prank(unauthorized); + vm.expectRevert(); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, bytes32(0)); + + // Trigger withdrawals (only paymaster) + IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); + requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: TEST_PUBKEY, amountGwei: 0 }); + + vm.prank(unauthorized); + vm.expectRevert(); + pufferModuleManager.triggerNonRestakedValidatorWithdrawals(moduleAddress, requests); + } + + // ============ Test: Oracle Integration ============ + + function test_oracleTracking() public { + // Setup: Create module + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + // Register and provision multiple validators + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(TEST_PUBKEY, TEST_MODULE_NAME, true, 100 ether); + + bytes memory pubkey2 = abi.encodePacked(bytes32(uint256(2)), bytes16(0)); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey2, TEST_MODULE_NAME, true, 200 ether); + + // Fund vault + vm.deal(address(pufferVault), 500 ether); + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Provision first validator + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + + assertEq(permissionedOracle.totalLockedEth(), 100 ether, "Should track 100 ETH after first provision"); + assertEq(permissionedOracle.getModuleLockedEth(TEST_MODULE_NAME), 100 ether); + + // Update deposit root after first deposit + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Provision second validator + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 1, TEST_SIGNATURE, depositRoot); + + assertEq(permissionedOracle.totalLockedEth(), 300 ether, "Should track 300 ETH after second provision"); + assertEq(permissionedOracle.getModuleLockedEth(TEST_MODULE_NAME), 300 ether); + + // Exit first validator + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 0, 100 ether); + + assertEq(permissionedOracle.totalLockedEth(), 200 ether, "Should track 200 ETH after exit"); + assertEq(permissionedOracle.getModuleLockedEth(TEST_MODULE_NAME), 200 ether); + } + + // ============ Helper Functions ============ + + function _setupProvisionedNonRestakedValidator(uint256 stakeAmount) internal { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey( + TEST_PUBKEY, + TEST_MODULE_NAME, + true, // isNonRestaked + stakeAmount + ); + + vm.deal(address(pufferVault), stakeAmount * 2); + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + } + + function _setupProvisionedRestakedValidator() internal { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey( + TEST_PUBKEY, + TEST_MODULE_NAME, + false, // restaked + 32 ether + ); + + vm.deal(address(pufferVault), 100 ether); + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + } + + function _grantNRWCAccess(address nrwc, address moduleAddress) internal { + // The PermissionedModule calls NonRestakingWithdrawalCredentials.requestWithdrawal + // So we need to grant the module the permission to call that function + // Execute through Timelock as COMMUNITY_MULTISIG for production-like flow + vm.startPrank(COMMUNITY_MULTISIG); + + bool success; + uint256 operationId = 200; // Use different range to avoid conflicts + + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = NonRestakingWithdrawalCredentials.requestWithdrawal.selector; + bytes memory callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (nrwc, selectors, ROLE_ID_OPERATIONS_PAYMASTER) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "setTargetFunctionRole for NRWC failed"); + + // Grant the module the OPERATIONS_PAYMASTER role so it can call requestWithdrawal + callData = abi.encodeCall(accessManager.grantRole, (ROLE_ID_OPERATIONS_PAYMASTER, moduleAddress, 0)); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "grantRole to module for NRWC failed"); + + vm.stopPrank(); + } +} From bf4e74738ed7e31ab85e1f47e034584245fd8622 Mon Sep 17 00:00:00 2001 From: ksatyarth2 <47723310+ksatyarth2@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:59:38 +0000 Subject: [PATCH 24/55] forge fmt --- mainnet-contracts/script/DeployPuffer.s.sol | 2 +- .../DeployPufferProtocolImplementation.s.sol | 2 +- .../script/DeployPufferVault.s.sol | 2 +- mainnet-contracts/src/PermissionedModule.sol | 5 +- mainnet-contracts/src/PufferModuleManager.sol | 7 ++- mainnet-contracts/src/PufferProtocol.sol | 9 ++-- mainnet-contracts/src/PufferVaultV5.sol | 5 +- .../src/struct/ProtocolStorage.sol | 4 +- mainnet-contracts/src/struct/Status.sol | 2 +- .../PermissionedValidatorFork.t.sol | 51 +++++++------------ .../unit/PermissionedModuleStandalone.t.sol | 4 +- 11 files changed, 41 insertions(+), 52 deletions(-) diff --git a/mainnet-contracts/script/DeployPuffer.s.sol b/mainnet-contracts/script/DeployPuffer.s.sol index cdd95c47..2c775805 100644 --- a/mainnet-contracts/script/DeployPuffer.s.sol +++ b/mainnet-contracts/script/DeployPuffer.s.sol @@ -164,7 +164,7 @@ contract DeployPuffer is BaseScript { oracle: IPufferOracleV2(oracle), beaconDepositContract: getStakingContract(), permissionedOracle: IPermissionedOracle(address(0)) // Will be set in upgrade - }); + }); } pufferProtocol = PufferProtocol(payable(address(proxy))); diff --git a/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol b/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol index 48dcf241..47e764d3 100644 --- a/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol +++ b/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol @@ -32,7 +32,7 @@ contract DeployPufferProtocolImplementation is DeployerHelper { oracle: IPufferOracleV2(_getPufferOracle()), beaconDepositContract: _getBeaconDepositContract(), permissionedOracle: IPermissionedOracle(address(0)) // TODO: set actual address - }) + }) ); //@todo Double check reinitialization diff --git a/mainnet-contracts/script/DeployPufferVault.s.sol b/mainnet-contracts/script/DeployPufferVault.s.sol index 2d4873f0..53ee83dc 100644 --- a/mainnet-contracts/script/DeployPufferVault.s.sol +++ b/mainnet-contracts/script/DeployPufferVault.s.sol @@ -38,7 +38,7 @@ contract DeployPufferVault is DeployerHelper { pufferOracle: IPufferOracleV2(_getPufferOracle()), revenueDepositor: IPufferRevenueDepositor(_getRevenueDepositor()), permissionedOracle: IPermissionedOracle(address(0)) // TODO: set actual address - }); + }); //@todo Double check reinitialization _consoleLogOrUpgradeUUPS({ diff --git a/mainnet-contracts/src/PermissionedModule.sol b/mainnet-contracts/src/PermissionedModule.sol index 0937bbe6..82c1ed16 100644 --- a/mainnet-contracts/src/PermissionedModule.sol +++ b/mainnet-contracts/src/PermissionedModule.sol @@ -86,8 +86,7 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss // Create EigenPod for restaked validators $.eigenPod = IEigenPod(address(EIGEN_POD_MANAGER.createPod())); // Deploy NonRestakingWithdrawalCredentials for non-restaked validators - $.nonRestakingWithdrawalCredentials = - new NonRestakingWithdrawalCredentials(address(this), initialAuthority); + $.nonRestakingWithdrawalCredentials = new NonRestakingWithdrawalCredentials(address(this), initialAuthority); emit NonRestakingWithdrawalCredentialsSet(address($.nonRestakingWithdrawalCredentials)); } @@ -240,7 +239,7 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss requests[i] = IEigenPodTypes.WithdrawalRequest({ pubkey: pubkeys[i], amountGwei: 0 // Full exit - }); + }); } $.eigenPod.requestWithdrawal{ value: msg.value }(requests); } diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index 39395434..af6be48c 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -439,7 +439,8 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, virtual restricted { - bytes32[] memory withdrawalRoots = PermissionedModule(payable(permissionedModule)).queueWithdrawals(sharesAmount); + bytes32[] memory withdrawalRoots = + PermissionedModule(payable(permissionedModule)).queueWithdrawals(sharesAmount); emit PermissionedModuleWithdrawalsQueued(permissionedModule, sharesAmount, withdrawalRoots[0]); } @@ -457,7 +458,9 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, ISignatureUtils.SignatureWithExpiry calldata approverSignatureAndExpiry, bytes32 approverSalt ) external virtual restricted { - PermissionedModule(payable(permissionedModule)).callDelegateTo(operator, approverSignatureAndExpiry, approverSalt); + PermissionedModule(payable(permissionedModule)).callDelegateTo( + operator, approverSignatureAndExpiry, approverSalt + ); emit PermissionedModuleDelegated(permissionedModule, operator); } diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index e2c4bc8d..ce86dd53 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -481,11 +481,10 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad * If withdrawalAmount < stakeAmount, a slashing event is emitted for transparency. * If withdrawalAmount > stakeAmount, extra is considered rewards (oracle only deducts stake). */ - function handlePermissionedValidatorExit( - bytes32 moduleName, - uint256 validatorIndex, - uint256 withdrawalAmount - ) external restricted { + function handlePermissionedValidatorExit(bytes32 moduleName, uint256 validatorIndex, uint256 withdrawalAmount) + external + restricted + { ProtocolStorage storage $ = _getPufferProtocolStorage(); // Bounds check: validatorIndex must be less than the number of registered validators diff --git a/mainnet-contracts/src/PufferVaultV5.sol b/mainnet-contracts/src/PufferVaultV5.sol index f60249de..2c4196fe 100644 --- a/mainnet-contracts/src/PufferVaultV5.sol +++ b/mainnet-contracts/src/PufferVaultV5.sol @@ -131,8 +131,9 @@ contract PufferVaultV5 is callValue := callvalue() } return _ST_ETH.balanceOf(address(this)) + getPendingLidoETHAmount() + _WETH.balanceOf(address(this)) - + (address(this).balance - callValue) + PUFFER_ORACLE.getLockedEthAmount() + PUFFER_PERMISSIONED_ORACLE.getLockedEthAmount() + getTotalRewardMintAmount() - - getTotalRewardDepositAmount() - RESTAKING_REWARDS_DEPOSITOR.getPendingDistributionAmount(); + + (address(this).balance - callValue) + PUFFER_ORACLE.getLockedEthAmount() + + PUFFER_PERMISSIONED_ORACLE.getLockedEthAmount() + getTotalRewardMintAmount() - getTotalRewardDepositAmount() + - RESTAKING_REWARDS_DEPOSITOR.getPendingDistributionAmount(); } /** diff --git a/mainnet-contracts/src/struct/ProtocolStorage.sol b/mainnet-contracts/src/struct/ProtocolStorage.sol index 21db573b..53f4dc27 100644 --- a/mainnet-contracts/src/struct/ProtocolStorage.sol +++ b/mainnet-contracts/src/struct/ProtocolStorage.sol @@ -68,7 +68,6 @@ struct ProtocolStorage { * Slot 9 */ uint256 vtPenalty; - /** * @dev Mapping of Module name => idx => PermissionedValidator * Slot 10 @@ -83,7 +82,8 @@ struct ProtocolStorage { * @dev Mapping of module name to next permissioned validator to be provisioned index * Slot 12 */ - mapping(bytes32 moduleName => uint256 nextPermissionedValidatorToBeProvisionedIndex) nextPermissionedValidatorToBeProvisionedIndices; + mapping(bytes32 moduleName => uint256 nextPermissionedValidatorToBeProvisionedIndex) + nextPermissionedValidatorToBeProvisionedIndices; /** * @dev Mapping between module name and a permissioned module * Slot 13 diff --git a/mainnet-contracts/src/struct/Status.sol b/mainnet-contracts/src/struct/Status.sol index dd948d40..89be9879 100644 --- a/mainnet-contracts/src/struct/Status.sol +++ b/mainnet-contracts/src/struct/Status.sol @@ -9,5 +9,5 @@ enum Status { PENDING, SKIPPED, ACTIVE, - FROZEN + FROZEN } diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol index 92342fc2..ba588e89 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol @@ -131,9 +131,8 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { vm.label(address(newProtocolImpl), "PufferProtocolNewImpl"); // Deploy new PufferModuleManager implementation - PufferModuleManager newModuleManagerImpl = new PufferModuleManager( - _getPufferModuleBeacon(), _getRestakingOperatorBeacon(), _getPufferProtocol() - ); + PufferModuleManager newModuleManagerImpl = + new PufferModuleManager(_getPufferModuleBeacon(), _getRestakingOperatorBeacon(), _getPufferProtocol()); vm.label(address(newModuleManagerImpl), "PufferModuleManagerNewImpl"); // Execute upgrades through Timelock as COMMUNITY_MULTISIG (instant execution, no delay) @@ -142,36 +141,29 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { bool success; // 1. Upgrade PufferProtocol via Timelock - bytes memory protocolUpgradeCalldata = abi.encodeCall( - UUPSUpgradeable.upgradeToAndCall, - (address(newProtocolImpl), "") - ); + bytes memory protocolUpgradeCalldata = + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (address(newProtocolImpl), "")); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (_getPufferProtocol(), protocolUpgradeCalldata, 1)) ); require(success, "PufferProtocol upgrade failed"); // 2. Upgrade PufferModuleManager via Timelock - bytes memory moduleManagerUpgradeCalldata = abi.encodeCall( - UUPSUpgradeable.upgradeToAndCall, - (address(newModuleManagerImpl), "") - ); + bytes memory moduleManagerUpgradeCalldata = + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (address(newModuleManagerImpl), "")); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (_getPufferModuleManager(), moduleManagerUpgradeCalldata, 2)) ); require(success, "PufferModuleManager upgrade failed"); // 3. Set permissioned module beacon via Timelock -> AccessManager -> PufferModuleManager - bytes memory setBeaconCalldata = abi.encodeCall( - PufferModuleManager.setPermissionedModuleBeacon, - (address(permissionedModuleBeacon)) - ); + bytes memory setBeaconCalldata = + abi.encodeCall(PufferModuleManager.setPermissionedModuleBeacon, (address(permissionedModuleBeacon))); // First, grant the DAO role permission to call setPermissionedModuleBeacon bytes4[] memory beaconSelectors = new bytes4[](1); beaconSelectors[0] = PufferModuleManager.setPermissionedModuleBeacon.selector; bytes memory grantBeaconRoleCalldata = abi.encodeCall( - accessManager.setTargetFunctionRole, - (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) + accessManager.setTargetFunctionRole, (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) ); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), grantBeaconRoleCalldata, 3)) @@ -198,10 +190,8 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { // Grant ROLE_ID_DAO to dao address for createPermissionedModule selectors = new bytes4[](1); selectors[0] = PufferProtocol.createPermissionedModule.selector; - bytes memory callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (_getPufferProtocol(), selectors, ROLE_ID_DAO) - ); + bytes memory callData = + abi.encodeCall(accessManager.setTargetFunctionRole, (_getPufferProtocol(), selectors, ROLE_ID_DAO)); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) ); @@ -218,8 +208,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { selectors = new bytes4[](1); selectors[0] = PufferProtocol.registerPermissionedValidatorKey.selector; callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (_getPufferProtocol(), selectors, ROLE_ID_PERMISSIONED_OPERATOR) + accessManager.setTargetFunctionRole, (_getPufferProtocol(), selectors, ROLE_ID_PERMISSIONED_OPERATOR) ); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) @@ -238,8 +227,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { selectors[1] = PufferProtocol.handlePermissionedValidatorExit.selector; selectors[2] = PufferProtocol.skipPermissionedProvisioning.selector; callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (_getPufferProtocol(), selectors, ROLE_ID_OPERATIONS_PAYMASTER) + accessManager.setTargetFunctionRole, (_getPufferProtocol(), selectors, ROLE_ID_OPERATIONS_PAYMASTER) ); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) @@ -273,8 +261,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { selectors[1] = PermissionedOracle.exitValidator.selector; selectors[2] = PermissionedOracle.adjustLockedEth.selector; callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (address(permissionedOracle), selectors, ROLE_ID_PUFFER_PROTOCOL) + accessManager.setTargetFunctionRole, (address(permissionedOracle), selectors, ROLE_ID_PUFFER_PROTOCOL) ); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) @@ -532,7 +519,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: TEST_PUBKEY, amountGwei: 0 // Full exit - }); + }); uint256 fee = _getWithdrawalRequestFee() * requests.length; vm.deal(paymaster, fee); @@ -557,7 +544,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: TEST_PUBKEY, amountGwei: uint64(5 ether / 1 gwei) // 5 ETH partial withdrawal - }); + }); uint256 fee = _getWithdrawalRequestFee() * requests.length; vm.deal(paymaster, fee); @@ -791,10 +778,8 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { bytes4[] memory selectors = new bytes4[](1); selectors[0] = NonRestakingWithdrawalCredentials.requestWithdrawal.selector; - bytes memory callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (nrwc, selectors, ROLE_ID_OPERATIONS_PAYMASTER) - ); + bytes memory callData = + abi.encodeCall(accessManager.setTargetFunctionRole, (nrwc, selectors, ROLE_ID_OPERATIONS_PAYMASTER)); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) ); diff --git a/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol index ca196bc3..16088af0 100644 --- a/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol +++ b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol @@ -126,7 +126,9 @@ contract PermissionedModuleStandaloneTest is Test { function test_immutableAddresses() public view { assertEq(address(permissionedModule.PUFFER_PROTOCOL()), pufferProtocolAddr, "PUFFER_PROTOCOL mismatch"); assertEq( - address(permissionedModule.PUFFER_MODULE_MANAGER()), pufferModuleManagerAddr, "PUFFER_MODULE_MANAGER mismatch" + address(permissionedModule.PUFFER_MODULE_MANAGER()), + pufferModuleManagerAddr, + "PUFFER_MODULE_MANAGER mismatch" ); assertEq(address(permissionedModule.EIGEN_POD_MANAGER()), eigenPodManagerMock, "EIGEN_POD_MANAGER mismatch"); assertEq( From 93466dd890cf793d0fc4fcf3cb4878c22c1498ea Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Mon, 9 Feb 2026 12:44:33 +0530 Subject: [PATCH 25/55] feat: edge case fuzz test --- .../PermissionedValidatorSecurityPOC.t.sol | 928 ++++++++++++++++++ 1 file changed, 928 insertions(+) create mode 100644 mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol new file mode 100644 index 00000000..3e93285c --- /dev/null +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol @@ -0,0 +1,928 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { console } from "forge-std/console.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import { MainnetForkTestHelper } from "../MainnetForkTestHelper.sol"; +import { PufferProtocol } from "../../src/PufferProtocol.sol"; +import { PufferModuleManager } from "../../src/PufferModuleManager.sol"; +import { PermissionedModule } from "../../src/PermissionedModule.sol"; +import { PermissionedOracle } from "../../src/PermissionedOracle.sol"; +import { NonRestakingWithdrawalCredentials } from "../../src/NonRestakingWithdrawalCredentials.sol"; +import { Timelock } from "../../src/Timelock.sol"; +import { IDelegationManager } from "../../src/interface/Eigenlayer-Slashing/IDelegationManager.sol"; +import { IBeaconDepositContract } from "../../src/interface/IBeaconDepositContract.sol"; +import { IRewardsCoordinator } from "../../src/interface/Eigenlayer-Slashing/IRewardsCoordinator.sol"; +import { IGuardianModule } from "../../src/interface/IGuardianModule.sol"; +import { ValidatorTicket } from "../../src/ValidatorTicket.sol"; +import { IPufferOracleV2 } from "../../src/interface/IPufferOracleV2.sol"; +import { IPermissionedOracle } from "../../src/interface/IPermissionedOracle.sol"; +import { IPufferProtocol } from "../../src/interface/IPufferProtocol.sol"; +import { PermissionedValidator } from "../../src/struct/Validator.sol"; +import { Status } from "../../src/struct/Status.sol"; + +import { + ROLE_ID_DAO, + ROLE_ID_PERMISSIONED_OPERATOR, + ROLE_ID_OPERATIONS_PAYMASTER, + ROLE_ID_PUFFER_PROTOCOL +} from "../../script/Roles.sol"; + +/** + * @title PermissionedValidatorEdgeCaseTest + * @notice Comprehensive edge case tests for permissioned validator system + * @dev Tests cover: + * - Oracle accounting with slashing and rewards + * - Skip provisioning FIFO enforcement + * - Mixed provisioning and skipping scenarios + * - Index tracking edge cases + */ +contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { + // Mainnet fork block - post-Pectra + uint256 constant FORK_BLOCK = 24_333_965; + + // Contract instances + PufferProtocol public pufferProtocol; + PufferModuleManager public pufferModuleManager; + PermissionedOracle public permissionedOracle; + UpgradeableBeacon public permissionedModuleBeacon; + + // Test actors + address permissionedOperator = makeAddr("permissionedOperator"); + address paymaster; + address dao; + + // Test constants + bytes32 constant TEST_MODULE_NAME = bytes32("TEST_MODULE"); + bytes constant TEST_SIGNATURE = + hex"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + + function setUp() public override { + string memory rpcUrl; + try vm.rpcUrl("mainnet") returns (string memory url) { + rpcUrl = url; + } catch { + rpcUrl = "https://ethereum-rpc.publicnode.com"; + } + vm.createSelectFork(rpcUrl, FORK_BLOCK); + + _setupLiveContracts(); + + pufferProtocol = PufferProtocol(payable(_getPufferProtocol())); + pufferModuleManager = PufferModuleManager(payable(_getPufferModuleManager())); + paymaster = _getPaymaster(); + dao = _getDAO(); + + _deployPermissionedInfrastructure(); + _setupAccessControl(); + } + + // ============================================================================ + // Oracle Accounting Tests + // ============================================================================ + + /** + * @notice Verifies oracle correctly accounts for slashing losses + */ + function test_oracleAccountsForSlashing() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + bytes memory pubkey = _generatePubkey(1); + uint256 originalStake = 100 ether; + + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, originalStake); + + vm.deal(address(pufferVault), 200 ether); + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + + uint256 oracleLockedBefore = permissionedOracle.totalLockedEth(); + assertEq(oracleLockedBefore, originalStake); + + // Slashing scenario: 5 ETH slashed + uint256 actualWithdrawal = 95 ether; + uint256 slashingAmount = originalStake - actualWithdrawal; + + // Expect slashing event + vm.expectEmit(true, true, false, true); + emit IPufferProtocol.PermissionedValidatorSlashingDetected( + TEST_MODULE_NAME, 0, originalStake, actualWithdrawal, slashingAmount + ); + + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 0, actualWithdrawal); + + uint256 oracleLockedAfter = permissionedOracle.totalLockedEth(); + assertEq(oracleLockedAfter, 0); + } + + /** + * @notice Verifies oracle correctly handles rewards (withdrawal > stake) + */ + function test_oracleHandlesRewards() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + bytes memory pubkey = _generatePubkey(1); + uint256 originalStake = 100 ether; + + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, originalStake); + + vm.deal(address(pufferVault), 200 ether); + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + + // Rewards scenario: 2 ETH earned + uint256 actualWithdrawal = 102 ether; + + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 0, actualWithdrawal); + + // Oracle should deduct original stake only + uint256 oracleLockedAfter = permissionedOracle.totalLockedEth(); + assertEq(oracleLockedAfter, 0); + } + + /** + * @notice Verifies cumulative slashing across multiple validators is tracked + */ + function test_cumulativeSlashingTracking() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), 1000 ether); + + uint256[5] memory stakes = [uint256(100 ether), 200 ether, 150 ether, 300 ether, 250 ether]; + uint256 totalOriginalStake = 0; + + for (uint256 i = 0; i < 5; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, stakes[i]); + totalOriginalStake += stakes[i]; + } + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + for (uint256 i = 0; i < 5; i++) { + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + } + + assertEq(permissionedOracle.totalLockedEth(), totalOriginalStake); + + // Exit all with 5% slashing + for (uint256 i = 0; i < 5; i++) { + uint256 actualWithdrawal = (stakes[i] * 95) / 100; + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, i, actualWithdrawal); + } + + assertEq(permissionedOracle.totalLockedEth(), 0); + } + + // ============================================================================ + // Skip Provisioning FIFO Tests + // ============================================================================ + + /** + * @notice Verifies non-sequential skip reverts with correct error + */ + function test_nonSequentialSkipReverts() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + for (uint256 i = 0; i < 5; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); + } + + // Try to skip index 2 when next is 0 + vm.prank(paymaster); + vm.expectRevert(abi.encodeWithSelector(IPufferProtocol.MustSkipNextValidator.selector, 0, 2)); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 2); + } + + /** + * @notice Verifies sequential skips work correctly + */ + function test_sequentialSkipsWork() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + for (uint256 i = 0; i < 5; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); + } + + // Skip 0, 1, 2 sequentially + for (uint256 i = 0; i < 3; i++) { + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, i); + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), i + 1); + } + } + + // ============================================================================ + // Mixed Provisioning and Skipping Edge Cases + // ============================================================================ + + /** + * @notice Tests skip, provision, skip, provision pattern + */ + function test_alternatingSkipAndProvision() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), 500 ether); + + // Register 6 validators + for (uint256 i = 0; i < 6; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); + } + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Skip 0 + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 1); + + // Provision 1 + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 1, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Skip 2 + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 2); + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 3); + + // Provision 3 + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 3, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Skip 4 + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 4); + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 5); + + // Provision 5 + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 5, TEST_SIGNATURE, depositRoot); + + // Verify final state + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 6); + assertEq(permissionedOracle.totalLockedEth(), 96 ether); // 3 validators * 32 ETH + + // Verify skipped validators are deleted + PermissionedValidator memory v0 = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 0); + PermissionedValidator memory v2 = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 2); + PermissionedValidator memory v4 = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 4); + assertEq(v0.node, address(0)); + assertEq(v2.node, address(0)); + assertEq(v4.node, address(0)); + + // Verify provisioned validators are active + PermissionedValidator memory v1 = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 1); + PermissionedValidator memory v3 = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 3); + PermissionedValidator memory v5 = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 5); + assertEq(uint8(v1.status), uint8(Status.ACTIVE)); + assertEq(uint8(v3.status), uint8(Status.ACTIVE)); + assertEq(uint8(v5.status), uint8(Status.ACTIVE)); + } + + /** + * @notice Tests multiple consecutive skips followed by provisions + */ + function test_multipleSkipsThenProvisions() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), 500 ether); + + // Register 8 validators + for (uint256 i = 0; i < 8; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); + } + + // Skip first 4 + for (uint256 i = 0; i < 4; i++) { + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, i); + } + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 4); + + // Provision remaining 4 + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + for (uint256 i = 4; i < 8; i++) { + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + } + + assertEq(permissionedOracle.totalLockedEth(), 128 ether); // 4 * 32 ETH + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 8); + } + + /** + * @notice Tests provision then exit then new registration + */ + function test_provisionExitThenNewRegistration() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), 500 ether); + + // Register and provision first validator + bytes memory pubkey1 = _generatePubkey(1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey1, TEST_MODULE_NAME, true, 100 ether); + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + + assertEq(permissionedOracle.totalLockedEth(), 100 ether); + + // Exit with slashing + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 0, 95 ether); + + assertEq(permissionedOracle.totalLockedEth(), 0); + + // Register new validator (will be at index 1) + bytes memory pubkey2 = _generatePubkey(2); + vm.prank(permissionedOperator); + uint256 newIndex = pufferProtocol.registerPermissionedValidatorKey(pubkey2, TEST_MODULE_NAME, true, 200 ether); + + assertEq(newIndex, 1); + assertEq(pufferProtocol.getPendingPermissionedValidatorIndex(TEST_MODULE_NAME), 2); + + // Provision new validator + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 1, TEST_SIGNATURE, depositRoot); + + assertEq(permissionedOracle.totalLockedEth(), 200 ether); + } + + /** + * @notice Tests skip at boundary (skip the last registered validator) + */ + function test_skipLastRegisteredValidator() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + // Register single validator + bytes memory pubkey = _generatePubkey(1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); + + assertEq(pufferProtocol.getPendingPermissionedValidatorIndex(TEST_MODULE_NAME), 1); + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 0); + + // Skip it + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); + + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 1); + + // Verify deleted + PermissionedValidator memory v = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 0); + assertEq(v.node, address(0)); + } + + /** + * @notice Tests cannot skip already provisioned validator + */ + function test_cannotSkipProvisionedValidator() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), 200 ether); + + // Register 2 validators + for (uint256 i = 0; i < 2; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); + } + + // Provision first + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + + // Try to skip index 0 (already provisioned) - should fail due to FIFO (next is 1) + vm.prank(paymaster); + vm.expectRevert(abi.encodeWithSelector(IPufferProtocol.MustSkipNextValidator.selector, 1, 0)); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); + } + + /** + * @notice Tests skip after some provisions have been made + */ + function test_skipAfterPartialProvisioning() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), 500 ether); + + // Register 5 validators + for (uint256 i = 0; i < 5; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); + } + + // Provision 0, 1, 2 + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + for (uint256 i = 0; i < 3; i++) { + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + } + + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 3); + + // Now skip 3 (next in line) + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 3); + + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 4); + + // Provision 4 + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 4, TEST_SIGNATURE, depositRoot); + + assertEq(permissionedOracle.totalLockedEth(), 128 ether); // 4 * 32 ETH + } + + /** + * @notice Tests mixed operations across multiple modules + */ + function test_mixedOperationsMultipleModules() public { + bytes32 moduleA = bytes32("MODULE_A"); + bytes32 moduleB = bytes32("MODULE_B"); + + vm.startPrank(dao); + pufferProtocol.createPermissionedModule(moduleA); + pufferProtocol.createPermissionedModule(moduleB); + vm.stopPrank(); + + vm.deal(address(pufferVault), 1000 ether); + + // Register 3 in each module + for (uint256 i = 0; i < 3; i++) { + bytes memory pubkeyA = _generatePubkey(i + 1); + bytes memory pubkeyB = _generatePubkey(i + 100); + + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkeyA, moduleA, true, 100 ether); + + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkeyB, moduleB, true, 50 ether); + } + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Module A: skip 0, provision 1, skip 2 + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(moduleA, 0); + + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(moduleA, 1, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(moduleA, 2); + + // Module B: provision all + for (uint256 i = 0; i < 3; i++) { + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(moduleB, i, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + } + + // Verify + assertEq(permissionedOracle.getModuleLockedEth(moduleA), 100 ether); // 1 * 100 + assertEq(permissionedOracle.getModuleLockedEth(moduleB), 150 ether); // 3 * 50 + assertEq(permissionedOracle.totalLockedEth(), 250 ether); + + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(moduleA), 3); + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(moduleB), 3); + } + + /** + * @notice Tests exit order doesn't affect oracle when validators exit out of order + */ + function test_outOfOrderExitsOracleAccounting() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), 500 ether); + + uint256[3] memory stakes = [uint256(100 ether), 150 ether, 200 ether]; + + for (uint256 i = 0; i < 3; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, stakes[i]); + } + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + for (uint256 i = 0; i < 3; i++) { + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + } + + assertEq(permissionedOracle.totalLockedEth(), 450 ether); + + // Exit in reverse order: 2, 0, 1 + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 2, 200 ether); + assertEq(permissionedOracle.totalLockedEth(), 250 ether); + + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 0, 95 ether); // slashed + assertEq(permissionedOracle.totalLockedEth(), 150 ether); + + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 1, 155 ether); // rewards + assertEq(permissionedOracle.totalLockedEth(), 0); + } + + /** + * @notice Tests registering validators after all previous ones are processed + */ + function test_registerAfterAllProcessed() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), 1000 ether); + + // First batch: register 2, skip both + for (uint256 i = 0; i < 2; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); + } + + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 1); + + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 2); + assertEq(pufferProtocol.getPendingPermissionedValidatorIndex(TEST_MODULE_NAME), 2); + + // Second batch: register 2 more + for (uint256 i = 2; i < 4; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 64 ether); + } + + assertEq(pufferProtocol.getPendingPermissionedValidatorIndex(TEST_MODULE_NAME), 4); + + // Provision new ones + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + for (uint256 i = 2; i < 4; i++) { + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + } + + assertEq(permissionedOracle.totalLockedEth(), 128 ether); // 2 * 64 + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 4); + } + + /** + * @notice Tests full lifecycle: register, provision, partial withdrawal via slashing, exit + */ + function test_fullLifecycleWithSlashing() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + // Register with max stake (2048 ETH) - need sufficient vault funds + bytes memory pubkey = _generatePubkey(1); + uint256 maxStake = 2048 ether; + + vm.deal(address(pufferVault), maxStake * 2); + + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, maxStake); + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + + assertEq(permissionedOracle.totalLockedEth(), maxStake); + + // Simulate major slashing (10%) + uint256 slashingPercent = 10; + uint256 slashingLoss = (maxStake * slashingPercent) / 100; + uint256 actualWithdrawal = maxStake - slashingLoss; + + vm.expectEmit(true, true, false, true); + emit IPufferProtocol.PermissionedValidatorSlashingDetected( + TEST_MODULE_NAME, 0, maxStake, actualWithdrawal, slashingLoss + ); + + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 0, actualWithdrawal); + + assertEq(permissionedOracle.totalLockedEth(), 0); + } + + /** + * @notice Tests that skipping doesn't affect already provisioned validators + */ + function test_skipDoesNotAffectActiveValidators() public { + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), 500 ether); + + // Register 4 validators + for (uint256 i = 0; i < 4; i++) { + bytes memory pubkey = _generatePubkey(i + 1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); + } + + // Provision first 2 + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + for (uint256 i = 0; i < 2; i++) { + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + } + + uint256 oracleBefore = permissionedOracle.totalLockedEth(); + assertEq(oracleBefore, 64 ether); + + // Skip remaining 2 + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 2); + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 3); + + // Oracle should be unchanged (skipping doesn't affect locked ETH) + assertEq(permissionedOracle.totalLockedEth(), oracleBefore); + + // Active validators should still be active + PermissionedValidator memory v0 = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 0); + PermissionedValidator memory v1 = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 1); + assertEq(uint8(v0.status), uint8(Status.ACTIVE)); + assertEq(uint8(v1.status), uint8(Status.ACTIVE)); + } + + // ============================================================================ + // Fuzz Tests + // ============================================================================ + + /** + * @notice Fuzz test for slashing amounts + */ + function testFuzz_slashingAmounts(uint256 stakeEther, uint256 slashingPercent) public { + // Stake must be between 32-2048 ETH in whole ether amounts (gwei divisible) + stakeEther = bound(stakeEther, 32, 2048); + uint256 stakeAmount = stakeEther * 1 ether; + slashingPercent = bound(slashingPercent, 1, 99); + + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), stakeAmount * 2); + + bytes memory pubkey = _generatePubkey(1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, stakeAmount); + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + + uint256 slashingLoss = (stakeAmount * slashingPercent) / 100; + uint256 actualWithdrawal = stakeAmount - slashingLoss; + + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 0, actualWithdrawal); + + assertEq(permissionedOracle.totalLockedEth(), 0); + } + + /** + * @notice Fuzz test for reward amounts + */ + function testFuzz_rewardAmounts(uint256 stakeEther, uint256 rewardPercent) public { + // Stake must be between 32-2048 ETH in whole ether amounts (gwei divisible) + stakeEther = bound(stakeEther, 32, 2048); + uint256 stakeAmount = stakeEther * 1 ether; + rewardPercent = bound(rewardPercent, 1, 50); // Up to 50% rewards + + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + vm.deal(address(pufferVault), stakeAmount * 2); + + bytes memory pubkey = _generatePubkey(1); + vm.prank(permissionedOperator); + pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, stakeAmount); + + bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + + uint256 rewards = (stakeAmount * rewardPercent) / 100; + uint256 actualWithdrawal = stakeAmount + rewards; + + vm.prank(paymaster); + pufferProtocol.handlePermissionedValidatorExit(TEST_MODULE_NAME, 0, actualWithdrawal); + + assertEq(permissionedOracle.totalLockedEth(), 0); + } + + // ============================================================================ + // Helper Functions + // ============================================================================ + + function _generatePubkey(uint256 seed) internal pure returns (bytes memory) { + return abi.encodePacked(bytes32(seed), bytes16(0)); + } + + function _deployPermissionedInfrastructure() internal { + permissionedOracle = new PermissionedOracle(_getAccessManager()); + vm.label(address(permissionedOracle), "PermissionedOracle"); + + PermissionedModule permissionedModuleImpl = new PermissionedModule( + pufferProtocol, + _getEigenPodManager(), + IDelegationManager(_getDelegationManager()), + pufferModuleManager, + IRewardsCoordinator(_getRewardsCoordinator()), + IBeaconDepositContract(_getBeaconDepositContract()) + ); + + vm.prank(COMMUNITY_MULTISIG); + permissionedModuleBeacon = new UpgradeableBeacon(address(permissionedModuleImpl), COMMUNITY_MULTISIG); + + PufferProtocol newProtocolImpl = new PufferProtocol( + pufferVault, + IGuardianModule(_getGuardianModule()), + address(pufferModuleManager), + ValidatorTicket(_getValidatorTicket()), + IPufferOracleV2(_getPufferOracle()), + _getBeaconDepositContract(), + IPermissionedOracle(address(permissionedOracle)) + ); + + PufferModuleManager newModuleManagerImpl = new PufferModuleManager( + _getPufferModuleBeacon(), _getRestakingOperatorBeacon(), _getPufferProtocol() + ); + + vm.startPrank(COMMUNITY_MULTISIG); + + bool success; + + bytes memory protocolUpgradeCalldata = abi.encodeCall( + UUPSUpgradeable.upgradeToAndCall, + (address(newProtocolImpl), "") + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (_getPufferProtocol(), protocolUpgradeCalldata, 1)) + ); + require(success, "PufferProtocol upgrade failed"); + + bytes memory moduleManagerUpgradeCalldata = abi.encodeCall( + UUPSUpgradeable.upgradeToAndCall, + (address(newModuleManagerImpl), "") + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (_getPufferModuleManager(), moduleManagerUpgradeCalldata, 2)) + ); + require(success, "PufferModuleManager upgrade failed"); + + bytes memory setBeaconCalldata = abi.encodeCall( + PufferModuleManager.setPermissionedModuleBeacon, + (address(permissionedModuleBeacon)) + ); + bytes4[] memory beaconSelectors = new bytes4[](1); + beaconSelectors[0] = PufferModuleManager.setPermissionedModuleBeacon.selector; + bytes memory grantBeaconRoleCalldata = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), grantBeaconRoleCalldata, 3)) + ); + require(success, "Grant beacon role failed"); + + vm.stopPrank(); + + vm.prank(dao); + accessManager.execute(_getPufferModuleManager(), setBeaconCalldata); + } + + function _setupAccessControl() internal { + vm.startPrank(COMMUNITY_MULTISIG); + + bool success; + uint256 operationId = 100; + bytes4[] memory selectors; + + selectors = new bytes4[](1); + selectors[0] = PufferProtocol.createPermissionedModule.selector; + bytes memory callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferProtocol(), selectors, ROLE_ID_DAO) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success); + + callData = abi.encodeCall(accessManager.grantRole, (ROLE_ID_DAO, dao, 0)); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success); + + selectors = new bytes4[](1); + selectors[0] = PufferProtocol.registerPermissionedValidatorKey.selector; + callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferProtocol(), selectors, ROLE_ID_PERMISSIONED_OPERATOR) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success); + + callData = abi.encodeCall(accessManager.grantRole, (ROLE_ID_PERMISSIONED_OPERATOR, permissionedOperator, 0)); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success); + + selectors = new bytes4[](3); + selectors[0] = PufferProtocol.provisionPermissionedValidator.selector; + selectors[1] = PufferProtocol.handlePermissionedValidatorExit.selector; + selectors[2] = PufferProtocol.skipPermissionedProvisioning.selector; + callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferProtocol(), selectors, ROLE_ID_OPERATIONS_PAYMASTER) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success); + + callData = abi.encodeCall(accessManager.grantRole, (ROLE_ID_OPERATIONS_PAYMASTER, paymaster, 0)); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success); + + selectors = new bytes4[](3); + selectors[0] = PermissionedOracle.provisionValidator.selector; + selectors[1] = PermissionedOracle.exitValidator.selector; + selectors[2] = PermissionedOracle.adjustLockedEth.selector; + callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (address(permissionedOracle), selectors, ROLE_ID_PUFFER_PROTOCOL) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success); + + callData = abi.encodeCall(accessManager.grantRole, (ROLE_ID_PUFFER_PROTOCOL, _getPufferProtocol(), 0)); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success); + + vm.stopPrank(); + } +} From e93a79cba4f289d9f2b5352e0e2698e4155a360f Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 18 Feb 2026 14:06:35 +0530 Subject: [PATCH 26/55] feat: make NRWC upgradable --- .../src/NonRestakingWithdrawalCredentials.sol | 47 +++++++++++++++---- mainnet-contracts/src/struct/NRWCStorage.sol | 17 +++++++ 2 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 mainnet-contracts/src/struct/NRWCStorage.sol diff --git a/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol b/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol index a4cb185b..53944f3e 100644 --- a/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol +++ b/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol @@ -2,17 +2,21 @@ pragma solidity >=0.8.0 <0.9.0; import { IEigenPodTypes } from "./interface/Eigenlayer-Slashing/IEigenPod.sol"; -import { AccessManaged } from "@openzeppelin/contracts/access/manager/AccessManaged.sol"; +import { AccessManagedUpgradeable } from + "@openzeppelin/contracts-upgradeable/access/manager/AccessManagedUpgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { NRWCStorage } from "./struct/NRWCStorage.sol"; import { Unauthorized } from "./Errors.sol"; /** * @title NonRestakingWithdrawalCredentials * @author Puffer Finance * @notice Non-restaked validators should point the withdrawal credentials to this contract + * @dev Deployed as a beacon proxy for upgradeability * @custom:security-contact security@puffer.fi */ -contract NonRestakingWithdrawalCredentials is AccessManaged { +contract NonRestakingWithdrawalCredentials is Initializable, AccessManagedUpgradeable { using Address for address payable; /** @@ -41,12 +45,23 @@ contract NonRestakingWithdrawalCredentials is AccessManaged { address internal constant WITHDRAWAL_REQUEST_ADDRESS = 0x00000961Ef480Eb55e80D19ad83579A64c007002; /** - * @notice The address of the PermissionedModule that owns this contract + * keccak256(abi.encode(uint256(keccak256("NonRestakingWithdrawalCredentials.storage")) - 1)) & ~bytes32(uint256(0xff)) */ - address public immutable PERMISSIONED_MODULE; + bytes32 private constant _NRWC_STORAGE = 0x75f3dc1703b3796fed3f2c6268997d3515c1e8991934a39283c37518525fd700; - constructor(address permissionedModule, address accessManager) AccessManaged(accessManager) { - PERMISSIONED_MODULE = permissionedModule; + constructor() { + _disableInitializers(); + } + + /** + * @notice Initializes the NonRestakingWithdrawalCredentials contract + * @param permissionedModule The address of the PermissionedModule that owns this contract + * @param accessManager The access manager address + */ + function initialize(address permissionedModule, address accessManager) external initializer { + __AccessManaged_init(accessManager); + NRWCStorage storage $ = _getNRWCStorage(); + $.permissionedModule = permissionedModule; } /** @@ -54,15 +69,24 @@ contract NonRestakingWithdrawalCredentials is AccessManaged { */ receive() external payable { } + /** + * @notice Returns the PermissionedModule that owns this contract + */ + function getPermissionedModule() public view returns (address) { + NRWCStorage storage $ = _getNRWCStorage(); + return $.permissionedModule; + } + /** * @notice Withdraw accumulated ETH to the PermissionedModule * @dev Only callable by the PermissionedModule */ function withdrawETH() external { - if (msg.sender != PERMISSIONED_MODULE) { + NRWCStorage storage $ = _getNRWCStorage(); + if (msg.sender != $.permissionedModule) { revert Unauthorized(); } - payable(PERMISSIONED_MODULE).sendValue(address(this).balance); + payable($.permissionedModule).sendValue(address(this).balance); } /** @@ -99,4 +123,11 @@ contract NonRestakingWithdrawalCredentials is AccessManaged { } return uint256(bytes32(result)); } + + function _getNRWCStorage() internal pure returns (NRWCStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _NRWC_STORAGE + } + } } diff --git a/mainnet-contracts/src/struct/NRWCStorage.sol b/mainnet-contracts/src/struct/NRWCStorage.sol new file mode 100644 index 00000000..1256c1fb --- /dev/null +++ b/mainnet-contracts/src/struct/NRWCStorage.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +/** + * @custom:storage-location erc7201:NonRestakingWithdrawalCredentials.storage + * @dev +-----------------------------------------------------------+ + * | | + * | DO NOT CHANGE, REORDER, REMOVE EXISTING STORAGE VARIABLES | + * | | + * +-----------------------------------------------------------+ + */ +struct NRWCStorage { + /** + * @dev The PermissionedModule that owns this NRWC contract + */ + address permissionedModule; +} From a941fbcb5ec8d058ebf985bd563ab32a770d7073 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 18 Feb 2026 14:28:35 +0530 Subject: [PATCH 27/55] feat: PermissionedModule storage and beacon deploy --- mainnet-contracts/src/PermissionedModule.sol | 55 ++++++++++--------- mainnet-contracts/src/PufferModuleManager.sol | 29 ++++++++++ .../src/interface/IPermissionedModule.sol | 4 +- .../src/struct/PermissionedModuleStorage.sol | 28 ++++++++++ 4 files changed, 90 insertions(+), 26 deletions(-) create mode 100644 mainnet-contracts/src/struct/PermissionedModuleStorage.sol diff --git a/mainnet-contracts/src/PermissionedModule.sol b/mainnet-contracts/src/PermissionedModule.sol index 82c1ed16..957d1adb 100644 --- a/mainnet-contracts/src/PermissionedModule.sol +++ b/mainnet-contracts/src/PermissionedModule.sol @@ -10,14 +10,16 @@ import { ISignatureUtils } from "./interface/Eigenlayer-Slashing/ISignatureUtils import { IStrategy } from "./interface/Eigenlayer-Slashing/IStrategy.sol"; import { IEigenPod, IEigenPodTypes } from "./interface/Eigenlayer-Slashing/IEigenPod.sol"; import { IRewardsCoordinator } from "./interface/Eigenlayer-Slashing/IRewardsCoordinator.sol"; -import { IBeaconDepositContract } from "./interface/IBeaconDepositContract.sol"; import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; import { IPermissionedModule } from "./interface/IPermissionedModule.sol"; import { PufferModuleManager } from "./PufferModuleManager.sol"; import { NonRestakingWithdrawalCredentials } from "./NonRestakingWithdrawalCredentials.sol"; +import { PermissionedModuleStorage } from "./struct/PermissionedModuleStorage.sol"; import { Unauthorized } from "./Errors.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; /** * @title PermissionedModule @@ -29,48 +31,35 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss using Address for address; using Address for address payable; + IEigenPodManager public immutable EIGEN_POD_MANAGER; + IRewardsCoordinator public immutable EIGEN_REWARDS_COORDINATOR; + IDelegationManager public immutable EIGEN_DELEGATION_MANAGER; + IPufferProtocol public immutable PUFFER_PROTOCOL; + PufferModuleManager public immutable PUFFER_MODULE_MANAGER; + /** * @dev Represents the Beacon Chain strategy in EigenLayer */ address internal constant _BEACON_CHAIN_STRATEGY = 0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0; - /** - * @dev Storage struct for PermissionedModule - * @custom:storage-location erc7201:PermissionedModule.storage - */ - struct PermissionedModuleStorage { - bytes32 moduleName; - IEigenPod eigenPod; - NonRestakingWithdrawalCredentials nonRestakingWithdrawalCredentials; - } - /** * keccak256(abi.encode(uint256(keccak256("PermissionedModule.storage")) - 1)) & ~bytes32(uint256(0xff)) */ bytes32 private constant _PERMISSIONED_MODULE_STORAGE = 0x7410446085c160ccc4c2b0e41801f8ac5004a5bf87d0402533c18d1e95927d00; - IEigenPodManager public immutable EIGEN_POD_MANAGER; - IRewardsCoordinator public immutable EIGEN_REWARDS_COORDINATOR; - IDelegationManager public immutable EIGEN_DELEGATION_MANAGER; - IBeaconDepositContract public immutable BEACON_DEPOSIT_CONTRACT; - IPufferProtocol public immutable PUFFER_PROTOCOL; - PufferModuleManager public immutable PUFFER_MODULE_MANAGER; - constructor( IPufferProtocol protocol, address eigenPodManager, IDelegationManager delegationManager, PufferModuleManager moduleManager, - IRewardsCoordinator rewardsCoordinator, - IBeaconDepositContract beaconDepositContract + IRewardsCoordinator rewardsCoordinator ) payable { EIGEN_POD_MANAGER = IEigenPodManager(eigenPodManager); EIGEN_DELEGATION_MANAGER = delegationManager; PUFFER_PROTOCOL = protocol; PUFFER_MODULE_MANAGER = moduleManager; EIGEN_REWARDS_COORDINATOR = rewardsCoordinator; - BEACON_DEPOSIT_CONTRACT = beaconDepositContract; _disableInitializers(); } @@ -85,10 +74,26 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss $.moduleName = moduleName; // Create EigenPod for restaked validators $.eigenPod = IEigenPod(address(EIGEN_POD_MANAGER.createPod())); - // Deploy NonRestakingWithdrawalCredentials for non-restaked validators - $.nonRestakingWithdrawalCredentials = new NonRestakingWithdrawalCredentials(address(this), initialAuthority); - emit NonRestakingWithdrawalCredentialsSet(address($.nonRestakingWithdrawalCredentials)); + // Deploy NonRestakingWithdrawalCredentials via beacon proxy for upgradeability + address nrwcBeacon = PUFFER_MODULE_MANAGER.getNRWCBeacon(); + $.nonRestakingWithdrawalCredentials = NonRestakingWithdrawalCredentials( + payable( + Create2.deploy({ + amount: 0, + salt: keccak256(abi.encodePacked("NRWC_", address(this))), + bytecode: abi.encodePacked( + type(BeaconProxy).creationCode, + abi.encode( + nrwcBeacon, + abi.encodeCall(NonRestakingWithdrawalCredentials.initialize, (address(this), initialAuthority)) + ) + ) + }) + ) + ); + + emit NonRestakingWithdrawalCredentialsSet(address(this), address($.nonRestakingWithdrawalCredentials)); } /** @@ -142,7 +147,7 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss bytes32 depositDataRoot, uint256 amount ) external payable onlyPufferProtocol { - BEACON_DEPOSIT_CONTRACT.deposit{ value: amount }( + PUFFER_PROTOCOL.BEACON_DEPOSIT_CONTRACT().deposit{ value: amount }( pubKey, getNonRestakingWithdrawalCredentials(), signature, depositDataRoot ); } diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index af6be48c..714f0579 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -361,6 +361,35 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, } } + /** + * @dev NRWC (NonRestakingWithdrawalCredentials) beacon address (stored in contract storage for upgradeability) + * keccak256(abi.encode(uint256(keccak256("PufferModuleManager.nrwcBeacon")) - 1)) & ~bytes32(uint256(0xff)) + */ + bytes32 private constant _NRWC_BEACON_SLOT = + 0x5e3efd71b10c8d5afd6e403b79d40030cf08570b8694200c7f3948cb18b66b00; + + /** + * @notice Sets the NRWC beacon address + * @param beacon The address of the NRWC beacon + * @dev Restricted to the DAO + */ + function setNRWCBeacon(address beacon) external virtual restricted { + assembly { + sstore(_NRWC_BEACON_SLOT, beacon) + } + emit NRWCBeaconSet(beacon); + } + + /** + * @notice Returns the NRWC beacon address + * @return beacon The address of the NRWC beacon + */ + function getNRWCBeacon() public view returns (address beacon) { + assembly { + beacon := sload(_NRWC_BEACON_SLOT) + } + } + /** * @notice Create a new Permissioned module * @dev This function creates a new Permissioned module with the given module name diff --git a/mainnet-contracts/src/interface/IPermissionedModule.sol b/mainnet-contracts/src/interface/IPermissionedModule.sol index b94de0b5..79b3e518 100644 --- a/mainnet-contracts/src/interface/IPermissionedModule.sol +++ b/mainnet-contracts/src/interface/IPermissionedModule.sol @@ -15,8 +15,10 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IPermissionedModule { /** * @notice Emitted when the non-restaking withdrawal credentials contract is set + * @param permissionedModule The permissioned module that owns the NRWC + * @param withdrawalCredentials The NRWC contract address */ - event NonRestakingWithdrawalCredentialsSet(address indexed withdrawalCredentials); + event NonRestakingWithdrawalCredentialsSet(address indexed permissionedModule, address indexed withdrawalCredentials); /** * @notice Stakes a validator via EigenLayer (restaked path) diff --git a/mainnet-contracts/src/struct/PermissionedModuleStorage.sol b/mainnet-contracts/src/struct/PermissionedModuleStorage.sol new file mode 100644 index 00000000..9975517e --- /dev/null +++ b/mainnet-contracts/src/struct/PermissionedModuleStorage.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { IEigenPod } from "../interface/Eigenlayer-Slashing/IEigenPod.sol"; +import { NonRestakingWithdrawalCredentials } from "../NonRestakingWithdrawalCredentials.sol"; + +/** + * @custom:storage-location erc7201:PermissionedModule.storage + * @dev +-----------------------------------------------------------+ + * | | + * | DO NOT CHANGE, REORDER, REMOVE EXISTING STORAGE VARIABLES | + * | | + * +-----------------------------------------------------------+ + */ +struct PermissionedModuleStorage { + /** + * @dev Module Name + */ + bytes32 moduleName; + /** + * @dev Owned EigenPod (for restaked validators with 0x01 withdrawal credentials) + */ + IEigenPod eigenPod; + /** + * @dev NonRestakingWithdrawalCredentials contract (for non-restaked validators with 0x02 withdrawal credentials) + */ + NonRestakingWithdrawalCredentials nonRestakingWithdrawalCredentials; +} From d25ddbedf7c4e08958d9e2deb172cd1536c1c418 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 18 Feb 2026 14:34:48 +0530 Subject: [PATCH 28/55] fix: improve natspec --- mainnet-contracts/src/PufferProtocol.sol | 20 ++++++++++++++----- .../src/interface/IPermissionedOracle.sol | 20 ++++++++++++------- .../src/interface/IPufferModuleManager.sol | 6 ++++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index ce86dd53..04bd14b5 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -475,11 +475,21 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad * @notice Handles the exit of a permissioned validator * @param moduleName The name of the permissioned module * @param validatorIndex The index of the validator - * @param withdrawalAmount The amount of ETH withdrawn from the validator - * @dev Restricted to authorized roles. Updates oracle and marks validator as exited. - * Oracle is updated based on actual withdrawal amount to ensure accurate totalAssets() accounting. - * If withdrawalAmount < stakeAmount, a slashing event is emitted for transparency. - * If withdrawalAmount > stakeAmount, extra is considered rewards (oracle only deducts stake). + * @param withdrawalAmount The actual withdrawal amount received from beacon chain + * @dev Restricted to ROLE_ID_OPERATIONS_PAYMASTER. + * + * For 0x02 (non-restaked) validators with Pectra auto-compounding: + * - withdrawalAmount includes original stake + any auto-compounded rewards + * - Oracle is debited only for stakeAmount (original principal) + * - Extra ETH (rewards) flows to module/vault balance automatically + * + * For 0x01 (restaked) validators: + * - Rewards flow through EigenLayer delegation mechanism + * - Oracle debited for full stakeAmount (always 32 ETH) + * + * If withdrawalAmount < stakeAmount, slashing is detected: + * - adjustLockedEth is called first to account for the loss + * - PermissionedValidatorSlashingDetected event emitted for transparency */ function handlePermissionedValidatorExit(bytes32 moduleName, uint256 validatorIndex, uint256 withdrawalAmount) external diff --git a/mainnet-contracts/src/interface/IPermissionedOracle.sol b/mainnet-contracts/src/interface/IPermissionedOracle.sol index 650e4b62..bf12f77c 100644 --- a/mainnet-contracts/src/interface/IPermissionedOracle.sol +++ b/mainnet-contracts/src/interface/IPermissionedOracle.sol @@ -52,24 +52,30 @@ interface IPermissionedOracle { function getModuleLockedEth(bytes32 moduleName) external view returns (uint256); /** - * @notice Called when a permissioned validator is provisioned + * @notice Records ETH locked when a permissioned validator is provisioned * @param moduleName The module name - * @param amount The staked ETH amount (32-2048 ETH) + * @param amount The amount of ETH locked (32-2048 ETH for Pectra) + * @dev Restricted to ROLE_ID_PUFFER_PROTOCOL. Called by PufferProtocol during validator provisioning. */ function provisionValidator(bytes32 moduleName, uint256 amount) external; /** - * @notice Called when a permissioned validator exits + * @notice Records ETH unlocked when a permissioned validator exits * @param moduleName The module name - * @param amount The exited ETH amount + * @param amount The amount of ETH unlocked (original principal, not including auto-compounded rewards) + * @dev Restricted to ROLE_ID_PUFFER_PROTOCOL. Called by PufferProtocol during exit handling. + * Note: For 0x02 validators with Pectra auto-compounding, consensus rewards auto-compound + * on the beacon chain and are NOT tracked here. Only the original principal stake is debited. */ function exitValidator(bytes32 moduleName, uint256 amount) external; /** - * @notice Adjusts locked ETH amount due to slashing or inactivity penalties + * @notice Adjusts locked ETH due to slashing or inactivity penalties * @param moduleName The module name - * @param reductionAmount The amount to reduce from locked ETH - * @dev This should be called when validator balance decreases due to slashing + * @param reductionAmount The amount to reduce (slashing/inactivity losses only, not rewards) + * @dev Restricted to ROLE_ID_PUFFER_PROTOCOL. Called when validator balance decreases due to slashing. + * Note: Consensus rewards auto-compound on the beacon chain and are NOT tracked here. + * Only the original principal stake is tracked in moduleLockedEth. */ function adjustLockedEth(bytes32 moduleName, uint256 reductionAmount) external; } diff --git a/mainnet-contracts/src/interface/IPufferModuleManager.sol b/mainnet-contracts/src/interface/IPufferModuleManager.sol index 50603841..5a762c62 100644 --- a/mainnet-contracts/src/interface/IPufferModuleManager.sol +++ b/mainnet-contracts/src/interface/IPufferModuleManager.sol @@ -124,6 +124,12 @@ interface IPufferModuleManager { */ event PermissionedModuleBeaconSet(address indexed beacon); + /** + * @notice Emitted when the NRWC (NonRestakingWithdrawalCredentials) beacon is set + * @param beacon The address of the NRWC beacon + */ + event NRWCBeaconSet(address indexed beacon); + /** * @notice Emitted when queued withdrawals are completed for a permissioned module * @param permissionedModule The address of the permissioned module From 14323ed603716968517d2ace116ce432b8867771 Mon Sep 17 00:00:00 2001 From: ksatyarth2 <47723310+ksatyarth2@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:04:57 +0000 Subject: [PATCH 29/55] forge fmt --- mainnet-contracts/src/PufferModuleManager.sol | 3 +- .../src/interface/IPermissionedModule.sol | 4 +- .../PermissionedValidatorSecurityPOC.t.sol | 41 +++++++------------ 3 files changed, 18 insertions(+), 30 deletions(-) diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index 714f0579..523d37a3 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -365,8 +365,7 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, * @dev NRWC (NonRestakingWithdrawalCredentials) beacon address (stored in contract storage for upgradeability) * keccak256(abi.encode(uint256(keccak256("PufferModuleManager.nrwcBeacon")) - 1)) & ~bytes32(uint256(0xff)) */ - bytes32 private constant _NRWC_BEACON_SLOT = - 0x5e3efd71b10c8d5afd6e403b79d40030cf08570b8694200c7f3948cb18b66b00; + bytes32 private constant _NRWC_BEACON_SLOT = 0x5e3efd71b10c8d5afd6e403b79d40030cf08570b8694200c7f3948cb18b66b00; /** * @notice Sets the NRWC beacon address diff --git a/mainnet-contracts/src/interface/IPermissionedModule.sol b/mainnet-contracts/src/interface/IPermissionedModule.sol index 79b3e518..ad6040e9 100644 --- a/mainnet-contracts/src/interface/IPermissionedModule.sol +++ b/mainnet-contracts/src/interface/IPermissionedModule.sol @@ -18,7 +18,9 @@ interface IPermissionedModule { * @param permissionedModule The permissioned module that owns the NRWC * @param withdrawalCredentials The NRWC contract address */ - event NonRestakingWithdrawalCredentialsSet(address indexed permissionedModule, address indexed withdrawalCredentials); + event NonRestakingWithdrawalCredentialsSet( + address indexed permissionedModule, address indexed withdrawalCredentials + ); /** * @notice Stakes a validator via EigenLayer (restaked path) diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol index 3e93285c..21adb44e 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol @@ -797,41 +797,33 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { IPermissionedOracle(address(permissionedOracle)) ); - PufferModuleManager newModuleManagerImpl = new PufferModuleManager( - _getPufferModuleBeacon(), _getRestakingOperatorBeacon(), _getPufferProtocol() - ); + PufferModuleManager newModuleManagerImpl = + new PufferModuleManager(_getPufferModuleBeacon(), _getRestakingOperatorBeacon(), _getPufferProtocol()); vm.startPrank(COMMUNITY_MULTISIG); bool success; - bytes memory protocolUpgradeCalldata = abi.encodeCall( - UUPSUpgradeable.upgradeToAndCall, - (address(newProtocolImpl), "") - ); + bytes memory protocolUpgradeCalldata = + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (address(newProtocolImpl), "")); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (_getPufferProtocol(), protocolUpgradeCalldata, 1)) ); require(success, "PufferProtocol upgrade failed"); - bytes memory moduleManagerUpgradeCalldata = abi.encodeCall( - UUPSUpgradeable.upgradeToAndCall, - (address(newModuleManagerImpl), "") - ); + bytes memory moduleManagerUpgradeCalldata = + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (address(newModuleManagerImpl), "")); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (_getPufferModuleManager(), moduleManagerUpgradeCalldata, 2)) ); require(success, "PufferModuleManager upgrade failed"); - bytes memory setBeaconCalldata = abi.encodeCall( - PufferModuleManager.setPermissionedModuleBeacon, - (address(permissionedModuleBeacon)) - ); + bytes memory setBeaconCalldata = + abi.encodeCall(PufferModuleManager.setPermissionedModuleBeacon, (address(permissionedModuleBeacon))); bytes4[] memory beaconSelectors = new bytes4[](1); beaconSelectors[0] = PufferModuleManager.setPermissionedModuleBeacon.selector; bytes memory grantBeaconRoleCalldata = abi.encodeCall( - accessManager.setTargetFunctionRole, - (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) + accessManager.setTargetFunctionRole, (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) ); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), grantBeaconRoleCalldata, 3)) @@ -853,10 +845,8 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { selectors = new bytes4[](1); selectors[0] = PufferProtocol.createPermissionedModule.selector; - bytes memory callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (_getPufferProtocol(), selectors, ROLE_ID_DAO) - ); + bytes memory callData = + abi.encodeCall(accessManager.setTargetFunctionRole, (_getPufferProtocol(), selectors, ROLE_ID_DAO)); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) ); @@ -871,8 +861,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { selectors = new bytes4[](1); selectors[0] = PufferProtocol.registerPermissionedValidatorKey.selector; callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (_getPufferProtocol(), selectors, ROLE_ID_PERMISSIONED_OPERATOR) + accessManager.setTargetFunctionRole, (_getPufferProtocol(), selectors, ROLE_ID_PERMISSIONED_OPERATOR) ); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) @@ -890,8 +879,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { selectors[1] = PufferProtocol.handlePermissionedValidatorExit.selector; selectors[2] = PufferProtocol.skipPermissionedProvisioning.selector; callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (_getPufferProtocol(), selectors, ROLE_ID_OPERATIONS_PAYMASTER) + accessManager.setTargetFunctionRole, (_getPufferProtocol(), selectors, ROLE_ID_OPERATIONS_PAYMASTER) ); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) @@ -909,8 +897,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { selectors[1] = PermissionedOracle.exitValidator.selector; selectors[2] = PermissionedOracle.adjustLockedEth.selector; callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (address(permissionedOracle), selectors, ROLE_ID_PUFFER_PROTOCOL) + accessManager.setTargetFunctionRole, (address(permissionedOracle), selectors, ROLE_ID_PUFFER_PROTOCOL) ); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) From 68e66c2c962ee180a7d12a36ceb36b417d6f56b5 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 18 Feb 2026 14:41:36 +0530 Subject: [PATCH 30/55] fix: poc to have beacon --- .../PermissionedValidatorSecurityPOC.t.sol | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol index 3e93285c..d83612a4 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol @@ -780,13 +780,20 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { _getEigenPodManager(), IDelegationManager(_getDelegationManager()), pufferModuleManager, - IRewardsCoordinator(_getRewardsCoordinator()), - IBeaconDepositContract(_getBeaconDepositContract()) + IRewardsCoordinator(_getRewardsCoordinator()) ); vm.prank(COMMUNITY_MULTISIG); permissionedModuleBeacon = new UpgradeableBeacon(address(permissionedModuleImpl), COMMUNITY_MULTISIG); + // Deploy NonRestakingWithdrawalCredentials implementation and beacon + NonRestakingWithdrawalCredentials nrwcImpl = new NonRestakingWithdrawalCredentials(); + vm.label(address(nrwcImpl), "NRWCImpl"); + + vm.prank(COMMUNITY_MULTISIG); + UpgradeableBeacon nrwcBeacon = new UpgradeableBeacon(address(nrwcImpl), COMMUNITY_MULTISIG); + vm.label(address(nrwcBeacon), "NRWCBeacon"); + PufferProtocol newProtocolImpl = new PufferProtocol( pufferVault, IGuardianModule(_getGuardianModule()), @@ -823,12 +830,10 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { ); require(success, "PufferModuleManager upgrade failed"); - bytes memory setBeaconCalldata = abi.encodeCall( - PufferModuleManager.setPermissionedModuleBeacon, - (address(permissionedModuleBeacon)) - ); - bytes4[] memory beaconSelectors = new bytes4[](1); + // Grant DAO role permission to call setPermissionedModuleBeacon and setNRWCBeacon + bytes4[] memory beaconSelectors = new bytes4[](2); beaconSelectors[0] = PufferModuleManager.setPermissionedModuleBeacon.selector; + beaconSelectors[1] = PufferModuleManager.setNRWCBeacon.selector; bytes memory grantBeaconRoleCalldata = abi.encodeCall( accessManager.setTargetFunctionRole, (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) @@ -840,8 +845,18 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { vm.stopPrank(); + // Set permissioned module beacon + bytes memory setBeaconCalldata = abi.encodeCall( + PufferModuleManager.setPermissionedModuleBeacon, + (address(permissionedModuleBeacon)) + ); vm.prank(dao); accessManager.execute(_getPufferModuleManager(), setBeaconCalldata); + + // Set NRWC beacon + bytes memory setNRWCBeaconCalldata = abi.encodeCall(PufferModuleManager.setNRWCBeacon, (address(nrwcBeacon))); + vm.prank(dao); + accessManager.execute(_getPufferModuleManager(), setNRWCBeaconCalldata); } function _setupAccessControl() internal { From 6ff2b08bbc0abc0894a0e6298e4c7d2076a072ba Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 18 Feb 2026 14:43:40 +0530 Subject: [PATCH 31/55] fix: pufferVault natspec --- mainnet-contracts/src/PufferVaultV5.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/mainnet-contracts/src/PufferVaultV5.sol b/mainnet-contracts/src/PufferVaultV5.sol index 2c4196fe..d74cb8fe 100644 --- a/mainnet-contracts/src/PufferVaultV5.sol +++ b/mainnet-contracts/src/PufferVaultV5.sol @@ -112,6 +112,7 @@ contract PufferVaultV5 is * + WETH held in the vault contract * + ETH held in the vault contract * + PUFFER_ORACLE.getLockedEthAmount(), which is the oracle-reported Puffer validator ETH locked in the Beacon chain + * + PUFFER_PERMISSIONED_ORACLE.getLockedEthAmount(), which is the ETH locked by permissioned validators (supports variable stakes 32-2048 ETH via Pectra) * + getTotalRewardMintAmount(), which is the total amount of rewards minted * - getTotalRewardDepositAmount(), which is the total amount of rewards deposited to the Vault * - RESTAKING_REWARDS_DEPOSITOR.getPendingDistributionAmount(), which is the total amount of rewards pending distribution From f708ad83d889382c8b15e2575d26efe209cc0d13 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 18 Feb 2026 14:45:34 +0530 Subject: [PATCH 32/55] fix: NRWC beacon deploy --- mainnet-contracts/script/DeployPuffer.s.sol | 3 + .../script/DeploymentStructs.sol | 3 + mainnet-contracts/script/SetupAccess.s.sol | 30 +++++++++- .../PermissionedValidatorFork.t.sol | 28 ++++++--- .../unit/PermissionedModuleStandalone.t.sol | 57 ++++++++++++------- 5 files changed, 89 insertions(+), 32 deletions(-) diff --git a/mainnet-contracts/script/DeployPuffer.s.sol b/mainnet-contracts/script/DeployPuffer.s.sol index 2c775805..f0fb4782 100644 --- a/mainnet-contracts/script/DeployPuffer.s.sol +++ b/mainnet-contracts/script/DeployPuffer.s.sol @@ -206,8 +206,11 @@ contract DeployPuffer is BaseScript { enclaveVerifier: guardiansDeployment.enclaveVerifier, beacon: address(pufferModuleBeacon), restakingOperatorBeacon: address(restakingOperatorBeacon), + permissionedModuleBeacon: address(0), // Set during permissioned module deployment + nrwcBeacon: address(0), // Set during permissioned module deployment moduleManager: address(moduleManagerProxy), pufferOracle: address(oracle), + permissionedOracle: address(0), // Set during permissioned module deployment operationsCoordinator: address(operationsCoordinator), aVSContractsRegistry: address(aVSContractsRegistry), restakingOperatorController: address(restakingOperatorController), diff --git a/mainnet-contracts/script/DeploymentStructs.sol b/mainnet-contracts/script/DeploymentStructs.sol index a650ce47..e65ee9cb 100644 --- a/mainnet-contracts/script/DeploymentStructs.sol +++ b/mainnet-contracts/script/DeploymentStructs.sol @@ -21,10 +21,13 @@ struct PufferProtocolDeployment { address enclaveVerifier; address beacon; // Beacon for Puffer modules address restakingOperatorBeacon; // Beacon for Restaking Operator + address permissionedModuleBeacon; // Beacon for Permissioned modules + address nrwcBeacon; // Beacon for NonRestakingWithdrawalCredentials address moduleManager; address validatorTicket; address validatorTicketPricer; address pufferOracle; + address permissionedOracle; // Oracle for permissioned validators address operationsCoordinator; address aVSContractsRegistry; address restakingOperatorController; diff --git a/mainnet-contracts/script/SetupAccess.s.sol b/mainnet-contracts/script/SetupAccess.s.sol index 502ab976..b8e30209 100644 --- a/mainnet-contracts/script/SetupAccess.s.sol +++ b/mainnet-contracts/script/SetupAccess.s.sol @@ -16,6 +16,7 @@ import { PufferProtocolDeployment } from "./DeploymentStructs.sol"; import { ValidatorTicket } from "../src/ValidatorTicket.sol"; import { PufferVaultV5 } from "../src/PufferVaultV5.sol"; import { OperationsCoordinator } from "../src/OperationsCoordinator.sol"; +import { PermissionedOracle } from "../src/PermissionedOracle.sol"; import { ValidatorTicketPricer } from "../src/ValidatorTicketPricer.sol"; import { GenerateAccessManagerCallData } from "../script/GenerateAccessManagerCallData.sol"; import { GenerateAccessManagerCalldata2 } from "../script/AccessManagerMigrations/GenerateAccessManagerCalldata2.s.sol"; @@ -52,7 +53,8 @@ contract SetupAccess is BaseScript { moduleManagerAccess: _setupPufferModuleManagerAccess(), roleLabels: _labelRoles(), coordinatorAccess: _setupCoordinatorAccess(), - validatorTicketAccess: _setupValidatorTicketPricerAccess() + validatorTicketAccess: _setupValidatorTicketPricerAccess(), + permissionedOracleAccess: _setupPermissionedOracleAccess() }); bytes memory multicallData = abi.encodeCall(Multicall.multicall, (calldatas)); @@ -97,9 +99,10 @@ contract SetupAccess is BaseScript { bytes[] memory moduleManagerAccess, bytes[] memory roleLabels, bytes[] memory coordinatorAccess, - bytes[] memory validatorTicketAccess + bytes[] memory validatorTicketAccess, + bytes[] memory permissionedOracleAccess ) internal view returns (bytes[] memory calldatas) { - calldatas = new bytes[](31); + calldatas = new bytes[](32); calldatas[0] = _setupGuardianModuleRoles(); calldatas[1] = _setupEnclaveVerifierRoles(); calldatas[2] = rolesCalldatas[0]; @@ -139,6 +142,8 @@ contract SetupAccess is BaseScript { calldatas[28] = validatorTicketAccess[1]; calldatas[29] = validatorTicketAccess[2]; calldatas[30] = validatorTicketAccess[3]; + + calldatas[31] = permissionedOracleAccess[0]; } function _labelRoles() internal pure returns (bytes[] memory) { @@ -424,6 +429,25 @@ contract SetupAccess is BaseScript { return calldatas; } + function _setupPermissionedOracleAccess() internal view returns (bytes[] memory) { + bytes[] memory calldatas = new bytes[](1); + + // PufferProtocol role - can provision, exit, and adjust validators + bytes4[] memory protocolSelectors = new bytes4[](3); + protocolSelectors[0] = PermissionedOracle.provisionValidator.selector; + protocolSelectors[1] = PermissionedOracle.exitValidator.selector; + protocolSelectors[2] = PermissionedOracle.adjustLockedEth.selector; + + calldatas[0] = abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, + pufferDeployment.permissionedOracle, + protocolSelectors, + ROLE_ID_PUFFER_PROTOCOL + ); + + return calldatas; + } + function _grantRoles(address DAO, address paymaster) internal view returns (bytes[] memory) { bytes[] memory calldatas = new bytes[](7); diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol index ba588e89..cbe132af 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol @@ -108,8 +108,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { _getEigenPodManager(), IDelegationManager(_getDelegationManager()), pufferModuleManager, - IRewardsCoordinator(_getRewardsCoordinator()), - IBeaconDepositContract(_getBeaconDepositContract()) + IRewardsCoordinator(_getRewardsCoordinator()) ); vm.label(address(permissionedModuleImpl), "PermissionedModuleImpl"); @@ -118,6 +117,15 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { permissionedModuleBeacon = new UpgradeableBeacon(address(permissionedModuleImpl), COMMUNITY_MULTISIG); vm.label(address(permissionedModuleBeacon), "PermissionedModuleBeacon"); + // Deploy NonRestakingWithdrawalCredentials implementation + NonRestakingWithdrawalCredentials nrwcImpl = new NonRestakingWithdrawalCredentials(); + vm.label(address(nrwcImpl), "NRWCImpl"); + + // Deploy UpgradeableBeacon for NRWC with COMMUNITY_MULTISIG as owner + vm.prank(COMMUNITY_MULTISIG); + UpgradeableBeacon nrwcBeacon = new UpgradeableBeacon(address(nrwcImpl), COMMUNITY_MULTISIG); + vm.label(address(nrwcBeacon), "NRWCBeacon"); + // Deploy new PufferProtocol implementation with PermissionedOracle PufferProtocol newProtocolImpl = new PufferProtocol( pufferVault, @@ -156,12 +164,11 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { ); require(success, "PufferModuleManager upgrade failed"); - // 3. Set permissioned module beacon via Timelock -> AccessManager -> PufferModuleManager - bytes memory setBeaconCalldata = - abi.encodeCall(PufferModuleManager.setPermissionedModuleBeacon, (address(permissionedModuleBeacon))); - // First, grant the DAO role permission to call setPermissionedModuleBeacon - bytes4[] memory beaconSelectors = new bytes4[](1); + // 3. Set permissioned module beacon and NRWC beacon via Timelock -> AccessManager -> PufferModuleManager + // First, grant the DAO role permission to call setPermissionedModuleBeacon and setNRWCBeacon + bytes4[] memory beaconSelectors = new bytes4[](2); beaconSelectors[0] = PufferModuleManager.setPermissionedModuleBeacon.selector; + beaconSelectors[1] = PufferModuleManager.setNRWCBeacon.selector; bytes memory grantBeaconRoleCalldata = abi.encodeCall( accessManager.setTargetFunctionRole, (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) ); @@ -173,8 +180,15 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { vm.stopPrank(); // Now execute setPermissionedModuleBeacon as dao (who has ROLE_ID_DAO) + bytes memory setBeaconCalldata = + abi.encodeCall(PufferModuleManager.setPermissionedModuleBeacon, (address(permissionedModuleBeacon))); vm.prank(dao); accessManager.execute(_getPufferModuleManager(), setBeaconCalldata); + + // Also set NRWC beacon + bytes memory setNRWCBeaconCalldata = abi.encodeCall(PufferModuleManager.setNRWCBeacon, (address(nrwcBeacon))); + vm.prank(dao); + accessManager.execute(_getPufferModuleManager(), setNRWCBeaconCalldata); } function _setupAccessControl() internal { diff --git a/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol index 16088af0..c3cb1fe6 100644 --- a/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol +++ b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol @@ -37,13 +37,13 @@ contract PermissionedModuleStandaloneTest is Test { // Mock addresses address public pufferProtocolAddr; - address public pufferModuleManagerAddr; + PufferModuleManager public pufferModuleManager; address public owner; function setUp() public { owner = makeAddr("owner"); pufferProtocolAddr = makeAddr("pufferProtocol"); - pufferModuleManagerAddr = makeAddr("pufferModuleManager"); + address pufferModuleManagerAddr = makeAddr("pufferModuleManager"); vm.deal(owner, 1000 ether); vm.deal(pufferModuleManagerAddr, 1000 ether); @@ -57,14 +57,27 @@ contract PermissionedModuleStandaloneTest is Test { rewardsCoordinatorMock = address(new RewardsCoordinatorMock()); beaconDepositMock = address(new BeaconMock()); + // Deploy NonRestakingWithdrawalCredentials implementation and beacon + NonRestakingWithdrawalCredentials nrwcImpl = new NonRestakingWithdrawalCredentials(); + UpgradeableBeacon nrwcBeacon = new UpgradeableBeacon(address(nrwcImpl), owner); + + // Mock the getNRWCBeacon call on the module manager mock address + vm.mockCall( + pufferModuleManagerAddr, + abi.encodeWithSelector(PufferModuleManager.getNRWCBeacon.selector), + abi.encode(address(nrwcBeacon)) + ); + + // Create a fake PufferModuleManager reference for the PermissionedModule constructor + pufferModuleManager = PufferModuleManager(payable(pufferModuleManagerAddr)); + // Deploy implementation PermissionedModule impl = new PermissionedModule( IPufferProtocol(pufferProtocolAddr), eigenPodManagerMock, IDelegationManager(delegationManagerMock), - PufferModuleManager(payable(pufferModuleManagerAddr)), - IRewardsCoordinator(rewardsCoordinatorMock), - IBeaconDepositContract(beaconDepositMock) + pufferModuleManager, + IRewardsCoordinator(rewardsCoordinatorMock) ); // Deploy beacon @@ -127,7 +140,7 @@ contract PermissionedModuleStandaloneTest is Test { assertEq(address(permissionedModule.PUFFER_PROTOCOL()), pufferProtocolAddr, "PUFFER_PROTOCOL mismatch"); assertEq( address(permissionedModule.PUFFER_MODULE_MANAGER()), - pufferModuleManagerAddr, + address(pufferModuleManager), "PUFFER_MODULE_MANAGER mismatch" ); assertEq(address(permissionedModule.EIGEN_POD_MANAGER()), eigenPodManagerMock, "EIGEN_POD_MANAGER mismatch"); @@ -147,7 +160,7 @@ contract PermissionedModuleStandaloneTest is Test { amountGwei: 0 // Full exit }); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); } @@ -158,7 +171,7 @@ contract PermissionedModuleStandaloneTest is Test { amountGwei: 1_000_000_000 // 1 ETH partial withdrawal }); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); } @@ -179,7 +192,7 @@ contract PermissionedModuleStandaloneTest is Test { amountGwei: 10_000_000_000 // 10 ETH partial }); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: 3 * EXIT_FEE }(requests); } @@ -189,7 +202,7 @@ contract PermissionedModuleStandaloneTest is Test { // Max uint64 amount in gwei requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(1), amountGwei: type(uint64).max }); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); } @@ -233,7 +246,7 @@ contract PermissionedModuleStandaloneTest is Test { IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); requests[0] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(1), amountGwei: amountGwei }); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); } @@ -249,7 +262,7 @@ contract PermissionedModuleStandaloneTest is Test { }); } - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: uint256(numValidators) * EXIT_FEE }(requests); } @@ -262,7 +275,7 @@ contract PermissionedModuleStandaloneTest is Test { requests[1] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(2), amountGwei: amount2 }); requests[2] = IEigenPodTypes.WithdrawalRequest({ pubkey: _generatePubkey(3), amountGwei: amount3 }); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: 3 * EXIT_FEE }(requests); } @@ -275,7 +288,7 @@ contract PermissionedModuleStandaloneTest is Test { amountGwei: 1 // Minimum possible partial withdrawal (1 gwei) }); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); } @@ -286,7 +299,7 @@ contract PermissionedModuleStandaloneTest is Test { amountGwei: 32_000_000_000 // 32 ETH in gwei }); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); } @@ -297,7 +310,7 @@ contract PermissionedModuleStandaloneTest is Test { amountGwei: 2048_000_000_000 // 2048 ETH in gwei (Pectra MaxEB) }); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: EXIT_FEE }(requests); } @@ -305,7 +318,7 @@ contract PermissionedModuleStandaloneTest is Test { IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](0); // Should not revert at module level - validation is in PufferModuleManager - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerNonRestakedValidatorWithdrawals{ value: 0 }(requests); } @@ -320,7 +333,7 @@ contract PermissionedModuleStandaloneTest is Test { uint256 moduleBalanceBefore = address(permissionedModule).balance; // Call withdrawNonRestakedETH - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.withdrawNonRestakedETH(); assertEq(address(permissionedModule).balance, moduleBalanceBefore + 10 ether, "ETH should be withdrawn"); @@ -354,7 +367,7 @@ contract PermissionedModuleStandaloneTest is Test { uint256 moduleBalanceBefore = address(permissionedModule).balance; - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.withdrawNonRestakedETH(); assertEq(address(permissionedModule).balance, moduleBalanceBefore + amount, "ETH should be withdrawn"); @@ -367,7 +380,7 @@ contract PermissionedModuleStandaloneTest is Test { bytes[] memory pubkeys = new bytes[](1); pubkeys[0] = _generatePubkey(1); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerRestakedValidatorsExit{ value: EXIT_FEE }(pubkeys); } @@ -377,7 +390,7 @@ contract PermissionedModuleStandaloneTest is Test { pubkeys[1] = _generatePubkey(2); pubkeys[2] = _generatePubkey(3); - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerRestakedValidatorsExit{ value: 3 * EXIT_FEE }(pubkeys); } @@ -401,7 +414,7 @@ contract PermissionedModuleStandaloneTest is Test { pubkeys[i] = _generatePubkey(i); } - vm.prank(pufferModuleManagerAddr); + vm.prank(address(pufferModuleManager)); permissionedModule.triggerRestakedValidatorsExit{ value: uint256(numPubkeys) * EXIT_FEE }(pubkeys); } From b223400b082b7ec711d61b5b592e4396cac287f7 Mon Sep 17 00:00:00 2001 From: ksatyarth2 <47723310+ksatyarth2@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:20:12 +0000 Subject: [PATCH 33/55] forge fmt --- .../test/fork-tests/PermissionedValidatorSecurityPOC.t.sol | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol index c98860bb..7755b6df 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol @@ -840,10 +840,8 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { vm.stopPrank(); // Set permissioned module beacon - bytes memory setBeaconCalldata = abi.encodeCall( - PufferModuleManager.setPermissionedModuleBeacon, - (address(permissionedModuleBeacon)) - ); + bytes memory setBeaconCalldata = + abi.encodeCall(PufferModuleManager.setPermissionedModuleBeacon, (address(permissionedModuleBeacon))); vm.prank(dao); accessManager.execute(_getPufferModuleManager(), setBeaconCalldata); From 7b7ad6e9a21e31094c8533e320ead3e9622afbd4 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 18 Feb 2026 17:34:05 +0530 Subject: [PATCH 34/55] feat: transfer rewards to the vault or recipient --- mainnet-contracts/script/SetupAccess.s.sol | 3 +- mainnet-contracts/src/PufferModuleManager.sol | 36 ++- .../src/interface/IPufferModuleManager.sol | 11 + .../PermissionedValidatorFork.t.sol | 234 +++++++++++++++++- 4 files changed, 269 insertions(+), 15 deletions(-) diff --git a/mainnet-contracts/script/SetupAccess.s.sol b/mainnet-contracts/script/SetupAccess.s.sol index b8e30209..4e56f713 100644 --- a/mainnet-contracts/script/SetupAccess.s.sol +++ b/mainnet-contracts/script/SetupAccess.s.sol @@ -168,7 +168,7 @@ contract SetupAccess is BaseScript { bytes[] memory calldatas = new bytes[](3); // Dao selectors - bytes4[] memory selectors = new bytes4[](7); + bytes4[] memory selectors = new bytes4[](8); selectors[0] = PufferModuleManager.createNewRestakingOperator.selector; selectors[1] = PufferModuleManager.callUndelegate.selector; selectors[2] = PufferModuleManager.callDelegateTo.selector; @@ -176,6 +176,7 @@ contract SetupAccess is BaseScript { selectors[4] = PufferModuleManager.callRegisterOperatorToAVS.selector; selectors[5] = PufferModuleManager.callDeregisterOperatorFromAVS.selector; selectors[6] = PufferModuleManager.customExternalCall.selector; + selectors[7] = PufferModuleManager.transferPermissionedModuleETH.selector; calldatas[0] = abi.encodeWithSelector( AccessManager.setTargetFunctionRole.selector, pufferDeployment.moduleManager, selectors, ROLE_ID_DAO diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index 523d37a3..9c04607d 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -535,25 +535,37 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, } /** - * @notice Transfers ETH from the permissioned module to the vault + * @notice Transfers ETH Rewards from permissioned modules to a recipient * @param permissionedModules The addresses of the permissioned modules - * @param amounts The amounts of ETH to transfer - * @dev Restricted to Puffer Paymaster - */ - function transferPermissionedModuleETHToVault(address[] calldata permissionedModules, uint256[] calldata amounts) - external - virtual - restricted - { + * @param amounts The amounts of ETH to transfer from each module + * @param recipient The recipient address (vault or external EOA/multisig) + * @dev If recipient is PUFFER_VAULT, ETH is sent directly to vault's receive() function, + * which increases totalAssets() and improves the exchange rate for pufETH holders. + * Otherwise, transfers ETH directly to the recipient. + * Restricted to DAO + */ + function transferPermissionedModuleETH( + address[] calldata permissionedModules, + uint256[] calldata amounts, + address recipient + ) external virtual restricted { + if (recipient == address(0)) revert InvalidAmount(); + if (permissionedModules.length != amounts.length) revert InvalidAmount(); + uint256 totalAmount; for (uint256 i = 0; i < permissionedModules.length; ++i) { - (bool success,) = PermissionedModule(payable(permissionedModules[i])).call(address(this), amounts[i], ""); - if (!success) { + (bool callSuccess,) = + PermissionedModule(payable(permissionedModules[i])).call(address(this), amounts[i], ""); + if (!callSuccess) { revert InvalidAmount(); } totalAmount += amounts[i]; } - PufferVaultV5(PUFFER_VAULT).depositRewards{ value: totalAmount }(); + + (bool transferSuccess,) = recipient.call{ value: totalAmount }(""); + if (!transferSuccess) revert InvalidAmount(); + + emit PermissionedModuleETHTransferred(permissionedModules, amounts, recipient, totalAmount); } /** diff --git a/mainnet-contracts/src/interface/IPufferModuleManager.sol b/mainnet-contracts/src/interface/IPufferModuleManager.sol index 5a762c62..4adb15cc 100644 --- a/mainnet-contracts/src/interface/IPufferModuleManager.sol +++ b/mainnet-contracts/src/interface/IPufferModuleManager.sol @@ -195,4 +195,15 @@ interface IPufferModuleManager { event PermissionedNonRestakedValidatorWithdrawalsTriggered( address indexed permissionedModule, IEigenPodTypes.WithdrawalRequest[] requests ); + + /** + * @notice Emitted when ETH is transferred from permissioned modules + * @param permissionedModules The addresses of the permissioned modules + * @param amounts The amounts transferred from each module + * @param recipient The recipient address (vault or external) + * @param totalAmount The total amount transferred + */ + event PermissionedModuleETHTransferred( + address[] permissionedModules, uint256[] amounts, address indexed recipient, uint256 totalAmount + ); } diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol index cbe132af..fa0f9d0b 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol @@ -255,11 +255,10 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { require(success, "grantRole OPERATIONS_PAYMASTER failed"); // Grant PufferModuleManager functions to paymaster - bytes4[] memory moduleManagerSelectors = new bytes4[](4); + bytes4[] memory moduleManagerSelectors = new bytes4[](3); moduleManagerSelectors[0] = PufferModuleManager.triggerRestakedValidatorsExit.selector; moduleManagerSelectors[1] = PufferModuleManager.triggerNonRestakedValidatorWithdrawals.selector; moduleManagerSelectors[2] = PufferModuleManager.withdrawNonRestakedETH.selector; - moduleManagerSelectors[3] = PufferModuleManager.transferPermissionedModuleETHToVault.selector; callData = abi.encodeCall( accessManager.setTargetFunctionRole, (_getPufferModuleManager(), moduleManagerSelectors, ROLE_ID_OPERATIONS_PAYMASTER) @@ -269,6 +268,18 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { ); require(success, "setTargetFunctionRole for paymaster module manager functions failed"); + // Grant PufferModuleManager ETH transfer function to DAO + bytes4[] memory daoModuleManagerSelectors = new bytes4[](1); + daoModuleManagerSelectors[0] = PufferModuleManager.transferPermissionedModuleETH.selector; + callData = abi.encodeCall( + accessManager.setTargetFunctionRole, + (_getPufferModuleManager(), daoModuleManagerSelectors, ROLE_ID_DAO) + ); + (success,) = address(timelock).call( + abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) + ); + require(success, "setTargetFunctionRole for DAO module manager functions failed"); + // Grant ROLE_ID_PUFFER_PROTOCOL to PufferProtocol for oracle updates selectors = new bytes4[](3); selectors[0] = PermissionedOracle.provisionValidator.selector; @@ -694,6 +705,225 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { pufferModuleManager.triggerNonRestakedValidatorWithdrawals(moduleAddress, requests); } + // ============ Test: ETH Transfer ============ + + function test_transferPermissionedModuleETH_toVault() public { + // Setup: Create module and fund it + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + address moduleAddress = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + + // Send ETH to the module (simulating rewards/withdrawals) + vm.deal(moduleAddress, 10 ether); + + uint256 vaultBalanceBefore = address(pufferVault).balance; + uint256 totalAssetsBefore = pufferVault.totalAssets(); + uint256 totalRewardDepositBefore = pufferVault.getTotalRewardDepositAmount(); + + // Transfer ETH from module to vault + address[] memory modules = new address[](1); + modules[0] = moduleAddress; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 5 ether; + + vm.prank(dao); + pufferModuleManager.transferPermissionedModuleETH(modules, amounts, address(pufferVault)); + + // Verify vault received the ETH + assertEq(address(pufferVault).balance, vaultBalanceBefore + 5 ether, "Vault should receive 5 ETH"); + assertEq(moduleAddress.balance, 5 ether, "Module should have 5 ETH remaining"); + + // Verify depositRewards() was NOT called - totalRewardDepositAmount should be unchanged + // ETH is sent directly to vault's receive() function + assertEq( + pufferVault.getTotalRewardDepositAmount(), + totalRewardDepositBefore, + "totalRewardDepositAmount should be unchanged (direct transfer, not depositRewards)" + ); + + // totalAssets should increase by 5 ETH because: + // - vault ETH balance increases by 5 ETH + // - no offsetting accounting (depositRewards not called) + // This means the exchange rate improves for existing pufETH holders + assertEq(pufferVault.totalAssets(), totalAssetsBefore + 5 ether, "totalAssets should increase by 5 ETH"); + } + + function test_transferPermissionedModuleETH_toVault_exchangeRateImproves() public { + // This test verifies that transferring to vault DOES improve exchange rate + // because ETH is sent directly (not via depositRewards) + + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + address moduleAddress = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + vm.deal(moduleAddress, 100 ether); + + // Record exchange rate before (shares per 1 ETH) + uint256 sharesBefore = pufferVault.convertToShares(1 ether); + + // Transfer large amount to vault + address[] memory modules = new address[](1); + modules[0] = moduleAddress; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100 ether; + + vm.prank(dao); + pufferModuleManager.transferPermissionedModuleETH(modules, amounts, address(pufferVault)); + + // Exchange rate should improve: fewer shares per ETH (each share is worth more ETH) + uint256 sharesAfter = pufferVault.convertToShares(1 ether); + assertLt(sharesAfter, sharesBefore, "Should get fewer shares per ETH (exchange rate improved)"); + + // Verify totalAssets increased + // This confirms the ETH contributed to backing existing pufETH holders + } + + function test_transferPermissionedModuleETH_toExternalRecipient() public { + // Setup: Create module and fund it + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + address moduleAddress = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + + // Send ETH to the module (simulating rewards/withdrawals) + vm.deal(moduleAddress, 10 ether); + + // External recipient (multisig) + address externalRecipient = makeAddr("externalMultisig"); + uint256 recipientBalanceBefore = externalRecipient.balance; + + // Record vault state before + uint256 vaultBalanceBefore = address(pufferVault).balance; + uint256 totalAssetsBefore = pufferVault.totalAssets(); + uint256 exchangeRateBefore = pufferVault.convertToShares(1 ether); + + // Transfer ETH from module to external recipient + address[] memory modules = new address[](1); + modules[0] = moduleAddress; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 7 ether; + + vm.prank(dao); + pufferModuleManager.transferPermissionedModuleETH(modules, amounts, externalRecipient); + + // Verify external recipient received the ETH + assertEq(externalRecipient.balance, recipientBalanceBefore + 7 ether, "External recipient should receive 7 ETH"); + assertEq(moduleAddress.balance, 3 ether, "Module should have 3 ETH remaining"); + + // Verify vault is completely unaffected + assertEq(address(pufferVault).balance, vaultBalanceBefore, "Vault balance should be unchanged"); + assertEq(pufferVault.totalAssets(), totalAssetsBefore, "totalAssets should be unchanged"); + assertEq(pufferVault.convertToShares(1 ether), exchangeRateBefore, "Exchange rate should be unchanged"); + } + + function test_transferPermissionedModuleETH_multipleModules() public { + // Setup: Create two modules + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + bytes32 moduleName2 = bytes32("PERM_MODULE_2"); + vm.prank(dao); + pufferProtocol.createPermissionedModule(moduleName2); + + address module1 = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + address module2 = pufferProtocol.getPermissionedModuleAddress(moduleName2); + + // Fund both modules + vm.deal(module1, 10 ether); + vm.deal(module2, 20 ether); + + address externalRecipient = makeAddr("multisig"); + + // Transfer from both modules + address[] memory modules = new address[](2); + modules[0] = module1; + modules[1] = module2; + uint256[] memory amounts = new uint256[](2); + amounts[0] = 5 ether; + amounts[1] = 15 ether; + + vm.prank(dao); + pufferModuleManager.transferPermissionedModuleETH(modules, amounts, externalRecipient); + + // Verify + assertEq(externalRecipient.balance, 20 ether, "Recipient should receive 20 ETH total"); + assertEq(module1.balance, 5 ether, "Module1 should have 5 ETH remaining"); + assertEq(module2.balance, 5 ether, "Module2 should have 5 ETH remaining"); + } + + function test_transferPermissionedModuleETH_unauthorized() public { + // Setup: Create module + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + address moduleAddress = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + vm.deal(moduleAddress, 10 ether); + + address[] memory modules = new address[](1); + modules[0] = moduleAddress; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 5 ether; + + // Try to call from unauthorized address (paymaster, not DAO) + vm.prank(paymaster); + vm.expectRevert(); + pufferModuleManager.transferPermissionedModuleETH(modules, amounts, makeAddr("recipient")); + } + + function test_transferPermissionedModuleETH_zeroRecipient() public { + // Setup: Create module + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + address moduleAddress = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + vm.deal(moduleAddress, 10 ether); + + address[] memory modules = new address[](1); + modules[0] = moduleAddress; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 5 ether; + + // Try to send to zero address + vm.prank(dao); + vm.expectRevert(); + pufferModuleManager.transferPermissionedModuleETH(modules, amounts, address(0)); + } + + function test_transferPermissionedModuleETH_arrayLengthMismatch() public { + // Setup: Create module + vm.prank(dao); + pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); + + address moduleAddress = pufferProtocol.getPermissionedModuleAddress(TEST_MODULE_NAME); + vm.deal(moduleAddress, 10 ether); + + // Create mismatched arrays (2 modules, 1 amount) + address[] memory modules = new address[](2); + modules[0] = moduleAddress; + modules[1] = moduleAddress; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 5 ether; + + address externalRecipient = makeAddr("externalRecipient"); + + // Should revert due to array length mismatch + vm.prank(dao); + vm.expectRevert(); + pufferModuleManager.transferPermissionedModuleETH(modules, amounts, externalRecipient); + + // Test opposite case: 1 module, 2 amounts + address[] memory modules2 = new address[](1); + modules2[0] = moduleAddress; + uint256[] memory amounts2 = new uint256[](2); + amounts2[0] = 3 ether; + amounts2[1] = 2 ether; + + vm.prank(dao); + vm.expectRevert(); + pufferModuleManager.transferPermissionedModuleETH(modules2, amounts2, externalRecipient); + } + // ============ Test: Oracle Integration ============ function test_oracleTracking() public { From d51cf6df83e1e86a79a6a61fe3fbd3593db67502 Mon Sep 17 00:00:00 2001 From: ksatyarth2 <47723310+ksatyarth2@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:07:59 +0000 Subject: [PATCH 35/55] forge fmt --- .../test/fork-tests/PermissionedValidatorFork.t.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol index fa0f9d0b..7f3a3643 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol @@ -272,8 +272,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { bytes4[] memory daoModuleManagerSelectors = new bytes4[](1); daoModuleManagerSelectors[0] = PufferModuleManager.transferPermissionedModuleETH.selector; callData = abi.encodeCall( - accessManager.setTargetFunctionRole, - (_getPufferModuleManager(), daoModuleManagerSelectors, ROLE_ID_DAO) + accessManager.setTargetFunctionRole, (_getPufferModuleManager(), daoModuleManagerSelectors, ROLE_ID_DAO) ); (success,) = address(timelock).call( abi.encodeCall(Timelock.executeTransaction, (address(accessManager), callData, operationId++)) From 55a843963cfd7b30abac59dff8c8af99b7be487d Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 18 Feb 2026 17:55:38 +0530 Subject: [PATCH 36/55] fix: error names in PMM --- mainnet-contracts/src/PufferModuleManager.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index 9c04607d..364f79ba 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.0 <0.9.0; import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; -import { Unauthorized, InvalidAmount } from "./Errors.sol"; +import { Unauthorized, InvalidAmount, InvalidAddress, TransferFailed } from "./Errors.sol"; import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; import { PufferModule } from "./PufferModule.sol"; import { PermissionedModule } from "./PermissionedModule.sol"; @@ -408,7 +408,7 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, address beacon = getPermissionedModuleBeacon(); if (beacon == address(0)) { - revert InvalidAmount(); // Beacon not set + revert InvalidAddress(); // Beacon not set } // This called from the PufferProtocol and the event is emitted there @@ -549,7 +549,7 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, uint256[] calldata amounts, address recipient ) external virtual restricted { - if (recipient == address(0)) revert InvalidAmount(); + if (recipient == address(0)) revert InvalidAddress(); if (permissionedModules.length != amounts.length) revert InvalidAmount(); uint256 totalAmount; @@ -557,13 +557,13 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, (bool callSuccess,) = PermissionedModule(payable(permissionedModules[i])).call(address(this), amounts[i], ""); if (!callSuccess) { - revert InvalidAmount(); + revert TransferFailed(); } totalAmount += amounts[i]; } (bool transferSuccess,) = recipient.call{ value: totalAmount }(""); - if (!transferSuccess) revert InvalidAmount(); + if (!transferSuccess) revert TransferFailed(); emit PermissionedModuleETHTransferred(permissionedModules, amounts, recipient, totalAmount); } From a65f7a8e46ada6e4fc56cccd792d03d90ee8d0e8 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 24 Feb 2026 01:01:59 +0530 Subject: [PATCH 37/55] feat: move beacons to constructor --- .../script/DeployEverything.s.sol | 4 +- mainnet-contracts/script/DeployPuffer.s.sol | 29 +++++-- .../script/DeployPufferModuleManager.s.sol | 12 +-- mainnet-contracts/script/UpgradePufETH.s.sol | 9 ++- mainnet-contracts/src/PermissionedModule.sol | 2 +- mainnet-contracts/src/PufferModuleManager.sol | 79 ++++--------------- .../src/interface/IPufferModuleManager.sol | 12 --- 7 files changed, 56 insertions(+), 91 deletions(-) diff --git a/mainnet-contracts/script/DeployEverything.s.sol b/mainnet-contracts/script/DeployEverything.s.sol index a2e86236..40e8d3b7 100644 --- a/mainnet-contracts/script/DeployEverything.s.sol +++ b/mainnet-contracts/script/DeployEverything.s.sol @@ -52,7 +52,7 @@ contract DeployEverything is BaseScript { ); PufferProtocolDeployment memory pufferDeployment = - new DeployPuffer().run(guardiansDeployment, puffETHDeployment.pufferVault, pufferOracle); + new DeployPuffer().run(guardiansDeployment, puffETHDeployment.pufferVault, pufferOracle, address(0)); pufferDeployment.pufferDepositor = puffETHDeployment.pufferDepositor; pufferDeployment.pufferVault = puffETHDeployment.pufferVault; @@ -64,7 +64,7 @@ contract DeployEverything is BaseScript { address revenueDepositor = _deployRevenueDepositor(puffETHDeployment); pufferDeployment.revenueDepositor = revenueDepositor; - new UpgradePufETH().run(puffETHDeployment, pufferOracle, revenueDepositor); + new UpgradePufETH().run(puffETHDeployment, pufferOracle, revenueDepositor, address(0)); // `anvil` in the terminal if (_localAnvil) { diff --git a/mainnet-contracts/script/DeployPuffer.s.sol b/mainnet-contracts/script/DeployPuffer.s.sol index f0fb4782..3c0be77c 100644 --- a/mainnet-contracts/script/DeployPuffer.s.sol +++ b/mainnet-contracts/script/DeployPuffer.s.sol @@ -6,6 +6,8 @@ import { PufferModuleManager } from "../src/PufferModuleManager.sol"; import { GuardianModule } from "../src/GuardianModule.sol"; import { NoImplementation } from "../src/NoImplementation.sol"; import { PufferModule } from "../src/PufferModule.sol"; +import { PermissionedModule } from "../src/PermissionedModule.sol"; +import { NonRestakingWithdrawalCredentials } from "../src/NonRestakingWithdrawalCredentials.sol"; import { RestakingOperator } from "../src/RestakingOperator.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { BaseScript } from "script/BaseScript.s.sol"; @@ -56,6 +58,8 @@ contract DeployPuffer is BaseScript { PufferProtocol pufferProtocol; UpgradeableBeacon pufferModuleBeacon; UpgradeableBeacon restakingOperatorBeacon; + UpgradeableBeacon permissionedModuleBeacon; + UpgradeableBeacon nrwcBeacon; PufferModuleManager moduleManager; OperationsCoordinator operationsCoordinator; ValidatorTicketPricer validatorTicketPricer; @@ -69,7 +73,7 @@ contract DeployPuffer is BaseScript { address treasury; address operationsMultisig; - function run(GuardiansDeployment calldata guardiansDeployment, address pufferVault, address oracle) + function run(GuardiansDeployment calldata guardiansDeployment, address pufferVault, address oracle, address permissionedOracle) public broadcast returns (PufferProtocolDeployment memory) @@ -151,9 +155,22 @@ contract DeployPuffer is BaseScript { address(restakingOperatorController) ); + PermissionedModule permissionedModuleImplementation = new PermissionedModule( + PufferProtocol(payable(proxy)), + eigenPodManager, + IDelegationManager(delegationManager), + PufferModuleManager(payable(address(moduleManagerProxy))), + IRewardsCoordinator(rewardsCoordinator) + ); + + NonRestakingWithdrawalCredentials nrwcImplementation = new NonRestakingWithdrawalCredentials(); + pufferModuleBeacon = new UpgradeableBeacon(address(moduleImplementation), address(accessManager)); restakingOperatorBeacon = new UpgradeableBeacon(address(restakingOperatorImplementation), address(accessManager)); + permissionedModuleBeacon = + new UpgradeableBeacon(address(permissionedModuleImplementation), address(accessManager)); + nrwcBeacon = new UpgradeableBeacon(address(nrwcImplementation), address(accessManager)); // Puffer Service implementation pufferProtocolImpl = new PufferProtocol({ @@ -163,7 +180,7 @@ contract DeployPuffer is BaseScript { moduleManager: address(moduleManagerProxy), oracle: IPufferOracleV2(oracle), beaconDepositContract: getStakingContract(), - permissionedOracle: IPermissionedOracle(address(0)) // Will be set in upgrade + permissionedOracle: IPermissionedOracle(permissionedOracle) }); } @@ -174,7 +191,9 @@ contract DeployPuffer is BaseScript { moduleManager = new PufferModuleManager({ pufferModuleBeacon: address(pufferModuleBeacon), restakingOperatorBeacon: address(restakingOperatorBeacon), - pufferProtocol: address(proxy) + pufferProtocol: address(proxy), + permissionedModuleBeacon: address(permissionedModuleBeacon), + nrwcBeacon: address(nrwcBeacon) }); NoImplementation(payable(address(moduleManagerProxy))).upgradeToAndCall( @@ -206,8 +225,8 @@ contract DeployPuffer is BaseScript { enclaveVerifier: guardiansDeployment.enclaveVerifier, beacon: address(pufferModuleBeacon), restakingOperatorBeacon: address(restakingOperatorBeacon), - permissionedModuleBeacon: address(0), // Set during permissioned module deployment - nrwcBeacon: address(0), // Set during permissioned module deployment + permissionedModuleBeacon: address(permissionedModuleBeacon), + nrwcBeacon: address(nrwcBeacon), moduleManager: address(moduleManagerProxy), pufferOracle: address(oracle), permissionedOracle: address(0), // Set during permissioned module deployment diff --git a/mainnet-contracts/script/DeployPufferModuleManager.s.sol b/mainnet-contracts/script/DeployPufferModuleManager.s.sol index f86a6cea..ab2147cc 100644 --- a/mainnet-contracts/script/DeployPufferModuleManager.s.sol +++ b/mainnet-contracts/script/DeployPufferModuleManager.s.sol @@ -13,21 +13,23 @@ import { DeployerHelper } from "./DeployerHelper.s.sol"; * forge script script/DeployPufferModuleManager.s.sol:DeployPufferModuleManager -vvvv --rpc-url=$RPC_URL --broadcast --verify */ contract DeployPufferModuleManager is DeployerHelper { - function run() public { + function run(address permissionedModuleBeacon, address nrwcBeacon) public { vm.startBroadcast(); - _deploy(); + _deploy(permissionedModuleBeacon, nrwcBeacon); } function deployPufferModuleManagerTests() public returns (PufferModuleManager) { - return _deploy(); + return _deploy(address(0), address(0)); } - function _deploy() internal returns (PufferModuleManager) { + function _deploy(address permissionedModuleBeacon, address nrwcBeacon) internal returns (PufferModuleManager) { PufferModuleManager newPufferModuleManagerImplementation = new PufferModuleManager({ pufferModuleBeacon: address(_getPufferModuleBeacon()), restakingOperatorBeacon: address(_getRestakingOperatorBeacon()), - pufferProtocol: address(_getPufferProtocol()) + pufferProtocol: address(_getPufferProtocol()), + permissionedModuleBeacon: permissionedModuleBeacon, + nrwcBeacon: nrwcBeacon }); _consoleLogOrUpgradeUUPSPrank({ diff --git a/mainnet-contracts/script/UpgradePufETH.s.sol b/mainnet-contracts/script/UpgradePufETH.s.sol index 3ca5c831..553d6420 100644 --- a/mainnet-contracts/script/UpgradePufETH.s.sol +++ b/mainnet-contracts/script/UpgradePufETH.s.sol @@ -50,7 +50,12 @@ contract UpgradePufETH is BaseScript { ILidoWithdrawalQueue internal constant _LIDO_WITHDRAWAL_QUEUE = ILidoWithdrawalQueue(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1); - function run(PufferDeployment memory deployment, address pufferOracle, address revenueDepositor) public broadcast { + function run( + PufferDeployment memory deployment, + address pufferOracle, + address revenueDepositor, + address permissionedOracle + ) public broadcast { //@todo this is for tests only AccessManager(deployment.accessManager).grantRole(1, _broadcaster, 0); @@ -60,7 +65,7 @@ contract UpgradePufETH is BaseScript { ILidoWithdrawalQueue(deployment.lidoWithdrawalQueueMock), IPufferOracleV2(pufferOracle), IPufferRevenueDepositor(revenueDepositor), - IPermissionedOracle(address(0)) + IPermissionedOracle(permissionedOracle) ); vm.label(address(newImplementation), "PufferVaultV5Implementation"); diff --git a/mainnet-contracts/src/PermissionedModule.sol b/mainnet-contracts/src/PermissionedModule.sol index 957d1adb..410e7749 100644 --- a/mainnet-contracts/src/PermissionedModule.sol +++ b/mainnet-contracts/src/PermissionedModule.sol @@ -76,7 +76,7 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss $.eigenPod = IEigenPod(address(EIGEN_POD_MANAGER.createPod())); // Deploy NonRestakingWithdrawalCredentials via beacon proxy for upgradeability - address nrwcBeacon = PUFFER_MODULE_MANAGER.getNRWCBeacon(); + address nrwcBeacon = PUFFER_MODULE_MANAGER.NRWC_BEACON(); $.nonRestakingWithdrawalCredentials = NonRestakingWithdrawalCredentials( payable( Create2.deploy({ diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index 364f79ba..aa340281 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -32,6 +32,8 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, address public immutable RESTAKING_OPERATOR_BEACON; address public immutable PUFFER_PROTOCOL; address payable public immutable PUFFER_VAULT; + address public immutable PERMISSIONED_MODULE_BEACON; + address public immutable NRWC_BEACON; modifier onlyPufferProtocol() { if (msg.sender != PUFFER_PROTOCOL) { @@ -40,11 +42,19 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, _; } - constructor(address pufferModuleBeacon, address restakingOperatorBeacon, address pufferProtocol) { + constructor( + address pufferModuleBeacon, + address restakingOperatorBeacon, + address pufferProtocol, + address permissionedModuleBeacon, + address nrwcBeacon + ) { PUFFER_MODULE_BEACON = pufferModuleBeacon; RESTAKING_OPERATOR_BEACON = restakingOperatorBeacon; PUFFER_PROTOCOL = pufferProtocol; PUFFER_VAULT = payable(address(IPufferProtocol(PUFFER_PROTOCOL).PUFFER_VAULT())); + PERMISSIONED_MODULE_BEACON = permissionedModuleBeacon; + NRWC_BEACON = nrwcBeacon; _disableInitializers(); } @@ -332,63 +342,6 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, // ============ Permissioned Module Support ============ - /** - * @dev Permissioned module beacon address (stored in contract storage for upgradeability) - * keccak256(abi.encode(uint256(keccak256("PufferModuleManager.permissionedModuleBeacon")) - 1)) & ~bytes32(uint256(0xff)) - */ - bytes32 private constant _PERMISSIONED_MODULE_BEACON_SLOT = - 0x12ddf963a4f129d061806b3796c3f91a43d3f59a05b31d1b6ef212e44874cf00; - - /** - * @notice Sets the permissioned module beacon address - * @param beacon The address of the permissioned module beacon - * @dev Restricted to the DAO - */ - function setPermissionedModuleBeacon(address beacon) external virtual restricted { - assembly { - sstore(_PERMISSIONED_MODULE_BEACON_SLOT, beacon) - } - emit PermissionedModuleBeaconSet(beacon); - } - - /** - * @notice Returns the permissioned module beacon address - * @return beacon The address of the permissioned module beacon - */ - function getPermissionedModuleBeacon() public view returns (address beacon) { - assembly { - beacon := sload(_PERMISSIONED_MODULE_BEACON_SLOT) - } - } - - /** - * @dev NRWC (NonRestakingWithdrawalCredentials) beacon address (stored in contract storage for upgradeability) - * keccak256(abi.encode(uint256(keccak256("PufferModuleManager.nrwcBeacon")) - 1)) & ~bytes32(uint256(0xff)) - */ - bytes32 private constant _NRWC_BEACON_SLOT = 0x5e3efd71b10c8d5afd6e403b79d40030cf08570b8694200c7f3948cb18b66b00; - - /** - * @notice Sets the NRWC beacon address - * @param beacon The address of the NRWC beacon - * @dev Restricted to the DAO - */ - function setNRWCBeacon(address beacon) external virtual restricted { - assembly { - sstore(_NRWC_BEACON_SLOT, beacon) - } - emit NRWCBeaconSet(beacon); - } - - /** - * @notice Returns the NRWC beacon address - * @return beacon The address of the NRWC beacon - */ - function getNRWCBeacon() public view returns (address beacon) { - assembly { - beacon := sload(_NRWC_BEACON_SLOT) - } - } - /** * @notice Create a new Permissioned module * @dev This function creates a new Permissioned module with the given module name @@ -406,11 +359,6 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, revert ForbiddenModuleName(); } - address beacon = getPermissionedModuleBeacon(); - if (beacon == address(0)) { - revert InvalidAddress(); // Beacon not set - } - // This called from the PufferProtocol and the event is emitted there return PermissionedModule( payable( @@ -419,7 +367,10 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, salt: keccak256(abi.encodePacked("PERMISSIONED_", moduleName)), bytecode: abi.encodePacked( type(BeaconProxy).creationCode, - abi.encode(beacon, abi.encodeCall(PermissionedModule.initialize, (moduleName, authority()))) + abi.encode( + PERMISSIONED_MODULE_BEACON, + abi.encodeCall(PermissionedModule.initialize, (moduleName, authority())) + ) ) }) ) diff --git a/mainnet-contracts/src/interface/IPufferModuleManager.sol b/mainnet-contracts/src/interface/IPufferModuleManager.sol index 4adb15cc..f9cbc525 100644 --- a/mainnet-contracts/src/interface/IPufferModuleManager.sol +++ b/mainnet-contracts/src/interface/IPufferModuleManager.sol @@ -118,18 +118,6 @@ interface IPufferModuleManager { */ event ClaimerSet(address indexed rewardsReceiver, address indexed claimer); - /** - * @notice Emitted when the permissioned module beacon is set - * @param beacon The address of the permissioned module beacon - */ - event PermissionedModuleBeaconSet(address indexed beacon); - - /** - * @notice Emitted when the NRWC (NonRestakingWithdrawalCredentials) beacon is set - * @param beacon The address of the NRWC beacon - */ - event NRWCBeaconSet(address indexed beacon); - /** * @notice Emitted when queued withdrawals are completed for a permissioned module * @param permissionedModule The address of the permissioned module From 03cc09fc0f5999819e7df167cda7bdcd3179796b Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 24 Feb 2026 20:02:00 +0530 Subject: [PATCH 38/55] feat: deployment upgrade scripts --- ...9_GeneratePermissionedModuleCalldata.s.sol | 204 ++++++++++++++++++ .../script/DeployPermissionedBeacons.s.sol | 73 +++++++ .../script/DeployPermissionedOracle.s.sol | 30 +++ mainnet-contracts/script/Roles.sol | 3 + .../script/UpgradePufferProtocol.s.sol | 53 +++++ mainnet-contracts/src/PufferModuleManager.sol | 2 +- mainnet-contracts/src/PufferProtocol.sol | 7 +- 7 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol create mode 100644 mainnet-contracts/script/DeployPermissionedBeacons.s.sol create mode 100644 mainnet-contracts/script/DeployPermissionedOracle.s.sol create mode 100644 mainnet-contracts/script/UpgradePufferProtocol.s.sol diff --git a/mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol b/mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol new file mode 100644 index 00000000..3e276da6 --- /dev/null +++ b/mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { Script } from "forge-std/Script.sol"; +import { AccessManager } from "@openzeppelin/contracts/access/manager/AccessManager.sol"; +import { Multicall } from "@openzeppelin/contracts/utils/Multicall.sol"; +import { PufferProtocol } from "../../src/PufferProtocol.sol"; +import { PufferModuleManager } from "../../src/PufferModuleManager.sol"; +import { PermissionedOracle } from "../../src/PermissionedOracle.sol"; +import { + ROLE_ID_DAO, + ROLE_ID_OPERATIONS_PAYMASTER, + ROLE_ID_PUFFER_PROTOCOL, + ROLE_ID_VALIDATOR_EJECTOR, + ROLE_ID_PERMISSIONED_OPERATOR, + ROLE_ID_PERMISSIONED_ETH_MANAGER +} from "../../script/Roles.sol"; + +/** + * @title GeneratePermissionedModuleCalldata + * @author Puffer Finance + * @notice Generates the AccessManager calldata to set up access control for the permissioned + * validator feature: PermissionedOracle, new PufferProtocol functions, and new + * PufferModuleManager functions. + * + * The returned calldata is queued and executed through the Timelock: + * 1. timelock.queueTransaction(address(accessManager), encodedMulticall, 1) + * 2. ... 7 days later ... + * 3. timelock.executeTransaction(address(accessManager), encodedMulticall, 1) + * + * forge script script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol \ + * --sig 'run(address,address,address)' \ + * \ + * -vvvv + */ +contract GeneratePermissionedModuleCalldata is Script { + function run(address pufferProtocol, address moduleManager, address permissionedOracle) + public + pure + returns (bytes memory) + { + bytes[] memory calldatas = new bytes[](9); + + // 1. PermissionedOracle: restrict to PUFFER_PROTOCOL role + calldatas[0] = _setupPermissionedOracleAccess(permissionedOracle); + + // 2. PufferProtocol: DAO-restricted permissioned functions + calldatas[1] = _setupProtocolDaoAccess(pufferProtocol); + + // 3. PufferProtocol: paymaster-restricted permissioned functions + calldatas[2] = _setupProtocolPaymasterAccess(pufferProtocol); + + // 4. PufferProtocol: permissioned-operator-restricted functions + calldatas[3] = _setupProtocolPermissionedOperatorAccess(pufferProtocol); + + // 5. PufferModuleManager: DAO permissioned functions + calldatas[4] = _setupModuleManagerDaoAccess(moduleManager); + + // 6. PufferModuleManager: paymaster permissioned functions + calldatas[5] = _setupModuleManagerPaymasterAccess(moduleManager); + + // 7. PufferModuleManager: validator ejector permissioned functions + calldatas[6] = _setupModuleManagerEjectorAccess(moduleManager); + + // 8. PufferModuleManager: dedicated role for ETH transfers out of permissioned modules + // Overrides the prior DAO assignment from SetupAccess — grant ROLE_ID_PERMISSIONED_ETH_MANAGER + // to the appropriate multisig/address via a separate DAO tx after this migration. + calldatas[7] = _setupModuleManagerEthManagerAccess(moduleManager); + + // 9. Label the new role + calldatas[8] = abi.encodeWithSelector( + AccessManager.labelRole.selector, ROLE_ID_PERMISSIONED_ETH_MANAGER, "Permissioned ETH Manager" + ); + + bytes memory encodedMulticall = abi.encodeCall(Multicall.multicall, (calldatas)); + return encodedMulticall; + } + + /** + * @dev PermissionedOracle functions are restricted to PUFFER_PROTOCOL (called by PufferProtocol). + */ + function _setupPermissionedOracleAccess(address permissionedOracle) internal pure returns (bytes memory) { + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = PermissionedOracle.provisionValidator.selector; + selectors[1] = PermissionedOracle.exitValidator.selector; + selectors[2] = PermissionedOracle.adjustLockedEth.selector; + + return abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, permissionedOracle, selectors, ROLE_ID_PUFFER_PROTOCOL + ); + } + + /** + * @dev PufferProtocol DAO functions: module creation (matches createPufferModule pattern). + */ + function _setupProtocolDaoAccess(address pufferProtocol) internal pure returns (bytes memory) { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = PufferProtocol.createPermissionedModule.selector; + + return abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, pufferProtocol, selectors, ROLE_ID_DAO + ); + } + + /** + * @dev PufferProtocol paymaster functions: provisioning and exit handling + * (matches provisionNode / batchHandleWithdrawals / skipProvisioning pattern). + */ + function _setupProtocolPaymasterAccess(address pufferProtocol) internal pure returns (bytes memory) { + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = PufferProtocol.provisionPermissionedValidator.selector; + selectors[1] = PufferProtocol.handlePermissionedValidatorExit.selector; + selectors[2] = PufferProtocol.skipPermissionedProvisioning.selector; + + return abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, + pufferProtocol, + selectors, + ROLE_ID_OPERATIONS_PAYMASTER + ); + } + + /** + * @dev PufferProtocol permissioned operator functions: validator key registration. + * ROLE_ID_PERMISSIONED_OPERATOR (29) must be granted to operator addresses separately. + */ + function _setupProtocolPermissionedOperatorAccess(address pufferProtocol) internal pure returns (bytes memory) { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = PufferProtocol.registerPermissionedValidatorKey.selector; + + return abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, + pufferProtocol, + selectors, + ROLE_ID_PERMISSIONED_OPERATOR + ); + } + + /** + * @dev PufferModuleManager DAO functions for permissioned modules + * (matches callDelegateTo / callUndelegate / callSetProofSubmitter / callSetClaimerFor pattern). + */ + function _setupModuleManagerDaoAccess(address moduleManager) internal pure returns (bytes memory) { + bytes4[] memory selectors = new bytes4[](4); + selectors[0] = PufferModuleManager.callDelegateToPermissioned.selector; + selectors[1] = PufferModuleManager.callUndelegatePermissioned.selector; + selectors[2] = PufferModuleManager.callSetProofSubmitterPermissioned.selector; + selectors[3] = PufferModuleManager.callSetClaimerForPermissioned.selector; + + return abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, moduleManager, selectors, ROLE_ID_DAO + ); + } + + /** + * @dev PufferModuleManager paymaster functions for permissioned modules: + * queue/complete withdrawals, withdraw non-restaked ETH, trigger non-restaked withdrawals. + */ + function _setupModuleManagerPaymasterAccess(address moduleManager) internal pure returns (bytes memory) { + bytes4[] memory selectors = new bytes4[](4); + selectors[0] = PufferModuleManager.callQueueWithdrawalsPermissioned.selector; + selectors[1] = PufferModuleManager.callCompleteQueuedWithdrawalsPermissioned.selector; + selectors[2] = PufferModuleManager.withdrawNonRestakedETH.selector; + selectors[3] = PufferModuleManager.triggerNonRestakedValidatorWithdrawals.selector; + + return abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, + moduleManager, + selectors, + ROLE_ID_OPERATIONS_PAYMASTER + ); + } + + /** + * @dev PufferModuleManager validator ejector functions for permissioned modules. + */ + function _setupModuleManagerEjectorAccess(address moduleManager) internal pure returns (bytes memory) { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = PufferModuleManager.triggerRestakedValidatorsExit.selector; + + return abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, + moduleManager, + selectors, + ROLE_ID_VALIDATOR_EJECTOR + ); + } + + /** + * @dev transferPermissionedModuleETH gets its own dedicated role because it directly controls + * outbound ETH flow from permissioned modules and deserves independent access governance. + */ + function _setupModuleManagerEthManagerAccess(address moduleManager) internal pure returns (bytes memory) { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = PufferModuleManager.transferPermissionedModuleETH.selector; + + return abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, + moduleManager, + selectors, + ROLE_ID_PERMISSIONED_ETH_MANAGER + ); + } +} diff --git a/mainnet-contracts/script/DeployPermissionedBeacons.s.sol b/mainnet-contracts/script/DeployPermissionedBeacons.s.sol new file mode 100644 index 00000000..ab6c1711 --- /dev/null +++ b/mainnet-contracts/script/DeployPermissionedBeacons.s.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { DeployerHelper } from "./DeployerHelper.s.sol"; +import { console } from "forge-std/console.sol"; +import { PermissionedModule } from "../src/PermissionedModule.sol"; +import { NonRestakingWithdrawalCredentials } from "../src/NonRestakingWithdrawalCredentials.sol"; +import { PufferProtocol } from "../src/PufferProtocol.sol"; +import { PufferModuleManager } from "../src/PufferModuleManager.sol"; +import { IDelegationManager } from "../src/interface/Eigenlayer-Slashing/IDelegationManager.sol"; +import { IRewardsCoordinator } from "../src/interface/Eigenlayer-Slashing/IRewardsCoordinator.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; + +/** + * @title DeployPermissionedBeacons + * @author Puffer Finance + * @notice Deploys the PermissionedModule and NonRestakingWithdrawalCredentials beacons. + * @dev These are new beacons required by PufferModuleManager to create PermissionedModule + * instances (each with an associated NonRestakingWithdrawalCredentials sub-contract). + * + * After deploying the beacons, upgrade PufferModuleManager via DeployPufferModuleManager + * passing the returned beacon addresses. + * + * forge script script/DeployPermissionedBeacons.s.sol:DeployPermissionedBeacons \ + * -vvvv --rpc-url=$RPC_URL --broadcast --verify + */ +contract DeployPermissionedBeacons is DeployerHelper { + function run() public returns (address permissionedModuleBeacon, address nrwcBeacon) { + vm.startBroadcast(); + + (permissionedModuleBeacon, nrwcBeacon) = _deploy(); + + vm.stopBroadcast(); + } + + function _deploy() internal returns (address permissionedModuleBeacon, address nrwcBeacon) { + address accessManager = _getAccessManager(); + + // Deploy PermissionedModule implementation + PermissionedModule permissionedModuleImpl = new PermissionedModule( + PufferProtocol(payable(_getPufferProtocol())), + _getEigenPodManager(), + IDelegationManager(_getDelegationManager()), + PufferModuleManager(payable(_getPufferModuleManager())), + IRewardsCoordinator(_getRewardsCoordinator()) + ); + vm.label(address(permissionedModuleImpl), "PermissionedModuleImplementation"); + console.log("Deployed PermissionedModuleImplementation at", address(permissionedModuleImpl)); + + // Deploy NonRestakingWithdrawalCredentials implementation + NonRestakingWithdrawalCredentials nrwcImpl = new NonRestakingWithdrawalCredentials(); + vm.label(address(nrwcImpl), "NonRestakingWithdrawalCredentialsImplementation"); + console.log("Deployed NonRestakingWithdrawalCredentialsImplementation at", address(nrwcImpl)); + + // Deploy beacons — owned by AccessManager so upgrades go through DAO/timelock + UpgradeableBeacon pmBeacon = new UpgradeableBeacon(address(permissionedModuleImpl), accessManager); + vm.label(address(pmBeacon), "PermissionedModuleBeacon"); + console.log("Deployed PermissionedModuleBeacon at", address(pmBeacon)); + + UpgradeableBeacon nrwcBeaconContract = new UpgradeableBeacon(address(nrwcImpl), accessManager); + vm.label(address(nrwcBeaconContract), "NonRestakingWithdrawalCredentialsBeacon"); + console.log("Deployed NonRestakingWithdrawalCredentialsBeacon at", address(nrwcBeaconContract)); + + console.log("================================================"); + console.log("Next step: upgrade PufferModuleManager with these beacon addresses:"); + console.log(" forge script script/DeployPufferModuleManager.s.sol:DeployPufferModuleManager \\"); + console.log(" --sig 'run(address,address)' \\"); + console.log(" ", address(pmBeacon), address(nrwcBeaconContract)); + console.log("================================================"); + + return (address(pmBeacon), address(nrwcBeaconContract)); + } +} diff --git a/mainnet-contracts/script/DeployPermissionedOracle.s.sol b/mainnet-contracts/script/DeployPermissionedOracle.s.sol new file mode 100644 index 00000000..a37f76de --- /dev/null +++ b/mainnet-contracts/script/DeployPermissionedOracle.s.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { DeployerHelper } from "./DeployerHelper.s.sol"; +import { PermissionedOracle } from "../src/PermissionedOracle.sol"; +import { console } from "forge-std/console.sol"; + +/** + * @title DeployPermissionedOracle + * @author Puffer Finance + * @notice Deploys the PermissionedOracle contract + * @dev Tracks actual ETH amounts locked by permissioned validators (supports Pectra variable 32-2048 ETH). + * + * forge script script/DeployPermissionedOracle.s.sol:DeployPermissionedOracle \ + * -vvvv --rpc-url=$RPC_URL --broadcast --verify + */ +contract DeployPermissionedOracle is DeployerHelper { + function run() public returns (PermissionedOracle) { + vm.startBroadcast(); + + PermissionedOracle oracle = new PermissionedOracle(_getAccessManager()); + + vm.label(address(oracle), "PermissionedOracle"); + console.log("Deployed PermissionedOracle at", address(oracle)); + + vm.stopBroadcast(); + + return oracle; + } +} diff --git a/mainnet-contracts/script/Roles.sol b/mainnet-contracts/script/Roles.sol index 9a83c3d4..50a845e1 100644 --- a/mainnet-contracts/script/Roles.sol +++ b/mainnet-contracts/script/Roles.sol @@ -18,6 +18,9 @@ uint64 constant ROLE_ID_VALIDATOR_EJECTOR = 28; // Role assigned to permissioned validator operators (no bond, no VT) uint64 constant ROLE_ID_PERMISSIONED_OPERATOR = 29; +// Role for transferring ETH out of permissioned modules (controls reward flow) +uint64 constant ROLE_ID_PERMISSIONED_ETH_MANAGER = 30; + // Role assigned to validator ticket price setter uint64 constant ROLE_ID_VT_PRICER = 25; diff --git a/mainnet-contracts/script/UpgradePufferProtocol.s.sol b/mainnet-contracts/script/UpgradePufferProtocol.s.sol new file mode 100644 index 00000000..dcf2be93 --- /dev/null +++ b/mainnet-contracts/script/UpgradePufferProtocol.s.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { DeployerHelper } from "./DeployerHelper.s.sol"; +import { PufferProtocol } from "../src/PufferProtocol.sol"; +import { PufferVaultV5 } from "../src/PufferVaultV5.sol"; +import { PufferModuleManager } from "../src/PufferModuleManager.sol"; +import { GuardianModule } from "../src/GuardianModule.sol"; +import { ValidatorTicket } from "../src/ValidatorTicket.sol"; +import { IPufferOracleV2 } from "../src/interface/IPufferOracleV2.sol"; +import { IPermissionedOracle } from "../src/interface/IPermissionedOracle.sol"; + +/** + * @title UpgradePufferProtocol + * @author Puffer Finance + * @notice Upgrades the PufferProtocol implementation to add permissioned validator support. + * @dev PufferProtocol uses immutables, so a new implementation must be deployed with + * the PermissionedOracle address set. Deploy PermissionedOracle first via + * DeployPermissionedOracle.s.sol, then run this script with the oracle address. + * + * On Holesky the upgrade is executed immediately. On mainnet the calldata is logged + * for queueing through the Timelock. + * + * forge script script/UpgradePufferProtocol.s.sol:UpgradePufferProtocol \ + * --sig 'run(address)' \ + * -vvvv --rpc-url=$RPC_URL --broadcast --verify + */ +contract UpgradePufferProtocol is DeployerHelper { + function run(address permissionedOracle) public { + vm.startBroadcast(); + + PufferProtocol existingProxy = PufferProtocol(payable(_getPufferProtocol())); + + PufferProtocol newImplementation = new PufferProtocol({ + pufferVault: PufferVaultV5(payable(existingProxy.PUFFER_VAULT())), + guardianModule: existingProxy.GUARDIAN_MODULE(), + moduleManager: address(existingProxy.PUFFER_MODULE_MANAGER()), + validatorTicket: existingProxy.VALIDATOR_TICKET(), + oracle: existingProxy.PUFFER_ORACLE(), + beaconDepositContract: address(existingProxy.BEACON_DEPOSIT_CONTRACT()), + permissionedOracle: IPermissionedOracle(permissionedOracle) + }); + + _consoleLogOrUpgradeUUPS({ + proxyTarget: _getPufferProtocol(), + implementation: address(newImplementation), + data: "", + contractName: "PufferProtocolImplementation" + }); + + vm.stopBroadcast(); + } +} diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index aa340281..ca1cb982 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -493,7 +493,7 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, * @dev If recipient is PUFFER_VAULT, ETH is sent directly to vault's receive() function, * which increases totalAssets() and improves the exchange rate for pufETH holders. * Otherwise, transfers ETH directly to the recipient. - * Restricted to DAO + * Restricted to Permissioned ETH Manager */ function transferPermissionedModuleETH( address[] calldata permissionedModules, diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index 04bd14b5..b9b1cf55 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -368,9 +368,7 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad * @param validatorIndex The index of the validator to provision * @param validatorSignature The validator's BLS signature * @param expectedDepositDataRoot Expected deposit data root (for reorg protection) - * @dev Restricted to multisig/provisioner role. Guardian signatures removed since - * permissioned validators are provisioned by trusted multisig and deposit data - * is verified on-chain. + * @dev Restricted to Puffer Paymaster. */ function provisionPermissionedValidator( bytes32 moduleName, @@ -541,7 +539,8 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad * @notice Skips provisioning of a permissioned validator (for invalid/unwanted registrations) * @param moduleName The name of the permissioned module * @param validatorIndex The index of the validator to skip - * @dev Restricted to authorized roles. Only PENDING validators can be skipped. + * @dev Restricted to Puffer Paymaster. + * Only PENDING validators can be skipped. * Unlike external validators, no VT penalty since permissioned validators don't pay VT. * Only the next validator in line can be skipped (FIFO ordering enforced). * This ensures consistent index tracking and prevents skipped validator tracking issues. From 0d6103f6600d84a9c61a48f5b0e6c2583c561a94 Mon Sep 17 00:00:00 2001 From: ksatyarth2 <47723310+ksatyarth2@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:35:54 +0000 Subject: [PATCH 39/55] forge fmt --- ...9_GeneratePermissionedModuleCalldata.s.sol | 35 +++++-------------- mainnet-contracts/script/DeployPuffer.s.sol | 13 +++---- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol b/mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol index 3e276da6..edc43cfe 100644 --- a/mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol +++ b/mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol @@ -97,9 +97,8 @@ contract GeneratePermissionedModuleCalldata is Script { bytes4[] memory selectors = new bytes4[](1); selectors[0] = PufferProtocol.createPermissionedModule.selector; - return abi.encodeWithSelector( - AccessManager.setTargetFunctionRole.selector, pufferProtocol, selectors, ROLE_ID_DAO - ); + return + abi.encodeWithSelector(AccessManager.setTargetFunctionRole.selector, pufferProtocol, selectors, ROLE_ID_DAO); } /** @@ -113,10 +112,7 @@ contract GeneratePermissionedModuleCalldata is Script { selectors[2] = PufferProtocol.skipPermissionedProvisioning.selector; return abi.encodeWithSelector( - AccessManager.setTargetFunctionRole.selector, - pufferProtocol, - selectors, - ROLE_ID_OPERATIONS_PAYMASTER + AccessManager.setTargetFunctionRole.selector, pufferProtocol, selectors, ROLE_ID_OPERATIONS_PAYMASTER ); } @@ -129,10 +125,7 @@ contract GeneratePermissionedModuleCalldata is Script { selectors[0] = PufferProtocol.registerPermissionedValidatorKey.selector; return abi.encodeWithSelector( - AccessManager.setTargetFunctionRole.selector, - pufferProtocol, - selectors, - ROLE_ID_PERMISSIONED_OPERATOR + AccessManager.setTargetFunctionRole.selector, pufferProtocol, selectors, ROLE_ID_PERMISSIONED_OPERATOR ); } @@ -147,9 +140,8 @@ contract GeneratePermissionedModuleCalldata is Script { selectors[2] = PufferModuleManager.callSetProofSubmitterPermissioned.selector; selectors[3] = PufferModuleManager.callSetClaimerForPermissioned.selector; - return abi.encodeWithSelector( - AccessManager.setTargetFunctionRole.selector, moduleManager, selectors, ROLE_ID_DAO - ); + return + abi.encodeWithSelector(AccessManager.setTargetFunctionRole.selector, moduleManager, selectors, ROLE_ID_DAO); } /** @@ -164,10 +156,7 @@ contract GeneratePermissionedModuleCalldata is Script { selectors[3] = PufferModuleManager.triggerNonRestakedValidatorWithdrawals.selector; return abi.encodeWithSelector( - AccessManager.setTargetFunctionRole.selector, - moduleManager, - selectors, - ROLE_ID_OPERATIONS_PAYMASTER + AccessManager.setTargetFunctionRole.selector, moduleManager, selectors, ROLE_ID_OPERATIONS_PAYMASTER ); } @@ -179,10 +168,7 @@ contract GeneratePermissionedModuleCalldata is Script { selectors[0] = PufferModuleManager.triggerRestakedValidatorsExit.selector; return abi.encodeWithSelector( - AccessManager.setTargetFunctionRole.selector, - moduleManager, - selectors, - ROLE_ID_VALIDATOR_EJECTOR + AccessManager.setTargetFunctionRole.selector, moduleManager, selectors, ROLE_ID_VALIDATOR_EJECTOR ); } @@ -195,10 +181,7 @@ contract GeneratePermissionedModuleCalldata is Script { selectors[0] = PufferModuleManager.transferPermissionedModuleETH.selector; return abi.encodeWithSelector( - AccessManager.setTargetFunctionRole.selector, - moduleManager, - selectors, - ROLE_ID_PERMISSIONED_ETH_MANAGER + AccessManager.setTargetFunctionRole.selector, moduleManager, selectors, ROLE_ID_PERMISSIONED_ETH_MANAGER ); } } diff --git a/mainnet-contracts/script/DeployPuffer.s.sol b/mainnet-contracts/script/DeployPuffer.s.sol index 3c0be77c..e7aa539e 100644 --- a/mainnet-contracts/script/DeployPuffer.s.sol +++ b/mainnet-contracts/script/DeployPuffer.s.sol @@ -73,11 +73,12 @@ contract DeployPuffer is BaseScript { address treasury; address operationsMultisig; - function run(GuardiansDeployment calldata guardiansDeployment, address pufferVault, address oracle, address permissionedOracle) - public - broadcast - returns (PufferProtocolDeployment memory) - { + function run( + GuardiansDeployment calldata guardiansDeployment, + address pufferVault, + address oracle, + address permissionedOracle + ) public broadcast returns (PufferProtocolDeployment memory) { accessManager = AccessManager(guardiansDeployment.accessManager); if (isMainnet()) { @@ -181,7 +182,7 @@ contract DeployPuffer is BaseScript { oracle: IPufferOracleV2(oracle), beaconDepositContract: getStakingContract(), permissionedOracle: IPermissionedOracle(permissionedOracle) - }); + }); } pufferProtocol = PufferProtocol(payable(address(proxy))); From 4f85c6791e5eb3a444471e09dd235d6e0f48617c Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Tue, 24 Feb 2026 20:45:09 +0530 Subject: [PATCH 40/55] fix: provisioning index error --- mainnet-contracts/src/PufferProtocol.sol | 6 ++++++ mainnet-contracts/src/interface/IPufferProtocol.sol | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index b9b1cf55..0a3d8e93 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -388,6 +388,12 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad revert InvalidValidatorIndex(); } + // Enforce FIFO ordering - only allow provisioning the next validator in line + uint256 nextToProvision = $.nextPermissionedValidatorToBeProvisionedIndices[moduleName]; + if (validatorIndex != nextToProvision) { + revert MustProvisionNextValidator(nextToProvision, validatorIndex); + } + PermissionedValidator storage validator = $.permissionedValidators[moduleName][validatorIndex]; if (validator.status != Status.PENDING) { diff --git a/mainnet-contracts/src/interface/IPufferProtocol.sol b/mainnet-contracts/src/interface/IPufferProtocol.sol index bdf7c680..27f8e02a 100644 --- a/mainnet-contracts/src/interface/IPufferProtocol.sol +++ b/mainnet-contracts/src/interface/IPufferProtocol.sol @@ -104,6 +104,13 @@ interface IPufferProtocol { */ error MustSkipNextValidator(uint256 expected, uint256 actual); + /** + * @notice Thrown when trying to provision a validator that is not next in line + * @param expected The expected validator index (next in line) + * @param actual The actual validator index that was provided + */ + error MustProvisionNextValidator(uint256 expected, uint256 actual); + /** * @notice Emitted when a permissioned validator experiences slashing loss * @param moduleName The module name From 4eef1931fc5efed6f671c8fb9a2c9bed9005fc45 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 25 Feb 2026 19:41:37 +0530 Subject: [PATCH 41/55] chore: remove unused imports --- mainnet-contracts/src/PufferModuleManager.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/mainnet-contracts/src/PufferModuleManager.sol b/mainnet-contracts/src/PufferModuleManager.sol index ca1cb982..59a36b8f 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -3,7 +3,6 @@ pragma solidity >=0.8.0 <0.9.0; import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; import { Unauthorized, InvalidAmount, InvalidAddress, TransferFailed } from "./Errors.sol"; -import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; import { PufferModule } from "./PufferModule.sol"; import { PermissionedModule } from "./PermissionedModule.sol"; import { PufferVaultV5 } from "./PufferVaultV5.sol"; @@ -17,10 +16,8 @@ import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils import { IDelegationManagerTypes } from "../src/interface/Eigenlayer-Slashing/IDelegationManager.sol"; import { ISignatureUtils } from "../src/interface/Eigenlayer-Slashing/ISignatureUtils.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { RestakingOperator } from "./RestakingOperator.sol"; import { IAllocationManager } from "../src/interface/Eigenlayer-Slashing/IAllocationManager.sol"; import { IEigenPodTypes } from "../src/interface/Eigenlayer-Slashing/IEigenPod.sol"; -import { PufferModule } from "./PufferModule.sol"; /** * @title PufferModuleManager From f5f25615db0f87384a714d5db942f559e7b5e21a Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 25 Feb 2026 20:17:41 +0530 Subject: [PATCH 42/55] fix: event --- mainnet-contracts/src/PermissionedModule.sol | 2 +- mainnet-contracts/src/interface/IPermissionedModule.sol | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/mainnet-contracts/src/PermissionedModule.sol b/mainnet-contracts/src/PermissionedModule.sol index 410e7749..8c988964 100644 --- a/mainnet-contracts/src/PermissionedModule.sol +++ b/mainnet-contracts/src/PermissionedModule.sol @@ -93,7 +93,7 @@ contract PermissionedModule is Initializable, AccessManagedUpgradeable, IPermiss ) ); - emit NonRestakingWithdrawalCredentialsSet(address(this), address($.nonRestakingWithdrawalCredentials)); + emit NonRestakingWithdrawalCredentialsSet(address($.nonRestakingWithdrawalCredentials)); } /** diff --git a/mainnet-contracts/src/interface/IPermissionedModule.sol b/mainnet-contracts/src/interface/IPermissionedModule.sol index ad6040e9..6a6a02d2 100644 --- a/mainnet-contracts/src/interface/IPermissionedModule.sol +++ b/mainnet-contracts/src/interface/IPermissionedModule.sol @@ -15,12 +15,9 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IPermissionedModule { /** * @notice Emitted when the non-restaking withdrawal credentials contract is set - * @param permissionedModule The permissioned module that owns the NRWC * @param withdrawalCredentials The NRWC contract address */ - event NonRestakingWithdrawalCredentialsSet( - address indexed permissionedModule, address indexed withdrawalCredentials - ); + event NonRestakingWithdrawalCredentialsSet(address indexed withdrawalCredentials); /** * @notice Stakes a validator via EigenLayer (restaked path) From c3808379e2c6f1d50ae122c1d0399b4d90e261e6 Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Wed, 25 Feb 2026 20:30:01 +0530 Subject: [PATCH 43/55] fix: remove index as args in skip and provision --- mainnet-contracts/src/PufferProtocol.sol | 35 ++++--------------- .../src/interface/IPufferProtocol.sol | 14 -------- 2 files changed, 6 insertions(+), 43 deletions(-) diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index 0a3d8e93..63fecc2b 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -365,14 +365,12 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad /** * @notice Provisions a permissioned validator (no bond, no VT, no guardian signatures) * @param moduleName The name of the permissioned module - * @param validatorIndex The index of the validator to provision * @param validatorSignature The validator's BLS signature * @param expectedDepositDataRoot Expected deposit data root (for reorg protection) - * @dev Restricted to Puffer Paymaster. + * @dev Restricted to Puffer Paymaster. Provisions the next validator in FIFO order. */ function provisionPermissionedValidator( bytes32 moduleName, - uint256 validatorIndex, bytes calldata validatorSignature, bytes32 expectedDepositDataRoot ) external restricted { @@ -383,16 +381,7 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad ProtocolStorage storage $ = _getPufferProtocolStorage(); - // Bounds check: validatorIndex must be less than the number of registered validators - if (validatorIndex >= $.pendingPermissionedValidatorIndices[moduleName]) { - revert InvalidValidatorIndex(); - } - - // Enforce FIFO ordering - only allow provisioning the next validator in line - uint256 nextToProvision = $.nextPermissionedValidatorToBeProvisionedIndices[moduleName]; - if (validatorIndex != nextToProvision) { - revert MustProvisionNextValidator(nextToProvision, validatorIndex); - } + uint256 validatorIndex = $.nextPermissionedValidatorToBeProvisionedIndices[moduleName]; PermissionedValidator storage validator = $.permissionedValidators[moduleName][validatorIndex]; @@ -544,27 +533,15 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad /** * @notice Skips provisioning of a permissioned validator (for invalid/unwanted registrations) * @param moduleName The name of the permissioned module - * @param validatorIndex The index of the validator to skip * @dev Restricted to Puffer Paymaster. * Only PENDING validators can be skipped. * Unlike external validators, no VT penalty since permissioned validators don't pay VT. - * Only the next validator in line can be skipped (FIFO ordering enforced). - * This ensures consistent index tracking and prevents skipped validator tracking issues. + * Skips the next validator in FIFO order. */ - function skipPermissionedProvisioning(bytes32 moduleName, uint256 validatorIndex) external restricted { + function skipPermissionedProvisioning(bytes32 moduleName) external restricted { ProtocolStorage storage $ = _getPufferProtocolStorage(); - // Bounds check - if (validatorIndex >= $.pendingPermissionedValidatorIndices[moduleName]) { - revert InvalidValidatorIndex(); - } - - // Enforce FIFO ordering - only allow skipping the next validator in line - // This ensures nextPermissionedValidatorToBeProvisionedIndices stays consistent - uint256 nextToProvision = $.nextPermissionedValidatorToBeProvisionedIndices[moduleName]; - if (validatorIndex != nextToProvision) { - revert MustSkipNextValidator(nextToProvision, validatorIndex); - } + uint256 validatorIndex = $.nextPermissionedValidatorToBeProvisionedIndices[moduleName]; PermissionedValidator storage validator = $.permissionedValidators[moduleName][validatorIndex]; @@ -577,7 +554,7 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad // Delete validator data delete $.permissionedValidators[moduleName][validatorIndex]; - // Always update next to be provisioned index (guaranteed to be the skipped one due to FIFO check) + // Update next to be provisioned index $.nextPermissionedValidatorToBeProvisionedIndices[moduleName] = validatorIndex + 1; emit PermissionedValidatorSkipped(pubKey, validatorIndex, moduleName); diff --git a/mainnet-contracts/src/interface/IPufferProtocol.sol b/mainnet-contracts/src/interface/IPufferProtocol.sol index 27f8e02a..ea60848d 100644 --- a/mainnet-contracts/src/interface/IPufferProtocol.sol +++ b/mainnet-contracts/src/interface/IPufferProtocol.sol @@ -97,20 +97,6 @@ interface IPufferProtocol { */ error InvalidValidatorIndex(); - /** - * @notice Thrown when trying to skip a validator that is not next in line for provisioning - * @param expected The expected validator index (next in line) - * @param actual The actual validator index that was provided - */ - error MustSkipNextValidator(uint256 expected, uint256 actual); - - /** - * @notice Thrown when trying to provision a validator that is not next in line - * @param expected The expected validator index (next in line) - * @param actual The actual validator index that was provided - */ - error MustProvisionNextValidator(uint256 expected, uint256 actual); - /** * @notice Emitted when a permissioned validator experiences slashing loss * @param moduleName The module name From 806bc8060e1ea974b9443c3be6c784cb3b794642 Mon Sep 17 00:00:00 2001 From: Eladio Date: Mon, 16 Mar 2026 17:18:41 +0100 Subject: [PATCH 44/55] Fixed compiling issues --- .../PermissionedValidatorFork.t.sol | 51 ++----- .../PermissionedValidatorSecurityPOC.t.sol | 141 +++++------------- .../unit/PermissionedModuleStandalone.t.sol | 2 +- 3 files changed, 56 insertions(+), 138 deletions(-) diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol index 7f3a3643..8a8158eb 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol @@ -139,8 +139,13 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { vm.label(address(newProtocolImpl), "PufferProtocolNewImpl"); // Deploy new PufferModuleManager implementation - PufferModuleManager newModuleManagerImpl = - new PufferModuleManager(_getPufferModuleBeacon(), _getRestakingOperatorBeacon(), _getPufferProtocol()); + PufferModuleManager newModuleManagerImpl = new PufferModuleManager( + _getPufferModuleBeacon(), + _getRestakingOperatorBeacon(), + _getPufferProtocol(), + address(permissionedModuleBeacon), + address(nrwcBeacon) + ); vm.label(address(newModuleManagerImpl), "PufferModuleManagerNewImpl"); // Execute upgrades through Timelock as COMMUNITY_MULTISIG (instant execution, no delay) @@ -164,31 +169,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { ); require(success, "PufferModuleManager upgrade failed"); - // 3. Set permissioned module beacon and NRWC beacon via Timelock -> AccessManager -> PufferModuleManager - // First, grant the DAO role permission to call setPermissionedModuleBeacon and setNRWCBeacon - bytes4[] memory beaconSelectors = new bytes4[](2); - beaconSelectors[0] = PufferModuleManager.setPermissionedModuleBeacon.selector; - beaconSelectors[1] = PufferModuleManager.setNRWCBeacon.selector; - bytes memory grantBeaconRoleCalldata = abi.encodeCall( - accessManager.setTargetFunctionRole, (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) - ); - (success,) = address(timelock).call( - abi.encodeCall(Timelock.executeTransaction, (address(accessManager), grantBeaconRoleCalldata, 3)) - ); - require(success, "Grant beacon role failed"); - - vm.stopPrank(); - - // Now execute setPermissionedModuleBeacon as dao (who has ROLE_ID_DAO) - bytes memory setBeaconCalldata = - abi.encodeCall(PufferModuleManager.setPermissionedModuleBeacon, (address(permissionedModuleBeacon))); - vm.prank(dao); - accessManager.execute(_getPufferModuleManager(), setBeaconCalldata); - - // Also set NRWC beacon - bytes memory setNRWCBeaconCalldata = abi.encodeCall(PufferModuleManager.setNRWCBeacon, (address(nrwcBeacon))); - vm.prank(dao); - accessManager.execute(_getPufferModuleManager(), setNRWCBeaconCalldata); + // TODO Redeploy PMM with new beacons } function _setupAccessControl() internal { @@ -482,7 +463,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { // Provision validator vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, index, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); // Verify status changed to ACTIVE PermissionedValidator memory validator = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, index); @@ -514,7 +495,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { // Provision validator vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, index, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); // Verify status changed to ACTIVE PermissionedValidator memory validator = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, index); @@ -657,7 +638,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { // Skip provisioning vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); // Verify validator data deleted PermissionedValidator memory validator = pufferProtocol.getPermissionedValidatorInfo(TEST_MODULE_NAME, 0); @@ -693,7 +674,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { vm.prank(unauthorized); vm.expectRevert(); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, bytes32(0)); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, bytes32(0)); // Trigger withdrawals (only paymaster) IEigenPodTypes.WithdrawalRequest[] memory requests = new IEigenPodTypes.WithdrawalRequest[](1); @@ -945,7 +926,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { // Provision first validator vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); assertEq(permissionedOracle.totalLockedEth(), 100 ether, "Should track 100 ETH after first provision"); assertEq(permissionedOracle.getModuleLockedEth(TEST_MODULE_NAME), 100 ether); @@ -955,7 +936,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { // Provision second validator vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 1, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); assertEq(permissionedOracle.totalLockedEth(), 300 ether, "Should track 300 ETH after second provision"); assertEq(permissionedOracle.getModuleLockedEth(TEST_MODULE_NAME), 300 ether); @@ -987,7 +968,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); } function _setupProvisionedRestakedValidator() internal { @@ -1007,7 +988,7 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); } function _grantNRWCAccess(address nrwc, address moduleAddress) internal { diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol index 7755b6df..c52492a0 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol @@ -100,7 +100,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); uint256 oracleLockedBefore = permissionedOracle.totalLockedEth(); assertEq(oracleLockedBefore, originalStake); @@ -139,7 +139,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); // Rewards scenario: 2 ETH earned uint256 actualWithdrawal = 102 ether; @@ -174,7 +174,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); for (uint256 i = 0; i < 5; i++) { vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); } @@ -194,25 +194,6 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { // Skip Provisioning FIFO Tests // ============================================================================ - /** - * @notice Verifies non-sequential skip reverts with correct error - */ - function test_nonSequentialSkipReverts() public { - vm.prank(dao); - pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); - - for (uint256 i = 0; i < 5; i++) { - bytes memory pubkey = _generatePubkey(i + 1); - vm.prank(permissionedOperator); - pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); - } - - // Try to skip index 2 when next is 0 - vm.prank(paymaster); - vm.expectRevert(abi.encodeWithSelector(IPufferProtocol.MustSkipNextValidator.selector, 0, 2)); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 2); - } - /** * @notice Verifies sequential skips work correctly */ @@ -229,7 +210,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { // Skip 0, 1, 2 sequentially for (uint256 i = 0; i < 3; i++) { vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, i); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), i + 1); } } @@ -258,32 +239,32 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { // Skip 0 vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 1); // Provision 1 vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 1, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); // Skip 2 vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 2); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 3); // Provision 3 vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 3, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); // Skip 4 vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 4); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 5); // Provision 5 vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 5, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); // Verify final state assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 6); @@ -325,7 +306,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { // Skip first 4 for (uint256 i = 0; i < 4; i++) { vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, i); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); } assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 4); @@ -333,7 +314,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); for (uint256 i = 4; i < 8; i++) { vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); } @@ -357,7 +338,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); assertEq(permissionedOracle.totalLockedEth(), 100 ether); @@ -378,7 +359,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { // Provision new validator depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 1, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); assertEq(permissionedOracle.totalLockedEth(), 200 ether); } @@ -400,7 +381,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { // Skip it vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 1); @@ -409,33 +390,6 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { assertEq(v.node, address(0)); } - /** - * @notice Tests cannot skip already provisioned validator - */ - function test_cannotSkipProvisionedValidator() public { - vm.prank(dao); - pufferProtocol.createPermissionedModule(TEST_MODULE_NAME); - - vm.deal(address(pufferVault), 200 ether); - - // Register 2 validators - for (uint256 i = 0; i < 2; i++) { - bytes memory pubkey = _generatePubkey(i + 1); - vm.prank(permissionedOperator); - pufferProtocol.registerPermissionedValidatorKey(pubkey, TEST_MODULE_NAME, true, 32 ether); - } - - // Provision first - bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); - vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); - - // Try to skip index 0 (already provisioned) - should fail due to FIFO (next is 1) - vm.prank(paymaster); - vm.expectRevert(abi.encodeWithSelector(IPufferProtocol.MustSkipNextValidator.selector, 1, 0)); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); - } - /** * @notice Tests skip after some provisions have been made */ @@ -456,7 +410,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); for (uint256 i = 0; i < 3; i++) { vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); } @@ -464,13 +418,13 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { // Now skip 3 (next in line) vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 3); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 4); // Provision 4 vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 4, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); assertEq(permissionedOracle.totalLockedEth(), 128 ether); // 4 * 32 ETH } @@ -505,19 +459,19 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { // Module A: skip 0, provision 1, skip 2 vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(moduleA, 0); + pufferProtocol.skipPermissionedProvisioning(moduleA); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(moduleA, 1, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(moduleA, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(moduleA, 2); + pufferProtocol.skipPermissionedProvisioning(moduleA); // Module B: provision all for (uint256 i = 0; i < 3; i++) { vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(moduleB, i, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(moduleB, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); } @@ -550,7 +504,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); for (uint256 i = 0; i < 3; i++) { vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); } @@ -587,9 +541,9 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { } vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 0); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 1); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 2); assertEq(pufferProtocol.getPendingPermissionedValidatorIndex(TEST_MODULE_NAME), 2); @@ -607,7 +561,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); for (uint256 i = 2; i < 4; i++) { vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); } @@ -634,7 +588,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); assertEq(permissionedOracle.totalLockedEth(), maxStake); @@ -674,7 +628,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); for (uint256 i = 0; i < 2; i++) { vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, i, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); } @@ -683,9 +637,9 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { // Skip remaining 2 vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 2); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); vm.prank(paymaster); - pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME, 3); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); // Oracle should be unchanged (skipping doesn't affect locked ETH) assertEq(permissionedOracle.totalLockedEth(), oracleBefore); @@ -721,7 +675,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); uint256 slashingLoss = (stakeAmount * slashingPercent) / 100; uint256 actualWithdrawal = stakeAmount - slashingLoss; @@ -752,7 +706,7 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { bytes32 depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); vm.prank(paymaster); - pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 0, TEST_SIGNATURE, depositRoot); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); uint256 rewards = (stakeAmount * rewardPercent) / 100; uint256 actualWithdrawal = stakeAmount + rewards; @@ -804,8 +758,13 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { IPermissionedOracle(address(permissionedOracle)) ); - PufferModuleManager newModuleManagerImpl = - new PufferModuleManager(_getPufferModuleBeacon(), _getRestakingOperatorBeacon(), _getPufferProtocol()); + PufferModuleManager newModuleManagerImpl = new PufferModuleManager( + _getPufferModuleBeacon(), + _getRestakingOperatorBeacon(), + _getPufferProtocol(), + address(permissionedModuleBeacon), + address(nrwcBeacon) + ); vm.startPrank(COMMUNITY_MULTISIG); @@ -825,30 +784,8 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { ); require(success, "PufferModuleManager upgrade failed"); - // Grant DAO role permission to call setPermissionedModuleBeacon and setNRWCBeacon - bytes4[] memory beaconSelectors = new bytes4[](2); - beaconSelectors[0] = PufferModuleManager.setPermissionedModuleBeacon.selector; - beaconSelectors[1] = PufferModuleManager.setNRWCBeacon.selector; - bytes memory grantBeaconRoleCalldata = abi.encodeCall( - accessManager.setTargetFunctionRole, (_getPufferModuleManager(), beaconSelectors, ROLE_ID_DAO) - ); - (success,) = address(timelock).call( - abi.encodeCall(Timelock.executeTransaction, (address(accessManager), grantBeaconRoleCalldata, 3)) - ); - require(success, "Grant beacon role failed"); - vm.stopPrank(); - // Set permissioned module beacon - bytes memory setBeaconCalldata = - abi.encodeCall(PufferModuleManager.setPermissionedModuleBeacon, (address(permissionedModuleBeacon))); - vm.prank(dao); - accessManager.execute(_getPufferModuleManager(), setBeaconCalldata); - - // Set NRWC beacon - bytes memory setNRWCBeaconCalldata = abi.encodeCall(PufferModuleManager.setNRWCBeacon, (address(nrwcBeacon))); - vm.prank(dao); - accessManager.execute(_getPufferModuleManager(), setNRWCBeaconCalldata); } function _setupAccessControl() internal { diff --git a/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol index c3cb1fe6..92b97e6a 100644 --- a/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol +++ b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol @@ -64,7 +64,7 @@ contract PermissionedModuleStandaloneTest is Test { // Mock the getNRWCBeacon call on the module manager mock address vm.mockCall( pufferModuleManagerAddr, - abi.encodeWithSelector(PufferModuleManager.getNRWCBeacon.selector), + abi.encodeWithSignature("NRWC_BEACON()"), abi.encode(address(nrwcBeacon)) ); From a15367c737878e35ad03737805c73419da431a94 Mon Sep 17 00:00:00 2001 From: eladiosch <3090613+eladiosch@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:22:53 +0000 Subject: [PATCH 45/55] forge fmt --- .../test/fork-tests/PermissionedValidatorSecurityPOC.t.sol | 1 - .../test/unit/PermissionedModuleStandalone.t.sol | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol index c52492a0..af372988 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol @@ -785,7 +785,6 @@ contract PermissionedValidatorEdgeCaseTest is MainnetForkTestHelper { require(success, "PufferModuleManager upgrade failed"); vm.stopPrank(); - } function _setupAccessControl() internal { diff --git a/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol index 92b97e6a..da97c0e3 100644 --- a/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol +++ b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol @@ -62,11 +62,7 @@ contract PermissionedModuleStandaloneTest is Test { UpgradeableBeacon nrwcBeacon = new UpgradeableBeacon(address(nrwcImpl), owner); // Mock the getNRWCBeacon call on the module manager mock address - vm.mockCall( - pufferModuleManagerAddr, - abi.encodeWithSignature("NRWC_BEACON()"), - abi.encode(address(nrwcBeacon)) - ); + vm.mockCall(pufferModuleManagerAddr, abi.encodeWithSignature("NRWC_BEACON()"), abi.encode(address(nrwcBeacon))); // Create a fake PufferModuleManager reference for the PermissionedModule constructor pufferModuleManager = PufferModuleManager(payable(pufferModuleManagerAddr)); From 2b499d0632ad1c32f89f8ae298f2bc578eaddbf9 Mon Sep 17 00:00:00 2001 From: Eladio Date: Tue, 17 Mar 2026 12:32:16 +0100 Subject: [PATCH 46/55] Fixed scripts and tests --- .../script/DeployEverything.s.sol | 7 +++++-- .../script/DeployPermissionedOracle.s.sol | 5 +++-- mainnet-contracts/script/DeployPuffer.s.sol | 2 +- .../test/helpers/UnitTestHelper.sol | 3 +++ .../test/mocks/MockPermissionedOracle.sol | 21 +++++++++++++++++++ mainnet-contracts/test/unit/PufETH.t.sol | 4 +++- mainnet-contracts/test/unit/PufferVault.t.sol | 7 ++++++- mainnet-contracts/test/unit/xPufETHTest.t.sol | 4 +++- 8 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 mainnet-contracts/test/mocks/MockPermissionedOracle.sol diff --git a/mainnet-contracts/script/DeployEverything.s.sol b/mainnet-contracts/script/DeployEverything.s.sol index 40e8d3b7..d6c2bcb4 100644 --- a/mainnet-contracts/script/DeployEverything.s.sol +++ b/mainnet-contracts/script/DeployEverything.s.sol @@ -10,6 +10,7 @@ import { DeployPufETH, PufferDeployment } from "../script/DeployPufETH.s.sol"; import { UpgradePufETH } from "../script/UpgradePufETH.s.sol"; import { DeployPufETHBridging } from "../script/DeployPufETHBridging.s.sol"; import { DeployPufferOracle } from "script/DeployPufferOracle.s.sol"; +import { DeployPermissionedOracle } from "script/DeployPermissionedOracle.s.sol"; import { GuardiansDeployment, PufferProtocolDeployment, BridgingDeployment } from "./DeploymentStructs.sol"; import { PufferRevenueDepositor } from "src/PufferRevenueDepositor.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; @@ -51,8 +52,10 @@ contract DeployEverything is BaseScript { puffETHDeployment.accessManager, guardiansDeployment.guardianModule, puffETHDeployment.pufferVault ); + address permissionedOracle = address(new DeployPermissionedOracle().run(puffETHDeployment.accessManager)); + PufferProtocolDeployment memory pufferDeployment = - new DeployPuffer().run(guardiansDeployment, puffETHDeployment.pufferVault, pufferOracle, address(0)); + new DeployPuffer().run(guardiansDeployment, puffETHDeployment.pufferVault, pufferOracle, permissionedOracle); pufferDeployment.pufferDepositor = puffETHDeployment.pufferDepositor; pufferDeployment.pufferVault = puffETHDeployment.pufferVault; @@ -64,7 +67,7 @@ contract DeployEverything is BaseScript { address revenueDepositor = _deployRevenueDepositor(puffETHDeployment); pufferDeployment.revenueDepositor = revenueDepositor; - new UpgradePufETH().run(puffETHDeployment, pufferOracle, revenueDepositor, address(0)); + new UpgradePufETH().run(puffETHDeployment, pufferOracle, revenueDepositor, permissionedOracle); // `anvil` in the terminal if (_localAnvil) { diff --git a/mainnet-contracts/script/DeployPermissionedOracle.s.sol b/mainnet-contracts/script/DeployPermissionedOracle.s.sol index a37f76de..492e8d50 100644 --- a/mainnet-contracts/script/DeployPermissionedOracle.s.sol +++ b/mainnet-contracts/script/DeployPermissionedOracle.s.sol @@ -12,13 +12,14 @@ import { console } from "forge-std/console.sol"; * @dev Tracks actual ETH amounts locked by permissioned validators (supports Pectra variable 32-2048 ETH). * * forge script script/DeployPermissionedOracle.s.sol:DeployPermissionedOracle \ + * --sig 'run(address)' \ * -vvvv --rpc-url=$RPC_URL --broadcast --verify */ contract DeployPermissionedOracle is DeployerHelper { - function run() public returns (PermissionedOracle) { + function run(address accessManager) public returns (PermissionedOracle) { vm.startBroadcast(); - PermissionedOracle oracle = new PermissionedOracle(_getAccessManager()); + PermissionedOracle oracle = new PermissionedOracle(accessManager); vm.label(address(oracle), "PermissionedOracle"); console.log("Deployed PermissionedOracle at", address(oracle)); diff --git a/mainnet-contracts/script/DeployPuffer.s.sol b/mainnet-contracts/script/DeployPuffer.s.sol index e7aa539e..fb100679 100644 --- a/mainnet-contracts/script/DeployPuffer.s.sol +++ b/mainnet-contracts/script/DeployPuffer.s.sol @@ -230,7 +230,7 @@ contract DeployPuffer is BaseScript { nrwcBeacon: address(nrwcBeacon), moduleManager: address(moduleManagerProxy), pufferOracle: address(oracle), - permissionedOracle: address(0), // Set during permissioned module deployment + permissionedOracle: address(permissionedOracle), operationsCoordinator: address(operationsCoordinator), aVSContractsRegistry: address(aVSContractsRegistry), restakingOperatorController: address(restakingOperatorController), diff --git a/mainnet-contracts/test/helpers/UnitTestHelper.sol b/mainnet-contracts/test/helpers/UnitTestHelper.sol index 73a3d3da..6ac5328d 100644 --- a/mainnet-contracts/test/helpers/UnitTestHelper.sol +++ b/mainnet-contracts/test/helpers/UnitTestHelper.sol @@ -5,6 +5,7 @@ import "forge-std/Test.sol"; import { BaseScript } from "../../script/BaseScript.s.sol"; import { GuardianModule } from "../../src/GuardianModule.sol"; import { PufferOracleV2 } from "../../src/PufferOracleV2.sol"; +import { PermissionedOracle } from "../../src/PermissionedOracle.sol"; import { PufferProtocol } from "../../src/PufferProtocol.sol"; import { PufferModuleManager } from "../../src/PufferModuleManager.sol"; import { AVSContractsRegistry } from "../../src/AVSContractsRegistry.sol"; @@ -97,6 +98,7 @@ contract UnitTestHelper is Test, BaseScript { PufferModuleManager public pufferModuleManager; ValidatorTicket public validatorTicket; PufferOracleV2 public pufferOracle; + PermissionedOracle public permissionedOracle; GuardianModule public guardianModule; @@ -216,6 +218,7 @@ contract UnitTestHelper is Test, BaseScript { pufferModuleManager = PufferModuleManager(payable(pufferDeployment.moduleManager)); validatorTicket = ValidatorTicket(pufferDeployment.validatorTicket); pufferOracle = PufferOracleV2(pufferDeployment.pufferOracle); + permissionedOracle = PermissionedOracle(pufferDeployment.permissionedOracle); operationsCoordinator = OperationsCoordinator(payable(pufferDeployment.operationsCoordinator)); validatorTicketPricer = ValidatorTicketPricer(pufferDeployment.validatorTicketPricer); avsContractsRegistry = AVSContractsRegistry(payable(pufferDeployment.aVSContractsRegistry)); diff --git a/mainnet-contracts/test/mocks/MockPermissionedOracle.sol b/mainnet-contracts/test/mocks/MockPermissionedOracle.sol new file mode 100644 index 00000000..bc9bd78a --- /dev/null +++ b/mainnet-contracts/test/mocks/MockPermissionedOracle.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { IPermissionedOracle } from "../../src/interface/IPermissionedOracle.sol"; + +/** + * @title MockPermissionedOracle + * @author Puffer Finance + * @custom:security-contact security@puffer.fi + */ +contract MockPermissionedOracle is IPermissionedOracle { + function getLockedEthAmount() external view override returns (uint256) { } + + function getModuleLockedEth(bytes32 moduleName) external view override returns (uint256) { } + + function provisionValidator(bytes32 moduleName, uint256 amount) external override { } + + function exitValidator(bytes32 moduleName, uint256 amount) external override { } + + function adjustLockedEth(bytes32 moduleName, uint256 reductionAmount) external override { } +} diff --git a/mainnet-contracts/test/unit/PufETH.t.sol b/mainnet-contracts/test/unit/PufETH.t.sol index 5a38baa7..1b3822f6 100644 --- a/mainnet-contracts/test/unit/PufETH.t.sol +++ b/mainnet-contracts/test/unit/PufETH.t.sol @@ -10,6 +10,7 @@ import { AccessManager } from "@openzeppelin/contracts/access/manager/AccessMana import { stETHMock } from "../mocks/stETHMock.sol"; import { WETH9 } from "../mocks/WETH9.sol"; import { MockPufferOracle } from "../mocks/MockPufferOracle.sol"; +import { MockPermissionedOracle } from "../mocks/MockPermissionedOracle.sol"; import { ILidoWithdrawalQueue } from "../../src/interface/Lido/ILidoWithdrawalQueue.sol"; import { IWETH } from "../../src/interface/Other/IWETH.sol"; import { IPufferRevenueDepositor } from "../../src/interface/IPufferRevenueDepositor.sol"; @@ -115,6 +116,7 @@ contract PufETHTest is ERC4626Test { vm.stopPrank(); MockPufferOracle mockOracle = new MockPufferOracle(); + MockPermissionedOracle mockPermissionedOracle = new MockPermissionedOracle(); PufferRevenueDepositorMock revenueDepositor = new PufferRevenueDepositorMock(); PufferVaultV5 pufferVaultNonBlocking = new PufferVaultV5Tests({ stETH: stETH, @@ -122,7 +124,7 @@ contract PufETHTest is ERC4626Test { weth: IWETH(deployment.weth), oracle: mockOracle, revenueDepositor: revenueDepositor, - permissionedOracle: IPermissionedOracle(address(0)) + permissionedOracle: mockPermissionedOracle }); vm.startPrank(communityMultisig); diff --git a/mainnet-contracts/test/unit/PufferVault.t.sol b/mainnet-contracts/test/unit/PufferVault.t.sol index 3dba415b..5746f1fd 100644 --- a/mainnet-contracts/test/unit/PufferVault.t.sol +++ b/mainnet-contracts/test/unit/PufferVault.t.sol @@ -10,6 +10,8 @@ import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils import { LidoWithdrawalQueueMock } from "../mocks/LidoWithdrawalQueueMock.sol"; import { IPermissionedOracle } from "src/interface/IPermissionedOracle.sol"; +import "forge-std/console.sol"; + contract PufferVaultTest is UnitTestHelper { uint256 pointZeroZeroOne = 0.0001e18; @@ -1042,9 +1044,12 @@ contract PufferVaultTest is UnitTestHelper { accessManager.grantRole(tempRole, address(timelock), 0); PufferVaultV5Liq newImplementation = new PufferVaultV5Liq( - stETH, weth, new LidoWithdrawalQueueMock(), pufferOracle, revenueDepositor, IPermissionedOracle(address(0)) + stETH, weth, new LidoWithdrawalQueueMock(), pufferOracle, revenueDepositor, permissionedOracle ); + + console.log("permissionedOracle", address(permissionedOracle)); + UUPSUpgradeable(address(pufferVault)).upgradeToAndCall(address(newImplementation), ""); vm.stopPrank(); } diff --git a/mainnet-contracts/test/unit/xPufETHTest.t.sol b/mainnet-contracts/test/unit/xPufETHTest.t.sol index a189baf8..78e42f17 100644 --- a/mainnet-contracts/test/unit/xPufETHTest.t.sol +++ b/mainnet-contracts/test/unit/xPufETHTest.t.sol @@ -17,6 +17,7 @@ import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable import { UUPSUpgradeable } from "@openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import { PufferRevenueDepositorMock } from "test/mocks/PufferRevenueDepositorMock.sol"; import { MockPufferOracle } from "test/mocks/MockPufferOracle.sol"; +import { MockPermissionedOracle } from "test/mocks/MockPermissionedOracle.sol"; import { PufferVaultV5Tests } from "test/mocks/PufferVaultV5Tests.sol"; import { ILidoWithdrawalQueue } from "src/interface/Lido/ILidoWithdrawalQueue.sol"; import { IWETH } from "src/interface/Other/IWETH.sol"; @@ -149,6 +150,7 @@ contract xPufETHTest is Test { vm.stopPrank(); MockPufferOracle mockOracle = new MockPufferOracle(); + MockPermissionedOracle mockPermissionedOracle = new MockPermissionedOracle(); PufferRevenueDepositorMock revenueDepositor = new PufferRevenueDepositorMock(); PufferVaultV5 pufferVaultNonBlocking = new PufferVaultV5Tests({ stETH: stETH, @@ -156,7 +158,7 @@ contract xPufETHTest is Test { weth: IWETH(deployment.weth), oracle: mockOracle, revenueDepositor: revenueDepositor, - permissionedOracle: IPermissionedOracle(address(0)) + permissionedOracle: mockPermissionedOracle }); vm.startPrank(communityMultisig); From 78a1e5eca44d0c702a04732bae9aceda399c7ab9 Mon Sep 17 00:00:00 2001 From: eladiosch <3090613+eladiosch@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:36:00 +0000 Subject: [PATCH 47/55] forge fmt --- mainnet-contracts/test/unit/PufferVault.t.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/mainnet-contracts/test/unit/PufferVault.t.sol b/mainnet-contracts/test/unit/PufferVault.t.sol index 5746f1fd..9f38fd4d 100644 --- a/mainnet-contracts/test/unit/PufferVault.t.sol +++ b/mainnet-contracts/test/unit/PufferVault.t.sol @@ -1047,7 +1047,6 @@ contract PufferVaultTest is UnitTestHelper { stETH, weth, new LidoWithdrawalQueueMock(), pufferOracle, revenueDepositor, permissionedOracle ); - console.log("permissionedOracle", address(permissionedOracle)); UUPSUpgradeable(address(pufferVault)).upgradeToAndCall(address(newImplementation), ""); From 520a1f06002ce4f3454808d33ee77c5ebb269c52 Mon Sep 17 00:00:00 2001 From: Eladio Date: Tue, 17 Mar 2026 17:31:11 +0100 Subject: [PATCH 48/55] Adapted scripts and tests to hoodi --- .github/workflows/mainnet-contracts.yml | 43 +++--- mainnet-contracts/foundry.toml | 1 + mainnet-contracts/script/BaseScript.s.sol | 4 + mainnet-contracts/script/DeployPufETH.s.sol | 8 +- mainnet-contracts/script/DeployPuffer.s.sol | 29 +++- .../script/DeployRestakingOperator.s.sol | 6 +- mainnet-contracts/script/DeployerHelper.s.sol | 124 +++++++++++++++++- ...GenerateBLSKeysAndRegisterValidators.s.sol | 7 +- .../PufferModuleManager.integration.t.sol | 31 ++--- ...fferModuleManagerSlasher.integration.t.sol | 21 +-- .../ffi/PufferModuleManagerHoleskyFfi.t.sol | 35 +++-- .../test/helpers/IntegrationTestHelper.sol | 8 +- 12 files changed, 233 insertions(+), 84 deletions(-) diff --git a/.github/workflows/mainnet-contracts.yml b/.github/workflows/mainnet-contracts.yml index ddae9b63..8701c2bd 100644 --- a/.github/workflows/mainnet-contracts.yml +++ b/.github/workflows/mainnet-contracts.yml @@ -11,33 +11,33 @@ on: jobs: codespell: - name: Check for spelling errors - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Codespell - uses: codespell-project/actions-codespell@v2.0 - with: - path: mainnet-contracts - check_hidden: true - check_filenames: true - skip: "pnpm-lock.yaml" + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Codespell + uses: codespell-project/actions-codespell@v2.0 + with: + path: mainnet-contracts + check_hidden: true + check_filenames: true + skip: "pnpm-lock.yaml" tests: runs-on: ubuntu-latest steps: - name: Cancel previous runs uses: styfle/cancel-workflow-action@0.12.1 - + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - cache: 'yarn' + cache: "yarn" cache-dependency-path: yarn.lock node-version: 20 @@ -69,7 +69,7 @@ jobs: uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: "forge fmt" - file_pattern: '*.sol' + file_pattern: "*.sol" - name: List selectors working-directory: mainnet-contracts @@ -81,11 +81,11 @@ jobs: steps: - name: Cancel previous runs uses: styfle/cancel-workflow-action@0.12.1 - + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - cache: 'yarn' + cache: "yarn" cache-dependency-path: yarn.lock node-version: 20 @@ -106,6 +106,7 @@ jobs: env: ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} HOLESKY_RPC_URL: ${{ secrets.HOLESKY_RPC_URL }} + HOODI_RPC_URL: ${{ secrets.HOODI_RPC_URL }} - name: "Upload coverage report to Codecov" uses: "codecov/codecov-action@v4" diff --git a/mainnet-contracts/foundry.toml b/mainnet-contracts/foundry.toml index 5718a887..74317cb3 100644 --- a/mainnet-contracts/foundry.toml +++ b/mainnet-contracts/foundry.toml @@ -44,6 +44,7 @@ bracket_spacing = true [rpc_endpoints] mainnet="${ETH_RPC_URL}" holesky="${HOLESKY_RPC_URL}" +hoodi="${HOODI_RPC_URL}" sepolia="${SEPOLIA_RPC_URL}" opsepolia ="${OP_SEPOLIA_RPC_URL}" diff --git a/mainnet-contracts/script/BaseScript.s.sol b/mainnet-contracts/script/BaseScript.s.sol index 374d85ed..b708db0b 100644 --- a/mainnet-contracts/script/BaseScript.s.sol +++ b/mainnet-contracts/script/BaseScript.s.sol @@ -44,6 +44,10 @@ abstract contract BaseScript is Script { return (block.chainid == 17000); } + function isHoodi() internal view returns (bool) { + return (block.chainid == 560048); + } + function isAnvil() internal view returns (bool) { return (block.chainid == 31337); } diff --git a/mainnet-contracts/script/DeployPufETH.s.sol b/mainnet-contracts/script/DeployPufETH.s.sol index df505005..1b448e54 100644 --- a/mainnet-contracts/script/DeployPufETH.s.sol +++ b/mainnet-contracts/script/DeployPufETH.s.sol @@ -255,7 +255,13 @@ contract DeployPufETH is BaseScript { lidoWithdrawalQueue = ILidoWithdrawalQueue(0xc7cc160b58F8Bb0baC94b80847E2CF2800565C50); stETHStrategy = IStrategy(0x7D704507b76571a51d9caE8AdDAbBFd0ba0e63d3); eigenStrategyManager = IEigenLayer(0xdfB5f6CE42aAA7830E94ECFCcAd411beF4d4D5b6); - } else { + } else if (isHoodi()) { + stETH = IStETH(address(0x3508A952176b3c15387C97BE809eaffB1982176a)); + weth = new WETH9(); + lidoWithdrawalQueue = ILidoWithdrawalQueue(0xfe56573178f1bcdf53F01A6E9977670dcBBD9186); + stETHStrategy = IStrategy(0xF8a1a66130D614c7360e868576D5E59203475FE0); + eigenStrategyManager = IEigenLayer(0xeE45e76ddbEDdA2918b8C7E3035cd37Eab3b5D41); + } else { stETH = IStETH(address(new stETHMock())); weth = new WETH9(); lidoWithdrawalQueue = new LidoWithdrawalQueueMock(); diff --git a/mainnet-contracts/script/DeployPuffer.s.sol b/mainnet-contracts/script/DeployPuffer.s.sol index fb100679..b8b3f599 100644 --- a/mainnet-contracts/script/DeployPuffer.s.sol +++ b/mainnet-contracts/script/DeployPuffer.s.sol @@ -69,7 +69,7 @@ contract DeployPuffer is BaseScript { address eigenPodManager; address delegationManager; address rewardsCoordinator; - address eigenSlasher; + address allocationManager; address treasury; address operationsMultisig; @@ -85,7 +85,7 @@ contract DeployPuffer is BaseScript { // Mainnet / Mainnet fork eigenPodManager = 0x91E677b07F7AF907ec9a428aafA9fc14a0d3A338; delegationManager = 0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A; - eigenSlasher = 0xD92145c07f8Ed1D392c1B88017934E301CC1c3Cd; + allocationManager = 0xD92145c07f8Ed1D392c1B88017934E301CC1c3Cd; rewardsCoordinator = address(0); //@todo treasury = vm.envAddress("TREASURY"); operationsMultisig = 0xC0896ab1A8cae8c2C1d27d011eb955Cca955580d; @@ -94,17 +94,27 @@ contract DeployPuffer is BaseScript { eigenPodManager = address(new EigenPodManagerMock()); delegationManager = address(new DelegationManagerMock()); rewardsCoordinator = address(new RewardsCoordinatorMock()); - eigenSlasher = address(new EigenAllocationManagerMock()); + allocationManager = address(new EigenAllocationManagerMock()); treasury = address(1); operationsMultisig = address(2); - } else { + } else if (isHolesky()) { // Holesky https://github.com/Layr-Labs/eigenlayer-contracts?tab=readme-ov-file#current-testnet-deployment eigenPodManager = 0x30770d7E3e71112d7A6b7259542D1f680a70e315; delegationManager = 0xA44151489861Fe9e3055d95adC98FbD462B948e7; - eigenSlasher = 0xcAe751b75833ef09627549868A04E32679386e7C; + allocationManager = 0xcAe751b75833ef09627549868A04E32679386e7C; treasury = 0x61A44645326846F9b5d9c6f91AD27C3aD28EA390; rewardsCoordinator = 0xAcc1fb458a1317E886dB376Fc8141540537E68fE; operationsMultisig = 0xDDDeAfB492752FC64220ddB3E7C9f1d5CcCdFdF0; + } else if (isHoodi()) { + // Hoodi https://github.com/Layr-Labs/eigenlayer-contracts?tab=readme-ov-file#current-deployment-contracts + eigenPodManager = 0xcd1442415Fc5C29Aa848A49d2e232720BE07976c; + delegationManager = 0x867837a9722C512e0862d8c2E15b8bE220E8b87d; + allocationManager = 0x95a7431400F362F3647a69535C5666cA0133CAA0; + treasury = 0x61A44645326846F9b5d9c6f91AD27C3aD28EA390; + rewardsCoordinator = 0x29e8572678e0c272350aa0b4B8f304E47EBcd5e7; + operationsMultisig = 0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE; + } else { + revert("Deployment not configured for this chain"); } operationsCoordinator = new OperationsCoordinator(PufferOracleV2(oracle), address(accessManager), 500); // 500 BPS = 5% @@ -150,7 +160,7 @@ contract DeployPuffer is BaseScript { RestakingOperator restakingOperatorImplementation = new RestakingOperator( IDelegationManager(delegationManager), - IAllocationManager(eigenSlasher), + IAllocationManager(allocationManager), PufferModuleManager(payable(address(moduleManagerProxy))), IRewardsCoordinator(rewardsCoordinator), address(restakingOperatorController) @@ -255,10 +265,15 @@ contract DeployPuffer is BaseScript { } // Holesky - if (block.chainid == 17000) { + if (isHolesky()) { return 0x4242424242424242424242424242424242424242; } + // Hoodi + if (isHoodi()) { + return 0x00000000219ab540356cBB839Cbe05303d7705Fa; + } + // Tests / local chain if (isAnvil()) { return address(new BeaconMock()); diff --git a/mainnet-contracts/script/DeployRestakingOperator.s.sol b/mainnet-contracts/script/DeployRestakingOperator.s.sol index e0241320..87b84d99 100644 --- a/mainnet-contracts/script/DeployRestakingOperator.s.sol +++ b/mainnet-contracts/script/DeployRestakingOperator.s.sol @@ -24,7 +24,7 @@ contract DeployRestakingOperator is DeployerHelper { RestakingOperator restakingOperatorImplementation = new RestakingOperator({ delegationManager: IDelegationManager(_getEigenDelegationManager()), - allocationManager: IAllocationManager(_getEigenSlasher()), + allocationManager: IAllocationManager(_getAllocationManager()), moduleManager: PufferModuleManager(payable(_getPufferModuleManager())), rewardsCoordinator: IRewardsCoordinator(_getRewardsCoordinator()), restakingOperatorController: _getRestakingOperatorController() @@ -49,7 +49,7 @@ contract DeployRestakingOperator is DeployerHelper { RestakingOperator restakingOperatorImplementation = new RestakingOperator({ delegationManager: IDelegationManager(_getEigenDelegationManager()), - allocationManager: IAllocationManager(_getEigenSlasher()), + allocationManager: IAllocationManager(_getAllocationManager()), moduleManager: PufferModuleManager(payable(_getPufferModuleManager())), rewardsCoordinator: IRewardsCoordinator(_getRewardsCoordinator()), restakingOperatorController: _getRestakingOperatorController() @@ -69,7 +69,7 @@ contract DeployRestakingOperator is DeployerHelper { RestakingOperator restakingOperatorImplementation = new RestakingOperator({ delegationManager: IDelegationManager(_getEigenDelegationManager()), - allocationManager: IAllocationManager(_getEigenSlasher()), + allocationManager: IAllocationManager(_getAllocationManager()), moduleManager: PufferModuleManager(payable(_getPufferModuleManager())), rewardsCoordinator: IRewardsCoordinator(_getRewardsCoordinator()), restakingOperatorController: restakingOperatorController diff --git a/mainnet-contracts/script/DeployerHelper.s.sol b/mainnet-contracts/script/DeployerHelper.s.sol index ed39e42a..187b518d 100644 --- a/mainnet-contracts/script/DeployerHelper.s.sol +++ b/mainnet-contracts/script/DeployerHelper.s.sol @@ -14,6 +14,7 @@ abstract contract DeployerHelper is Script { // Chain IDs uint256 public mainnet = 1; uint256 public holesky = 17000; + uint256 public hoodi = 560048; uint256 public binance = 56; uint256 public base = 8453; uint256 public sepolia = 11155111; @@ -33,6 +34,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xDDDeAfB492752FC64220ddB3E7C9f1d5CcCdFdF0 return 0xDDDeAfB492752FC64220ddB3E7C9f1d5CcCdFdF0; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE + return 0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE; } else if (block.chainid == ape) { // https://apescan.io/address/0xb7d83623906AC3fa577F45B7D2b9D4BD26BC5d76 return 0xb7d83623906AC3fa577F45B7D2b9D4BD26BC5d76; @@ -108,11 +112,14 @@ abstract contract DeployerHelper is Script { console.logBytes(upgradeCallData); console.log("================================================"); } + vm.stopPrank(); } function _getBeaconChainStrategy() internal view returns (address) { if (block.chainid == holesky) { return 0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0; + } else if (block.chainid == hoodi) { + return 0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0; } revert("BEACON_CHAIN_STRATEGY not available for this chain"); @@ -125,21 +132,28 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x61A44645326846F9b5d9c6f91AD27C3aD28EA390 return 0x61A44645326846F9b5d9c6f91AD27C3aD28EA390; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0x61A44645326846F9b5d9c6f91AD27C3aD28EA390 + return 0x61A44645326846F9b5d9c6f91AD27C3aD28EA390; } revert("Treasury not available for this chain"); } - function _getEigenSlasher() internal view returns (address) { + function _getAllocationManager() internal view returns (address) { if (block.chainid == mainnet) { - // https://etherscan.io/address/0xD92145c07f8Ed1D392c1B88017934E301CC1c3Cd - return 0xD92145c07f8Ed1D392c1B88017934E301CC1c3Cd; + // https://etherscan.io/address/0x948a420b8CC1d6BFd0B6087C2E7c344a2CD0bc39 + return 0x948a420b8CC1d6BFd0B6087C2E7c344a2CD0bc39; } else if (block.chainid == holesky) { + // @DEPRECATED // https://holesky.etherscan.io/address/0xcAe751b75833ef09627549868A04E32679386e7C return 0xcAe751b75833ef09627549868A04E32679386e7C; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0x95a7431400F362F3647a69535C5666cA0133CAA0 + return 0x95a7431400F362F3647a69535C5666cA0133CAA0; } - revert("EigenSlasher not available for this chain"); + revert("AllocationManager not available for this chain"); } function _getRestakingOperatorBeacon() internal view returns (address) { @@ -149,6 +163,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x99c3E46E575df251149866285DdA7DAEba875B71 return 0x99c3E46E575df251149866285DdA7DAEba875B71; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("RestakingOperatorBeacon not available for this chain"); @@ -161,6 +179,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x4242424242424242424242424242424242424242 return 0x4242424242424242424242424242424242424242; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0x00000000219ab540356cBB839Cbe05303d7705Fa + return 0x00000000219ab540356cBB839Cbe05303d7705Fa; } revert("BeaconDepositContract not available for this chain"); @@ -173,6 +194,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x0910310130d1c062DEF8B807528bdac80203BC66 return 0x0910310130d1c062DEF8B807528bdac80203BC66; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("GuardianModule not available for this chain"); @@ -185,6 +210,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x4B0542470935ed4b085C3AD1983E85f5623ABf89 return 0x4B0542470935ed4b085C3AD1983E85f5623ABf89; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("PufferModuleBeacon not available for this chain"); @@ -197,6 +226,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x30770d7E3e71112d7A6b7259542D1f680a70e315 return 0x30770d7E3e71112d7A6b7259542D1f680a70e315; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0xcd1442415Fc5C29Aa848A49d2e232720BE07976c + return 0xcd1442415Fc5C29Aa848A49d2e232720BE07976c; } revert("EigenPodManager not available for this chain"); @@ -209,6 +241,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xA44151489861Fe9e3055d95adC98FbD462B948e7 return 0xA44151489861Fe9e3055d95adC98FbD462B948e7; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0x867837a9722C512e0862d8c2E15b8bE220E8b87d + return 0x867837a9722C512e0862d8c2E15b8bE220E8b87d; } revert("DelegationManager not available for this chain"); @@ -221,6 +256,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x09BE86B01c1e32dCa2ebdEDb01cD5A3F798b80C5 return 0x09BE86B01c1e32dCa2ebdEDb01cD5A3F798b80C5; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("AVSContractsRegistry not available for this chain"); @@ -233,6 +272,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // Holesky Timelock: https://explorer.pops.one/address/0x829aF0B3d099a12F0aE1b806f466EF771E2C07F8 return 0x829aF0B3d099a12F0aE1b806f466EF771E2C07F8; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("Timelock not available for this chain"); @@ -245,6 +288,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xAcc1fb458a1317E886dB376Fc8141540537E68fE return 0xAcc1fb458a1317E886dB376Fc8141540537E68fE; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("RewardsCoordinator not available for this chain"); @@ -257,6 +304,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x3F1c547b21f65e10480dE3ad8E19fAAC46C95034 return 0x3F1c547b21f65e10480dE3ad8E19fAAC46C95034; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0x3508A952176b3c15387C97BE809eaffB1982176a + return 0x3508A952176b3c15387C97BE809eaffB1982176a; } revert("stETH not available for this chain"); @@ -265,6 +315,8 @@ abstract contract DeployerHelper is Script { function _getWstETH() internal view returns (address) { if (block.chainid == mainnet) { return 0x8d09a4502Cc8Cf1547aD300E066060D043f6982D; + } else if(block.chainid == hoodi) { + return 0x7E99eE3C66636DE415D2d7C880938F2f40f94De4; } revert("WstETH not available for this chain"); @@ -277,6 +329,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x7D704507b76571a51d9caE8AdDAbBFd0ba0e63d3 return 0x7D704507b76571a51d9caE8AdDAbBFd0ba0e63d3; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0xF8a1a66130D614c7360e868576D5E59203475FE0 + return 0xF8a1a66130D614c7360e868576D5E59203475FE0; } revert("stETH strategy not available for this chain"); @@ -289,6 +344,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xdfB5f6CE42aAA7830E94ECFCcAd411beF4d4D5b6 return 0xdfB5f6CE42aAA7830E94ECFCcAd411beF4d4D5b6; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0xeE45e76ddbEDdA2918b8C7E3035cd37Eab3b5D41 + return 0xeE45e76ddbEDdA2918b8C7E3035cd37Eab3b5D41; } revert("strategy manager not available for this chain"); @@ -301,11 +359,29 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x8e043ed3F06720615685D4978770Cd5C8fe90fe3 return 0x8e043ed3F06720615685D4978770Cd5C8fe90fe3; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("puffer oracle not available for this chain"); } + function _getPermissionedOracle() internal view returns (address) { + if (block.chainid == mainnet) { + // https://etherscan.io/address/??? + // return ???; + // @todo Add address once deployed + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed + } + + revert("permissioned oracle not available for this chain"); + } + function _getEigenDelegationManager() internal view returns (address) { if (block.chainid == mainnet) { // https://etherscan.io/address/0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A @@ -313,6 +389,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xA44151489861Fe9e3055d95adC98FbD462B948e7 return 0xA44151489861Fe9e3055d95adC98FbD462B948e7; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0x867837a9722C512e0862d8c2E15b8bE220E8b87d + return 0x867837a9722C512e0862d8c2E15b8bE220E8b87d; } revert("eigen delegation manager not available for this chain"); @@ -325,6 +404,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x1d181cBd1825e9eBC6AD966878D555A7215FF4F0 return 0x1d181cBd1825e9eBC6AD966878D555A7215FF4F0; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("WETH not available for this chain"); @@ -337,6 +420,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xc7cc160b58F8Bb0baC94b80847E2CF2800565C50 return 0xc7cc160b58F8Bb0baC94b80847E2CF2800565C50; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0xfe56573178f1bcdf53F01A6E9977670dcBBD9186 + return 0xfe56573178f1bcdf53F01A6E9977670dcBBD9186; } revert("lido withdrawal queue not available for this chain"); @@ -352,6 +438,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x180a345906e42293dcAd5CCD9b0e1DB26aE0274e return 0x180a345906e42293dcAd5CCD9b0e1DB26aE0274e; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } else if (block.chainid == binance) { // https://bscscan.com/address/0x8849e9eB8bb27c1916AfB17ee4dEcAd375916474 return 0x8849e9eB8bb27c1916AfB17ee4dEcAd375916474; @@ -377,6 +467,10 @@ abstract contract DeployerHelper is Script { // PufferVaultMock // https://sepolia.etherscan.io/address/0xd85D701A660a61D9737D05397612EF08be2cE62D return 0xd85D701A660a61D9737D05397612EF08be2cE62D; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("PufferVault not available for this chain"); @@ -389,6 +483,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0x20377c306451140119C9967Ba6D0158a05b4eD07 return 0x20377c306451140119C9967Ba6D0158a05b4eD07; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("PufferModuleManager not available for this chain"); @@ -401,6 +499,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xB028194785178a94Fe608994A4d5AD84c285A640 return 0xB028194785178a94Fe608994A4d5AD84c285A640; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("ValidatorTicket not available for this chain"); @@ -413,6 +515,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xE00c79408B9De5BaD2FDEbB1688997a68eC988CD return 0xE00c79408B9De5BaD2FDEbB1688997a68eC988CD; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("PufferProtocol not available for this chain"); @@ -425,6 +531,10 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/TODO return address(0); // TODO + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/??? + // return ???; + // @todo Add address once deployed } revert("RestakingOperatorController not available for this chain"); @@ -544,6 +654,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xDDDeAfB492752FC64220ddB3E7C9f1d5CcCdFdF0 return 0xDDDeAfB492752FC64220ddB3E7C9f1d5CcCdFdF0; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE + return 0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE; } revert("Paymaster not available for this chain"); @@ -580,6 +693,9 @@ abstract contract DeployerHelper is Script { } else if (block.chainid == holesky) { // https://holesky.etherscan.io/address/0xDDDeAfB492752FC64220ddB3E7C9f1d5CcCdFdF0 return 0xDDDeAfB492752FC64220ddB3E7C9f1d5CcCdFdF0; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE + return 0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE; } else if (block.chainid == ape) { // https://apescan.io/address/0x36E3881Ff855c264045c22179b6fBc01430F97EC return 0x36E3881Ff855c264045c22179b6fBc01430F97EC; diff --git a/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol b/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol index 649ac165..cfaacab0 100644 --- a/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol +++ b/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol @@ -43,7 +43,12 @@ contract GenerateBLSKeysAndRegisterValidators is Script { protocolAddress = 0xE00c79408B9De5BaD2FDEbB1688997a68eC988CD; pufferProtocol = PufferProtocol(protocolAddress); forkVersion = "0x01017000"; - } else if (block.chainid == 1) { + } else if (block.chainid == 560048) { + // Hoodi + protocolAddress = address(0); // @todo Add protocol address once deployed + pufferProtocol = PufferProtocol(protocolAddress); + forkVersion = "0x10000910"; + } else if (block.chainid == 1) { // Mainnet protocolAddress = 0xf7b6B32492c2e13799D921E84202450131bd238B; pufferProtocol = PufferProtocol(protocolAddress); diff --git a/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol b/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol index 7fd56baf..7f3d565b 100644 --- a/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol +++ b/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol @@ -26,37 +26,38 @@ contract PufferModuleManagerIntegrationTest is IntegrationTestHelper { uint256[] privKeys; - address EIGEN_DA_REGISTRY_COORDINATOR_HOLESKY = 0x53012C69A189cfA2D9d29eb6F19B32e0A2EA3490; - address EIGEN_DA_SERVICE_MANAGER = 0xD4A7E1Bd8015057293f0D0A557088c286942e84b; + address EIGEN_DA_REGISTRY_COORDINATOR_HOODI = 0xB5b76D561eeF36CD772890C94C6Bde8b895455e2; + address EIGEN_DA_SERVICE_MANAGER = 0x3FF2204A567C15dC3731140B95362ABb4b17d8ED; // IAVSDirectory public avsDirectory = IAVSDirectory(0x055733000064333CaDDbC92763c58BF0192fFeBf); + address private constant HOODI_WETH_ADDRESS = address(0); // @todo + address private constant HOODI_STRATEGY_MANAGER = 0xeE45e76ddbEDdA2918b8C7E3035cd37Eab3b5D41; + address private constant HOODI_WETH_STRATEGY = 0x24579aD4fe83aC53546E5c2D3dF5F85D6383420d; + address private constant HOODI_DELEGATION_MANAGER = 0x867837a9722C512e0862d8c2E15b8bE220E8b87d; + function setUp() public { - deployContractsHolesky(0); // on latest block + deployContractsHoodi(0); // on latest block } function test_create_puffer_module() public { vm.startPrank(DAO); pufferProtocol.createPufferModule(bytes32("SOME_MODULE_NAME")); + vm.stopPrank(); } function _depositToWETHEigenLayerStrategyAndDelegateTo(address restakingOperator) internal { // buy weth - vm.startPrank(0xA85Fdcb45aaFF3C310a47FE309D4a35FAfbdc0ad); - Weth(0x94373a4919B3240D86eA41593D5eBa789FEF3848).deposit{ value: 500 ether }(); - Weth(0x94373a4919B3240D86eA41593D5eBa789FEF3848).approve( - 0xdfB5f6CE42aAA7830E94ECFCcAd411beF4d4D5b6, type(uint256).max - ); + vm.startPrank(0xA85Fdcb45aaFF3C310a47FE309D4a35FAfbdc0ad); // TODO Change + Weth(HOODI_WETH_ADDRESS).deposit{ value: 500 ether }(); + Weth(HOODI_WETH_ADDRESS).approve(HOODI_STRATEGY_MANAGER, type(uint256).max); // deposit into weth strategy - IStrategyManager(0xdfB5f6CE42aAA7830E94ECFCcAd411beF4d4D5b6).depositIntoStrategy( - IStrategy(0x80528D6e9A2BAbFc766965E0E26d5aB08D9CFaF9), - IERC20(0x94373a4919B3240D86eA41593D5eBa789FEF3848), - 500 ether + IStrategyManager(HOODI_STRATEGY_MANAGER).depositIntoStrategy( + IStrategy(HOODI_WETH_STRATEGY), IERC20(HOODI_WETH_ADDRESS), 500 ether ); ISignatureUtils.SignatureWithExpiry memory signatureWithExpiry; - IDelegationManager(0xA44151489861Fe9e3055d95adC98FbD462B948e7).delegateTo( - restakingOperator, signatureWithExpiry, bytes32(0) - ); + IDelegationManager(HOODI_DELEGATION_MANAGER).delegateTo(restakingOperator, signatureWithExpiry, bytes32(0)); + vm.stopPrank(); } // Creates a new restaking operator and returns it diff --git a/mainnet-contracts/test/fork-tests/PufferModuleManagerSlasher.integration.t.sol b/mainnet-contracts/test/fork-tests/PufferModuleManagerSlasher.integration.t.sol index 45ccae7c..ae3fa3a9 100644 --- a/mainnet-contracts/test/fork-tests/PufferModuleManagerSlasher.integration.t.sol +++ b/mainnet-contracts/test/fork-tests/PufferModuleManagerSlasher.integration.t.sol @@ -19,19 +19,19 @@ import { RestakingOperatorController } from "../../src/RestakingOperatorControll contract PufferModuleManagerSlasherIntegrationTest is Test, DeployerHelper { PufferModuleManager public pufferModuleManager; - address PUFFER_MODULE_0_HOLESKY = 0x9017a172578458E1204691D6E1dB92ca61381655; - address EIGENPOD_0_HOLESKY = 0xeD9B08B8958B89E7A9008CAc0937E46F73Bf8f52; - address RESTAKING_OPERATOR_0_HOLESKY = 0x57b6FdEF3A23B81547df68F44e5524b987755c99; + address PUFFER_MODULE_0_HOODI = address(0); // @todo + address EIGENPOD_0_HOODI = address(0); // @todo + address RESTAKING_OPERATOR_0_HOODI = address(0); // @todo bytes32 PUFFER_MODULE_0_NAME = bytes32("PUFFER_MODULE_0"); DeployPufferModuleManager deployPufferModuleManager; DeployPufferModuleImplementation deployPufferModule; DeployRestakingOperator deployRestakingOperator; - uint32 START_BLOCK = 2994229; // Dec-23-2024 09:43:00 AM +UTC + uint32 START_BLOCK = 2994229; // Dec-23-2024 09:43:00 AM +UTC @todo change function setUp() public { - vm.createSelectFork(vm.rpcUrl("holesky"), START_BLOCK); + vm.createSelectFork(vm.rpcUrl("hoodi"), START_BLOCK); // I want to use the deployment scripts to deploy the contracts in tests. deployPufferModuleManager = new DeployPufferModuleManager(); @@ -61,6 +61,7 @@ contract PufferModuleManagerSlasherIntegrationTest is Test, DeployerHelper { // New withdrawal flow function test_queue_and_claim_withdrawals() public { + // @todo Checkpoint and adjust amount once the validator is live vm.startPrank(_getPaymaster()); uint256 amount = 0.1 ether; @@ -74,10 +75,10 @@ contract PufferModuleManagerSlasherIntegrationTest is Test, DeployerHelper { IDelegationManagerTypes.Withdrawal[] memory withdrawals = new IDelegationManagerTypes.Withdrawal[](1); withdrawals[0] = IDelegationManagerTypes.Withdrawal({ - staker: PUFFER_MODULE_0_HOLESKY, - delegatedTo: RESTAKING_OPERATOR_0_HOLESKY, - withdrawer: PUFFER_MODULE_0_HOLESKY, - nonce: 42, + staker: PUFFER_MODULE_0_HOODI, + delegatedTo: RESTAKING_OPERATOR_0_HOODI, + withdrawer: PUFFER_MODULE_0_HOODI, + nonce: 0, startBlock: START_BLOCK, strategies: strategies, scaledShares: scaledShares @@ -89,7 +90,7 @@ contract PufferModuleManagerSlasherIntegrationTest is Test, DeployerHelper { bool[] memory receiveAsTokens = new bool[](1); receiveAsTokens[0] = true; - vm.roll(START_BLOCK + 50 + 1); // on Holesky its 50 blocks wait time, in Production it will be 14 days in blocks.. + vm.roll(START_BLOCK + 50 + 1); // on Hoodi its 50 blocks wait time, in Production it will be 14 days in blocks.. pufferModuleManager.callCompleteQueuedWithdrawals(PUFFER_MODULE_0_NAME, withdrawals, tokens, receiveAsTokens); } diff --git a/mainnet-contracts/test/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol b/mainnet-contracts/test/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol index a02f0fbb..8dfa62dc 100644 --- a/mainnet-contracts/test/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol +++ b/mainnet-contracts/test/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol @@ -12,31 +12,30 @@ interface Weth { } // PufferTestnet V1 deployment -contract PufferModuleManagerHoleskyTestnetFFI is Test { +contract PufferModuleManagerHoodiTestnetFFI is Test { using BN254 for BN254.G1Point; using Strings for uint256; uint256[] privKeys; // https://github.com/Layr-Labs/eigenlayer-contracts?tab=readme-ov-file#deployments - address EIGEN_DA_REGISTRY_COORDINATOR_HOLESKY = 0x53012C69A189cfA2D9d29eb6F19B32e0A2EA3490; - address EIGEN_DA_SERVICE_MANAGER = 0xD4A7E1Bd8015057293f0D0A557088c286942e84b; + address EIGEN_DA_REGISTRY_COORDINATOR_HOODI = 0xB5b76D561eeF36CD772890C94C6Bde8b895455e2; + address EIGEN_DA_SERVICE_MANAGER = 0x3FF2204A567C15dC3731140B95362ABb4b17d8ED; address BEACON_CHAIN_STRATEGY = 0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0; - address EIGEN_POD_MANAGER = 0x30770d7E3e71112d7A6b7259542D1f680a70e315; - address DELAYED_WITHDRAWAL_ROUTER = 0x642c646053eaf2254f088e9019ACD73d9AE0FA32; - address DELEGATION_MANAGER = 0xA44151489861Fe9e3055d95adC98FbD462B948e7; - - // Puffer Holesky deployment - address PUFFER_SHARED_DEV_WALLET = 0xDDDeAfB492752FC64220ddB3E7C9f1d5CcCdFdF0; - address ACCESS_MANAGER_HOLESKY = 0xA6c916f85DAfeb6f726E03a1Ce8d08cf835138fF; - address MODULE_BEACON_HOLESKY = 0x5B81A4579f466fB17af4d8CC0ED51256b94c61D4; - address PUFFER_PROTOCOL_HOLESKY = 0x705E27D6A6A0c77081D32C07DbDE5A1E139D3F14; - address PUFFER_MODULE_MANAGER = 0xe4695ab93163F91665Ce5b96527408336f070a71; - address PUFFER_MODULE_0_HOLESKY = 0x0B0456ec773B7D89C9deCc38b682F98556CF9862; - // https://holesky.eigenlayer.xyz/operator/0xe2c2dc296a0bff351f6bc3e98d37ea798e393e56 - address RESTAKING_OPERATOR_CONTRACT = 0xe2c2dc296a0bFF351F6bC3e98D37ea798e393e56; - address RESTAKING_OPERATOR_BEACON = 0xa7DC88c059F57ADcE41070cEfEFd31F74649a261; - address REWARDS_COORDINATOR = 0xAcc1fb458a1317E886dB376Fc8141540537E68fE; + address EIGEN_POD_MANAGER = 0xcd1442415Fc5C29Aa848A49d2e232720BE07976c; + address DELEGATION_MANAGER = 0x867837a9722C512e0862d8c2E15b8bE220E8b87d; + + // Puffer Hoodi deployment + address PUFFER_SHARED_DEV_WALLET = 0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE; + address ACCESS_MANAGER_HOODI = address(0); // @todo + address MODULE_BEACON_HOODI = address(0); // @todo + address PUFFER_PROTOCOL_HOODI = address(0); // @todo + address PUFFER_MODULE_MANAGER = address(0); // @todo + address PUFFER_MODULE_0_HOODI = address(0); // @todo + // https://holesky.eigenlayer.xyz/operator/address(0); // @todo + address RESTAKING_OPERATOR_CONTRACT = address(0); // @todo + address RESTAKING_OPERATOR_BEACON = address(0); // @todo + address REWARDS_COORDINATOR = address(0); // @todo function _mulGo(uint256 x) internal returns (BN254.G2Point memory g2Point) { string[] memory inputs = new string[](3); diff --git a/mainnet-contracts/test/helpers/IntegrationTestHelper.sol b/mainnet-contracts/test/helpers/IntegrationTestHelper.sol index 6ce53cbf..cb60b238 100644 --- a/mainnet-contracts/test/helpers/IntegrationTestHelper.sol +++ b/mainnet-contracts/test/helpers/IntegrationTestHelper.sol @@ -24,10 +24,10 @@ contract IntegrationTestHelper is Test { IEnclaveVerifier public verifier; bytes32 PUFFER_MODULE_0 = bytes32("PUFFER_MODULE_0"); - address PAYMASTER = 0xDDDeAfB492752FC64220ddB3E7C9f1d5CcCdFdF0; + address PAYMASTER = 0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE; // custom block number - function deployContractsHolesky(uint256 blockNumber) public virtual { + function deployContractsHoodi(uint256 blockNumber) public virtual { // see foundry.toml for the rpc urls if (blockNumber == 0) { vm.createSelectFork(vm.rpcUrl("holesky")); @@ -42,8 +42,8 @@ contract IntegrationTestHelper is Test { } // 'default' block number - function deployContractsHolesky() public virtual { - deployContractsHolesky(1_212_252); + function deployContractsHoodi() public virtual { + deployContractsHoodi(1_212_252); // TODO Change } function _deployAndLabel(address[] memory guardians, uint256 threshold) internal { From 20d47bc2b77865185b246acd4eee1091d7badc20 Mon Sep 17 00:00:00 2001 From: eladiosch <3090613+eladiosch@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:34:50 +0000 Subject: [PATCH 49/55] forge fmt --- mainnet-contracts/script/DeployPufETH.s.sol | 2 +- mainnet-contracts/script/DeployerHelper.s.sol | 2 +- .../script/GenerateBLSKeysAndRegisterValidators.s.sol | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mainnet-contracts/script/DeployPufETH.s.sol b/mainnet-contracts/script/DeployPufETH.s.sol index 1b448e54..01a6a848 100644 --- a/mainnet-contracts/script/DeployPufETH.s.sol +++ b/mainnet-contracts/script/DeployPufETH.s.sol @@ -261,7 +261,7 @@ contract DeployPufETH is BaseScript { lidoWithdrawalQueue = ILidoWithdrawalQueue(0xfe56573178f1bcdf53F01A6E9977670dcBBD9186); stETHStrategy = IStrategy(0xF8a1a66130D614c7360e868576D5E59203475FE0); eigenStrategyManager = IEigenLayer(0xeE45e76ddbEDdA2918b8C7E3035cd37Eab3b5D41); - } else { + } else { stETH = IStETH(address(new stETHMock())); weth = new WETH9(); lidoWithdrawalQueue = new LidoWithdrawalQueueMock(); diff --git a/mainnet-contracts/script/DeployerHelper.s.sol b/mainnet-contracts/script/DeployerHelper.s.sol index 187b518d..f8b43e2d 100644 --- a/mainnet-contracts/script/DeployerHelper.s.sol +++ b/mainnet-contracts/script/DeployerHelper.s.sol @@ -315,7 +315,7 @@ abstract contract DeployerHelper is Script { function _getWstETH() internal view returns (address) { if (block.chainid == mainnet) { return 0x8d09a4502Cc8Cf1547aD300E066060D043f6982D; - } else if(block.chainid == hoodi) { + } else if (block.chainid == hoodi) { return 0x7E99eE3C66636DE415D2d7C880938F2f40f94De4; } diff --git a/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol b/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol index cfaacab0..f8e88d6d 100644 --- a/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol +++ b/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol @@ -48,7 +48,7 @@ contract GenerateBLSKeysAndRegisterValidators is Script { protocolAddress = address(0); // @todo Add protocol address once deployed pufferProtocol = PufferProtocol(protocolAddress); forkVersion = "0x10000910"; - } else if (block.chainid == 1) { + } else if (block.chainid == 1) { // Mainnet protocolAddress = 0xf7b6B32492c2e13799D921E84202450131bd238B; pufferProtocol = PufferProtocol(protocolAddress); From ad9485ac71200c34e42dbb4c1132e39164d0fab2 Mon Sep 17 00:00:00 2001 From: Eladio Date: Tue, 17 Mar 2026 17:38:55 +0100 Subject: [PATCH 50/55] Added broadcast to _deployRevenueDepositor --- mainnet-contracts/script/DeployEverything.s.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mainnet-contracts/script/DeployEverything.s.sol b/mainnet-contracts/script/DeployEverything.s.sol index d6c2bcb4..85c7ae73 100644 --- a/mainnet-contracts/script/DeployEverything.s.sol +++ b/mainnet-contracts/script/DeployEverything.s.sol @@ -108,6 +108,7 @@ contract DeployEverything is BaseScript { // script/DeployRevenueDepositor.s.sol It should match the one in the script function _deployRevenueDepositor(PufferDeployment memory puffETHDeployment) internal returns (address) { + vm.startBroadcast(); MockAeraVault mockAeraVault = new MockAeraVault(); PufferRevenueDepositor revenueDepositorImpl = new PufferRevenueDepositor({ @@ -126,6 +127,7 @@ contract DeployEverything is BaseScript { ) ) ); + vm.stopBroadcast(); bytes memory accessManagerCd = new GenerateRevenueDepositorCalldata().run(address(revenueDepositor), makeAddr("operationsMultisig")); From c0a2c5fc3f8001510fed17cab4e0884f819bfccd Mon Sep 17 00:00:00 2001 From: Eladio Date: Tue, 17 Mar 2026 18:11:04 +0100 Subject: [PATCH 51/55] Adapted natspec of DeployEverything script --- mainnet-contracts/script/DeployEverything.s.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mainnet-contracts/script/DeployEverything.s.sol b/mainnet-contracts/script/DeployEverything.s.sol index 85c7ae73..fb820095 100644 --- a/mainnet-contracts/script/DeployEverything.s.sol +++ b/mainnet-contracts/script/DeployEverything.s.sol @@ -23,7 +23,7 @@ import { MockAeraVault } from "test/mocks/MockAeraVault.sol"; * @author Puffer Finance * @notice Deploys pufETH (upgrade it in test environment), Guardians, Oracle, Puffer, and sets up the access control * @dev Example on how to run the script - * forge script script/DeployEverything.s.sol:DeployEverything --rpc-url=$RPC_URL --sig 'run(address[] calldata, uint256)' "[$DEV_WALLET]" 1 --broadcast + * forge script script/DeployEverything.s.sol:DeployEverything --rpc-url=$RPC_URL --sig 'run(address[] calldata, uint256, address)' "[$DEV_WALLET]" 1 $DEV_WALLET --broadcast */ contract DeployEverything is BaseScript { address DAO; From 35a337ca5ff1e474c0e671c6b66b4f43d84e983a Mon Sep 17 00:00:00 2001 From: Eladio Date: Thu, 19 Mar 2026 11:28:51 +0100 Subject: [PATCH 52/55] Adapted tests and scripts --- .../DeployPufferProtocolImplementation.s.sol | 2 +- .../script/DeployPufferVault.s.sol | 2 +- mainnet-contracts/script/DeployerHelper.s.sol | 104 ++++++++++-------- ...GenerateBLSKeysAndRegisterValidators.s.sol | 2 +- .../PermissionedValidatorFork.t.sol | 2 - .../PufferModuleManager.integration.t.sol | 2 +- ...fferModuleManagerSlasher.integration.t.sol | 4 +- .../ffi/PufferModuleManagerHoleskyFfi.t.sol | 18 +-- mainnet-contracts/test/unit/PufferVault.t.sol | 4 - 9 files changed, 71 insertions(+), 69 deletions(-) diff --git a/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol b/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol index 47e764d3..b2c5e08f 100644 --- a/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol +++ b/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol @@ -31,7 +31,7 @@ contract DeployPufferProtocolImplementation is DeployerHelper { moduleManager: _getPufferModuleManager(), oracle: IPufferOracleV2(_getPufferOracle()), beaconDepositContract: _getBeaconDepositContract(), - permissionedOracle: IPermissionedOracle(address(0)) // TODO: set actual address + permissionedOracle: IPermissionedOracle(_getPermissionedOracle()) }) ); diff --git a/mainnet-contracts/script/DeployPufferVault.s.sol b/mainnet-contracts/script/DeployPufferVault.s.sol index 53ee83dc..6cc91041 100644 --- a/mainnet-contracts/script/DeployPufferVault.s.sol +++ b/mainnet-contracts/script/DeployPufferVault.s.sol @@ -37,7 +37,7 @@ contract DeployPufferVault is DeployerHelper { weth: IWETH(_getWETH()), pufferOracle: IPufferOracleV2(_getPufferOracle()), revenueDepositor: IPufferRevenueDepositor(_getRevenueDepositor()), - permissionedOracle: IPermissionedOracle(address(0)) // TODO: set actual address + permissionedOracle: IPermissionedOracle(_getPermissionedOracle()) }); //@todo Double check reinitialization diff --git a/mainnet-contracts/script/DeployerHelper.s.sol b/mainnet-contracts/script/DeployerHelper.s.sol index f8b43e2d..1f3d2f2f 100644 --- a/mainnet-contracts/script/DeployerHelper.s.sol +++ b/mainnet-contracts/script/DeployerHelper.s.sol @@ -164,9 +164,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0x99c3E46E575df251149866285DdA7DAEba875B71 return 0x99c3E46E575df251149866285DdA7DAEba875B71; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0x48564bF0a15F3B0a6d2f16De35c810578a667982 + return 0x48564bF0a15F3B0a6d2f16De35c810578a667982; } revert("RestakingOperatorBeacon not available for this chain"); @@ -195,9 +194,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0x0910310130d1c062DEF8B807528bdac80203BC66 return 0x0910310130d1c062DEF8B807528bdac80203BC66; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0xE28D1F2532bc05d3CF9853Ca8Cff26fCb70fAA6e + return 0xE28D1F2532bc05d3CF9853Ca8Cff26fCb70fAA6e; } revert("GuardianModule not available for this chain"); @@ -211,9 +209,32 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0x4B0542470935ed4b085C3AD1983E85f5623ABf89 return 0x4B0542470935ed4b085C3AD1983E85f5623ABf89; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0xbAfD7A578351baDC855328963562EE7a3b8Fae00 + return 0xbAfD7A578351baDC855328963562EE7a3b8Fae00; + } + + revert("PufferModuleBeacon not available for this chain"); + } + + function _getPermissionedModuleBeacon() internal view returns (address) { + if (block.chainid == mainnet) { + // https://etherscan.io/address/0x0000000000000000000000000000000000000000 + return 0x0000000000000000000000000000000000000000; // TODO: set actual address + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0x37d1a646CE67CA2109ECf599D152f98A1b9EAaEa + return 0x37d1a646CE67CA2109ECf599D152f98A1b9EAaEa; + } + + revert("PufferModuleBeacon not available for this chain"); + } + + function _getNRWCBeacon() internal view returns (address) { + if (block.chainid == mainnet) { + // https://etherscan.io/address/0x0000000000000000000000000000000000000000 + return 0x0000000000000000000000000000000000000000; // TODO: set actual address + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0xaCFfb74fdfA7a04E4a578a6d0F6669E2195bFaa3 + return 0xaCFfb74fdfA7a04E4a578a6d0F6669E2195bFaa3; } revert("PufferModuleBeacon not available for this chain"); @@ -257,9 +278,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0x09BE86B01c1e32dCa2ebdEDb01cD5A3F798b80C5 return 0x09BE86B01c1e32dCa2ebdEDb01cD5A3F798b80C5; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0x5206826bD2Ba51D466119E108bE840e39934f8B2 + return 0x5206826bD2Ba51D466119E108bE840e39934f8B2; } revert("AVSContractsRegistry not available for this chain"); @@ -273,9 +293,8 @@ abstract contract DeployerHelper is Script { // Holesky Timelock: https://explorer.pops.one/address/0x829aF0B3d099a12F0aE1b806f466EF771E2C07F8 return 0x829aF0B3d099a12F0aE1b806f466EF771E2C07F8; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0x05bAe90D333840039Ef74725FC131563daEf86fF + return 0x05bAe90D333840039Ef74725FC131563daEf86fF; } revert("Timelock not available for this chain"); @@ -289,9 +308,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0xAcc1fb458a1317E886dB376Fc8141540537E68fE return 0xAcc1fb458a1317E886dB376Fc8141540537E68fE; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0x29e8572678e0c272350aa0b4B8f304E47EBcd5e7 + return 0x29e8572678e0c272350aa0b4B8f304E47EBcd5e7; } revert("RewardsCoordinator not available for this chain"); @@ -360,9 +378,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0x8e043ed3F06720615685D4978770Cd5C8fe90fe3 return 0x8e043ed3F06720615685D4978770Cd5C8fe90fe3; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0x8DbC27D87718CE753da1D01DB40b3e4680e2fe2e + return 0x8DbC27D87718CE753da1D01DB40b3e4680e2fe2e; } revert("puffer oracle not available for this chain"); @@ -370,13 +387,11 @@ abstract contract DeployerHelper is Script { function _getPermissionedOracle() internal view returns (address) { if (block.chainid == mainnet) { - // https://etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://etherscan.io/address/0x0000000000000000000000000000000000000000 + return 0x0000000000000000000000000000000000000000; // TODO: set actual address } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0xA5de9F662CFF1D54662f1FB96b84F00B19A52f3a + return 0xA5de9F662CFF1D54662f1FB96b84F00B19A52f3a; } revert("permissioned oracle not available for this chain"); @@ -405,9 +420,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0x1d181cBd1825e9eBC6AD966878D555A7215FF4F0 return 0x1d181cBd1825e9eBC6AD966878D555A7215FF4F0; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0xd769634d2b828ae2b219c5C1bC7b4067fa316869 + return 0xd769634d2b828ae2b219c5C1bC7b4067fa316869; } revert("WETH not available for this chain"); @@ -439,9 +453,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0x180a345906e42293dcAd5CCD9b0e1DB26aE0274e return 0x180a345906e42293dcAd5CCD9b0e1DB26aE0274e; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0x08FB343f638e18421Be7A26Ed1e8ADFAf378cf97 + return 0x08FB343f638e18421Be7A26Ed1e8ADFAf378cf97; } else if (block.chainid == binance) { // https://bscscan.com/address/0x8849e9eB8bb27c1916AfB17ee4dEcAd375916474 return 0x8849e9eB8bb27c1916AfB17ee4dEcAd375916474; @@ -468,9 +481,8 @@ abstract contract DeployerHelper is Script { // https://sepolia.etherscan.io/address/0xd85D701A660a61D9737D05397612EF08be2cE62D return 0xd85D701A660a61D9737D05397612EF08be2cE62D; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0x0c745c1535a0AeF453aeaCD1DeFEa00486cb7cCa + return 0x0c745c1535a0AeF453aeaCD1DeFEa00486cb7cCa; } revert("PufferVault not available for this chain"); @@ -484,9 +496,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0x20377c306451140119C9967Ba6D0158a05b4eD07 return 0x20377c306451140119C9967Ba6D0158a05b4eD07; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0xc97d22D8638044C27a59E1930C4C684A40778046 + return 0xc97d22D8638044C27a59E1930C4C684A40778046; } revert("PufferModuleManager not available for this chain"); @@ -500,9 +511,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0xB028194785178a94Fe608994A4d5AD84c285A640 return 0xB028194785178a94Fe608994A4d5AD84c285A640; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0x7C61DD5EE46518d86B27E3947aC152491f7aC0E5 + return 0x7C61DD5EE46518d86B27E3947aC152491f7aC0E5; } revert("ValidatorTicket not available for this chain"); @@ -516,9 +526,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/0xE00c79408B9De5BaD2FDEbB1688997a68eC988CD return 0xE00c79408B9De5BaD2FDEbB1688997a68eC988CD; } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0xb39cA8C580eEA0996CEaAd1f199A135F9Bdfc74C + return 0xb39cA8C580eEA0996CEaAd1f199A135F9Bdfc74C; } revert("PufferProtocol not available for this chain"); @@ -532,9 +541,8 @@ abstract contract DeployerHelper is Script { // https://holesky.etherscan.io/address/TODO return address(0); // TODO } else if (block.chainid == hoodi) { - // https://hoodi.etherscan.io/address/??? - // return ???; - // @todo Add address once deployed + // https://hoodi.etherscan.io/address/0xF24DF237Fa9f9120daE7db84890382D4586C41C2 + return 0xF24DF237Fa9f9120daE7db84890382D4586C41C2; } revert("RestakingOperatorController not available for this chain"); diff --git a/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol b/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol index f8e88d6d..49f59d60 100644 --- a/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol +++ b/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol @@ -45,7 +45,7 @@ contract GenerateBLSKeysAndRegisterValidators is Script { forkVersion = "0x01017000"; } else if (block.chainid == 560048) { // Hoodi - protocolAddress = address(0); // @todo Add protocol address once deployed + protocolAddress = 0xb39cA8C580eEA0996CEaAd1f199A135F9Bdfc74C; pufferProtocol = PufferProtocol(protocolAddress); forkVersion = "0x10000910"; } else if (block.chainid == 1) { diff --git a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol index 8a8158eb..7697500c 100644 --- a/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol @@ -168,8 +168,6 @@ contract PermissionedValidatorForkTest is MainnetForkTestHelper { abi.encodeCall(Timelock.executeTransaction, (_getPufferModuleManager(), moduleManagerUpgradeCalldata, 2)) ); require(success, "PufferModuleManager upgrade failed"); - - // TODO Redeploy PMM with new beacons } function _setupAccessControl() internal { diff --git a/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol b/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol index 7f3d565b..24c6bec2 100644 --- a/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol +++ b/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol @@ -30,7 +30,7 @@ contract PufferModuleManagerIntegrationTest is IntegrationTestHelper { address EIGEN_DA_SERVICE_MANAGER = 0x3FF2204A567C15dC3731140B95362ABb4b17d8ED; // IAVSDirectory public avsDirectory = IAVSDirectory(0x055733000064333CaDDbC92763c58BF0192fFeBf); - address private constant HOODI_WETH_ADDRESS = address(0); // @todo + address private constant HOODI_WETH_ADDRESS = 0xc1454A618E65ba3e1E2e1088b79ec5fB6b5433ac; address private constant HOODI_STRATEGY_MANAGER = 0xeE45e76ddbEDdA2918b8C7E3035cd37Eab3b5D41; address private constant HOODI_WETH_STRATEGY = 0x24579aD4fe83aC53546E5c2D3dF5F85D6383420d; address private constant HOODI_DELEGATION_MANAGER = 0x867837a9722C512e0862d8c2E15b8bE220E8b87d; diff --git a/mainnet-contracts/test/fork-tests/PufferModuleManagerSlasher.integration.t.sol b/mainnet-contracts/test/fork-tests/PufferModuleManagerSlasher.integration.t.sol index ae3fa3a9..3ffb0017 100644 --- a/mainnet-contracts/test/fork-tests/PufferModuleManagerSlasher.integration.t.sol +++ b/mainnet-contracts/test/fork-tests/PufferModuleManagerSlasher.integration.t.sol @@ -19,8 +19,8 @@ import { RestakingOperatorController } from "../../src/RestakingOperatorControll contract PufferModuleManagerSlasherIntegrationTest is Test, DeployerHelper { PufferModuleManager public pufferModuleManager; - address PUFFER_MODULE_0_HOODI = address(0); // @todo - address EIGENPOD_0_HOODI = address(0); // @todo + address PUFFER_MODULE_0_HOODI = 0xcabed454A76f1d6CB41241dDD8361312b949Fb20; + address EIGENPOD_0_HOODI = 0x8549C205aAA07Ec5A47DcC55Ea38299b31Fa726e; address RESTAKING_OPERATOR_0_HOODI = address(0); // @todo bytes32 PUFFER_MODULE_0_NAME = bytes32("PUFFER_MODULE_0"); diff --git a/mainnet-contracts/test/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol b/mainnet-contracts/test/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol index 8dfa62dc..a5df99c1 100644 --- a/mainnet-contracts/test/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol +++ b/mainnet-contracts/test/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol @@ -27,15 +27,15 @@ contract PufferModuleManagerHoodiTestnetFFI is Test { // Puffer Hoodi deployment address PUFFER_SHARED_DEV_WALLET = 0xeeE554b5b2bF5FBc9730Ce33c6dc92828DA01BeE; - address ACCESS_MANAGER_HOODI = address(0); // @todo - address MODULE_BEACON_HOODI = address(0); // @todo - address PUFFER_PROTOCOL_HOODI = address(0); // @todo - address PUFFER_MODULE_MANAGER = address(0); // @todo - address PUFFER_MODULE_0_HOODI = address(0); // @todo - // https://holesky.eigenlayer.xyz/operator/address(0); // @todo - address RESTAKING_OPERATOR_CONTRACT = address(0); // @todo - address RESTAKING_OPERATOR_BEACON = address(0); // @todo - address REWARDS_COORDINATOR = address(0); // @todo + address ACCESS_MANAGER_HOODI = 0x08FB343f638e18421Be7A26Ed1e8ADFAf378cf97; + address MODULE_BEACON_HOODI = 0xbAfD7A578351baDC855328963562EE7a3b8Fae00; + address PUFFER_PROTOCOL_HOODI = 0xb39cA8C580eEA0996CEaAd1f199A135F9Bdfc74C; + address PUFFER_MODULE_MANAGER = 0xc97d22D8638044C27a59E1930C4C684A40778046; + address PUFFER_MODULE_0_HOODI = 0xcabed454A76f1d6CB41241dDD8361312b949Fb20; + // https://holesky.eigenlayer.xyz/operator/0xE9C3DE989D30dE331AaE0771F1b81Ed158d25b0b + address RESTAKING_OPERATOR_CONTRACT = 0xE9C3DE989D30dE331AaE0771F1b81Ed158d25b0b; + address RESTAKING_OPERATOR_BEACON = 0x48564bF0a15F3B0a6d2f16De35c810578a667982; + address REWARDS_COORDINATOR = 0x29e8572678e0c272350aa0b4B8f304E47EBcd5e7; function _mulGo(uint256 x) internal returns (BN254.G2Point memory g2Point) { string[] memory inputs = new string[](3); diff --git a/mainnet-contracts/test/unit/PufferVault.t.sol b/mainnet-contracts/test/unit/PufferVault.t.sol index 9f38fd4d..5470fae2 100644 --- a/mainnet-contracts/test/unit/PufferVault.t.sol +++ b/mainnet-contracts/test/unit/PufferVault.t.sol @@ -10,8 +10,6 @@ import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils import { LidoWithdrawalQueueMock } from "../mocks/LidoWithdrawalQueueMock.sol"; import { IPermissionedOracle } from "src/interface/IPermissionedOracle.sol"; -import "forge-std/console.sol"; - contract PufferVaultTest is UnitTestHelper { uint256 pointZeroZeroOne = 0.0001e18; @@ -1047,8 +1045,6 @@ contract PufferVaultTest is UnitTestHelper { stETH, weth, new LidoWithdrawalQueueMock(), pufferOracle, revenueDepositor, permissionedOracle ); - console.log("permissionedOracle", address(permissionedOracle)); - UUPSUpgradeable(address(pufferVault)).upgradeToAndCall(address(newImplementation), ""); vm.stopPrank(); } From 585fe5e2d172a9e7fae42783782e85f21e51c0e3 Mon Sep 17 00:00:00 2001 From: eladiosch <3090613+eladiosch@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:32:44 +0000 Subject: [PATCH 53/55] forge fmt --- .../script/DeployPufferProtocolImplementation.s.sol | 2 +- mainnet-contracts/script/DeployPufferVault.s.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol b/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol index b2c5e08f..3df61f59 100644 --- a/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol +++ b/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol @@ -32,7 +32,7 @@ contract DeployPufferProtocolImplementation is DeployerHelper { oracle: IPufferOracleV2(_getPufferOracle()), beaconDepositContract: _getBeaconDepositContract(), permissionedOracle: IPermissionedOracle(_getPermissionedOracle()) - }) + }) ); //@todo Double check reinitialization diff --git a/mainnet-contracts/script/DeployPufferVault.s.sol b/mainnet-contracts/script/DeployPufferVault.s.sol index 6cc91041..84f4b643 100644 --- a/mainnet-contracts/script/DeployPufferVault.s.sol +++ b/mainnet-contracts/script/DeployPufferVault.s.sol @@ -38,7 +38,7 @@ contract DeployPufferVault is DeployerHelper { pufferOracle: IPufferOracleV2(_getPufferOracle()), revenueDepositor: IPufferRevenueDepositor(_getRevenueDepositor()), permissionedOracle: IPermissionedOracle(_getPermissionedOracle()) - }); + }); //@todo Double check reinitialization _consoleLogOrUpgradeUUPS({ From 81b4325b765cfadf3f100e7e1ccd15f1077d8806 Mon Sep 17 00:00:00 2001 From: Eladio Date: Fri, 20 Mar 2026 10:59:15 +0100 Subject: [PATCH 54/55] Added permissioned validators calldata to SetupAccess --- mainnet-contracts/script/SetupAccess.s.sol | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mainnet-contracts/script/SetupAccess.s.sol b/mainnet-contracts/script/SetupAccess.s.sol index 4e56f713..df6527b2 100644 --- a/mainnet-contracts/script/SetupAccess.s.sol +++ b/mainnet-contracts/script/SetupAccess.s.sol @@ -23,7 +23,7 @@ import { GenerateAccessManagerCalldata2 } from "../script/AccessManagerMigration import { GenerateRestakingOperatorCalldata } from "../script/AccessManagerMigrations/07_GenerateRestakingOperatorCalldata.s.sol"; import { GenerateFeeSetterCalldata } from "../script/AccessManagerMigrations/08_GenerateFeeSetterCalldata.s.sol"; - +import { GeneratePermissionedModuleCalldata } from "../script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol"; import { ROLE_ID_OPERATIONS_MULTISIG, ROLE_ID_OPERATIONS_PAYMASTER, @@ -88,6 +88,12 @@ contract SetupAccess is BaseScript { cd = new GenerateFeeSetterCalldata().run(deployment.pufferVault); (s,) = address(accessManager).call(cd); require(s, "failed setupAccess GenerateFeeSetterCalldata"); + + cd = new GeneratePermissionedModuleCalldata().run( + deployment.pufferProtocol, deployment.moduleManager, deployment.permissionedOracle + ); + (s,) = address(accessManager).call(cd); + require(s, "failed setupAccess GeneratePermissionedModuleCalldata"); } function _generateAccessCalldata( From b3d53236784e9827fbd13cc42030cd8856bd5aca Mon Sep 17 00:00:00 2001 From: eladiosch <3090613+eladiosch@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:03:02 +0000 Subject: [PATCH 55/55] forge fmt --- mainnet-contracts/script/SetupAccess.s.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mainnet-contracts/script/SetupAccess.s.sol b/mainnet-contracts/script/SetupAccess.s.sol index df6527b2..3a6baae5 100644 --- a/mainnet-contracts/script/SetupAccess.s.sol +++ b/mainnet-contracts/script/SetupAccess.s.sol @@ -23,7 +23,8 @@ import { GenerateAccessManagerCalldata2 } from "../script/AccessManagerMigration import { GenerateRestakingOperatorCalldata } from "../script/AccessManagerMigrations/07_GenerateRestakingOperatorCalldata.s.sol"; import { GenerateFeeSetterCalldata } from "../script/AccessManagerMigrations/08_GenerateFeeSetterCalldata.s.sol"; -import { GeneratePermissionedModuleCalldata } from "../script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol"; +import { GeneratePermissionedModuleCalldata } from + "../script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol"; import { ROLE_ID_OPERATIONS_MULTISIG, ROLE_ID_OPERATIONS_PAYMASTER,