The StakingFacet.sol contract has a bug in the _processCooldownLogic function where unstaking more funds from the same validator reuses the cooldown timer for all previous unstaked funds. This keeps users who unstake in repeated transactions locked into their previously unstaked funds longer than expected, effectively extending the withdrawal time interval by as much as the total cooldown period with each additional unstake action.
Vulnerability Details
The issue lies in the _processCooldownLogic function responsible for unstaking by adding funds into a cooldown before they can be withdrawn. Each user-validator pair has a single cooldown entry (userValidatorCooldowns[user][validatorId]). When unstaking, a new end timestamp is computed as block.timestamp + cooldownInterval. If an existing cooldown is present and has already matured (block.timestamp >= currentCooldownEndTimeInSlot), the matured amount is moved to parked and a fresh cooldown is started for the current unstake amount. Otherwise (unmatured cooldown), the code sums the new amount with the existing cooled amount and reschedules the timestamp for the total accumulated amount to the new end time — effectively resetting the cooldown for all previously cooled amounts.
The relevant code excerpt:
if (currentCooledAmountInSlot > 0 && block.timestamp >= currentCooldownEndTimeInSlot) {
// Previous cooldown for this slot has matured - move to parked and start new cooldown
_updateParkedAmounts(user, currentCooledAmountInSlot);
_removeCoolingAmounts(user, validatorId, currentCooledAmountInSlot);
_updateCoolingAmounts(user, validatorId, amount);
finalNewCooledAmountForSlot = amount;
} else {
// No matured cooldown - add to existing cooldown
_updateCoolingAmounts(user, validatorId, amount);
finalNewCooledAmountForSlot = currentCooledAmountInSlot + amount;
}
cooldownEntrySlot.amount = finalNewCooledAmountForSlot;
cooldownEntrySlot.cooldownEndTime = newCooldownEndTime;
return newCooldownEndTime;
User unstakes 1 ETH at timestamp T; cooldown completes at T + 7 days.
3 days later (T + 3 days), the user unstakes another 1 ETH.
The total amount becomes 2 ETH but the end timestamp is updated to (T + 3) + 7 = T + 10 days.
The first 1 ETH is now locked for 10 days instead of 7.
Suggested fix: when adding to an unmatured cooldown, use the maximum of the existing cooldownEndTime and the newly computed newCooldownEndTime, e.g.:
Impact Details
This vulnerability can temporarily freeze user funds for at least 24 hours and can extend that lock further if the user performs additional unstake actions — effectively adding more days to the withdrawal delay for previously cooled funds.
Proof of Concept
For testing the behavior, Hardhat was used with a simplified contract reproducing the cooldown logic and a test demonstrating the timestamp reset and extension.
Contract added to contracts (StakingFacetSimplified):
Test added to test (Hardhat):
Test run output:
1
Test step
Stake 2 ETH to the validator.
2
Test step
Unstake 1 ETH -> cooldown set for ~T + 7 days.
3
Test step
Advance time by 3 days (cooldown not matured).
4
Test step
Unstake another 1 ETH -> cooled amounts combine and cooldown end time is reset to ~now + 7 days, thus extending the initial batch's lock.
Notes / Recommendation
The fix is to avoid decreasing the cooldown end time when adding to an existing unmatured cooldown. Use the max between the existing cooldown end time and the newly calculated one (i.e., do not shorten the existing end time).
Keep the logic that moves matured cooled amounts to parked and starts a fresh cooldown, but when accumulating into an unmatured slot, preserve the furthest cooldown end timestamp.
(Links and code references are preserved exactly as provided.)
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");
describe("StakingFacetSimplified - Cooldown Timestamp Reset Vulnerability", function () {
let stakingFacet, deployer;
const validatorId = 1;
const cooldownInterval = 604800; // 7 days in seconds
const minStakeAmount = ethers.parseEther("0.1"); // 0.1 ETH
beforeEach(async function () {
[deployer] = await ethers.getSigners();
const StakingFacetSimplified = await ethers.getContractFactory("StakingFacetSimplified");
stakingFacet = await StakingFacetSimplified.deploy();
await stakingFacet.initialize(cooldownInterval, minStakeAmount);
});
it("should reset cooldown timestamp on additional unstake, extending the lock", async function () {
// Step 1: Stake 2 ETH
await stakingFacet.stake(validatorId, { value: ethers.parseEther("2") });
// Get current block timestamp before first unstake
const startTime = await time.latest();
console.log(`Start timestamp: ${startTime}`);
// Step 2: Unstake 1 ETH (first unstake)
await stakingFacet.unstake(validatorId, ethers.parseEther("1"));
let [amount1, endTime1] = await stakingFacet.getCooldown(deployer.address, validatorId);
expect(amount1).to.equal(ethers.parseEther("1"));
const initialEndTime = endTime1;
console.log(`After first unstake: Amount = ${ethers.formatEther(amount1)} ETH, Cooldown End = ${initialEndTime} (expected ~${startTime + cooldownInterval})`);
// Step 3: Advance time by 3 days (unmatured)
const threeDays = 3 * 24 * 60 * 60; // 259200 seconds
await time.increase(threeDays);
const midTime = await time.latest();
console.log(`Time advanced by 3 days: Current timestamp = ${midTime}`);
// Step 4: Unstake another 1 ETH
await stakingFacet.unstake(validatorId, ethers.parseEther("1"));
let [amount2, endTime2] = await stakingFacet.getCooldown(deployer.address, validatorId);
expect(amount2).to.equal(ethers.parseEther("2")); // Combined amount
expect(endTime2).to.be.gt(initialEndTime); // New end time is later (reset)
console.log(`After second unstake: Amount = ${ethers.formatEther(amount2)} ETH, New Cooldown End = ${endTime2} (expected ~${midTime + cooldownInterval})`);
// Log the extension details
const extensionSeconds = Number(endTime2) - Number(initialEndTime);
console.log(`Extension due to reset: ${extensionSeconds} seconds (~${Math.floor(extensionSeconds / 86400)} days)`);
// Original end was ~7 days from start, now total for first batch is ~10 days
const expectedNewEnd = midTime + cooldownInterval;
expect(Number(endTime2)).to.be.closeTo(expectedNewEnd, 2); // Convert BigInt to number for comparison; allow minor block time variance
});
});
StakingFacetSimplified - Cooldown Timestamp Reset Vulnerability
Start timestamp: 1753296350
After first unstake: Amount = 1.0 ETH, Cooldown End = 1753901151 (expected ~1753901150)
Time advanced by 3 days: Current timestamp = 1753555551
After second unstake: Amount = 2.0 ETH, New Cooldown End = 1754160352 (expected ~1754160351)
Extension due to reset: 259201 seconds (~3 days)
✔ should reset cooldown timestamp on additional unstake, extending the lock
1 passing (375ms)