Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0010·input-validation

settleBatch does not bound the decoded item count

Fixednfterc-1155marketplace
TL;DR

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.

Severity
LOW
Impact
LOW
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
LOW
02Section · Description

Description

settleBatch() bounds total calldata, then allocates and returns settled from an independently decoded items.length:

solidity
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.

03Section · Impact

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.

04Section · Recommendation

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:

diff
+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);
05Section · Resolution

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.

06Section · Affected files

Affected files

  • src/PlakxioSettlement.sol#L354-L370 at commit 5c38893
Status
Fixed
F-2026-0010