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

Non-Binding Agreement Stored in Pool Permanently Blocks CORRUPTED Resolution

Author Revealed upon completion

Description

ConfidencePool resolves its outcome by reading the on-chain state of the agreement address
stored at initialization via _getAgreementState(). The factory validates that the agreement is
registered in the SafeHarborRegistry and that the caller owns it, and validates scope accounts
via IAgreement.isContractInScope. However, it never verifies that the stored agreement is the
Binding Agreement for the scope accounts as determined by
AttackRegistry.getAgreementForContract(account).

A protocol that owns multiple valid agreements can create a pool
using a non-Binding Agreement (Agreement A) while the scope accounts' actual Binding Agreement
registered in the AttackRegistry is Agreement B. The factory comment explicitly acknowledges
this gap: "duplicates are allowed and curated off-chain" — meaning no on-chain enforcement
exists. When Agreement B goes CORRUPTED in the AttackRegistry, flagOutcome(CORRUPTED)
reverts unconditionally because _getAgreementState() reads Agreement A (which remains
PRODUCTION). The moderator is permanently blocked from flagging CORRUPTED. The pool can only
resolve SURVIVED, zeroing the whitehat's bounty and incorrectly paying stakers. There is no
setAgreement function and the pool is a non-upgradeable clone — the misconfiguration is
permanent.

// ConfidencePoolFactory.sol — createPool validation
// @> Only checks registry membership and caller ownership.
// @> Never verifies this is the Binding Agreement for the scope accounts.
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
// ConfidencePool.sol — _replaceScope
// @> Validates accounts are in-scope of the stored agreement.
// @> Never cross-checks via attackRegistry.getAgreementForContract(account).
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
// ConfidencePool.sol — flagOutcome
// @> Reads state of the stored agreement address — not the Binding Agreement.
IAttackRegistry.ContractState state = _observePoolState(); // → _getAgreementState()
// @> CORRUPTED flag requires state == CORRUPTED.
// @> If stored agreement is PRODUCTION, this reverts unconditionally.
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();

Risk

Impact: High

  • The moderator is permanently and unconditionally blocked from flagging CORRUPTED on the pool.
    Even a good-faith moderator cannot override this — flagOutcome(CORRUPTED, ...) reverts at the
    registry state check. The only callable path is flagOutcome(SURVIVED, ...), which accepts
    PRODUCTION state.

  • The whitehat's bountyEntitlement is never set (remains 0) because CORRUPTED is never
    flagged. The whitehat receives nothing despite a legitimate corruption event under the Binding
    Agreement. The pool permanently misreports the protocol as SURVIVED.

Proof of Concept

Place this file at test/unit/ConfidencePool.bindingAgreementMismatch.t.sol.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
/// @dev A MockAttackRegistry that supports per-agreement state so we can
/// independently set Agreement A (pool's stored agreement) and Agreement B
/// (the actual Binding Agreement for the scope accounts).
contract MockAttackRegistryPerAgreement {
mapping(address => IAttackRegistry.ContractState) public states;
address public moderator;
constructor(address mod) {
moderator = mod;
}
function setState(address agreement, IAttackRegistry.ContractState s) external {
states[agreement] = s;
}
function getAgreementState(address agreement)
external
view
returns (IAttackRegistry.ContractState)
{
return states[agreement];
}
function getAttackModerator(address) external view returns (address) {
return moderator;
}
}
contract BindingAgreementMismatchTest is Test {
uint256 constant ONE = 1e18;
MockAttackRegistryPerAgreement attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
MockAgreement agreementA; // stored in pool — NOT the Binding Agreement
MockAgreement agreementB; // actual Binding Agreement for scope accounts
MockERC20 token;
ConfidencePoolFactory factory;
IConfidencePool pool;
address sponsor = makeAddr("sponsor");
address moderator = makeAddr("moderator");
address whitehat = makeAddr("whitehat");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address scopeAcct = makeAddr("scopeAcct");
function setUp() external {
token = new MockERC20("Token", "TKN");
attackRegistry = new MockAttackRegistryPerAgreement(moderator);
safeHarborRegistry = new MockSafeHarborRegistry(address(attackRegistry));
// Agreement A: valid, owned by sponsor, scopeAcct is in scope.
// This is the agreement the pool will be created with.
agreementA = new MockAgreement(sponsor);
agreementA.addToScope(scopeAcct);
safeHarborRegistry.setValid(address(agreementA), true);
// Agreement B: also valid, owned by sponsor, scopeAcct is in scope.
// This is the ACTUAL Binding Agreement registered in the AttackRegistry.
agreementB = new MockAgreement(sponsor);
agreementB.addToScope(scopeAcct);
safeHarborRegistry.setValid(address(agreementB), true);
// Both agreements start in NEW_DEPLOYMENT.
attackRegistry.setState(address(agreementA), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
attackRegistry.setState(address(agreementB), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
// Deploy factory and pool using Agreement A (the non-Binding Agreement).
ConfidencePool impl = new ConfidencePool();
factory = new ConfidencePoolFactory();
factory.initialize(address(safeHarborRegistry), address(impl), moderator);
factory.allowStakeToken(address(token), true);
address[] memory accounts = new address[](1);
accounts[0] = scopeAcct;
vm.prank(sponsor);
pool = IConfidencePool(
factory.createPool(
address(agreementA), // <-- non-Binding Agreement
address(token),
block.timestamp + 60 days,
0,
sponsor,
accounts
)
);
// Stakers deposit.
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(bob, 50 * ONE);
vm.startPrank(bob);
token.approve(address(pool), 50 * ONE);
pool.stake(50 * ONE);
vm.stopPrank();
}
function testNonBindingAgreementPermanentlyBlocksCorruptedResolution() external {
// ── 1. Scope accounts attacked under Agreement B (Binding Agreement) ──
// Agreement B transitions: NEW_DEPLOYMENT → UNDER_ATTACK → CORRUPTED
attackRegistry.setState(address(agreementB), IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setState(address(agreementB), IAttackRegistry.ContractState.CORRUPTED);
// Agreement A (pool's stored agreement) moves to PRODUCTION independently.
attackRegistry.setState(address(agreementA), IAttackRegistry.ContractState.PRODUCTION);
// ── 2. Moderator tries to flag CORRUPTED — REVERTS ────────────────────
// pool._getAgreementState() → getAgreementState(agreementA) → PRODUCTION
// flagOutcome(CORRUPTED) requires state == CORRUPTED → InvalidOutcome
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// ── 3. Moderator forced to flag SURVIVED ──────────────────────────────
// SURVIVED accepts PRODUCTION → succeeds
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.SURVIVED),
"pool resolved SURVIVED despite Binding Agreement being CORRUPTED"
);
// ── 4. Stakers claim principal + bonus (incorrectly) ──────────────────
vm.prank(alice);
pool.claimSurvived();
vm.prank(bob);
pool.claimSurvived();
// ── 5. Whitehat receives zero ──────────────────────────────────────────
assertEq(pool.bountyEntitlement(), 0, "whitehat bounty entitlement is zero");
// ── Proof: state mismatch between the two agreements ──────────────────
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"Agreement B (Binding Agreement) is CORRUPTED"
);
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.PRODUCTION),
"Agreement A (pool's stored agreement) is PRODUCTION — pool reads this"
);
}
}

Run with:

forge test --match-test testNonBindingAgreementPermanentlyBlocksCorruptedResolution -vvv

Recommended Mitigation

Add a Binding Agreement cross-check in _replaceScope for each scope account using
attackRegistry.getAgreementForContract. This ensures the pool's stored agreement is the
agreement that actually governs the scope accounts in the AttackRegistry.

// ConfidencePool.sol — _replaceScope
+ address attackRegistry_ = safeHarborRegistry.getAttackRegistry();
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
+ address binding = IAttackRegistry(attackRegistry_).getAgreementForContract(account);
+ if (binding != address(0) && binding != agreement) {
+ revert AgreementNotBindingForAccount(account);
+ }
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}

The binding != address(0) guard handles child contracts (where getAgreementForContract
returns zero and the Binding Agreement is resolved via the deployer chain). For top-level
contracts, the check enforces that the pool's stored agreement matches the AttackRegistry's
authoritative Binding Agreement for each scope account.

Support

FAQs

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

Give us feedback!