diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index d910547e..75b53ff9 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -323,6 +323,11 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad // Get the burnAmount for the withdrawal at the current exchange rate uint256 burnAmount = _getBondBurnAmount({ validatorInfo: validatorInfos[i], validatorBondAmount: bondAmount }); + // Failsafe in extreme rare case validator balance falls under (32-bondAmount) ETH + if (burnAmount > bondAmount) { + burnAmount = bondAmount; // residual loss socialized to pufETH holders + } + uint256 vtBurnAmount = _getVTBurnAmount($, bondWithdrawals[i].node, validatorInfos[i]); // Update the burnAmounts @@ -330,6 +335,7 @@ contract PufferProtocol is IPufferProtocol, AccessManagedUpgradeable, UUPSUpgrad burnAmounts.vt += vtBurnAmount; // Store the withdrawal amount for that node operator + // Underflow is not possible because of the checks in the `_getBondBurnAmount` function and the fact that we are capping the burn amount to the bond amount // nosemgrep basic-arithmetic-underflow bondWithdrawals[i].pufETHAmount = (bondAmount - burnAmount); diff --git a/mainnet-contracts/test/unit/PufferProtocol.t.sol b/mainnet-contracts/test/unit/PufferProtocol.t.sol index bff105a3..21b032d3 100644 --- a/mainnet-contracts/test/unit/PufferProtocol.t.sol +++ b/mainnet-contracts/test/unit/PufferProtocol.t.sol @@ -1250,6 +1250,35 @@ contract PufferProtocolTest is UnitTestHelper { assertApproxEqAbs(_getUnderlyingETHAmount(address(bob)), 1 ether, 1, "bob got back the bond"); } + function test_batch_claim_underflow() public { + _registerAndProvisionNode(bytes32("alice"), PUFFER_MODULE_0, alice); + + StoppedValidatorInfo memory aliceInfo = StoppedValidatorInfo({ + module: NoRestakingModule, + moduleName: PUFFER_MODULE_0, + pufferModuleIndex: 0, + withdrawalAmount: 29 ether, // Withdrawal amount is less than 32 ETH - bond, but still shouldn't revert due to fix + startEpoch: 100, + endEpoch: _getEpochNumber(28 days, 100), + wasSlashed: false + }); + + StoppedValidatorInfo[] memory stopInfos = new StoppedValidatorInfo[](1); + stopInfos[0] = aliceInfo; + + uint256 burnedAmount = pufferVault.convertToShares(1 ether); // 1 ETH to pufETH. This is the bond, the max that can be burned + + vm.expectEmit(true, true, true, true); + emit IPufferProtocol.ValidatorExited( + _getPubKey(bytes32("alice")), + 0, + PUFFER_MODULE_0, + burnedAmount, + _getVTBurnAmount(100, _getEpochNumber(28 days, 100)) + ); + pufferProtocol.batchHandleWithdrawals(stopInfos, _getHandleBatchWithdrawalMessage(stopInfos)); + } + // Batch claim of different amounts function test_different_amounts_batch_claim() public { // Buy and approve VT 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);