Root + Impact
Description
-
Normally, a sponsor can create a pool with a non-empty list of BattleChain accounts and can replace that list while the agreement remains in pre-attack staging. Scope replacement is expected to remain available until the scope is permanently locked by an observed registry transition.
The issue is that neither pool creation nor scope replacement bounds accounts.length, while _replaceScope() performs the whole operation atomically. One call must copy and clear every old account, delete the old array, validate every new account through an external call, write every new array and mapping entry, and emit the complete new array. There is no pagination, continuation cursor, or alternative hash-commit path. A future BattleChain/ZKsync OS upgrade that increases storage, account-access, external-call, or calldata costs can therefore make a scope that was valid under the old gas schedule impossible to replace within the new per-transaction execution budget.
This is a future-compatibility issue rather than a currently active Ethereum L1 exploit: Confidence Pools execute on BattleChain, so the condition becomes reachable only when BattleChain adopts a sufficiently restrictive gas repricing or transaction gas cap. Draft proposals such as EIP-8037 show that substantial state-creation repricing is an active Ethereum design direction, but do not by themselves prove that BattleChain will adopt the same parameters.
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
@> address[] calldata accounts
) external whenNotPaused returns (address pool) {
...
pool = Clones.cloneDeterministic(poolImplementation, salt);
@> IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
@> accounts
);
...
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
@> _replaceScope(accounts);
}
function _replaceScope(address[] calldata accounts) internal {
@> if (accounts.length == 0) revert EmptyScope();
address[] memory old = _scopeAccounts;
@> for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
@> delete _scopeAccounts;
@> 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);
}
@> _scopeAccounts.push(account);
@> isAccountInScope[account] = true;
}
@> emit ScopeUpdated(accounts);
}
Risk
Likelihood:
-
This occurs when a pool contains a scope that was writable under the gas schedule in force at creation, and a later BattleChain/ZKsync OS upgrade raises the regular execution cost of clearing, validating, or rewriting that scope above the maximum gas available to one transaction.
-
The likelihood is low and upgrade-dependent. Ethereum gas repricing proposals are evidence that opcode and state-access costs can change, but Ethereum L1 upgrades do not automatically apply to BattleChain and the exact future L2 schedule is unknown.
Impact:
-
The sponsor can lose the documented ability to update the pool scope during pre-attack staging, leaving the pool committed to an obsolete or operationally incorrect account list.
-
The failure does not directly steal or permanently lock staker funds: withdrawals remain available before active risk, settlement paths do not iterate over scope, and the sponsor can create a replacement pool. These escape paths limit the issue to Low severity.
Proof of Concept
The following Foundry test models a stricter future per-transaction execution budget by making the replacement through a gas-capped subcall. It demonstrates that a large replacement fails atomically, preserves the old scope, and exposes no cursor with which the sponsor can resume the operation.
function testScopeReplacementCannotProgressAcrossGasBoundedCalls() external {
uint256 accountCount = 100;
address[] memory replacement = new address[]();
for (uint256 i; i < accountCount; ++i) {
address account = address(uint160(0x1000 + i));
replacement[i] = account;
agreementContract.setContractInScope(account, true);
}
address[] memory oldScope = pool.getScopeAccounts();
assertEq(oldScope.length, 1);
assertEq(oldScope[0], DEFAULT_SCOPE_ACCOUNT);
(bool success,) = address(pool).call{gas: 500_000}(
abi.encodeCall(IConfidencePool.setPoolScope, (replacement))
);
assertFalse(success);
address[] memory scopeAfterFailure = pool.getScopeAccounts();
assertEq(scopeAfterFailure.length, 1);
assertEq(scopeAfterFailure[0], DEFAULT_SCOPE_ACCOUNT);
pool.setPoolScope(replacement);
assertEq(pool.getScopeAccounts().length, accountCount);
}
```
Recommended Mitigation
Set a conservative maximum scope size derived from BattleChain's per-transaction gas budget and a pessimistic future gas schedule. Apply the same bound at factory entry and inside the pool so direct clone initialization and future factory implementations cannot bypass it.
```diff
contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausable, IConfidencePool {
+ uint256 public constant MAX_SCOPE_ACCOUNTS = 128;
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
+ if (accounts.length > MAX_SCOPE_ACCOUNTS) revert ScopeTooLarge();
...
}
}
+contract ConfidencePoolFactory is ... {
+ uint256 public constant MAX_SCOPE_ACCOUNTS = 128;
+
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
+ if (accounts.length > MAX_SCOPE_ACCOUNTS) revert ScopeTooLarge();
...
}
```
If large scopes are a protocol requirement, replace wholesale array mutation with a two-phase mechanism: commit the intended scope hash, add and remove entries in bounded batches, and activate the new scope only after the accumulated hash and account count match the commitment. This preserves atomic activation without requiring all storage work to fit in one transaction.