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

Sponsor Bait-and-Switch via goToProduction

Author Revealed upon completion

Root + Impact

The sponsor can call goToProduction on the agreement, which moves it directly to PRODUCTION without entering an active‑risk state. This leaves riskWindowStart == 0 permanently, while the withdraw function blocks exits based on the broader registry state. Simultaneously, the entire snapshotTotalBonus is treated as unearned and swept to the sponsor’s recoveryAddress. The combined effect locks the stakers’ principal for the full term and steals the entire bonus pool.

Description

  • Stakers expect to earn a bonus if they keep their funds in the pool through the risk window. They can withdraw before the risk window opens.

  • The sponsor calls goToProduction, which moves the agreement directly to PRODUCTION without entering UNDER_ATTACK. The pool never sets riskWindowStart, so withdrawals are permanently disabled even though no risk materialised, and the entire bonus is swept to the sponsor.

// src/ConfidencePool.sol withdraw()
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled(); // @> Blocks withdrawal on terminal states even if riskWindowStart == 0
}
// src/ConfidencePool.sol _bonusShare()
if (riskWindowStart == 0) return 0; // @> No bonus owed if risk window never opened
// src/ConfidencePool.sol sweepUnclaimedBonus()
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus; // @> Bonus only reserved if riskWindowStart != 0
}
}

Risk

Likelihood:

  • The sponsor is also the agreement owner (enforced by the factory). They can call goToProduction in a single transaction, skipping the attack phase entirely.

  • Stakers have no window to react because the transition is instant and withdrawals are locked immediately.

Impact:

  • Stakers lose access to their principal for the full term and earn zero bonus.

  • The sponsor captures the entire bonus pool, which may include third‑party contributions.

Proof of Concept

Explanation:

The sponsor creates a pool in ATTACK_REQUESTED state and contributes a 50‑token bonus. A staker deposits 100 tokens. The sponsor then calls goToProduction, which directly moves the agreement to PRODUCTION. The staker attempts to withdraw but the transaction reverts because the registry state is no longer pre‑attack, even though no risk was observed. After expiry, claimExpired resolves the pool to SURVIVED. The staker claims and receives only their principal (100 tokens). The sponsor then calls sweepUnclaimedBonus and gets the full 50‑token bonus. This shows the bait‑and‑switch: principal locked without risk, bonus stolen by sponsor.

function test_H3_BaitAndSwitch_GoToProduction() public {
MockRegistry registry = new MockRegistry();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.ATTACK_REQUESTED);
address sponsor = makeAddr("sponsor");
address staker = makeAddr("staker");
vm.startPrank(sponsor);
ConfidencePool pool = factory.createPool(agreement, address(stakeToken), expiry, minStake, sponsor, accounts);
deal(address(stakeToken), sponsor, 50 ether);
stakeToken.approve(address(pool), 50 ether);
pool.contributeBonus(50 ether);
vm.stopPrank();
deal(address(stakeToken), staker, 100 ether);
vm.startPrank(staker);
stakeToken.approve(address(pool), 100 ether);
pool.stake(100 ether);
vm.stopPrank();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.PRODUCTION);
vm.prank(staker);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(expiry + 1 days);
pool.claimExpired();
uint256 stakerBefore = stakeToken.balanceOf(staker);
vm.prank(staker);
pool.claimSurvived();
uint256 stakerAfter = stakeToken.balanceOf(staker);
assertEq(stakerAfter - stakerBefore, 100 ether);
uint256 sponsorBefore = stakeToken.balanceOf(sponsor);
pool.sweepUnclaimedBonus();
uint256 sponsorAfter = stakeToken.balanceOf(sponsor);
assertEq(sponsorAfter - sponsorBefore, 50 ether);
}

Recommended Mitigation

Mitigation explanation:

Two separate changes address the bait‑and‑switch:

1. Withdrawal gating: The original withdraw function locks withdrawals if the registry state is anything other than the three pre‑attack states, even when riskWindowStart == 0. The fix removes the registry‑state disjunction and locks withdrawals only on riskWindowStart != 0. This restores the guarantee that stakers can exit freely until actual risk is observed. A terminal PRODUCTION reached via goToProduction will no longer block withdrawals because riskWindowStart remains zero.

2. Bonus destination: When riskWindowStart == 0, the bonus was treated as unearned and swept to the sponsor. The mitigation replaces this with a refund to the original bonus contributors (pro‑rata). This ensures third‑party bonus funds are never captured by the sponsor on a no‑risk outcome.


Together, these changes prevent the sponsor from both locking principal and stealing the bonus with a single goToProduction call.

- if (riskWindowStart != 0 || (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED))
+ if (riskWindowStart != 0) // Gate withdrawal lock only on observed risk, not terminal state
- if (riskWindowStart == 0) return 0; // bonus zero
+ // Return unearned bonus to contributors pro‑rata instead of sweeping to sponsor
+ if (riskWindowStart == 0) _refundBonusToContributors();

Support

FAQs

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

Give us feedback!