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/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol b/mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol new file mode 100644 index 00000000..edc43cfe --- /dev/null +++ b/mainnet-contracts/script/AccessManagerMigrations/09_GeneratePermissionedModuleCalldata.s.sol @@ -0,0 +1,187 @@ +// 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/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/DeployEverything.s.sol b/mainnet-contracts/script/DeployEverything.s.sol index a2e86236..fb820095 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"; @@ -22,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; @@ -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); + 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); + new UpgradePufETH().run(puffETHDeployment, pufferOracle, revenueDepositor, permissionedOracle); // `anvil` in the terminal if (_localAnvil) { @@ -105,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({ @@ -123,6 +127,7 @@ contract DeployEverything is BaseScript { ) ) ); + vm.stopBroadcast(); bytes memory accessManagerCd = new GenerateRevenueDepositorCalldata().run(address(revenueDepositor), makeAddr("operationsMultisig")); 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..492e8d50 --- /dev/null +++ b/mainnet-contracts/script/DeployPermissionedOracle.s.sol @@ -0,0 +1,31 @@ +// 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 \ + * --sig 'run(address)' \ + * -vvvv --rpc-url=$RPC_URL --broadcast --verify + */ +contract DeployPermissionedOracle is DeployerHelper { + function run(address accessManager) public returns (PermissionedOracle) { + vm.startBroadcast(); + + PermissionedOracle oracle = new PermissionedOracle(accessManager); + + vm.label(address(oracle), "PermissionedOracle"); + console.log("Deployed PermissionedOracle at", address(oracle)); + + vm.stopBroadcast(); + + return oracle; + } +} diff --git a/mainnet-contracts/script/DeployPufETH.s.sol b/mainnet-contracts/script/DeployPufETH.s.sol index 6309d523..01a6a848 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 = @@ -253,6 +255,12 @@ contract DeployPufETH is BaseScript { lidoWithdrawalQueue = ILidoWithdrawalQueue(0xc7cc160b58F8Bb0baC94b80847E2CF2800565C50); stETHStrategy = IStrategy(0x7D704507b76571a51d9caE8AdDAbBFd0ba0e63d3); eigenStrategyManager = IEigenLayer(0xdfB5f6CE42aAA7830E94ECFCcAd411beF4d4D5b6); + } 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(); diff --git a/mainnet-contracts/script/DeployPuffer.s.sol b/mainnet-contracts/script/DeployPuffer.s.sol index ea294c78..b8b3f599 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"; @@ -30,6 +32,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 @@ -55,6 +58,8 @@ contract DeployPuffer is BaseScript { PufferProtocol pufferProtocol; UpgradeableBeacon pufferModuleBeacon; UpgradeableBeacon restakingOperatorBeacon; + UpgradeableBeacon permissionedModuleBeacon; + UpgradeableBeacon nrwcBeacon; PufferModuleManager moduleManager; OperationsCoordinator operationsCoordinator; ValidatorTicketPricer validatorTicketPricer; @@ -64,22 +69,23 @@ contract DeployPuffer is BaseScript { address eigenPodManager; address delegationManager; address rewardsCoordinator; - address eigenSlasher; + address allocationManager; address treasury; address operationsMultisig; - function run(GuardiansDeployment calldata guardiansDeployment, address pufferVault, address oracle) - 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()) { // Mainnet / Mainnet fork eigenPodManager = 0x91E677b07F7AF907ec9a428aafA9fc14a0d3A338; delegationManager = 0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A; - eigenSlasher = 0xD92145c07f8Ed1D392c1B88017934E301CC1c3Cd; + allocationManager = 0xD92145c07f8Ed1D392c1B88017934E301CC1c3Cd; rewardsCoordinator = address(0); //@todo treasury = vm.envAddress("TREASURY"); operationsMultisig = 0xC0896ab1A8cae8c2C1d27d011eb955Cca955580d; @@ -88,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% @@ -144,15 +160,28 @@ contract DeployPuffer is BaseScript { RestakingOperator restakingOperatorImplementation = new RestakingOperator( IDelegationManager(delegationManager), - IAllocationManager(eigenSlasher), + IAllocationManager(allocationManager), PufferModuleManager(payable(address(moduleManagerProxy))), IRewardsCoordinator(rewardsCoordinator), 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({ @@ -161,7 +190,8 @@ contract DeployPuffer is BaseScript { guardianModule: GuardianModule(payable(guardiansDeployment.guardianModule)), moduleManager: address(moduleManagerProxy), oracle: IPufferOracleV2(oracle), - beaconDepositContract: getStakingContract() + beaconDepositContract: getStakingContract(), + permissionedOracle: IPermissionedOracle(permissionedOracle) }); } @@ -172,7 +202,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( @@ -204,8 +236,11 @@ contract DeployPuffer is BaseScript { enclaveVerifier: guardiansDeployment.enclaveVerifier, beacon: address(pufferModuleBeacon), restakingOperatorBeacon: address(restakingOperatorBeacon), + permissionedModuleBeacon: address(permissionedModuleBeacon), + nrwcBeacon: address(nrwcBeacon), moduleManager: address(moduleManagerProxy), pufferOracle: address(oracle), + permissionedOracle: address(permissionedOracle), operationsCoordinator: address(operationsCoordinator), aVSContractsRegistry: address(aVSContractsRegistry), restakingOperatorController: address(restakingOperatorController), @@ -230,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/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/DeployPufferProtocolImplementation.s.sol b/mainnet-contracts/script/DeployPufferProtocolImplementation.s.sol index d7fba15d..3df61f59 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(_getPermissionedOracle()) }) ); diff --git a/mainnet-contracts/script/DeployPufferVault.s.sol b/mainnet-contracts/script/DeployPufferVault.s.sol index a9b96e7a..84f4b643 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(_getPermissionedOracle()) }); //@todo Double check reinitialization 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..1f3d2f2f 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,9 @@ 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/0x48564bF0a15F3B0a6d2f16De35c810578a667982 + return 0x48564bF0a15F3B0a6d2f16De35c810578a667982; } revert("RestakingOperatorBeacon not available for this chain"); @@ -161,6 +178,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 +193,9 @@ 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/0xE28D1F2532bc05d3CF9853Ca8Cff26fCb70fAA6e + return 0xE28D1F2532bc05d3CF9853Ca8Cff26fCb70fAA6e; } revert("GuardianModule not available for this chain"); @@ -185,6 +208,33 @@ 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/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"); @@ -197,6 +247,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 +262,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 +277,9 @@ 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/0x5206826bD2Ba51D466119E108bE840e39934f8B2 + return 0x5206826bD2Ba51D466119E108bE840e39934f8B2; } revert("AVSContractsRegistry not available for this chain"); @@ -233,6 +292,9 @@ 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/0x05bAe90D333840039Ef74725FC131563daEf86fF + return 0x05bAe90D333840039Ef74725FC131563daEf86fF; } revert("Timelock not available for this chain"); @@ -245,6 +307,9 @@ 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/0x29e8572678e0c272350aa0b4B8f304E47EBcd5e7 + return 0x29e8572678e0c272350aa0b4B8f304E47EBcd5e7; } revert("RewardsCoordinator not available for this chain"); @@ -257,6 +322,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 +333,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 +347,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 +362,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 +377,26 @@ 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/0x8DbC27D87718CE753da1D01DB40b3e4680e2fe2e + return 0x8DbC27D87718CE753da1D01DB40b3e4680e2fe2e; } revert("puffer oracle not available for this chain"); } + function _getPermissionedOracle() 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/0xA5de9F662CFF1D54662f1FB96b84F00B19A52f3a + return 0xA5de9F662CFF1D54662f1FB96b84F00B19A52f3a; + } + + 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 +404,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 +419,9 @@ 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/0xd769634d2b828ae2b219c5C1bC7b4067fa316869 + return 0xd769634d2b828ae2b219c5C1bC7b4067fa316869; } revert("WETH not available for this chain"); @@ -337,6 +434,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 +452,9 @@ 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/0x08FB343f638e18421Be7A26Ed1e8ADFAf378cf97 + return 0x08FB343f638e18421Be7A26Ed1e8ADFAf378cf97; } else if (block.chainid == binance) { // https://bscscan.com/address/0x8849e9eB8bb27c1916AfB17ee4dEcAd375916474 return 0x8849e9eB8bb27c1916AfB17ee4dEcAd375916474; @@ -377,6 +480,9 @@ abstract contract DeployerHelper is Script { // PufferVaultMock // https://sepolia.etherscan.io/address/0xd85D701A660a61D9737D05397612EF08be2cE62D return 0xd85D701A660a61D9737D05397612EF08be2cE62D; + } else if (block.chainid == hoodi) { + // https://hoodi.etherscan.io/address/0x0c745c1535a0AeF453aeaCD1DeFEa00486cb7cCa + return 0x0c745c1535a0AeF453aeaCD1DeFEa00486cb7cCa; } revert("PufferVault not available for this chain"); @@ -389,6 +495,9 @@ 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/0xc97d22D8638044C27a59E1930C4C684A40778046 + return 0xc97d22D8638044C27a59E1930C4C684A40778046; } revert("PufferModuleManager not available for this chain"); @@ -401,6 +510,9 @@ 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/0x7C61DD5EE46518d86B27E3947aC152491f7aC0E5 + return 0x7C61DD5EE46518d86B27E3947aC152491f7aC0E5; } revert("ValidatorTicket not available for this chain"); @@ -413,6 +525,9 @@ 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/0xb39cA8C580eEA0996CEaAd1f199A135F9Bdfc74C + return 0xb39cA8C580eEA0996CEaAd1f199A135F9Bdfc74C; } revert("PufferProtocol not available for this chain"); @@ -425,6 +540,9 @@ 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/0xF24DF237Fa9f9120daE7db84890382D4586C41C2 + return 0xF24DF237Fa9f9120daE7db84890382D4586C41C2; } revert("RestakingOperatorController not available for this chain"); @@ -544,6 +662,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 +701,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/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/GenerateBLSKeysAndRegisterValidators.s.sol b/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol index 649ac165..49f59d60 100644 --- a/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol +++ b/mainnet-contracts/script/GenerateBLSKeysAndRegisterValidators.s.sol @@ -43,6 +43,11 @@ contract GenerateBLSKeysAndRegisterValidators is Script { protocolAddress = 0xE00c79408B9De5BaD2FDEbB1688997a68eC988CD; pufferProtocol = PufferProtocol(protocolAddress); forkVersion = "0x01017000"; + } else if (block.chainid == 560048) { + // Hoodi + protocolAddress = 0xb39cA8C580eEA0996CEaAd1f199A135F9Bdfc74C; + pufferProtocol = PufferProtocol(protocolAddress); + forkVersion = "0x10000910"; } else if (block.chainid == 1) { // Mainnet protocolAddress = 0xf7b6B32492c2e13799D921E84202450131bd238B; diff --git a/mainnet-contracts/script/Roles.sol b/mainnet-contracts/script/Roles.sol index f969b0b8..50a845e1 100644 --- a/mainnet-contracts/script/Roles.sol +++ b/mainnet-contracts/script/Roles.sol @@ -13,6 +13,13 @@ 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 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/SetupAccess.s.sol b/mainnet-contracts/script/SetupAccess.s.sol index e0349cc1..3a6baae5 100644 --- a/mainnet-contracts/script/SetupAccess.s.sol +++ b/mainnet-contracts/script/SetupAccess.s.sol @@ -16,20 +16,23 @@ 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"; 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, 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 { @@ -51,7 +54,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)); @@ -85,6 +89,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( @@ -96,9 +106,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[](30); + calldatas = new bytes[](32); calldatas[0] = _setupGuardianModuleRoles(); calldatas[1] = _setupEnclaveVerifierRoles(); calldatas[2] = rolesCalldatas[0]; @@ -124,19 +135,22 @@ 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]; + + calldatas[31] = permissionedOracleAccess[0]; } function _labelRoles() internal pure returns (bytes[] memory) { @@ -158,10 +172,10 @@ 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); + bytes4[] memory selectors = new bytes4[](8); selectors[0] = PufferModuleManager.createNewRestakingOperator.selector; selectors[1] = PufferModuleManager.callUndelegate.selector; selectors[2] = PufferModuleManager.callDelegateTo.selector; @@ -169,6 +183,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 @@ -186,6 +201,17 @@ contract SetupAccess is BaseScript { ROLE_ID_OPERATIONS_PAYMASTER ); + // Validator Ejector selectors + bytes4[] memory validatorEjectorSelectors = new bytes4[](1); + validatorEjectorSelectors[0] = PufferModuleManager.triggerValidatorsExit.selector; + + calldatas[2] = abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, + pufferDeployment.moduleManager, + validatorEjectorSelectors, + ROLE_ID_VALIDATOR_EJECTOR + ); + return calldatas; } @@ -319,11 +345,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, @@ -410,6 +437,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/script/UpgradePufETH.s.sol b/mainnet-contracts/script/UpgradePufETH.s.sol index 77c0640b..553d6420 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 @@ -49,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); @@ -58,7 +64,8 @@ contract UpgradePufETH is BaseScript { IWETH(deployment.weth), ILidoWithdrawalQueue(deployment.lidoWithdrawalQueueMock), IPufferOracleV2(pufferOracle), - IPufferRevenueDepositor(revenueDepositor) + IPufferRevenueDepositor(revenueDepositor), + IPermissionedOracle(permissionedOracle) ); vm.label(address(newImplementation), "PufferVaultV5Implementation"); 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/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/NonRestakingWithdrawalCredentials.sol b/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol new file mode 100644 index 00000000..53944f3e --- /dev/null +++ b/mainnet-contracts/src/NonRestakingWithdrawalCredentials.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { IEigenPodTypes } from "./interface/Eigenlayer-Slashing/IEigenPod.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 Initializable, AccessManagedUpgradeable { + using Address for address payable; + + /** + * @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 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 fee query fails + */ + error FeeQueryFailed(); + + // https://eips.ethereum.org/EIPS/eip-7002 + address internal constant WITHDRAWAL_REQUEST_ADDRESS = 0x00000961Ef480Eb55e80D19ad83579A64c007002; + + /** + * keccak256(abi.encode(uint256(keccak256("NonRestakingWithdrawalCredentials.storage")) - 1)) & ~bytes32(uint256(0xff)) + */ + bytes32 private constant _NRWC_STORAGE = 0x75f3dc1703b3796fed3f2c6268997d3515c1e8991934a39283c37518525fd700; + + 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; + } + + /** + * @notice Allow contract to receive ETH from Beacon Chain withdrawals + */ + 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 { + NRWCStorage storage $ = _getNRWCStorage(); + if (msg.sender != $.permissionedModule) { + revert Unauthorized(); + } + payable($.permissionedModule).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 Get the fee for a withdrawal request + * @return The fee for a withdrawal request + */ + function getWithdrawalRequestFee() public view returns (uint256) { + (bool success, bytes memory result) = WITHDRAWAL_REQUEST_ADDRESS.staticcall(""); + if (!success || result.length != 32) { + revert FeeQueryFailed(); + } + 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/PermissionedModule.sol b/mainnet-contracts/src/PermissionedModule.sol new file mode 100644 index 00000000..8c988964 --- /dev/null +++ b/mainnet-contracts/src/PermissionedModule.sol @@ -0,0 +1,327 @@ +// 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 { 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 + * @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; + + 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; + + /** + * keccak256(abi.encode(uint256(keccak256("PermissionedModule.storage")) - 1)) & ~bytes32(uint256(0xff)) + */ + bytes32 private constant _PERMISSIONED_MODULE_STORAGE = + 0x7410446085c160ccc4c2b0e41801f8ac5004a5bf87d0402533c18d1e95927d00; + + constructor( + IPufferProtocol protocol, + address eigenPodManager, + IDelegationManager delegationManager, + PufferModuleManager moduleManager, + IRewardsCoordinator rewardsCoordinator + ) payable { + EIGEN_POD_MANAGER = IEigenPodManager(eigenPodManager); + EIGEN_DELEGATION_MANAGER = delegationManager; + PUFFER_PROTOCOL = protocol; + PUFFER_MODULE_MANAGER = moduleManager; + EIGEN_REWARDS_COORDINATOR = rewardsCoordinator; + _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 via beacon proxy for upgradeability + address nrwcBeacon = PUFFER_MODULE_MANAGER.NRWC_BEACON(); + $.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($.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, + uint256 amount + ) external payable onlyPufferProtocol { + PUFFER_PROTOCOL.BEACON_DEPOSIT_CONTRACT().deposit{ value: amount }( + 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 triggerNonRestakedValidatorWithdrawals(IEigenPodTypes.WithdrawalRequest[] calldata requests) + external + payable + virtual + onlyPufferModuleManager + { + PermissionedModuleStorage storage $ = _getPermissionedModuleStorage(); + $.nonRestakingWithdrawalCredentials.requestWithdrawal{ value: msg.value }(requests); + } + + /** + * @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 + } + } +} diff --git a/mainnet-contracts/src/PermissionedOracle.sol b/mainnet-contracts/src/PermissionedOracle.sol new file mode 100644 index 00000000..f16c7cc6 --- /dev/null +++ b/mainnet-contracts/src/PermissionedOracle.sol @@ -0,0 +1,74 @@ +// 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 { + 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/PufferModule.sol b/mainnet-contracts/src/PufferModule.sol index 7c2e57cb..a72d4cb8 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 payable virtual 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..59a36b8f 100644 --- a/mainnet-contracts/src/PufferModuleManager.sol +++ b/mainnet-contracts/src/PufferModuleManager.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.0 <0.9.0; import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; -import { Unauthorized, InvalidAmount } from "./Errors.sol"; -import { IPufferProtocol } from "./interface/IPufferProtocol.sol"; +import { Unauthorized, InvalidAmount, InvalidAddress, TransferFailed } from "./Errors.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"; @@ -16,9 +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 { PufferModule } from "./PufferModule.sol"; +import { IEigenPodTypes } from "../src/interface/Eigenlayer-Slashing/IEigenPod.sol"; /** * @title PufferModuleManager @@ -30,6 +29,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) { @@ -38,11 +39,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(); } @@ -239,6 +248,22 @@ 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 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 payable virtual restricted { + require(pubkeys.length > 0, InputArrayLengthZero()); + 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 @@ -311,4 +336,228 @@ contract PufferModuleManager is IPufferModuleManager, AccessManagedUpgradeable, } function _authorizeUpgrade(address newImplementation) internal virtual override restricted { } + + // ============ Permissioned Module Support ============ + + /** + * @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(); + } + + // 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( + PERMISSIONED_MODULE_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 Rewards from permissioned modules to a recipient + * @param permissionedModules The addresses of the permissioned modules + * @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 Permissioned ETH Manager + */ + function transferPermissionedModuleETH( + address[] calldata permissionedModules, + uint256[] calldata amounts, + address recipient + ) external virtual restricted { + if (recipient == address(0)) revert InvalidAddress(); + if (permissionedModules.length != amounts.length) revert InvalidAmount(); + + uint256 totalAmount; + for (uint256 i = 0; i < permissionedModules.length; ++i) { + (bool callSuccess,) = + PermissionedModule(payable(permissionedModules[i])).call(address(this), amounts[i], ""); + if (!callSuccess) { + revert TransferFailed(); + } + totalAmount += amounts[i]; + } + + (bool transferSuccess,) = recipient.call{ value: totalAmount }(""); + if (!transferSuccess) revert TransferFailed(); + + emit PermissionedModuleETHTransferred(permissionedModules, amounts, recipient, 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 d910547e..63fecc2b 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"; @@ -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(); } @@ -247,6 +256,71 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad }); } + /** + * @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( + bytes calldata blsPubKey, + bytes32 moduleName, + 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({ + node: msg.sender, + status: Status.PENDING, + isNonRestaked: isNonRestaked, + stakeAmountGwei: stakeAmountGwei, + module: address(module), + pubKey: blsPubKey + }); + + unchecked { + ++$.pendingPermissionedValidatorIndices[moduleName]; + } + + emit PermissionedValidatorKeyRegistered(blsPubKey, index, moduleName, isNonRestaked, stakeAmount); + } + /** * @inheritdoc IPufferProtocol * @dev Restricted to Puffer Paymaster @@ -288,6 +362,222 @@ 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 validatorSignature The validator's BLS signature + * @param expectedDepositDataRoot Expected deposit data root (for reorg protection) + * @dev Restricted to Puffer Paymaster. Provisions the next validator in FIFO order. + */ + function provisionPermissionedValidator( + bytes32 moduleName, + bytes calldata validatorSignature, + bytes32 expectedDepositDataRoot + ) external restricted { + // Verify deposit root matches (protects against reorgs) + if (expectedDepositDataRoot != BEACON_DEPOSIT_CONTRACT.get_deposit_root()) { + revert InvalidDepositRootHash(); + } + + ProtocolStorage storage $ = _getPufferProtocolStorage(); + + uint256 validatorIndex = $.nextPermissionedValidatorToBeProvisionedIndices[moduleName]; + + 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) + // 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) { + // Non-restaked: uses 0x02 credentials and variable amount (32-2048 ETH) + depositDataRoot = LibBeaconchainContract.getDepositDataRootWithAmount({ + pubKey: validator.pubKey, + signature: validatorSignature, + withdrawalCredentials: withdrawalCredentials, + amount: stakeAmount + }); + } else { + // Restaked: uses 0x01 credentials and fixed 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 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 + 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) { + revert InvalidValidatorState(validator.status); + } + + uint256 stakeAmount = uint256(validator.stakeAmountGwei) * 1 gwei; + bytes memory pubKey = validator.pubKey; + + // 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]; + + 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 + * @dev Restricted to Puffer Paymaster. + * Only PENDING validators can be skipped. + * Unlike external validators, no VT penalty since permissioned validators don't pay VT. + * Skips the next validator in FIFO order. + */ + function skipPermissionedProvisioning(bytes32 moduleName) external restricted { + ProtocolStorage storage $ = _getPufferProtocolStorage(); + + uint256 validatorIndex = $.nextPermissionedValidatorToBeProvisionedIndices[moduleName]; + + 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 + $.nextPermissionedValidatorToBeProvisionedIndices[moduleName] = validatorIndex + 1; + + emit PermissionedValidatorSkipped(pubKey, validatorIndex, moduleName); + } + + /** + * @inheritdoc IPufferProtocol + * @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 payable restricted { + 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 @@ -437,6 +727,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 */ @@ -553,6 +873,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/PufferVaultV5.sol b/mainnet-contracts/src/PufferVaultV5.sol index 3adfce95..d74cb8fe 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(); } @@ -108,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 @@ -127,8 +132,9 @@ contract PufferVaultV5 is callValue := callvalue() } return _ST_ETH.balanceOf(address(this)) + getPendingLidoETHAmount() + _WETH.balanceOf(address(this)) - + (address(this).balance - callValue) + PUFFER_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/interface/Eigenlayer-Slashing/IEigenPod.sol b/mainnet-contracts/src/interface/Eigenlayer-Slashing/IEigenPod.sol index b465f711..a6bd714e 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,6 +78,8 @@ 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 { @@ -78,14 +90,16 @@ interface IEigenPodTypes { } + /** + * @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,18 @@ 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; /// @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; @@ -243,16 +292,105 @@ 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` + /// pod owner will be able to call EigenPod methods. function setProofSubmitter(address newProofSubmitter) external; /** @@ -267,7 +405,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 @@ -282,10 +421,10 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents { /// @notice Returns the validatorInfo struct for the provided pubkey function validatorPubkeyToInfo(bytes calldata validatorPubkey) external view returns (ValidatorInfo memory); - /// @notice This returns the status of a given validator + /// @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 + /// @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 @@ -298,6 +437,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 @@ -335,4 +476,14 @@ interface IEigenPod is IEigenPodErrors, IEigenPodEvents { /// 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); + + /// @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/IPermissionedModule.sol b/mainnet-contracts/src/interface/IPermissionedModule.sol new file mode 100644 index 00000000..6a6a02d2 --- /dev/null +++ b/mainnet-contracts/src/interface/IPermissionedModule.sol @@ -0,0 +1,156 @@ +// 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 { IEigenPodTypes } from "./Eigenlayer-Slashing/IEigenPod.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 + * @param withdrawalCredentials The NRWC contract address + */ + 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 + * @param amount The stake amount in wei (32-2048 ETH for Pectra support) + */ + function callStakeNonRestaked( + bytes calldata pubKey, + bytes calldata signature, + bytes32 depositDataRoot, + uint256 amount + ) 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 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 + */ + 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); +} diff --git a/mainnet-contracts/src/interface/IPermissionedOracle.sol b/mainnet-contracts/src/interface/IPermissionedOracle.sol new file mode 100644 index 00000000..bf12f77c --- /dev/null +++ b/mainnet-contracts/src/interface/IPermissionedOracle.sol @@ -0,0 +1,81 @@ +// 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 Records ETH locked when a permissioned validator is provisioned + * @param moduleName The module name + * @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 Records ETH unlocked when a permissioned validator exits + * @param moduleName The module name + * @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 due to slashing or inactivity penalties + * @param moduleName The module name + * @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 fa5b754f..f9cbc525 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 @@ -14,6 +15,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" @@ -73,6 +79,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 @@ -103,4 +117,81 @@ interface IPufferModuleManager { * @dev Signature "0x4925eafc82d0c4d67889898eeed64b18488ab19811e61620f387026dec126a28" */ event ClaimerSet(address indexed rewardsReceiver, address indexed claimer); + + /** + * @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 + ); + + /** + * @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/src/interface/IPufferProtocol.sol b/mainnet-contracts/src/interface/IPufferProtocol.sol index f6a87a69..ea60848d 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" @@ -86,6 +92,27 @@ interface IPufferProtocol { */ error Failed(); + /** + * @notice Thrown when an invalid validator index is provided + */ + error InvalidValidatorIndex(); + + /** + * @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" @@ -177,6 +204,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 @@ -210,6 +295,16 @@ 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 * 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; +} 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; +} diff --git a/mainnet-contracts/src/struct/ProtocolStorage.sol b/mainnet-contracts/src/struct/ProtocolStorage.sol index c87d18e2..53f4dc27 100644 --- a/mainnet-contracts/src/struct/ProtocolStorage.sol +++ b/mainnet-contracts/src/struct/ProtocolStorage.sol @@ -1,9 +1,10 @@ // 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"; +import { PermissionedModule } from "../PermissionedModule.sol"; /** * @custom:storage-location erc7201:PufferProtocol.storage * @dev +-----------------------------------------------------------+ @@ -67,6 +68,27 @@ struct ProtocolStorage { * Slot 9 */ 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 f1bddf25..29879bc2 100644 --- a/mainnet-contracts/src/struct/Validator.sol +++ b/mainnet-contracts/src/struct/Validator.sol @@ -13,3 +13,15 @@ struct Validator { Status status; // Validator status bytes pubKey; // Validator public key } + +struct PermissionedValidator { + // Slot 1: node (20) + status (1) + isNonRestaked (1) + stakeAmountGwei (8) = 30 bytes + address node; // Address of the Node operator + Status status; // Validator status + 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 +} 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/PermissionedValidatorFork.t.sol b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol new file mode 100644 index 00000000..7697500c --- /dev/null +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorFork.t.sol @@ -0,0 +1,1019 @@ +// 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()) + ); + 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 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, + 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(), + address(permissionedModuleBeacon), + address(nrwcBeacon) + ); + 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"); + } + + 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[](3); + moduleManagerSelectors[0] = PufferModuleManager.triggerRestakedValidatorsExit.selector; + moduleManagerSelectors[1] = PufferModuleManager.triggerNonRestakedValidatorWithdrawals.selector; + moduleManagerSelectors[2] = PufferModuleManager.withdrawNonRestakedETH.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 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; + 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, 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, 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); + + // 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, 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: 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 { + // 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, 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, 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, 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, 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(); + } +} 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..af372988 --- /dev/null +++ b/mainnet-contracts/test/fork-tests/PermissionedValidatorSecurityPOC.t.sol @@ -0,0 +1,866 @@ +// 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, 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, 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, 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 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); + 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); + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 1); + + // Provision 1 + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Skip 2 + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 3); + + // Provision 3 + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + // Skip 4 + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 5); + + // Provision 5 + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 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); + } + 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, 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, 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, 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); + + 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 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, 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); + + assertEq(pufferProtocol.getNextPermissionedValidatorToBeProvisionedIndex(TEST_MODULE_NAME), 4); + + // Provision 4 + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(TEST_MODULE_NAME, 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); + + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(moduleA, TEST_SIGNATURE, depositRoot); + depositRoot = IBeaconDepositContract(_getBeaconDepositContract()).get_deposit_root(); + + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(moduleA); + + // Module B: provision all + for (uint256 i = 0; i < 3; i++) { + vm.prank(paymaster); + pufferProtocol.provisionPermissionedValidator(moduleB, 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, 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); + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); + + 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, 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, 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, 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); + vm.prank(paymaster); + pufferProtocol.skipPermissionedProvisioning(TEST_MODULE_NAME); + + // 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, 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, 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()) + ); + + 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()), + address(pufferModuleManager), + ValidatorTicket(_getValidatorTicket()), + IPufferOracleV2(_getPufferOracle()), + _getBeaconDepositContract(), + IPermissionedOracle(address(permissionedOracle)) + ); + + PufferModuleManager newModuleManagerImpl = new PufferModuleManager( + _getPufferModuleBeacon(), + _getRestakingOperatorBeacon(), + _getPufferProtocol(), + address(permissionedModuleBeacon), + address(nrwcBeacon) + ); + + 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"); + + vm.stopPrank(); + } + + 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(); + } +} diff --git a/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol b/mainnet-contracts/test/fork-tests/PufferModuleManager.integration.t.sol index 7fd56baf..24c6bec2 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 = 0xc1454A618E65ba3e1E2e1088b79ec5fB6b5433ac; + 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..3ffb0017 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 = 0xcabed454A76f1d6CB41241dDD8361312b949Fb20; + address EIGENPOD_0_HOODI = 0x8549C205aAA07Ec5A47DcC55Ea38299b31Fa726e; + 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/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/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol b/mainnet-contracts/test/fork-tests/ffi/PufferModuleManagerHoleskyFfi.t.sol index a02f0fbb..a5df99c1 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 = 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/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 { 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/EigenPodManagerMock.sol b/mainnet-contracts/test/mocks/EigenPodManagerMock.sol index 67312086..054c9ef7 100644 --- a/mainnet-contracts/test/mocks/EigenPodManagerMock.sol +++ b/mainnet-contracts/test/mocks/EigenPodManagerMock.sol @@ -6,9 +6,20 @@ 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/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/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/PermissionedModuleStandalone.t.sol b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol new file mode 100644 index 00000000..da97c0e3 --- /dev/null +++ b/mainnet-contracts/test/unit/PermissionedModuleStandalone.t.sol @@ -0,0 +1,435 @@ +// 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; + PufferModuleManager public pufferModuleManager; + address public owner; + + function setUp() public { + owner = makeAddr("owner"); + pufferProtocolAddr = makeAddr("pufferProtocol"); + address 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 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.encodeWithSignature("NRWC_BEACON()"), 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, + IRewardsCoordinator(rewardsCoordinatorMock) + ); + + // 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()), + address(pufferModuleManager), + "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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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(address(pufferModuleManager)); + 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; + } +} diff --git a/mainnet-contracts/test/unit/PufETH.t.sol b/mainnet-contracts/test/unit/PufETH.t.sol index 2c874221..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"; @@ -20,6 +21,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; @@ -114,13 +116,15 @@ contract PufETHTest is ERC4626Test { vm.stopPrank(); MockPufferOracle mockOracle = new MockPufferOracle(); + MockPermissionedOracle mockPermissionedOracle = new MockPermissionedOracle(); PufferRevenueDepositorMock revenueDepositor = new PufferRevenueDepositorMock(); PufferVaultV5 pufferVaultNonBlocking = new PufferVaultV5Tests({ stETH: stETH, lidoWithdrawalQueue: ILidoWithdrawalQueue(deployment.lidoWithdrawalQueueMock), weth: IWETH(deployment.weth), oracle: mockOracle, - revenueDepositor: revenueDepositor + revenueDepositor: revenueDepositor, + permissionedOracle: mockPermissionedOracle }); vm.startPrank(communityMultisig); diff --git a/mainnet-contracts/test/unit/PufferModuleManager.t.sol b/mainnet-contracts/test/unit/PufferModuleManager.t.sol index afd284a2..0f72ac02 100644 --- a/mainnet-contracts/test/unit/PufferModuleManager.t.sol +++ b/mainnet-contracts/test/unit/PufferModuleManager.t.sol @@ -5,12 +5,14 @@ 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"; 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"; @@ -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(); @@ -46,6 +52,7 @@ 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"); @@ -335,6 +342,105 @@ contract PufferModuleManagerTest is UnitTestHelper { vm.stopPrank(); } + function test_triggerValidatorsExitExactFee1() 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_triggerValidatorsExitExactFee2() 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_triggerValidatorsExitExcessFee() 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_triggerValidatorsExitExcessFee2() 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_triggerValidatorsExitNoFee() 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_triggerValidatorsExitUnauthorized() 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_triggerValidatorsExitInputArrayLengthZero() 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); diff --git a/mainnet-contracts/test/unit/PufferProtocol.t.sol b/mainnet-contracts/test/unit/PufferProtocol.t.sol index bff105a3..9747c71c 100644 --- a/mainnet-contracts/test/unit/PufferProtocol.t.sol +++ b/mainnet-contracts/test/unit/PufferProtocol.t.sol @@ -5,12 +5,19 @@ 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, + ROLE_ID_VALIDATOR_EJECTOR +} 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 +37,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; @@ -64,7 +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_VALIDATOR_EJECTOR, address(pufferProtocol), 0); vm.stopPrank(); _skipDefaultFuzzAddresses(); @@ -1854,6 +1862,126 @@ 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 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) { (bytes32 moduleName, uint256 pendingIdx) = pufferProtocol.getNextValidatorToProvision(); Validator memory validator = pufferProtocol.getValidatorInfo(moduleName, pendingIdx); diff --git a/mainnet-contracts/test/unit/PufferVault.t.sol b/mainnet-contracts/test/unit/PufferVault.t.sol index 7f9ea095..5470fae2 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, permissionedOracle + ); UUPSUpgradeable(address(pufferVault)).upgradeToAndCall(address(newImplementation), ""); vm.stopPrank(); 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); diff --git a/mainnet-contracts/test/unit/xPufETHTest.t.sol b/mainnet-contracts/test/unit/xPufETHTest.t.sol index 02a62513..78e42f17 100644 --- a/mainnet-contracts/test/unit/xPufETHTest.t.sol +++ b/mainnet-contracts/test/unit/xPufETHTest.t.sol @@ -17,9 +17,11 @@ 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"; +import { IPermissionedOracle } from "src/interface/IPermissionedOracle.sol"; contract xPufETHTest is Test { PufferDepositor public pufferDepositor; @@ -148,13 +150,15 @@ contract xPufETHTest is Test { vm.stopPrank(); MockPufferOracle mockOracle = new MockPufferOracle(); + MockPermissionedOracle mockPermissionedOracle = new MockPermissionedOracle(); PufferRevenueDepositorMock revenueDepositor = new PufferRevenueDepositorMock(); PufferVaultV5 pufferVaultNonBlocking = new PufferVaultV5Tests({ stETH: stETH, lidoWithdrawalQueue: ILidoWithdrawalQueue(deployment.lidoWithdrawalQueueMock), weth: IWETH(deployment.weth), oracle: mockOracle, - revenueDepositor: revenueDepositor + revenueDepositor: revenueDepositor, + permissionedOracle: mockPermissionedOracle }); vm.startPrank(communityMultisig);