settleBatch does not bound the decoded item count
settleBatch bounds total calldata but allocates from an independently decoded item count, so the calldata cap does not bound the batch the way its documentation relies on.
Description
settleBatch() bounds total calldata, then allocates and returns settled from
an independently decoded items.length:
if (msg.data.length > MAX_BATCH_CALLDATA) {revert BatchCalldataTooLarge(msg.data.length, MAX_BATCH_CALLDATA);}uint256 n = items.length;if (n == 0) revert EmptyBatch();settled = new bool[](n);
MAX_BATCH_CALLDATA is documented at L144-L159 as the load-bearing guard, on the
reasoning that capping total bytes bounds everything the loop can be asked to do;
L157 sizes it as "~100 honest pairs (each ~1.2 KB)". That reasoning holds only
for canonical ABI encoding. The Solidity decoder accepts a dynamic array whose
element offsets point at the same tuple body, so the marginal cost of an item is
the 32-byte offset head rather than the ~736-byte tuple. A call carrying two
distinct tuple bodies decodes to 3,774 items while measuring exactly 131,072
bytes — roughly 21x the assumed ceiling, with the byte cap respected.
n then drives three costs that BATCH_GAS_FLOOR was sized against a far
smaller number: the settled allocation, the loop bound, and the return
encoding. Once n is large enough, the gas EIP-150 retains for the parent after
a pair consumes its SETTLE_ONE_GAS_CAP share is insufficient to ABI-encode the
return. The call then runs out of gas and reverts wholesale, unwinding pairs that
had already settled, where the design intends a partial result the relayer
resubmits.
The item count is never bounded directly, so this rests on a property of the
encoder rather than on a check. settleBatch is onlyRole(RELAYER_ROLE) and the
relayer builds the encoding, and standard ABI encoders emit unique canonical
offsets — so maker-supplied data alone does not reach it. What is reported is the
missing bound, not an externally driven attack.
Impact
A batch that should return a partial result and let the relayer re-submit the remainder instead reverts wholesale, unwinding pairs that had already settled in the same call. The relayer loses the transaction gas and the makers in the batch lose their settlement turn until it is re-submitted. No funds are stolen and no state is corrupted.
Recommendation
Bound the decoded item count before allocating settled, in addition to the
existing byte caps rather than instead of them. The byte caps bound what the loop
copies; a count cap separately bounds allocation, iteration and return-data cost,
and makes all three independent of how the calldata was encoded.
A limit of 100 matches the capacity already documented at L157:
+uint256 public constant MAX_BATCH_ITEMS = 100;+error BatchTooManyItems(uint256 size, uint256 max);uint256 n = items.length;if (n == 0) revert EmptyBatch();+if (n > MAX_BATCH_ITEMS) revert BatchTooManyItems(n, MAX_BATCH_ITEMS);settled = new bool[](n);
Resolution
MAX_BATCH_ITEMS = 100 is asserted before the settled allocation, so the bound no longer
depends on the calldata being canonically encoded. Verified at audit-v2.
Affected files
src/PlakxioSettlement.sol#L354-L370at commit5c38893