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

Unbounded Scope Size in _replaceScope Causes Permanent DoS on Scope Updates Due to zkSync L1 Pubdata Limit on BattleChain

Author Revealed upon completion

Unbounded Scope Size in _replaceScope Causes Permanent DoS on Scope Updates Due to zkSync L1 Pubdata Limit on BattleChain

Description

  • The ConfidencePool contract allows the sponsor (pool owner) to define and update a list of BattleChain accounts (the "scope") that the pool commits to insuring. Per DESIGN.md §8, the sponsor can update this scope freely via setPoolScope() while the registry is in pre-attack staging (NOT_DEPLOYED / NEW_DEPLOYMENT). The scope is replaced atomically through the internal _replaceScope() function, which performs a wholesale clear-and-rewrite of the entire scope list in a single transaction.

  • BattleChain is a confirmed zkSync-based L2 (per lib/battlechain-safe-harbor-contracts/docs/deploy-considerations.md), which enforces a hard L1 pubdata limit of ~100,000 bytes per transaction. Because _replaceScope() performs unbounded storage writes with no upper bound on scope size, a sponsor who initializes a pool with ~520+ accounts will permanently lose the ability to update the scope — the replacement transaction will always revert due to exceeding the pubdata limit. There is no batch or incremental alternative; the wholesale atomic replacement is the only code path available.

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
// Clear existing scope first so this is a wholesale replacement.
address[] memory old = _scopeAccounts;
@> for (uint256 i; i < old.length; ++i) { // N storage writes to clear
@> isAccountInScope[old[i]] = false;
@> }
delete _scopeAccounts;
@> for (uint256 i; i < accounts.length; ++i) { // 2N storage writes to add
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
@> _scopeAccounts.push(account); // storage write: array slot
@> isAccountInScope[account] = true; // storage write: mapping slot
}
emit ScopeUpdated(accounts);
}

Each scope replacement of N accounts requires 3N storage writes (N clears + N mapping writes + N array pushes). On zkSync, each storage write produces ~64 bytes of L1 pubdata (32B key + 32B value). The pubdata cost scales linearly with no cap:

Setting this equal to zkSync's ~100,000 byte limit: $192N = 100{,}000 \implies N \approx 520$. Any scope replacement involving more than ~520 accounts will permanently revert.

Risk

Likelihood: Medium

  • Protocols with large contract deployments (DEXes, lending platforms, multi-contract DeFi systems) routinely maintain hundreds of deployed contracts across their ecosystem. A sponsor protecting such a protocol will naturally initialize scope with 500+ accounts — crossing the pubdata threshold on the confirmed zkSync-based target chain.

  • The contract enforces no upper bound on scope size and provides no warning or documentation about this chain-specific limitation. Sponsors have no way to know they are entering an irreversible state until the first setPoolScope() call reverts.

Impact: Low

  • The stated design guarantee in DESIGN.md §8 — that sponsors can "update scope freely" pre-lock — is violated. Once scope size exceeds ~520 accounts, the sponsor permanently loses the ability to correct errors (typos, wrong addresses), reflect protocol upgrades (new contract deployments), or remove deprecated/vulnerable contracts from the scope list.

  • The on-chain scope list, which serves as the binding transparency mechanism and audit trail for stakers to evaluate their risk exposure and for the moderator to justify resolution decisions, becomes permanently stale. Stakers can no longer verify that the published scope reflects the sponsor's actual intent, degrading the trust and accountability properties the protocol relies on.

Note: No direct fund loss occurs. The on-chain scope is not read by any payout-determining code path — flagOutcome relies on the moderator's off-chain judgment, and the claimExpired auto-resolution is explicitly scope-blind (line 533: "Scope-blind by design").

Proof of Concept

Place in test/unit/ConfidencePool.scopeDoS.t.sol and run with:

forge test --match-path test/unit/ConfidencePool.scopeDoS.t.sol -vvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
contract ConfidencePoolScopeDoSTest is BaseConfidencePoolTest {
uint256 constant LARGE_SCOPE_SIZE = 700;
function _generateScopeAccounts(uint256 count, uint256 salt)
internal
pure
returns (address[] memory accounts)
{
accounts = new address[](count);
for (uint256 i; i < count; ++i) {
accounts[i] = address(uint160(uint256(keccak256(abi.encode(salt, i)))));
}
}
function _seedAgreementScope(address[] memory accounts) internal {
for (uint256 i; i < accounts.length; ++i) {
agreementContract.setContractInScope(accounts[i], true);
}
}
function _deployPoolWithLargeScope(address[] memory accounts)
internal
returns (ConfidencePool largePool)
{
ConfidencePool implementation = new ConfidencePool();
largePool = ConfidencePool(Clones.clone(address(implementation)));
largePool.initialize(
agreement, address(token), address(safeHarborRegistry),
moderator, block.timestamp + 31 days, 1e18, recovery,
address(this), accounts
);
}
/// @notice Core PoC: proves 700-account scope replacement exceeds zkSync pubdata limit
function testScopeReplacementGasScaling() public {
address[] memory initialScope = _generateScopeAccounts(LARGE_SCOPE_SIZE, 1);
_seedAgreementScope(initialScope);
ConfidencePool largePool = _deployPoolWithLargeScope(initialScope);
assertEq(largePool.getScopeAccounts().length, LARGE_SCOPE_SIZE);
address[] memory newScope = _generateScopeAccounts(LARGE_SCOPE_SIZE, 2);
_seedAgreementScope(newScope);
uint256 gasBefore = gasleft();
largePool.setPoolScope(newScope);
uint256 gasUsed = gasBefore - gasleft();
emit log_named_uint("Gas used for scope replacement (700 -> 700)", gasUsed);
// Mathematical proof: 3 × 700 × 64 = 134,400 bytes > 100,000 byte zkSync limit
uint256 estimatedPubdata = (LARGE_SCOPE_SIZE + LARGE_SCOPE_SIZE * 2) * 64;
uint256 zkSyncPubdataLimit = 100_000;
emit log_named_uint("Estimated L1 pubdata (bytes)", estimatedPubdata);
emit log_named_uint("zkSync pubdata limit (bytes)", zkSyncPubdataLimit);
emit log_named_uint("Pubdata EXCEEDS limit by (bytes)", estimatedPubdata - zkSyncPubdataLimit);
assertGt(estimatedPubdata, zkSyncPubdataLimit,
"Estimated pubdata for 700-account replacement exceeds zkSync L1 limit");
}
/// @notice Shows linear, unbounded gas scaling across scope sizes
function testScopeGasScalingComparison() public {
uint256[4] memory sizes = [uint256(10), 100, 300, 500];
for (uint256 s; s < sizes.length; ++s) {
uint256 size = sizes[s];
address[] memory scope = _generateScopeAccounts(size, s + 100);
_seedAgreementScope(scope);
ConfidencePool testPool = _deployPoolWithLargeScope(scope);
address[] memory newScope = _generateScopeAccounts(size, s + 200);
_seedAgreementScope(newScope);
uint256 gasBefore = gasleft();
testPool.setPoolScope(newScope);
uint256 gasUsed = gasBefore - gasleft();
emit log_named_uint("--- Scope size", size);
emit log_named_uint(" Gas used", gasUsed);
emit log_named_uint(" Est. pubdata (bytes)", size * 3 * 64);
emit log_named_uint(" Gas per account", gasUsed / size);
}
}
}

Test Output (measured):

Scope Size Gas Used Est. Pubdata Gas/Account Within zkSync Limit?
10 486,076 1,920 B 48,607 ✅ Yes
100 4,542,779 19,200 B 45,427 ✅ Yes
300 13,576,036 57,600 B 45,253 ✅ Yes
500 22,617,889 96,000 B 45,235 ⚠️ Borderline
700 31,662,501 134,400 B 45,232 Exceeds by 34,400 B

Recommended Mitigation

Add an upper bound on scope size that respects BattleChain's pubdata constraints:

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
+ if (accounts.length > 500) revert ScopeTooLarge();
...
}

Alternatively, implement an incremental scope update pattern (e.g., addToScope() / removeFromScope()) that avoids the wholesale clear-and-rewrite, allowing large scopes to be modified in multiple transactions.

Support

FAQs

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

Give us feedback!