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

sponsor posts no bonded capital, so it can attack its own in-scope contract and sweep 100% of staker principal to an address it controls at zero cost

Author Revealed upon completion

Root + Impact

Description

  • a confidence pool is an underwriting market. stakers deposit principal betting the insured agreement survives its term, and on SURVIVED/EXPIRED they recover principal plus a k=2 time-weighted bonus. the pool sponsor bootstraps the market: it creates the pool, seeds an optional bonus to attract deposits, owns the insured agreement, and names recoveryAddress, the destination the whole pool is swept to on a CORRUPTED resolution.

  • the sponsor is never required to post any capital of its own that is at risk against the outcome it influences. createPool takes no bond (ConfidencePoolFactory.sol#L67-L113), the bonus is optional and permissionless, and recoveryAddress is sponsor-set and freely mutable (ConfidencePool.sol#L611-L618). this is the core asymmetry: the sponsor owns the in-scope contract yet holds nothing in the pool, so it can deliberately compromise its own in-scope contract to force a CORRUPTED resolution and take the entire pool, which includes 100% of staker principal. any bonus it seeded to attract deposits round-trips back to it in the same resolution, so it underwrites the stakers' bet, profits from the exact event they insured against, and risks nothing, because attacking its own contract has no sponsor stake to slash.

  • a CORRUPTED resolution is settled by the moderator as either bad-faith or good-faith, and both classifications route the pool to a party aligned with the sponsor. the sponsor executes the compromise from a fresh, anonymous address it controls: as the unattributable external exploiter that address reads as a bad-faith hack, so a goodFaith=false flag sweeps the pool to recoveryAddress via claimCorrupted (ConfidencePool.sol#L408-L425), and as the address that performed the exploit it is the natural whitehat the moderator names under a goodFaith=true flag, so the whole pool is paid to it via claimAttackerBounty (ConfidencePool.sol#L432-L453). the good-faith recipient need not even be the sponsor's own address: a security researcher colluding with the sponsor can be named instead. either terminal classification the moderator can form from the on-chain facts alone lands the pool at an address the sponsor is aligned with.

Risk

Likelihood:

  • at creation every pool leaves the sponsor in control of recoveryAddress with no bond posted, and the sponsor owns the in-scope contract the pool insures.

  • the sponsor can drive the agreement to CORRUPTED at a time of its choosing, compromising its own in-scope contract through a subtle flaw only it knows, from an anonymous address it controls, at zero cost because no sponsor capital is held to slash.

  • at resolution the moderator sees a genuine external exploit, so either terminal CORRUPTED classification it forms routes the full pool to a sponsor-controlled address.

  • once resolved, the full pool moves to a sponsor-controlled address with no gate the sponsor does not already clear: claimCorrupted is permissionless under a bad-faith flag, and claimAttackerBounty is callable by the sponsor's own named address under a good-faith flag. the attempt costs nothing, so it need succeed only once.

Impact:

  • total loss of staked principal. on a CORRUPTED resolution the whole pool, including 100% of every staker's deposited principal, is transferred to a sponsor-controlled address, recoveryAddress under a bad-faith flag or the sponsor's named exploiter under a good-faith flag. stakers recover nothing.

  • the sponsor profits from attacking its own contract at no downside. because it posts no bond and the optional bonus it seeded round-trips back to it in the same resolution, a sponsor can compromise its own in-scope contract, force the CORRUPTED resolution, and walk away with 100% of the staker principal through an address it controls, having risked none of its own capital on the outcome. its realized cost is 0 and its realized gain is the stakers' principal.

  • the mechanism has no aligned party. because the outcome-influencing sponsor holds no slashable capital, nothing in the contract deters it from steering the pool toward CORRUPTED, and the entire downside is borne by stakers.

Proof of Concept

runnable against the project's own harness (test/helpers/BaseConfidencePoolTest.sol), which deploys the pool with moderator as the outcome moderator and the test contract as the owner/sponsor. drop this into test/poc/SponsorNoCollateral.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {console2} from "forge-std/console2.sol";
contract SponsorNoCollateralTest is BaseConfidencePoolTest {
// route 1 -- bad-faith CORRUPTED sweeps the full pool to the sponsor-controlled recovery sink
function test_sponsorCapturesStakerPrincipalAtZeroCost() public {
address sponsorSink = makeAddr("sponsorSink");
pool.setRecoveryAddress(sponsorSink);
uint256 sponsorBonus = 10_000 * ONE;
token.mint(address(this), sponsorBonus);
token.approve(address(pool), sponsorBonus);
pool.contributeBonus(sponsorBonus);
uint256 principal = 100_000 * ONE;
_stake(alice, principal);
assertEq(token.balanceOf(address(pool)), principal + sponsorBonus);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
pool.claimCorrupted();
assertEq(token.balanceOf(sponsorSink), principal + sponsorBonus); // sink got everything
assertEq(token.balanceOf(alice), 0); // staker recovers nothing
uint256 sponsorNetGain = token.balanceOf(sponsorSink) - sponsorBonus;
assertEq(sponsorNetGain, principal);
console2.log("bad-faith route -> recovery sink gets:", token.balanceOf(sponsorSink) / ONE);
console2.log("staker principal lost: ", principal / ONE);
console2.log("sponsor net gain (== principal): ", sponsorNetGain / ONE);
}
// route 2 -- good-faith CORRUPTED names the sponsor's anonymous exploiter, which claims the
// full pool as the bounty; redirecting to good-faith does not deny the sponsor, because the
// address that performed the exploit (the natural whitehat to name) is its own
function test_sponsorCapturesViaGoodFaithSockPuppet() public {
address sockPuppet = makeAddr("sponsorSockPuppet");
uint256 sponsorBonus = 10_000 * ONE;
token.mint(address(this), sponsorBonus);
token.approve(address(pool), sponsorBonus);
pool.contributeBonus(sponsorBonus);
uint256 principal = 100_000 * ONE;
_stake(alice, principal);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// moderator treats the exploiter as a good-faith whitehat and names it
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, sockPuppet);
vm.prank(sockPuppet);
pool.claimAttackerBounty();
assertEq(token.balanceOf(sockPuppet), principal + sponsorBonus); // sponsor address got everything
assertEq(token.balanceOf(alice), 0); // staker recovers nothing
uint256 sponsorNetGain = token.balanceOf(sockPuppet) - sponsorBonus;
assertEq(sponsorNetGain, principal);
console2.log("good-faith route -> sock-puppet gets: ", token.balanceOf(sockPuppet) / ONE);
console2.log("staker principal lost: ", principal / ONE);
console2.log("sponsor net gain (== principal): ", sponsorNetGain / ONE);
}
}

run with forge test --match-path test/poc/SponsorNoCollateral.t.sol -vv:

Ran 2 tests for test/poc/SponsorNoCollateral.t.sol:SponsorNoCollateralTest
[PASS] test_sponsorCapturesStakerPrincipalAtZeroCost() (gas: 530689)
Logs:
bad-faith route -> recovery sink gets: 110000
staker principal lost: 100000
sponsor net gain (== principal): 100000
[PASS] test_sponsorCapturesViaGoodFaithSockPuppet() (gas: 581024)
Logs:
good-faith route -> sock-puppet gets: 110000
staker principal lost: 100000
sponsor net gain (== principal): 100000
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.58ms

both terminal CORRUPTED classifications land the full 110_000 pool at a sponsor-controlled address: bad-faith at recoveryAddress, good-faith at the sponsor's own named exploiter. the staker deposits 100_000 and recovers 0 either way, and the sponsor nets exactly the 100_000 of staker principal at zero cost.

Recommended Mitigation

the recommended fix is to discourage a sponsor from attacking its own contract by requiring a sponsor bond that is refundable only when the contract survives. this aligns the sponsor with the stakers: a legitimate sponsor already wants its in-scope contract to remain secure through expiry, so refunding the bond on a SURVIVED or EXPIRED resolution is the expected reward for the sponsor reaching its own goal, and the bonus paid to the stakers is the expected cost of reaching it. forfeiting the bond to the stakers on a CORRUPTED resolution is the penalty for the contract failing, and it removes the sponsor's incentive to induce a CORRUPTED outcome, because attacking its own contract now costs it the bond.

concretely: escrow the bond at pool creation, refund it to the sponsor only on SURVIVED/EXPIRED, and forfeit it to the affected stakers on CORRUPTED, excluded from the recoveryAddress sweep. gate the bond behind an admin-controlled minimum: the factory owner, the same admin that already controls the stake-token allowlist and default moderator, sets and adjusts a minSponsorBond floor so the required coverage tracks expected pool size.

+ // factory storage + admin control: minimum sponsor bond floor
+ uint256 public minSponsorBond;
+
+ function setMinSponsorBond(uint256 newMinSponsorBond) external onlyOwner {
+ minSponsorBond = newMinSponsorBond;
+ emit MinSponsorBondUpdated(newMinSponsorBond);
+ }
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
- address[] calldata accounts
+ address[] calldata accounts,
+ uint256 sponsorBond
) external whenNotPaused returns (address pool) {
...
+ // enforce the admin-controlled minimum bond floor
+ if (sponsorBond < minSponsorBond) revert SponsorBondBelowMinimum();
pool = Clones.cloneDeterministic(poolImplementation, salt);
+ // escrow the sponsor bond into the clone, tracked separately from staker principal and bonus
+ IERC20(stakeToken).safeTransferFrom(msg.sender, pool, sponsorBond);
+ IConfidencePool(pool).setSponsorBond(sponsorBond);
...
}
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
+ // never sweep the sponsor bond to the sponsor-controlled sink
+ toSweep -= sponsorBond;
if (toSweep == 0) revert NothingToSweep();
...
stakeToken.safeTransfer(recoveryAddress, toSweep);
+ // on bad-faith CORRUPTED the sponsor bond is forfeited to the affected stakers, not returned
+ if (!goodFaith) _distributeBondToStakers(sponsorBond);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}
+ // the sponsor reclaims its bond only once the pool resolves in the stakers' favour
+ function claimSponsorBond() external {
+ if (msg.sender != sponsor) revert NotSponsor();
+ if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
+ revert BondNotRefundable();
+ }
+ uint256 amount = sponsorBond;
+ sponsorBond = 0;
+ stakeToken.safeTransfer(sponsor, amount);
+ }

Support

FAQs

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

Give us feedback!