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)
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.)

Was this helpful?