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

Bonus Loss on registry direct promotion

Author Revealed upon completion

Root + Impact

Stakers unfairly loose out on bonus in cases where registry state jumps directly from NEW_DEPLOYMENT to PRODUCTION.

Description

Contract natspac mentions no risk == no bonus, but attackRegistry.goToProduction() allows promotion from non-risk state to PRODUCTION. and bonus is sweepable to a recoveryAddress.
In some cases when bonus contributions are relatively large a direct promotion (unintentionally or maliciously) make stakers loose out on bonuses.

//ATTACK REGISTRY
//IAttackRegistry.registerDeployment(agreement, deployer);
//IAttackRegistry.goToProduction(agreement);
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
// No observable risk → no bonus (see contract natspec).
@> if (riskWindowStart == 0) return 0;

Risk

Likelyhood

  • Likelyhood is medium since pool sponsor also has the power to goToProdoction hence causing the state change

Impact

  • A malicious pool sponsor can deliberately push for attack registry state change since they control recoveryAddress

Proof of Concept

add test to test/unit/ConfidencePool.t.sol

contract BonusLossTest is BaseConfidencePoolTest {
function getBonusSharePercentages(uint256 bonus) internal view returns (uint256) {
uint256 bonusTotal = pool.snapshotTotalBonus();
return Math.mulDiv(bonus, 100, bonusTotal);
}
function _passThroughAttackRequested() internal {
//REGISTRY NEVER GOES THROUGH UNDERATTACK STATE
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
pool.pokeRiskWindow();
}
function _passThroughDirectPromotion() internal {
//REGISTRY NEVER GOES THROUGH UNDERATTACK STATE
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
}
function testUnfairSweepWhenDirectRegistryPromotion() external {
_stake(alice, 1000 * ONE);
uint256 aliceStake = pool.eligibleStake(alice);
_stake(bob, 330 * ONE);
uint256 bobStake = pool.eligibleStake(bob);
_stake(dave, 520 * ONE);
uint256 daveStake = pool.eligibleStake(dave);
//such good people
_contributeBonus(alice, 1000 * ONE);
_contributeBonus(bob, 350 * ONE);
_contributeBonus(dave, 20 * ONE);
//DIRECT PROMOTION
/**
* GOES INTO PRODUCTION DIRECTLY FROM NEW_DEPLOYMENT
* IAttackRegistry.registerDeployment(agreement, deployer);
* IAttackRegistry.goToProduction(agreement);
*
*/
_passThroughDirectPromotion();
//simplified
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
console.log("TOTAL BONUS: ", pool.snapshotTotalBonus());
//GET BONUS SHARES PERCENTAGES
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - aliceStake;
console.log("alice bonus & balance: ", aliceBonus, token.balanceOf(alice));
vm.prank(bob);
pool.claimSurvived();
uint256 bobBonus = token.balanceOf(bob) - bobStake;
console.log("bob bonus & balance: ", bobBonus, token.balanceOf(bob));
vm.prank(dave);
pool.claimSurvived();
uint256 daveBonus = token.balanceOf(dave) - daveStake;
console.log("dave bonus & balance: ", daveBonus, token.balanceOf(dave));
console.log("CLAIMED BONUS", pool.claimedBonus());
console.log("recoveryBalance", token.balanceOf(pool.recoveryAddress()));
assertEq(0, pool.claimedBonus());
assertEq(token.balanceOf(address(pool)), pool.snapshotTotalBonus());
console.log("bonusSweepable", token.balanceOf(address(pool)));
pool.sweepUnclaimedBonus();
console.log("SweptBonusToRecovery", token.balanceOf(pool.recoveryAddress()));
assertEq(token.balanceOf(pool.recoveryAddress()), pool.snapshotTotalBonus());
}
}

Mitigation

  • cache and observe poolstate for direct promotion.

  • Accumulate and distribute bonus to Stakers only in cases of direct promotion.

diff

+uint256 public stateCache = uint256(_observePoolState);
+function _observeStatedifference() internal returns (uint256) {
+ uint256 state = uint256(_observePoolState());
+ return stateCache -= state;
+}
+function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
+ if (snapshotTotalBonus == 0) return 0;
+ // No observable risk → no bonus (see contract natspec).
- if (riskWindowStart == 0) return 0;
+ if (_observeStatedifference() > 3) return 0;
+ uint256 T = outcomeFlaggedAt;

Support

FAQs

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

Give us feedback!