FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: low
Likelihood: low

L-02 — Bonus is forfeited to the sponsor's `recoveryAddress` when a pool resolves with no stakers

Author Revealed upon completion

Root + Impact

Description

contributeBonus is permissionless and there is no refund path. The bonus is only ever paid to stakers via the k=2 split or swept to recoveryAddress. If a pool draws no stakers and simply expires (organic lack of interest), the entire bonus routes to the sponsor-controlled recoveryAddress, never back to an independent contributor.

Concretely, with riskWindowStart == 0 (no risk ever observed) the sweepUnclaimedBonus reserve logic reserves nothing for the bonus (totalEligibleStake == 0, and the riskWindowStart != 0 branch is skipped), so 100% of the bonus is sweepable to recoveryAddress. Because the sponsor both seeds the bonus and controls recoveryAddress, the sponsor is made whole while an unrelated contributor is not.

Risk

Likelihood: Low — pools with a bonus but no staking interest are plausible but not the common case, and the acute harm requires a bonus contributor who is not the sponsor.

Impact: Low — bounded to the contributor's own funds. docs/DESIGN.md (Actors) states contributors gain "no rights," but does not document that a staker-less expiry redirects their principal to the sponsor rather than stranding or refunding it.

Proof of Concept

An independent user contributes bonus. No one stakes. After expiry the pool resolves and the bonus is swept to the sponsor’s recoveryAddress. The contributor loses the full amount.

function test_L02_bonusForfeitedToRecoveryWhenNoStakers() external {
_contributeBonus(alice, 1_000 * ONE); // independent contributor
vm.warp(pool.expiry() + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.claimExpired(); // SURVIVED, zero stakers
pool.sweepUnclaimedBonus(); // riskWindowStart == 0 -> whole bonus swept
assertEq(token.balanceOf(recovery), 1_000 * ONE, "bonus swept to sponsor recovery");
assertEq(token.balanceOf(alice), 0, "contributor recovers nothing");
}

Recommended Mitigation

Document explicitly in DESIGN §10 that bonus contributions are unconditionally forfeit to recoveryAddress if the pool resolves with no eligible stakers, so contributors price it in. If unintended, gate a refund on snapshotTotalStaked == 0 at resolution:

+ mapping(address contributor => uint256 amount) public bonusContributed;
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
...
totalBonus += received;
+ bonusContributed[msg.sender] += received;
emit BonusContributed(msg.sender, received);
}
+ /// @notice Refund a contributor's bonus iff the pool resolved with zero credited stake.
+ function refundBonusIfNoStakers() external nonReentrant {
+ if (outcome == PoolStates.Outcome.UNRESOLVED) revert OutcomeNotSet();
+ if (snapshotTotalStaked != 0) revert NothingToSweep();
+ uint256 amt = bonusContributed[msg.sender];
+ if (amt == 0) revert InvalidAmount();
+ bonusContributed[msg.sender] = 0;
+ stakeToken.safeTransfer(msg.sender, amt);
+ }

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!