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

ConfidencePool leaves public live accounting stale after terminal settlement

Author Revealed upon completion

Severity

Low

Summary

ConfidencePool exposes totalEligibleStake, totalBonus, eligibleStake, sumStakeTime, and sumStakeTimeSq as public live accounting. Terminal settlement paths move all real assets out of the pool but do not fully reconcile these public accounting variables.

In the SURVIVED / EXPIRED branches, a final staker claim pays the staker's full principal plus bonus, but totalBonus, sumStakeTime, and sumStakeTimeSq remain at their pre-claim values. In the CORRUPTED branch, claimCorrupted() can sweep the whole pool to recoveryAddress, while eligibleStake, totalEligibleStake, and totalBonus still report the swept liabilities.

This does not enable a second withdrawal in the current implementation, so the issue is Low severity. The impact is incorrect public state after settlement: external users, integrators, or indexers can observe nonzero pool liabilities even when the pool has no remaining token balance and no remaining claimable assets.

Vulnerability Details

The contract comments define the affected variables as live accounting:

// Live stake accounting (maintained incrementally on stake/withdraw).
uint256 public totalEligibleStake;
uint256 public totalBonus;
mapping(address staker => uint256 amount) public eligibleStake;
uint256 public sumStakeTime;
uint256 public sumStakeTimeSq;

However, claimSurvived() and claimExpired() only subtract the user's principal from totalEligibleStake and increment claimedBonus:

uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare;
totalEligibleStake -= userEligible;
claimedBonus += bonusShare;
delete eligibleStake[msg.sender];
delete userSumStakeTime[msg.sender];
delete userSumStakeTimeSq[msg.sender];

The paid bonus is not subtracted from totalBonus, and the user's per-user score sums are not subtracted from the global sumStakeTime / sumStakeTimeSq values.

The CORRUPTED path has the same stale-accounting issue in a stronger form. claimCorrupted() sweeps the entire pool balance to recoveryAddress and clears corruptedReserve, but it does not clear the forfeited stake or bonus accounting:

uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, toSweep);

After this call, the pool can have zero token balance while still reporting nonzero eligibleStake(staker), totalEligibleStake(), and totalBonus().

Impact

The issue breaks the expected live-accounting invariant after terminal settlement:

stakeToken.balanceOf(pool) >= totalEligibleStake + totalBonus

Concrete effects:

  • After the final SURVIVED or EXPIRED claim, the pool can have zero token balance while totalBonus() and global score sums remain nonzero.

  • After claimCorrupted() sweeps the entire pool, the pool can have zero token balance while eligibleStake(staker), totalEligibleStake(), and totalBonus() still report swept/forfeited liabilities.

  • External consumers that rely on these public getters as live pool state can display incorrect remaining liabilities, incorrectly classify a pool as still funded, or miscompute outstanding stake/bonus after settlement.

No current function uses the stale values to allow a second claim or to move additional funds, so this should be treated as Low severity rather than Medium or High.

Proof of Concept

Place the following file at audit-poc/poc1.t.sol and run:

FOUNDRY_TEST=audit-poc forge test --match-path 'audit-poc/poc1.t.sol' -vvv

PoC:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract Poc1StalePostSettlementAccounting is BaseConfidencePoolTest {
function test_totalBonusAndGlobalSumsRemainStaleAfterFinalClaim() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
uint256 liveSumStakeTime = pool.sumStakeTime();
uint256 liveSumStakeTimeSq = pool.sumStakeTimeSq();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "claim paid full principal plus bonus");
assertEq(token.balanceOf(address(pool)), 0, "pool has no remaining token balance");
assertEq(pool.totalEligibleStake(), 0, "no remaining eligible stake");
assertEq(pool.claimedBonus(), 50 * ONE, "entire bonus was paid");
assertEq(pool.totalBonus(), 50 * ONE, "stale totalBonus still reports paid bonus");
assertEq(pool.sumStakeTime(), liveSumStakeTime, "global sumStakeTime was not reduced on final claim");
assertEq(pool.sumStakeTimeSq(), liveSumStakeTimeSq, "global sumStakeTimeSq was not reduced on final claim");
assertGt(pool.totalEligibleStake() + pool.totalBonus(), token.balanceOf(address(pool)));
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertEq(pool.totalBonus(), 50 * ONE, "sweep cannot reconcile the stale value without free balance");
}
function test_corruptedSweepLeavesStakeAndBonusAccountingStale() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "recovery received the whole pool");
assertEq(token.balanceOf(address(pool)), 0, "pool has no remaining token balance");
assertEq(pool.corruptedReserve(), 0, "corrupted reserve was cleared");
assertEq(pool.eligibleStake(alice), 100 * ONE, "staker accounting still reports forfeited stake");
assertEq(pool.totalEligibleStake(), 100 * ONE, "total eligible stake still reports swept principal");
assertEq(pool.totalBonus(), 50 * ONE, "total bonus still reports swept bonus");
assertGt(pool.totalEligibleStake() + pool.totalBonus(), token.balanceOf(address(pool)));
}
}

Result:

Ran 2 tests for audit-poc/poc1.t.sol:Poc1StalePostSettlementAccounting
[PASS] test_corruptedSweepLeavesStakeAndBonusAccountingStale() (gas: 532589)
[PASS] test_totalBonusAndGlobalSumsRemainStaleAfterFinalClaim() (gas: 540059)
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Either reconcile the live accounting variables during terminal settlement, or explicitly separate historical snapshot state from live remaining liabilities.

For claimSurvived() and claimExpired(), subtract the paid bonus and global score weight before deleting user state:

uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare;
totalEligibleStake -= userEligible;
+totalBonus -= bonusShare;
+sumStakeTime -= userSumStakeTime[msg.sender];
+sumStakeTimeSq -= userSumStakeTimeSq[msg.sender];
claimedBonus += bonusShare;

For CORRUPTED settlement, clear the forfeited stake and bonus accounting when the pool is fully swept, or add separate remainingEligibleStake / remainingBonus views and document that the existing public variables become historical after outcome == CORRUPTED.

Support

FAQs

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

Give us feedback!