52312 sc low cooldown coalescing bug unintended cooldown extension for prior unstakes
Submitted on Aug 9th 2025 at 19:03:25 UTC by @Ambitious_DyDx for Attackathon | Plume Network
Report ID: #52312
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
In StakingFacet.sol, userValidatorCooldowns[user][validatorId] usually stores one {amount, cooldownEndTime} slot. When a user unstakes again for the same validator before that slot matures, _processCooldownLogic (internal function) adds the new amount and overwrites cooldownEndTime = block.timestamp + cooldownInterval. This causes earlier-unstaked funds to be re-locked until the new (later) timestamp — multiple unstakes are not independently timed.
Vulnerability Details
Relevant lines in _processCooldownLogic (lines ~818 - 848) show:
finalNewCooledAmountForSlot = currentCooledAmountInSlot + amount;
cooldownEntrySlot.amount = finalNewCooledAmountForSlot;
cooldownEntrySlot.cooldownEndTime = newCooldownEndTime; // <-- overwrites prior end_processCooldownLogiccoalesces unstakes into a single slot and always sets a freshcooldownEndTimewhen adding to an unmatured slot.As a result,
_processMaturedCooldownsand_calculateTotalWithdrawableAmountuse that single timestamp for maturity checks. Overwriting it hides matured portions until the new end, effectively extending their lock.
Impact Details
Previously-unstaked funds can be re-locked for up to
cooldownIntervalwhen a later unstake occurs.High user-impact (funds inaccessible for days), even if not stolen — opportunity cost/trust damage.
Any user doing repeated unstakes to the same validator before the prior cooldown matures won't be able to withdraw prior unstake amounts on time.
References
https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/StakingFacet.sol?utm_source=immunefi
Proof of Concept
Expected vs actual withdrawability
At t = t0 + cooldownInterval + ε, calling
withdraw()will not include the original 100 — it remains locked untilt1 + cooldownInterval.Observables:
getUserCooldowns(user)returns a single entry with aggregated amount and the later end.amountWithdrawable(user)att0 + interval + εdoes not include the original 100.
Recommendation
Change storage to allow multiple cooldown entries per (user, validator), and push a new entry on each unstake, so each unstake has its own expiry:
struct CooldownEntry {
uint256 amount;
uint256 cooldownEndTime;
}
mapping(address => mapping(uint16 => CooldownEntry[])) userCooldownQueues;
// In _processCooldownLogic():
userCooldownQueues[user][validatorId].push(CooldownEntry({
amount: amount,
cooldownEndTime: block.timestamp + cooldownInterval
}));This preserves independent timing for each unstake (no overwriting of earlier cooldownEndTime).
Was this helpful?