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

Unsealed risk window (riskWindowStart == 0) zeroes every survivor's bonus and diverts the entire bonus pool to the sponsor's recovery address

Author Revealed upon completion

Root + Impact

Description

  • Normally, stakers who keep their capital in the pool through the agreement's risk period and survive are paid back their principal plus a share of the bonus pool, per the protocol's stated purpose ("stakers … are rewarded from the bonus pool if they [survive]"). The bonus is split by the k=2 time-weighting keyed on riskWindowStart.

  • The bonus a survivor receives is entirely gated on riskWindowStart != 0, and riskWindowStart is only set as a side effect of some account interacting with the pool while the registry is in an active-risk state. When the agreement genuinely passes through UNDER_ATTACK/PROMOTION_REQUESTED but no account touches the pool during that interval, riskWindowStart stays 0; every survivor is then paid zero bonus, and sweepUnclaimedBonus transfers the entire bonus pool to the sponsor-controlled recoveryAddress. The moderator's authoritative SURVIVED resolution does not seal riskWindowStart, so it cannot restore the bonus.

The root cause spans two functions in src/ConfidencePool.sol (abridged; @> marks the root-cause lines):

// ROOT CAUSE (1/2) — _bonusShare forces a survivor's bonus to zero when the window was never sealed:
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0; // survivors get ZERO bonus whenever riskWindowStart == 0
// ... (normal k=2 time-weighted path omitted) ...
}
// ROOT CAUSE (2/2) — sweepUnclaimedBonus reserves the survivors' bonus ONLY when riskWindowStart != 0;
// otherwise it treats the whole bonus as sweepable excess and sends it to the sponsor's recoveryAddress:
function sweepUnclaimedBonus() external nonReentrant {
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) {
@> reserved += snapshotTotalBonus - claimedBonus; // SKIPPED when riskWindowStart == 0
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
// ... (NothingToSweep check and totalBonus decrement omitted) ...
@> stakeToken.safeTransfer(recoveryAddress, amount); // the entire unreserved bonus -> recovery
}

Risk

Likelihood:

  • Occurs whenever the agreement moves through an active-risk state while no account calls stake / withdraw / contributeBonus / pokeRiskWindow during that window — the natural state of any low-activity pool, or one funded by a single up-front deposit that then sits idle until resolution.

  • The sponsor sets and controls recoveryAddress and directly receives the swept bonus, giving them a standing incentive to leave the window unsealed and to not surface the (non-obvious) pokeRiskWindow() requirement to stakers.

Impact:

  • Stakers who bore real, on-chain-visible risk and survived the term receive none of the bonus they were promised — the core reward mechanism of the protocol silently fails for them.

  • The full bonus — which may be funded by an unrelated third party through the permissionless contributeBonus — is captured by the sponsor's recoveryAddress: unjust enrichment of a party that neither funded the bonus nor bore the risk. There is no refund path for the misdirected funds.

Proof of Concept

The following self-contained test (test/BonusLossPoC.t.sol, using the repo's existing mocks) walks the full loss path end to end: two stakers deposit, a third party funds the bonus, the agreement genuinely transits UNDER_ATTACK → PRODUCTION while the pool sits idle, and the moderator resolves SURVIVED. It then asserts the three facts that make this a loss: riskWindowStart == 0, each survivor ends with exactly their principal (zero bonus), and sweepUnclaimedBonus moves the entire 300e18 bonus to the sponsor-controlled recoveryAddress. Run with forge test --match-path test/BonusLossPoC.t.sol -vv — it passes, confirming the survivors are paid nothing and the bonus is captured by the recovery address.

Save as test/BonusLossPoC.t.sol and run forge test --match-path test/BonusLossPoC.t.sol -vv. It compiles and passes with the repository's own foundry.toml settings — no flags or config changes required.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
/// @notice PoC: surviving stakers earn zero bonus and the sponsor-controlled recovery address captures
/// the entire (here third-party-funded) bonus, even though the moderator authoritatively confirms the
/// pool survived a real UNDER_ATTACK period — because `riskWindowStart` was never sealed on-chain.
contract BonusLossPoC is Test {
ConfidencePool internal pool;
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal shr;
MockAgreement internal agreement;
address internal moderator = makeAddr("moderator");
address internal sponsor = makeAddr("sponsor"); // pool owner; controls recoveryAddress
address internal recovery = makeAddr("recovery"); // sponsor-controlled sweep destination
address internal constant SCOPE = address(0xC0FFEE);
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
shr = new MockSafeHarborRegistry();
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(SCOPE, true);
shr.setAttackRegistry(address(attackRegistry));
shr.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
address[] memory scope = new address[](1);
scope[0] = SCOPE;
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
pool.initialize(
address(agreement), address(token), address(shr), moderator, block.timestamp + 365 days, 1, recovery, sponsor, scope
);
}
function test_survivorsLoseBonusToRecovery() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address donor = makeAddr("donor");
// Stakers commit capital during pre-attack staging.
_stake(alice, 100 ether);
_stake(bob, 100 ether);
// A third party funds a bonus meant to reward the survivors.
token.mint(donor, 300 ether);
vm.startPrank(donor);
token.approve(address(pool), 300 ether);
pool.contributeBonus(300 ether);
vm.stopPrank();
// The agreement GENUINELY goes under attack and then survives to production. No one interacts
// with the pool during the active-risk interval, so `riskWindowStart` is never sealed.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// The moderator authoritatively resolves SURVIVED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.riskWindowStart()), 0, "risk window was never sealed");
// Survivors claim: principal only, ZERO bonus.
vm.prank(alice);
pool.claimSurvived();
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(alice), 100 ether, "alice received bonus");
assertEq(token.balanceOf(bob), 100 ether, "bob received bonus");
// The whole 300e18 bonus is swept to the sponsor-controlled recovery address (permissionless).
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 300 ether, "bonus not captured by recovery");
}
function _stake(address u, uint256 amount) internal {
token.mint(u, amount);
vm.startPrank(u);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
}

Recommended Mitigation

The fix is to treat a SURVIVED/EXPIRED cohort as owed the bonus even when the risk window was never sealed: with no observed time spread there is nothing to k=2-weight, so fall back to an amount-weighted split among the survivors (mirroring the existing globalScore == 0 fallback) instead of returning zero and letting the sweep route it to recoveryAddress. This requires two coordinated changes — pay the amount-weighted share in _bonusShare, and reserve that same bonus in sweepUnclaimedBonus so it is no longer treated as sweepable excess — after which survivors receive the bonus they were promised and only genuine surplus/donations reach recovery.

Two coordinated edits in src/ConfidencePool.sol, in two separate functions:

@@ function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) @@
if (snapshotTotalBonus == 0) return 0;
- if (riskWindowStart == 0) return 0;
+ // No observed risk window means no time spread to weight by, but a SURVIVED/EXPIRED cohort was
+ // still promised the bonus. Split it amount-weighted among survivors instead of forfeiting it
+ // to recoveryAddress.
+ if (riskWindowStart == 0) {
+ if (snapshotTotalStaked == 0) return 0;
+ return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
+ }
@@ function sweepUnclaimedBonus() external nonReentrant @@
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
- if (riskWindowStart != 0) {
- reserved += snapshotTotalBonus - claimedBonus;
- }
+ // Reserve the survivors' bonus regardless of whether the window was sealed, so it is paid to
+ // them rather than swept to recovery.
+ reserved += snapshotTotalBonus - claimedBonus;
}

Support

FAQs

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

Give us feedback!