Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: medium
Valid

Invalid Calls and Reverts Due to Trailing Zeros in FeeConversionKeeper’s Upkeep Data

Summary

This issue arises from allocating arrays with a fixed, oversized length while only populating a portion of the elements in checkUpkeep. As a result, when these arrays are decoded and iterated over in performUpkeep, the function attempts to process trailing entries that remain uninitialized, forcing calls with invalid parameters (marketId=0, asset=address(0)), potentially leading to a revert. Consequently, normal fee distribution is disrupted, and the keeper’s functionality may fail entirely due to these unintended calls.

Vulnerability Details

The core problem stems from creating an array of length liveMarketIds.length * 10 in checkUpkeep but only partially populating it with valid market and asset data. Unused indices remain initialized to zero (uint128(0), address(0)). Later, in performUpkeep, the entire array is decoded and iterated through, including those zeroed entries. This causes invalid calls to convertAccumulatedFeesToWeth(0, address(0), ...), which may revert or behave unexpectedly. If the first invalid call reverts, it prevents any further fee distribution, effectively undermining the keeper’s intended functionality.

Impact

Failing to properly handle trailing zero entries can cause the entire performUpkeep function to revert on the first invalid call. This prevents all subsequent fee distributions and interrupts the keeper’s automation process. As a result, accumulated fees remain undistributed, which can halt critical protocol operations that rely on timely conversion and distribution of these assets.

Proof of Concept

During the encoding process of the performData array within checkUpkeep, the code allocates an array of size liveMarketIds.length * 10 but only partially fills it with valid market and asset data. This leaves unused slots containing zeros. When that performData is later decoded in performUpkeep, the loop attempts to process these “garbage” (trailing zeros) entries that were never intended to be used, resulting in calls like:

convertAccumulatedFeesToWeth(0, address(0), ...)

which may revert or exhibit unexpected behavior.

Code Analysis

Below is a key snippet illustrating the issue of oversized arrays:

function checkUpkeep(bytes calldata /**/ ) external view returns (bool upkeepNeeded, bytes memory performData) {
// ... Preliminary code ...
uint128[] memory marketIds = new uint128[](liveMarketIds.length * 10);
address[] memory assets = new address[](liveMarketIds.length * 10);
uint256 index = 0;
// Iterates through each market
for (uint128 i; i < liveMarketIds.length; i++) {
// ... Retrieves fees for each market ...
// (!) Stores market IDs and assets only where distributionNeeded is true
// Other positions remain as zero (0, 0x00)
if (distributionNeeded) {
marketIds[index] = marketId;
assets[index] = marketAssets[j];
index++;
}
}
if (upkeepNeeded) {
// All elements in the array (including unused zero positions) are encoded into performData
performData = abi.encode(marketIds, assets);
}
}
function performUpkeep(bytes calldata performData) external override onlyForwarder {
// ... Preliminary code ...
// (!) Decodes the entire array, which may contain trailing zero entries at the end
(uint128[] memory marketIds, address[] memory assets) = abi.decode(performData, (uint128[], address[]));
for (uint256 i; i < marketIds.length; i++) {
// Potentially reverts if marketIds[i] == 0 and assets[i] == address(0)
marketMakingEngine.convertAccumulatedFeesToWeth(
marketIds[i],
assets[i],
self.dexSwapStrategyId,
""
);
}
}

Explanation

  • The array is instantiated with a length of liveMarketIds.length * 10 but only partially populated.

  • Unused positions remain as zero for both uint128 and address types.

  • Decoding this oversized array in performUpkeep causes iteration over all elements, including zeroed entries.

  • This leads to calls like convertAccumulatedFeesToWeth(0, address(0), ...), which can revert or otherwise behave unpredictably.

Vulnerable Scenario

  • There are 2 markets that require fee distribution (distributionNeeded = true).

  • liveMarketIds.length could be 1, resulting in an array length of 1 * 10 = 10.

  • Only 2 of those 10 entries are valid; the rest remain (0, address(0)).

  • When performUpkeep is invoked, the contract decodes an array with 10 total elements and processes each one.

  • By the third loop iteration (index 2), it tries convertAccumulatedFeesToWeth(0, address(0), ...), triggering a revert or undesired outcome.

This scenario disrupts performUpkeep execution and compromises fee distribution functionality.

Test and Result

This test demonstrates that once the forwarder slot is patched, calls to performUpkeep() bypass the onlyForwarder restriction and proceed to process trailing-zero array entries. The mock engine intentionally reverts with "Mock revert for trailing zeros", confirming that these oversized arrays indeed trigger unintended execution paths. The log trace shows the call to convertAccumulatedFeesToWeth() with invalid (marketId, asset), validating the vulnerability and the associated revert.

  • Add the following test to test/FeeConversionKeeperTrailingZerosTest.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
// Zaros dependencies
import { Base_Test } from "test/Base.t.sol";
import { FeeConversionKeeper } from "@zaros/external/chainlink/keepers/fee-conversion-keeper/FeeConversionKeeper.sol";
import { ChainlinkAutomationUtils } from "script/utils/ChainlinkAutomationUtils.sol";
import { DexSwapStrategy } from "@zaros/market-making/leaves/DexSwapStrategy.sol";
import { IMarketMakingEngine } from "@zaros/market-making/MarketMakingEngine.sol";
/**
* @notice Tests the "trailing zeros" issue in FeeConversionKeeper by patching the `forwarder` address
* and forcing a revert different from "Unauthorized".
*/
contract FeeConversionKeeperTrailingZerosTest is Base_Test {
FeeConversionKeeper internal _feeConversionKeeper;
MockMarketMakingEngineForTrailingZeros internal mockEngine;
MockForwarder internal mockForwarder;
// Copied exactly from BaseKeeper (see your BaseKeeper.sol)
bytes32 internal constant BASE_KEEPER_LOCATION = keccak256(
abi.encode(uint256(keccak256("fi.zaros.external.chainlink.keepers.BaseKeeper")) - 1)
) & ~bytes32(uint256(0xff));
function setUp() public virtual override {
super.setUp();
// Deploy the mock MarketMakingEngine
mockEngine = new MockMarketMakingEngineForTrailingZeros();
// Deploy and initialize FeeConversionKeeper
changePrank({ msgSender: users.owner.account });
address keeperAddr = ChainlinkAutomationUtils.deployFeeConversionKeeper(
users.owner.account,
address(mockEngine),
1, // dexSwapStrategyId
1 // minFeeDistributionValueUsd
);
_feeConversionKeeper = FeeConversionKeeper(keeperAddr);
// Deploy the MockForwarder
mockForwarder = new MockForwarder(_feeConversionKeeper);
// Patch the storage slot where `forwarder` is stored
vm.store(
address(_feeConversionKeeper),
BASE_KEEPER_LOCATION,
bytes32(uint256(uint160(address(mockForwarder))))
);
// If FeeConversionKeeper checks for forwarder authorization in MarketMakingEngine, authorize it
IMarketMakingEngine(address(mockEngine)).configureSystemKeeper(address(mockForwarder), true);
vm.stopPrank();
}
/**
* @notice Verifies that processing trailing zeros leads to a revert with the message
* "Mock revert for trailing zeros", instead of "Unauthorized(...)"
*/
function testPerformUpkeepWithTrailingZeros() external {
// Generate performData with oversized arrays (part of the indices set to zero)
(bool upkeepNeeded, bytes memory performData) = _feeConversionKeeper.checkUpkeep("");
assertTrue(upkeepNeeded, "upkeepNeeded should be true");
// Expect a custom revert (not 'Unauthorized')
vm.expectRevert(bytes("Mock revert for trailing zeros"));
// Call performUpkeep from the patched forwarder
vm.prank(address(mockForwarder));
mockForwarder.forwardPerformUpkeep(performData);
}
}
/**
* @notice A mock contract that calls `performUpkeep` as if it were the authorized forwarder.
*/
contract MockForwarder {
FeeConversionKeeper public keeper;
constructor(FeeConversionKeeper _keeper) {
keeper = _keeper;
}
function forwardPerformUpkeep(bytes memory data) public {
keeper.performUpkeep(data);
}
}
/**
* @notice A mock IMarketMakingEngine that:
* - Returns 2 fees to trigger trailing zeros in FeeConversionKeeper
* - Reverts with "Mock revert for trailing zeros" in `convertAccumulatedFeesToWeth`.
*/
contract MockMarketMakingEngineForTrailingZeros {
mapping(address => bool) public keepers;
/// @notice Authorizes a keeper (mock of "configureSystemKeeper")
function configureSystemKeeper(address keeper, bool status) external {
keepers[keeper] = status;
}
/// @notice Returns a valid DexSwapStrategy
function getDexSwapStrategy(uint128 /*dexSwapStrategyId*/)
external
pure
returns (DexSwapStrategy.Data memory)
{
DexSwapStrategy.Data memory data;
data.dexAdapter = address(1);
return data;
}
/// @notice Returns a single market ID
function getLiveMarketIds() external pure returns (uint128[] memory) {
uint128[] memory marketIds = new uint128[](1);
marketIds[0] = 1;
return marketIds;
}
/// @notice Returns only 2 assets with fees => FeeConversionKeeper creates a larger array => trailing zeros
function getReceivedMarketFees(uint128 /*marketId*/)
external
pure
returns (address[] memory, uint256[] memory)
{
address[] memory assets = new address[](2);
assets[0] = address(100);
assets[1] = address(101);
uint256[] memory fees = new uint256[](2);
fees[0] = 500;
fees[1] = 600;
return (assets, fees);
}
/// @notice Always returns 1000, enough to make distributionNeeded = true
function getAssetValue(address /*asset*/, uint256 /*collectedFee*/)
external
pure
returns (uint256)
{
return 1000;
}
/// @notice Simulates the final logic that reverts if something is wrong (e.g. trailing zeros with marketId=0)
function convertAccumulatedFeesToWeth(
uint128 /*marketId*/,
address /*asset*/,
uint128 /*dexSwapStrategyId*/,
bytes calldata /*extraData*/
)
external
pure
{
revert("Mock revert for trailing zeros");
}
}
[PASS] testPerformUpkeepWithTrailingZeros() (gas: 52726)
│ │ │ ├─ [775] MockMarketMakingEngineForTrailingZeros::convertAccumulatedFeesToWeth(1, 0x0000000000000000000000000000000000000064, 1, 0x)
│ │ │ │ └─ ← [Revert] revert: Mock revert for trailing zeros
│ │ │ └─ ← [Revert] revert: Mock revert for trailing zeros
│ │ └─ ← [Revert] revert: Mock revert for trailing zeros
│ └─ ← [Revert] revert: Mock revert for trailing zeros
└─ ← [Stop]

Confirmation

By comparing the actual number of valid items (tracked by index) to the full array length used in performData, it becomes clear that trailing zeros force the contract to process invalid entries in performUpkeep. This confirms the finding and highlights the potential for failure in the fee conversion logic.

Tools Used

Manual Code Review:
A thorough inspection of the contract’s logic and storage structures was performed to identify how the array is allocated and used, revealing the trailing zero entries and their unintended execution paths.

Recommendations

Provide only the populated portion of the array when encoding performData (e.g., by creating a new array sized exactly to the valid entries) or by passing both the full array and the actual count of meaningful elements. On decoding, rely on the real count to skip trailing zeros and avoid invalid calls such as convertAccumulatedFeesToWeth(0, address(0), ...).

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

FeeConversionKeeper performUpkeep iterates over entire oversized arrays instead of just populated entries, causing reverts on address(0) slots

Support

FAQs

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