50343 sc low cooldown reset vulnerability
Submitted on Jul 23rd 2025 at 20:21:31 UTC by @ciphermalware for Attackathon | Plume Network
Report ID: #50343
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/StakingFacet.sol
Impacts:
Temporary freezing of funds for at least 24 hours
Description
Brief/Intro
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;Source: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/StakingFacet.sol?utm_source=immunefi#L832-L847
Example:
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.:
newCooldownEndTime = block.timestamp + $.cooldownInterval;
if (currentCooledAmountInSlot > 0 && currentCooldownEndTimeInSlot > newCooldownEndTime) {
newCooldownEndTime = currentCooldownEndTimeInSlot;
}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):
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
contract StakingFacetSimplified is ReentrancyGuardUpgradeable {
// Mocked Storage Layout
struct Layout {
uint256 cooldownInterval; // e.g., 7 days in seconds
uint256 minStakeAmount;
mapping(address => StakeInfo) stakeInfo;
mapping(address => mapping(uint16 => UserValidatorStake)) userValidatorStakes;
mapping(address => mapping(uint16 => CooldownEntry)) userValidatorCooldowns;
mapping(uint16 => Validator) validators;
mapping(uint16 => bool) validatorExists;
uint256 totalStaked;
uint256 totalCooling;
uint256 totalWithdrawable;
}
Layout internal $;
struct StakeInfo {
uint256 staked;
uint256 cooled;
uint256 parked;
}
struct UserValidatorStake {
uint256 staked;
}
struct CooldownEntry {
uint256 amount;
uint256 cooldownEndTime;
}
struct Validator {
uint256 delegatedAmount;
bool active;
bool slashed;
uint256 slashedAtTimestamp;
}
// Custom Errors (simplified)
error InvalidAmount(uint256 amount);
error InsufficientFunds(uint256 available, uint256 requested);
error ValidatorDoesNotExist(uint16 validatorId);
error ValidatorInactive(uint16 validatorId);
// Events (simplified)
event CooldownStarted(address user, uint16 validatorId, uint256 amount, uint256 cooldownEndTimestamp);
event Staked(address user, uint16 validatorId, uint256 amount);
event Unstaked(address user, uint16 validatorId, uint256 amount);
// Constructor/Initializer
function initialize(uint256 _cooldownInterval, uint256 _minStakeAmount) external initializer {
__ReentrancyGuard_init();
$.cooldownInterval = _cooldownInterval;
$.minStakeAmount = _minStakeAmount;
// Mock a validator for testing
uint16 validatorId = 1;
$.validatorExists[validatorId] = true;
$.validators[validatorId].active = true;
}
// Simplified stake function
function stake(uint16 validatorId) external payable {
uint256 amount = msg.value;
require(amount >= $.minStakeAmount, "Stake too small");
_validateValidatorForStaking(validatorId);
$.userValidatorStakes[msg.sender][validatorId].staked += amount;
$.stakeInfo[msg.sender].staked += amount;
$.validators[validatorId].delegatedAmount += amount;
$.totalStaked += amount;
emit Staked(msg.sender, validatorId, amount);
}
// Unstake function
function unstake(uint16 validatorId, uint256 amount) external returns (uint256) {
if (amount == 0) revert InvalidAmount(0);
UserValidatorStake storage userStake = $.userValidatorStakes[msg.sender][validatorId];
if (userStake.staked < amount) revert InsufficientFunds(userStake.staked, amount);
_validateValidatorForUnstaking(validatorId);
// Update stake amounts
userStake.staked -= amount;
$.stakeInfo[msg.sender].staked -= amount;
$.validators[validatorId].delegatedAmount -= amount;
$.totalStaked -= amount;
// Process cooldown
uint256 newCooldownEndTimestamp = _processCooldownLogic(msg.sender, validatorId, amount);
emit CooldownStarted(msg.sender, validatorId, amount, newCooldownEndTimestamp);
emit Unstaked(msg.sender, validatorId, amount);
return amount;
}
// The core cooldown logic (as in original)
function _processCooldownLogic(address user, uint16 validatorId, uint256 amount) internal returns (uint256 newCooldownEndTime) {
CooldownEntry storage cooldownEntrySlot = $.userValidatorCooldowns[user][validatorId];
uint256 currentCooledAmountInSlot = cooldownEntrySlot.amount;
uint256 currentCooldownEndTimeInSlot = cooldownEntrySlot.cooldownEndTime;
uint256 finalNewCooledAmountForSlot;
newCooldownEndTime = block.timestamp + $.cooldownInterval;
if (currentCooledAmountInSlot > 0 && block.timestamp >= currentCooldownEndTimeInSlot) {
// Matured, move to parked (simplified)
$.stakeInfo[user].parked += currentCooledAmountInSlot;
$.totalWithdrawable += currentCooledAmountInSlot;
$.stakeInfo[user].cooled -= currentCooledAmountInSlot;
$.totalCooling -= currentCooledAmountInSlot;
// Start new cooldown for current amount
$.stakeInfo[user].cooled += amount;
$.totalCooling += amount;
finalNewCooledAmountForSlot = amount;
} else {
// Unmatured. Add to existing and reset timestamp
$.stakeInfo[user].cooled += amount;
$.totalCooling += amount;
finalNewCooledAmountForSlot = currentCooledAmountInSlot + amount;
}
cooldownEntrySlot.amount = finalNewCooledAmountForSlot;
cooldownEntrySlot.cooldownEndTime = newCooldownEndTime;
return newCooldownEndTime;
}
// View function to check cooldown for a user validator
function getCooldown(address user, uint16 validatorId) external view returns (uint256 amount, uint256 endTime) {
CooldownEntry storage entry = $.userValidatorCooldowns[user][validatorId];
return (entry.amount, entry.cooldownEndTime);
}
// Mock validations
function _validateValidatorForStaking(uint16 validatorId) internal view {
if (!$.validatorExists[validatorId]) revert ValidatorDoesNotExist(validatorId);
if (!$.validators[validatorId].active) revert ValidatorInactive(validatorId);
}
function _validateValidatorForUnstaking(uint16 validatorId) internal view {
if (!$.validatorExists[validatorId]) revert ValidatorDoesNotExist(validatorId);
// Simplified: No slashed check for this test
}
}Test added to test (Hardhat):
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
});
});Test run output:
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)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.)
Was this helpful?