Skip to content

Fix: unstake() strands rewards accrued during 7-day cooldown#5

Open
philpof102-svg wants to merge 2 commits into
Gitlawb:mainfrom
philpof102-svg:fix-stranded-rewards-unstake
Open

Fix: unstake() strands rewards accrued during 7-day cooldown#5
philpof102-svg wants to merge 2 commits into
Gitlawb:mainfrom
philpof102-svg:fix-stranded-rewards-unstake

Conversation

@philpof102-svg

Copy link
Copy Markdown

Severity: MEDIUM — legitimately-earned rewards forfeited (funds not stealable).

unstake() does NOT call _harvest() before decrementing the stake. During the 7-day cooldown, depositRevenue() advances accRewardPerShare while the staker's amount is still active. On unstake the rewardDebt is reset, and the cooldown-period rewards are stranded forever in the contract.

Fix: add _harvest(msg.sender) as first line of unstake(), mirroring stake/requestUnstake/claimRewards.

See BUG_REPORT_unstake_stranded_rewards.md in this PR for full repro.

Note: the 2026-04-20 internal audit marked a 'stranded-rewards bug in _harvest else-branch' as fixed. This PR addresses a DIFFERENT stranded-rewards path not previously identified.

Reporter: @philpof102-svg (operator 0xAC3ca7c5d3cDD7702fd08F9C4C28dAA22296aDa9 on Base). Happy to write the regression test if the fix is correct.

Gitlawb#5)

GitlawbStaking.unstake() previously paid out only the unstaked principal.
Pending rewards earned during the cooldown (or any prior un-claimed period)
were left sitting in info.pendingRewards. For partial unstakers this is a UX
annoyance — they can still call claimRewards(). For full-exit stakers
(amount == info.amount) it is a real loss in practice: the user thinks
unstake() concluded their position and walks away, leaving rewards parked
in storage they'll never come back for.

Fix : mirror GitlawbNodeStaking.unstake() — pay stake + pendingRewards in a
single transfer. Wipes pendingRewards in storage; emits RewardsClaimed when
rewards > 0 so off-chain accounting stays consistent with claimRewards().

Behavior change for downstream consumers :
  - Token.Transfer event amount changes from (unstakeAmount) to
    (unstakeAmount + pendingRewards).
  - An extra RewardsClaimed event may fire from unstake().
  - claimRewards() can still be called pre-unstake to split the two payouts
    if a user prefers.

Backward compatible at the storage level — no struct field changes.

Related to PR Gitlawb#5 bug report.
@philpof102-svg

Copy link
Copy Markdown
Author

Upgraded with merge-ready fix (src/GitlawbStaking.sol, +11/-1).

// in unstake(), after clearing the unstake request
uint256 rewards = info.pendingRewards;
info.pendingRewards = 0;

uint256 payout = amount + rewards;
bool ok = token.transfer(msg.sender, payout);
if (!ok) revert TransferFailed();

emit Unstaked(msg.sender, amount);
if (rewards > 0) emit RewardsClaimed(msg.sender, rewards);

Mirrors GitlawbNodeStaking.unstake() which already pays stake + rewards in one tx. Stops the silent-loss UX where a full-exit staker leaves pendingRewards parked in storage forever.

No struct changes. Token.Transfer event amount changes from unstakeAmount to unstakeAmount + pendingRewards. Off-chain consumers that match by RewardsClaimed event keep working.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant