Root + Impact
Description
The flagOutcome() function writes snapshotTotalStaked and snapshotTotalBonus to storage at lines 357-358, then immediately reads them back from storage at lines 361-362 to compute corruptedReserve and bountyEntitlement. Solidity's optimizer does not eliminate these redundant SLOADs because the writes and reads are separated by other storage writes (snapshotSumStakeTime, snapshotSumStakeTimeSq at lines 359-360). The values are already available in the local variables totalEligibleStake and totalBonus (read from storage at lines 357-358). Caching them in memory locals avoids 4 warm SLOADs (~400 gas) on every flagOutcome call.
The same pattern occurs in claimExpired() at lines 524-527 and 545, where snapshot values are written then immediately read back for the corruptedReserve computation.
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external onlyModerator
{
@> snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
@> corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED
@> ? snapshotTotalStaked + snapshotTotalBonus : 0;
@> bountyEntitlement = willBeGoodFaithCorrupted
@> ? snapshotTotalStaked + snapshotTotalBonus : 0;
}
Risk
Likelihood: High
-
flagOutcome() is called at least once per pool resolution and potentially multiple times during the moderator's re-flag window
-
Every invocation of flagOutcome() pays the redundant SLOAD cost unconditionally
-
claimExpired() auto-resolution path has the same pattern — every first post-expiry call pays it
Impact: Low
-
Approximately 400 gas wasted per flagOutcome() call, and ~200 gas per claimExpired() auto-resolution
-
No correctness impact — the stored values are read back correctly
-
Purely a gas efficiency concern; does not affect security or functionality
Proof of Concept
File: G2-SloadAfterSstore-FlagOutcome.poc.t.sol
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract G2_SloadAfterSstore_FlagOutcome_POC is BaseConfidencePoolTest {
function testPOC_G2_snapshotValuesReadAfterWriteInFlagOutcome() external {
_stake(alice, 100 * ONE);
_contributeBonus(bob, 50 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
uint256 gasBefore = gasleft();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 gasUsed = gasBefore - gasleft();
assertEq(pool.corruptedReserve(), pool.snapshotTotalStaked() + pool.snapshotTotalBonus());
assertEq(pool.bountyEntitlement(), pool.snapshotTotalStaked() + pool.snapshotTotalBonus());
assertGt(gasUsed, 0, "gas measurement sanity");
}
}
Run with:
forge test --match-path 'G2-SloadAfterSstore-FlagOutcome.poc.t.sol' -vv
Recommended Mitigation
Cache totalEligibleStake and totalBonus in memory locals (snapStaked, snapBonus) before writing them to the snapshot storage slots. Use the memory copies — not storage reads — when computing corruptedReserve and bountyEntitlement on the following lines. This eliminates 4 warm SLOADs (~400 gas) without changing any storage layout, event emission, or control flow. The memory values are guaranteed identical to the stored values because no other code executes between the SSTORE and the computation. Apply the same pattern to claimExpired() which has an identical write-then-read sequence.
// ConfidencePool.sol — flagOutcome() lines 357-376
+ uint256 snapStaked = totalEligibleStake;
+ uint256 snapBonus = totalBonus;
+ snapshotTotalStaked = snapStaked;
+ snapshotTotalBonus = snapBonus;
- snapshotTotalStaked = totalEligibleStake;
- snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
- corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED
- ? snapshotTotalStaked + snapshotTotalBonus : 0;
- bountyEntitlement = willBeGoodFaithCorrupted
- ? snapshotTotalStaked + snapshotTotalBonus : 0;
+ corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED
+ ? snapStaked + snapBonus : 0;
+ bountyEntitlement = willBeGoodFaithCorrupted
+ ? snapStaked + snapBonus : 0;
Same pattern applied to claimExpired() lines 524-527, 545:
+ uint256 snapStaked = totalEligibleStake;
+ uint256 snapBonus = totalBonus;
+ snapshotTotalStaked = snapStaked;
+ snapshotTotalBonus = snapBonus;
- snapshotTotalStaked = totalEligibleStake;
- snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
// ...
- corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
+ corruptedReserve = snapStaked + snapBonus;