52865 sc high inconsistency in how stake cooldown is handled due to off by one error
Submitted on Aug 13th 2025 at 19:12:10 UTC by @silver_eth for Attackathon | Plume Network
- Report ID: #52865
- Report Type: Smart Contract
- Report severity: High
- Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/StakingFacet.sol
Description
Brief/Intro
Inconsistency in how the cooldown / slash logic is handled causes a stake to be simultaneously withdrawable and slashable in the same block due to an off-by-one style comparison mismatch.
Vulnerability Details
The staking logic treats a stake as withdrawable when:
block.timestamp >= cooldownEndTime
However, the cooldown is considered fully processed only if:
cooldownEndTime < slashTimestamp
This creates a conflict when:
cooldownEndTime == slashTimestamp
In that case:
- Because
block.timestamp == cooldownEndTime, the cooldown is considered fulfilled and the stake can be withdrawn. - But since
cooldownEndTime !< slashTimestamp, the slash logic still considers the stake slashable.
Thus a stake can be concurrently withdrawable and slashable within the same block.
Impact Details
Two possible undesirable outcomes exist depending on how transactions are ordered within the same block:
- Premature withdrawals / protocol loss: If the intended invariant is
cooldownEnd < slashTimestamp, a user can withdraw a stake that is still slashable, effectively removing tokens that the protocol intended to keep available for slashing (protocol loss/theft). - Permanent lock-up: If the intended invariant is
block.timestamp >= cooldownEndTimefor withdrawal, a user’s stake can become permanently locked because the slash logic still treats it as slashable and the user cannot withdraw if the slash occurs in the same block.
These outcomes can lead to permanent freezing of funds or direct theft of user funds (other than unclaimed yield).
Proof of Concept
Setup / Conditions
- Two users (User A and User B) unstake in block A. Both receive the same
cooldownEndTime. - At block B (
B == cooldownEndTime), the validator is slashed.
Transaction ordering in block B
- If User A’s withdrawal transaction is processed before the slash transaction:
- User A successfully withdraws their stake (because
block.timestamp >= cooldownEndTime). - The subsequent slash cannot affect User A’s withdrawn tokens, resulting in protocol loss / theft.
- User A successfully withdraws their stake (because
- If User B’s withdrawal transaction is processed after the slash transaction:
- The slash marks the stake as slashable and the withdrawal may be prevented, potentially leaving User B’s stake locked or lost.
Outcome
Both users had identical cooldownEndTime, yet only one could successfully withdraw depending on intra-block ordering. This results in either:
- A user withdrawing tokens that were still slashable (stealing from the protocol), or
- A user being unable to withdraw despite their cooldown being reached (permanent lock-up).
References
- Source code reference: https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/facets/StakingFacet.sol#L917
Notes / Suggested focus for remediation
Align the comparison semantics for "withdrawable" and "cooldown fully processed" so the same boundary (<= or <) is used consistently. Typical fixes include:
- Making both checks inclusive (e.g., use
<=consistently where appropriate), or- Adjusting the slash timestamp logic so it differs by at least one second from
cooldownEndTimein a deterministic manner. Carefully consider expected invariants and document the intended ordering to avoid intra-block ambiguity.