From 36850f89352b7f3067f6ab74f07e401f74b46b32 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 4 Mar 2026 11:55:31 +0000 Subject: [PATCH] fix: avoid quicksort stack overflow on worst-case inputs Rework Arrays._quickSort to recurse on the smaller partition and iterate over the larger partition, preventing deep recursion on already-sorted/reversed arrays. Add a regression test for uint256 arrays of length 256. --- contracts/utils/Arrays.sol | 50 ++++++++++++++++++---------- scripts/generate/templates/Arrays.js | 50 ++++++++++++++++++---------- test/utils/Arrays.test.js | 7 +++- 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/contracts/utils/Arrays.sol b/contracts/utils/Arrays.sol index f837004ff92..32aa2f2335f 100644 --- a/contracts/utils/Arrays.sol +++ b/contracts/utils/Arrays.sol @@ -114,25 +114,41 @@ library Arrays { */ function _quickSort(uint256 begin, uint256 end, function(uint256, uint256) pure returns (bool) comp) private pure { unchecked { - if (end - begin < 0x40) return; - - // Use first element as pivot - uint256 pivot = _mload(begin); - // Position where the pivot should be at the end of the loop - uint256 pos = begin; - - for (uint256 it = begin + 0x20; it < end; it += 0x20) { - if (comp(_mload(it), pivot)) { - // If the value stored at the iterator's position comes before the pivot, we increment the - // position of the pivot and move the value there. - pos += 0x20; - _swap(pos, it); + // Iterative quicksort with recursion on the smaller partition only. + // This avoids unbounded recursion depth in the worst case (e.g. already-sorted arrays). + while (end - begin >= 0x40) { + // Use first element as pivot + uint256 pivot = _mload(begin); + // Position where the pivot should be at the end of the loop + uint256 pos = begin; + + for (uint256 it = begin + 0x20; it < end; it += 0x20) { + if (comp(_mload(it), pivot)) { + // If the value stored at the iterator's position comes before the pivot, we increment the + // position of the pivot and move the value there. + pos += 0x20; + _swap(pos, it); + } } - } - _swap(begin, pos); // Swap pivot into place - _quickSort(begin, pos, comp); // Sort the left side of the pivot - _quickSort(pos + 0x20, end, comp); // Sort the right side of the pivot + _swap(begin, pos); // Swap pivot into place + + // Recurse on the smaller side, then loop on the larger side. + uint256 leftBegin = begin; + uint256 leftEnd = pos; + uint256 rightBegin = pos + 0x20; + uint256 rightEnd = end; + + if (leftEnd - leftBegin < rightEnd - rightBegin) { + _quickSort(leftBegin, leftEnd, comp); + begin = rightBegin; + end = rightEnd; + } else { + _quickSort(rightBegin, rightEnd, comp); + begin = leftBegin; + end = leftEnd; + } + } } } diff --git a/scripts/generate/templates/Arrays.js b/scripts/generate/templates/Arrays.js index 3dd4d8c7eea..4117acd2e92 100644 --- a/scripts/generate/templates/Arrays.js +++ b/scripts/generate/templates/Arrays.js @@ -62,25 +62,41 @@ const quickSort = `\ */ function _quickSort(uint256 begin, uint256 end, function(uint256, uint256) pure returns (bool) comp) private pure { unchecked { - if (end - begin < 0x40) return; - - // Use first element as pivot - uint256 pivot = _mload(begin); - // Position where the pivot should be at the end of the loop - uint256 pos = begin; - - for (uint256 it = begin + 0x20; it < end; it += 0x20) { - if (comp(_mload(it), pivot)) { - // If the value stored at the iterator's position comes before the pivot, we increment the - // position of the pivot and move the value there. - pos += 0x20; - _swap(pos, it); + // Iterative quicksort with recursion on the smaller partition only. + // This avoids unbounded recursion depth in the worst case (e.g. already-sorted arrays). + while (end - begin >= 0x40) { + // Use first element as pivot + uint256 pivot = _mload(begin); + // Position where the pivot should be at the end of the loop + uint256 pos = begin; + + for (uint256 it = begin + 0x20; it < end; it += 0x20) { + if (comp(_mload(it), pivot)) { + // If the value stored at the iterator's position comes before the pivot, we increment the + // position of the pivot and move the value there. + pos += 0x20; + _swap(pos, it); + } } - } - _swap(begin, pos); // Swap pivot into place - _quickSort(begin, pos, comp); // Sort the left side of the pivot - _quickSort(pos + 0x20, end, comp); // Sort the right side of the pivot + _swap(begin, pos); // Swap pivot into place + + // Recurse on the smaller side, then loop on the larger side. + uint256 leftBegin = begin; + uint256 leftEnd = pos; + uint256 rightBegin = pos + 0x20; + uint256 rightEnd = end; + + if (leftEnd - leftBegin < rightEnd - rightBegin) { + _quickSort(leftBegin, leftEnd, comp); + begin = rightBegin; + end = rightEnd; + } else { + _quickSort(rightBegin, rightEnd, comp); + begin = leftBegin; + end = leftEnd; + } + } } } diff --git a/test/utils/Arrays.test.js b/test/utils/Arrays.test.js index 8a4bcb0162b..bffa3978dbf 100644 --- a/test/utils/Arrays.test.js +++ b/test/utils/Arrays.test.js @@ -133,7 +133,12 @@ describe('Arrays', function () { if (isValueType) { describe('sort', function () { - for (const length of [0, 1, 2, 8, 32, 128]) { + const lengths = [0, 1, 2, 8, 32, 128]; + // Sorting larger arrays in the worst case (already reversed) used to overflow the EVM stack + // due to unbounded recursion depth in the internal quicksort implementation. + if (name === 'uint256') lengths.push(256); + + for (const length of lengths) { describe(`${name}[] of length ${length}`, function () { beforeEach(async function () { this.array = Array.from({ length }, generators[name]);