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

`moderator` setting `badFaith`, leading to RUG-PULL or scam.

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

**Description:**
firstly,
- The tokens to be awarded if a `scope` is breached legally is for The `attacker`.
- if the `attacker` breached illegally, for his punishment the funds are taken to `recoveryAddress`.
This will break `stakers` TRUST on the bet, it's bad for the BUSINESS.
This will cause no `staker` to stake, but only `sponsor/poolOwner` will contribute, then no difference with the traditional bounty system, but only services/deployment charges.
This function can be called later in `ConfidencePool.sol` to sweep `stakers` funds, even thou the `pool` isn't really breached, cheating on `stakers` and will break TRUST.
```solidity
@> /// @dev Sweeps the pool's full token balance to `recoveryAddress`. For good-faith CORRUPTED,
/// reverts with `MustClaimBountyFirst` until the attacker has fully claimed their bounty;
/// after that, sweeps whatever remains. Repeat-callable so post-resolution donations into
/// the pool can also be recovered.
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
// aderyn-fp-next-line(reentrancy-state-change)
@> uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
// Clamp the decrement — `toSweep` can exceed the original reserve when post-resolution
// donations have inflated the balance.
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
@> stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}
```

Risk

Likelihood:

A highly secured `scope` might be used intentionally again and again to scam the `stakers`, `stakers` will deposits since they are sure about it and later it will be finalized as `BadFaith` even thou it survived, cheating on `stakers`, `stakers` lose money.
`ATTACKER` might scam or RUG-PULL `stakers` by observing if too much token is `staked` then he'll finalize as `BadFaith` regardless, to steal a lot from `stakers`, while `sponsor/poolOwner` in a bounty supposed to give out and not take.
Why not if take some percentage.

Impact:

`stakers` lose money on false `outcome`.
This system will break `stakers` TRUST.

Proof of Concept

**Proof of Concept:**
⚠⚠⚠⚠⚠⚠⚠⚠⚠
`sponsor/poolOwner` and `moderator` = `ATTACKER`
1. `sponsor/poolOwner` contributes to the pool
2. `sponsor/poolOwner` sets some expiry
3. `stakers` staked some tokens for the bet.
4. `pool` then past the attack stage.
5. `moderator` finalized as `BadFaith`, even thou it's false, the `pool` survived.
6. `ATTACKER` claimed all the staked tokens and bonus to `recoveryAddress` of the `moderator`, hurting/cheating the community or `stakers`.
create a .sol file in `./test/unit` and paste this code, then run `forge test --mt test_ModeratorSettingBadFaithBreaksTrust` on terminal.
<details>
<summary>Code</summary>
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "../helpers/BaseConfidencePoolTest.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {Test,console2} from "forge-std/Test.sol";
contract BonusCalculationBugTest is BaseConfidencePoolTest {
uint256 internal constant RISK_WINDOW = 30 days;
uint256 internal constant BONUS_POOL = 1000 * ONE;
function setUp() public override {
super.setUp();
// Contribute bonus to pool
_contributeBonus(address(this), BONUS_POOL);
}
// ============================================================
// HELPER: Trigger risk window observation
// ============================================================
function _triggerRiskWindow() internal {
// Set registry to UNDER_ATTACK and call any function that observes state
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// pokeRiskWindow is a public function that calls _observePoolState()
pool.pokeRiskWindow();
}
// ============================================================
// HELPER: Flag outcome as CORRUPTED (moderator only)
// ============================================================
function _flagCorrupted(bool goodFaith, address attacker) internal {
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, goodFaith, attacker);
}
function test_ModeratorSettingBadFaithBreaksTrust() public {
address contributor = makeAddr("c");
uint256 amount = 100e18;
token.mint(contributor, amount);
uint256 stakeAmount = 1 * ONE;
// 1. `sponsor/poolOwner` contributes to the pool
vm.startPrank(contributor);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
// 2. `sponsor/poolOwner` sets some expiry
pool.setExpiry(block.timestamp + 30 days);
// 3. `stakers` staked some tokens for the bet.
_stake(bob, stakeAmount);
_stake(alice, stakeAmount);
_triggerRiskWindow();
vm.warp(block.timestamp + 30 days);
// 4. `pool` then past the attack stage
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// 5. `moderator` finalized as `BadFaith`, even thou it's false.
_flagCorrupted(false, address(0));
// 6. `ATTACKER` claimed all the staked tokens and bonus to `recoveryAddress` of the , hurting/cheating the community or `stakers`.
pool.claimCorrupted();
// 7. `staker` has been RUG-PULLED and there's no money for stakers.
uint256 poolRemainingBalance = token.balanceOf(address(pool));
console2.log("Pool Remaining Balance:", poolRemainingBalance);
}
}
```
</details>

Recommended Mitigation

**Recommended Mitigation:**
For a justice and also to gain TRUST.
1. if a `pool` is breached illegally and the `moderator` finalized as `BadFaith`. some percentage of the `stakers` funds should be returned to `stakers`, and the remaining should be taken to `recoveryAddress`.
2. if a `pool` is breached illegally and the `moderator` finalized as `BadFaith`. The `stakers` funds should be returned to `stakers`, and the remaining should be taken to `recoveryAddress`.
```diff
- function claimCorrupted() external nonReentrant {
- if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
- if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
- // aderyn-fp-next-line(reentrancy-state-change)
- uint256 toSweep = stakeToken.balanceOf(address(this));
- if (toSweep == 0) revert NothingToSweep();
- // Clamp the decrement — `toSweep` can exceed the original reserve when post-resolution
- // donations have inflated the balance.
- corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
- if (!goodFaith) {
- bountyClaimed = bountyEntitlement;
- }
- if (!claimsStarted) claimsStarted = true;
- stakeToken.safeTransfer(recoveryAddress, toSweep);
- emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
- }
```
```diff
+ mapping(address => bool) public hasWithdrawn;
+ function claimCorrupted() external nonReentrant {
+ if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
+ if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
+ uint256 balance = stakeToken.balanceOf(address(this));
+ if (balance == 0) revert NothingToSweep();
+ if (!goodFaith) {
+ // ✅ Bad-faith: reserve staker principal, sweep the rest
+ uint256 toSweep = balance > totalEligibleStake ? balance - totalEligibleStake : 0;
+ if (toSweep == 0) revert NothingToSweep();
+ // records in corruptedReserve is kept
+ corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
+ bountyClaimed = bountyEntitlement;
+ if (!claimsStarted) claimsStarted = true;
+ stakeToken.safeTransfer(recoveryAddress, toSweep);
+ emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
+ } else {
+ // ✅ Good-faith: entire pool goes to attacker (bounty)
+ // This should be handled by claimAttackerBounty(), not here
+ revert("Good-faith: use claimAttackerBounty()");
+ }
+ }
+ function emergencyWithdraw() external nonReentrant {
+ // ✅ Only bad-faith CORRUPTED
+ if (goodFaith || outcome != PoolStates.Outcome.CORRUPTED) revert();
+ if (hasWithdrawn[msg.sender]) revert InvalidAmount();
+ uint256 amount = eligibleStake[msg.sender];
+ if (amount == 0) revert InvalidAmount();
+ // ✅ Update global accounting
+ _clampUserSums(msg.sender);
+ sumStakeTime -= userSumStakeTime[msg.sender];
+ sumStakeTimeSq -= userSumStakeTimeSq[msg.sender];
+ eligibleStake[msg.sender] = 0;
+ userSumStakeTime[msg.sender] = 0;
+ userSumStakeTimeSq[msg.sender] = 0;
+ totalEligibleStake -= amount;
+ // ✅ Reduce corruptedReserve by the amount withdrawn
+ corruptedReserve -= amount;
+ hasWithdrawn[msg.sender] = true;
+ if (!claimsStarted) claimsStarted = true;
+ stakeToken.safeTransfer(msg.sender, amount);
+ emit Withdrawn(msg.sender, amount);
+ }
```
Thank you, i hope codehawks will be back.
we love you patrick and the team❤❤❤

Support

FAQs

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

Give us feedback!