From 22faa54dbaf99923da8471e1c30b87d2a9b0c1da Mon Sep 17 00:00:00 2001 From: Eladio Date: Fri, 8 May 2026 12:22:19 +0200 Subject: [PATCH 1/5] Implemented failsafe in case burn amount is greater than bond --- mainnet-contracts/src/PufferProtocol.sol | 4 +++ .../test/unit/PufferProtocol.t.sol | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index d910547e..3ce36cc5 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -323,6 +323,10 @@ 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 }); + if (burnAmount > bondAmount) { // Failsafe in extreme rare case validator balance falls under (32-bondAmount) ETH + burnAmount = bondAmount; // residual loss socialized to pufETH holders + } + uint256 vtBurnAmount = _getVTBurnAmount($, bondWithdrawals[i].node, validatorInfos[i]); // Update the burnAmounts diff --git a/mainnet-contracts/test/unit/PufferProtocol.t.sol b/mainnet-contracts/test/unit/PufferProtocol.t.sol index bff105a3..cd6fed47 100644 --- a/mainnet-contracts/test/unit/PufferProtocol.t.sol +++ b/mainnet-contracts/test/unit/PufferProtocol.t.sol @@ -17,6 +17,8 @@ import { Permit } from "../../src/structs/Permit.sol"; import { ModuleLimit } from "../../src/struct/ProtocolStorage.sol"; import { StoppedValidatorInfo } from "../../src/struct/StoppedValidatorInfo.sol"; +import "forge-std/console.sol"; + contract PufferProtocolTest is UnitTestHelper { using ECDSA for bytes32; @@ -1250,6 +1252,31 @@ 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 From e7709732b7d5c1ccccb4e671d96173a07455c6e4 Mon Sep 17 00:00:00 2001 From: eladiosch <3090613+eladiosch@users.noreply.github.com> Date: Fri, 8 May 2026 10:35:01 +0000 Subject: [PATCH 2/5] forge fmt --- mainnet-contracts/src/PufferProtocol.sol | 5 +++-- mainnet-contracts/test/unit/PufferProtocol.t.sol | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index 3ce36cc5..3bd3ffba 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -323,8 +323,9 @@ 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 }); - if (burnAmount > bondAmount) { // Failsafe in extreme rare case validator balance falls under (32-bondAmount) ETH - burnAmount = bondAmount; // residual loss socialized to pufETH holders + if (burnAmount > bondAmount) { + // Failsafe in extreme rare case validator balance falls under (32-bondAmount) ETH + burnAmount = bondAmount; // residual loss socialized to pufETH holders } uint256 vtBurnAmount = _getVTBurnAmount($, bondWithdrawals[i].node, validatorInfos[i]); diff --git a/mainnet-contracts/test/unit/PufferProtocol.t.sol b/mainnet-contracts/test/unit/PufferProtocol.t.sol index cd6fed47..14ee507e 100644 --- a/mainnet-contracts/test/unit/PufferProtocol.t.sol +++ b/mainnet-contracts/test/unit/PufferProtocol.t.sol @@ -1272,7 +1272,11 @@ contract PufferProtocolTest is UnitTestHelper { vm.expectEmit(true, true, true, true); emit IPufferProtocol.ValidatorExited( - _getPubKey(bytes32("alice")), 0, PUFFER_MODULE_0, burnedAmount, _getVTBurnAmount(100, _getEpochNumber(28 days, 100)) + _getPubKey(bytes32("alice")), + 0, + PUFFER_MODULE_0, + burnedAmount, + _getVTBurnAmount(100, _getEpochNumber(28 days, 100)) ); pufferProtocol.batchHandleWithdrawals(stopInfos, _getHandleBatchWithdrawalMessage(stopInfos)); } From a0ede35b737acbe2a736fb85c602a638d01f2062 Mon Sep 17 00:00:00 2001 From: Eladio Date: Fri, 8 May 2026 13:45:52 +0200 Subject: [PATCH 3/5] Removed unused import and improved comments --- mainnet-contracts/src/PufferProtocol.sol | 3 ++- mainnet-contracts/test/unit/PufferProtocol.t.sol | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/mainnet-contracts/src/PufferProtocol.sol b/mainnet-contracts/src/PufferProtocol.sol index 3bd3ffba..75b53ff9 100644 --- a/mainnet-contracts/src/PufferProtocol.sol +++ b/mainnet-contracts/src/PufferProtocol.sol @@ -323,8 +323,8 @@ 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) { - // Failsafe in extreme rare case validator balance falls under (32-bondAmount) ETH burnAmount = bondAmount; // residual loss socialized to pufETH holders } @@ -335,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 14ee507e..21b032d3 100644 --- a/mainnet-contracts/test/unit/PufferProtocol.t.sol +++ b/mainnet-contracts/test/unit/PufferProtocol.t.sol @@ -17,8 +17,6 @@ import { Permit } from "../../src/structs/Permit.sol"; import { ModuleLimit } from "../../src/struct/ProtocolStorage.sol"; import { StoppedValidatorInfo } from "../../src/struct/StoppedValidatorInfo.sol"; -import "forge-std/console.sol"; - contract PufferProtocolTest is UnitTestHelper { using ECDSA for bytes32; From c4170bee0545a35746b3064f97ba513dcb6832de Mon Sep 17 00:00:00 2001 From: Eladio Date: Mon, 11 May 2026 12:43:35 +0200 Subject: [PATCH 4/5] Fixed problem with timelock test --- mainnet-contracts/test/unit/Timelock.t.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mainnet-contracts/test/unit/Timelock.t.sol b/mainnet-contracts/test/unit/Timelock.t.sol index ce6fc788..e5a521aa 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); @@ -308,4 +312,4 @@ contract TimelockTest is Test { } } } -} +} \ No newline at end of file From 0dd57e969fa95ecd5f9e9f778a1b922bb7130075 Mon Sep 17 00:00:00 2001 From: eladiosch <3090613+eladiosch@users.noreply.github.com> Date: Mon, 11 May 2026 10:46:56 +0000 Subject: [PATCH 5/5] forge fmt --- mainnet-contracts/test/unit/Timelock.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mainnet-contracts/test/unit/Timelock.t.sol b/mainnet-contracts/test/unit/Timelock.t.sol index e5a521aa..a7bab6c7 100644 --- a/mainnet-contracts/test/unit/Timelock.t.sol +++ b/mainnet-contracts/test/unit/Timelock.t.sol @@ -312,4 +312,4 @@ contract TimelockTest is Test { } } } -} \ No newline at end of file +}