KeepersHelper

0xbc22d10f51d55084cba2371e0f7f53f6f08916aa

Verification
Verified
v0.8.33+commit.64118f21
Type
Contract
8,384 bytes
ABI entries
46
17 read · 10 write
License
none

Contract information

Address
0xbc22d10f51d55084cba2371e0f7f53f6f08916aa
Chain
Robinhood Chain (4663)
Compiler
v0.8.33+commit.64118f21
Optimization
Enabled
Creation tx
0x65ab27a9b0…ceae06710e

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (17)

MAX_CHECK_INTERVAL()uint256
MAX_FEE_TOKENS()uint256
MIN_CHECK_INTERVAL()uint256
checkInterval()uint256
checkUpkeep(bytes)bool, bytes
feeTokens(uint256)address
getFeeTokens()address[]
lastCheckedIndexByShard(uint256)uint256
lastPerformTime()uint256
maxPositionsPerCheck()uint256
minGasReserve()uint256
owner()address
pendingOwner()address
renounceOwnership()
treasurySplitter()address
vault()address
viewHelper()address

Events (10)

ActionFailedBatchExecutedCheckIntervalUpdatedFeeTokenAddedFeeTokenRemovedFeesDistributedMaxPositionsPerCheckUpdatedMinGasReserveUpdatedOwnershipTransferStartedOwnershipTransferred

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "_vault",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_viewHelper",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_treasurySplitter",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_owner",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "FeeTokenAlreadyAdded",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "FeeTokenNotFound",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "IntervalOutOfRange",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidParameter",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidRangeAtBoundary",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "LengthMismatch",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotAuthorized",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "TooManyFeeTokens",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint8",
        "name": "actionType",
        "type": "uint8"
      }
    ],
    "name": "ActionFailed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "actionsAttempted",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "actionsSucceeded",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "emergency",
        "type": "bool"
      }
    ],
    "name": "BatchExecuted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "oldInterval",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "newInterval",
        "type": "uint256"
      }
    ],
    "name": "CheckIntervalUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "FeeTokenAdded",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "FeeTokenRemoved",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "tokenCount",
        "type": "uint256"
      }
    ],
    "name": "FeesDistributed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "oldMax",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "newMax",
        "type": "uint256"
      }
    ],
    "name": "MaxPositionsPerCheckUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "oldReserve",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "newReserve",
        "type": "uint256"
      }
    ],
    "name": "MinGasReserveUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferStarted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "MAX_CHECK_INTERVAL",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_FEE_TOKENS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MIN_CHECK_INTERVAL",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "acceptOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "addFeeToken",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "checkInterval",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes",
        "name": "checkData",
        "type": "bytes"
      }
    ],
    "name": "checkUpkeep",
    "outputs": [
      {
        "internalType": "bool",
        "name": "upkeepNeeded",
        "type": "bool"
      },
      {
        "internalType": "bytes",
        "name": "performData",
        "type": "bytes"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "distributeFees",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256[]",
        "name": "tokenIds",
        "type": "uint256[]"
      },
      {
        "internalType": "uint8[]",
        "name": "actionTypes",
        "type": "uint8[]"
      }
    ],
    "name": "emergencyExecute",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "feeTokens",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "getFeeTokens",
    "outputs": [
      {
        "internalType": "address[]",
        "name": "",
        "type": "address[]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "lastCheckedIndexByShard",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "lastPerformTime",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "maxPositionsPerCheck",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "minGasReserve",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "pendingOwner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes",
        "name": "performData",
        "type": "bytes"
      }
    ],
    "name": "performUpkeep",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "removeFeeToken",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "pure",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "_interval",
        "type": "uint256"
      }
    ],
    "name": "setCheckInterval",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "_max",
        "type": "uint256"
      }
    ],
    "name": "setMaxPositionsPerCheck",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "_reserve",
        "type": "uint256"
      }
    ],
    "name": "setMinGasReserve",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "treasurySplitter",
    "outputs": [
      {
        "internalType": "contract TreasurySplitter",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "vault",
    "outputs": [
      {
        "internalType": "contract ISnuggleVault",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "viewHelper",
    "outputs": [
      {
        "internalType": "contract ViewHelper",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/automation/AutomationCompatible.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "./interfaces/ISnuggleVault.sol";
import "./ViewHelper.sol";
import "./TreasurySplitter.sol";
import "./libraries/TickMath.sol";

/// @title KeepersHelper V2 — Batched Design
/// @notice Handles Chainlink Automation logic for SnuggleVault with batched execution
/// @dev Key improvements over V1:
///      - Batched execution: all actions collected and executed in a single performUpkeep
///      - Pool-level TWAP caching: O(pools) instead of O(positions) for TWAP calls
///      - Priority ordering: rebalances (2) > start tracking (1) > reset tracking (3)
///      - gasleft() guard: auto-adapts to whatever Chainlink gas limit is set
///      - Sharding-ready: checkData can encode (shardId, numShards) for parallel upkeeps
///      - Decoupled fee distribution: separate permissionless function
///      - Emergency batch execute: owner can bypass Chainlink
contract KeepersHelper is AutomationCompatibleInterface, Ownable2Step {
    // === Structs ===

    struct CachedTWAP {
        address pool;
        int24 tick;
    }

    // === Constants ===

    uint256 public constant MIN_CHECK_INTERVAL = 1 minutes;
    uint256 public constant MAX_CHECK_INTERVAL = 1 hours;
    uint256 public constant MAX_FEE_TOKENS = 50;

    // === Immutables ===

    ISnuggleVault public immutable vault;
    ViewHelper public immutable viewHelper;
    TreasurySplitter public immutable treasurySplitter;

    // === Configurable State ===

    uint256 public maxPositionsPerCheck = 500;
    uint256 public minGasReserve = 500_000;
    uint256 public checkInterval = 5 minutes;
    address[] public feeTokens;

    // === Pagination State ===

    /// @notice INT-12 (Valves internal V31): per-shard scan cursor. A single shared cursor let parallel
    ///         shard upkeeps clobber each other's pagination (coverage gaps / rescans). Keyed by shardId
    ///         (0 for the unsharded/empty-checkData case).
    mapping(uint256 => uint256) public lastCheckedIndexByShard;
    uint256 public lastPerformTime;

    // === Events ===

    event BatchExecuted(uint256 actionsAttempted, uint256 actionsSucceeded, bool emergency);
    event FeesDistributed(uint256 tokenCount);
    event CheckIntervalUpdated(uint256 oldInterval, uint256 newInterval);
    event MaxPositionsPerCheckUpdated(uint256 oldMax, uint256 newMax);
    event MinGasReserveUpdated(uint256 oldReserve, uint256 newReserve);
    event FeeTokenAdded(address indexed token);
    event FeeTokenRemoved(address indexed token);
    event ActionFailed(uint256 indexed tokenId, uint8 actionType);

    // === Errors ===

    error NotAuthorized();
    error IntervalOutOfRange();
    error InvalidParameter();
    error LengthMismatch();
    error FeeTokenAlreadyAdded();
    error FeeTokenNotFound();
    error TooManyFeeTokens();

    constructor(address _vault, address _viewHelper, address _treasurySplitter, address _owner) {
        vault = ISnuggleVault(_vault);
        viewHelper = ViewHelper(_viewHelper);
        treasurySplitter = TreasurySplitter(_treasurySplitter);
        if (_owner != msg.sender) {
            _transferOwnership(_owner);
        }
    }

    /// @dev Disable renounceOwnership to prevent accidental loss of owner-only functions
    function renounceOwnership() public pure override {
        revert InvalidParameter();
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // CORE AUTOMATION
    // ═══════════════════════════════════════════════════════════════════════════

    /// @notice Check all positions for needed actions, return batched performData
    /// @param checkData Optional: abi.encode(uint8 shardId, uint8 numShards) for parallel upkeeps.
    ///                  Empty checkData scans all positions (backward compatible).
    /// @return upkeepNeeded Whether any position needs attention
    /// @return performData Encoded (uint256[] tokenIds, uint8[] actionTypes, uint256 newLastChecked)
    function checkUpkeep(
        bytes calldata checkData
    ) external view override returns (bool upkeepNeeded, bytes memory performData) {
        // Time gate
        if (block.timestamp < lastPerformTime + checkInterval) {
            return (false, "");
        }

        uint256 totalPositions = viewHelper.getActivePositionCount();
        if (totalPositions == 0) return (false, "");

        // Shard filtering (backward compatible: empty checkData = scan all)
        uint8 shardId;
        uint8 numShards = 1;
        if (checkData.length >= 64) {
            (shardId, numShards) = abi.decode(checkData, (uint8, uint8));
            if (numShards == 0) numShards = 1;
        }

        uint256 scanLimit = maxPositionsPerCheck < totalPositions
            ? maxPositionsPerCheck
            : totalPositions;
        uint256 startIndex = lastCheckedIndexByShard[shardId] % totalPositions;

        // INT-08 (Valves internal V31): bound raw array traversal. positionsChecked only counts
        // shard-matched positions, but `i` advances over EVERY slot the shard filter skips, so a
        // sharded checkUpkeep could walk all `totalPositions` (O(n)) while positionsChecked stayed
        // below scanLimit — defeating the maxPositionsPerCheck work-bound and risking an off-chain
        // gas-cap stall (M-06 regression). Cap the window at scanLimit*numShards (enough for a shard
        // to find ~scanLimit matches), never exceeding totalPositions. The cursor already advances by
        // arrayPositionsTraversed (below), so a truncated window simply resumes next call. No-op when
        // unsharded (numShards==1): every position matches, so positionsChecked bounds the loop first.
        uint256 maxTraversal = scanLimit * numShards;
        if (maxTraversal > totalPositions) maxTraversal = totalPositions;

        // Pre-allocate action arrays per priority type
        uint256[] memory type2Ids = new uint256[](scanLimit);
        uint256[] memory type1Ids = new uint256[](scanLimit);
        uint256[] memory type3Ids = new uint256[](scanLimit);
        uint256 count2;
        uint256 count1;
        uint256 count3;

        // TWAP cache — one entry per unique pool
        CachedTWAP[] memory twapCache = new CachedTWAP[](scanLimit);
        uint256 cacheSize;

        uint32 cachedTwapInterval = vault.twapInterval();
        uint256 positionsChecked;

        uint256 arrayPositionsTraversed;

        for (uint256 i = 0; i < totalPositions && positionsChecked < scanLimit && arrayPositionsTraversed < maxTraversal; ) {
            uint256 currentIndex = (startIndex + i) % totalPositions;
            uint256 tokenId = vault.allPositionIds(currentIndex);

            unchecked {
                ++i;
                ++arrayPositionsTraversed;
            }

            // Shard filter — skip positions not assigned to this shard
            // V3 fix: positionsChecked increments AFTER shard filter so sharding
            // actually reduces scan time proportionally (V2 counted skipped positions)
            if (tokenId % numShards != shardId) continue;

            unchecked {
                ++positionsChecked;
            }

            ISnuggleVault.UserPosition memory pos = vault.positions(tokenId);

            // Skip invalid or disabled positions
            if (pos.owner == address(0)) continue;
            if (!pos.autoSnuggleEnabled) continue;

            ISnuggleVault.PoolConfig memory poolConfig = vault.approvedPools(pos.poolId);
            if (!poolConfig.active) continue;

            // TWAP lookup with per-pool caching
            int24 currentTick;
            bool tickFound;
            (tickFound, currentTick) = _findInCache(twapCache, cacheSize, poolConfig.pool);

            if (!tickFound) {
                try poolConfig.positionAdapter.getTWAPTick(poolConfig.pool, cachedTwapInterval) returns (int24 tick) {
                    currentTick = tick;
                    twapCache[cacheSize] = CachedTWAP(poolConfig.pool, tick);
                    cacheSize++;
                } catch {
                    continue; // Skip positions whose pool TWAP is unavailable
                }
            }

            bool isOutOfRange = currentTick < pos.currentTickLower || currentTick >= pos.currentTickUpper;

            if (isOutOfRange) {
                if (pos.outOfRangeSince == 0) {
                    // Needs to start tracking — action type 1
                    type1Ids[count1++] = tokenId;
                } else if (block.timestamp >= pos.outOfRangeSince + pos.rebalanceDelay) {
                    bool wouldChange = TickMath.wouldSnuggleChangeRange(
                        currentTick,
                        poolConfig.tickSpacing,
                        pos.rangeWidthBps,
                        pos.currentTickLower,
                        pos.currentTickUpper
                    );
                    if (wouldChange) {
                        // Ready to rebalance — action type 2 (highest priority)
                        type2Ids[count2++] = tokenId;
                    }
                }
            } else if (pos.outOfRangeSince > 0) {
                // Position is back in range, reset tracking — action type 3
                type3Ids[count3++] = tokenId;
            }
        }

        uint256 totalActions = count2 + count1 + count3;
        if (totalActions == 0) {
            // V33-03 (internal V33): the pagination cursor only moves in performUpkeep, which used to run
            // ONLY when a window contained work. So a calm FRONT window (all positions in range) pinned
            // the cursor at 0 forever and positions in the TAIL of the array (index > maxTraversal) were
            // never scanned or rebalanced. Fix: when a window is calm but did NOT cover the whole set,
            // return upkeepNeeded=true with an EMPTY batch and the advanced cursor, so performUpkeep rolls
            // the cursor to the next window (it writes the cursor and the empty action loop is a no-op).
            // The checkInterval time-gate + the throttle in performUpkeep bound this to one window per
            // interval. If the window already covered every position, there is genuinely no work.
            if (arrayPositionsTraversed < totalPositions) {
                return (true, abi.encode(
                    new uint256[](0), new uint8[](0),
                    lastCheckedIndexByShard[shardId] + arrayPositionsTraversed, shardId
                ));
            }
            return (false, "");
        }

        // Build priority-ordered arrays: rebalances > start tracking > reset tracking
        uint256[] memory allIds = new uint256[](totalActions);
        uint8[] memory allTypes = new uint8[](totalActions);
        uint256 idx;

        for (uint256 j = 0; j < count2; j++) {
            allIds[idx] = type2Ids[j];
            allTypes[idx] = 2;
            idx++;
        }
        for (uint256 j = 0; j < count1; j++) {
            allIds[idx] = type1Ids[j];
            allTypes[idx] = 1;
            idx++;
        }
        for (uint256 j = 0; j < count3; j++) {
            allIds[idx] = type3Ids[j];
            allTypes[idx] = 3;
            idx++;
        }

        // Pagination advances to end of scan window
        // Pagination advances by how far we traversed the array (not just shard positions)
        uint256 newLastChecked = lastCheckedIndexByShard[shardId] + arrayPositionsTraversed;
        // INT-12: carry shardId so performUpkeep advances THIS shard's cursor, not a shared one.
        return (true, abi.encode(allIds, allTypes, newLastChecked, shardId));
    }

    /// @notice Execute batched keeper actions
    /// @dev Called by Chainlink Automation. Processes actions with gasleft() guard.
    /// @param performData Encoded (uint256[] tokenIds, uint8[] actionTypes, uint256 newLastChecked)
    function performUpkeep(bytes calldata performData) external override {
        if (!vault.authorizedKeepers(msg.sender)) revert NotAuthorized();

        (uint256[] memory tokenIds, uint8[] memory actionTypes, uint256 newLastChecked, uint8 shardId) =
            abi.decode(performData, (uint256[], uint8[], uint256, uint8));

        if (tokenIds.length != actionTypes.length) revert LengthMismatch();

        lastCheckedIndexByShard[shardId] = newLastChecked; // INT-12: advance only this shard's cursor

        uint256 actionsAttempted;
        uint256 actionsSucceeded;

        bool gasStarved;
        for (uint256 i = 0; i < tokenIds.length; i++) {
            if (gasleft() < minGasReserve) { gasStarved = true; break; }
            actionsAttempted++;
            try vault.executeKeeperAction(tokenIds[i], actionTypes[i]) {
                actionsSucceeded++;
            } catch {
                emit ActionFailed(tokenIds[i], actionTypes[i]);
            }
        }

        // INT-12: advance the checkInterval throttle unless we bailed early for gas. Previously it
        // advanced only when actionsSucceeded > 0, so an all-FAILING batch (all attempted, all reverted)
        // left the throttle unmoved and checkUpkeep kept returning that same batch — an unbounded
        // gas-wasting retry loop. Gating on !gasStarved instead means: a batch we fully attempted (even
        // if every action failed) respects checkInterval; only a genuinely gas-starved call retries
        // immediately to finish its remaining work.
        if (!gasStarved) {
            lastPerformTime = block.timestamp;
        }

        emit BatchExecuted(actionsAttempted, actionsSucceeded, false);
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // EMERGENCY & FEE DISTRIBUTION
    // ═══════════════════════════════════════════════════════════════════════════

    /// @notice Owner-only batch execute bypassing Chainlink
    /// @dev Works because the KeepersHelper itself is an authorized keeper on the vault
    /// @param tokenIds Array of position token IDs to process
    /// @param actionTypes Array of action types (1=start tracking, 2=rebalance, 3=reset tracking)
    function emergencyExecute(
        uint256[] calldata tokenIds,
        uint8[] calldata actionTypes
    ) external onlyOwner {
        if (tokenIds.length != actionTypes.length) revert LengthMismatch();

        uint256 actionsAttempted;
        uint256 actionsSucceeded;

        for (uint256 i = 0; i < tokenIds.length; i++) {
            if (gasleft() < minGasReserve) break;
            actionsAttempted++;
            try vault.executeKeeperAction(tokenIds[i], actionTypes[i]) {
                actionsSucceeded++;
            } catch {
                emit ActionFailed(tokenIds[i], actionTypes[i]);
            }
        }

        emit BatchExecuted(actionsAttempted, actionsSucceeded, true);
    }

    /// @notice Permissionless fee distribution (decoupled from rebalances)
    /// @dev Can be called by anyone, by a cron, or by a separate Chainlink time-based upkeep
    function distributeFees() external {
        if (address(treasurySplitter) == address(0)) return;
        for (uint256 i = 0; i < feeTokens.length; i++) {
            try treasurySplitter.distribute(feeTokens[i]) {} catch {}
        }
        emit FeesDistributed(feeTokens.length);
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // CONFIGURATION
    // ═══════════════════════════════════════════════════════════════════════════

    /// @notice Set maximum positions to scan per checkUpkeep call
    /// @param _max New max (1–10000)
    function setMaxPositionsPerCheck(uint256 _max) external onlyOwner {
        if (_max == 0 || _max > 10000) revert InvalidParameter();
        uint256 oldMax = maxPositionsPerCheck;
        maxPositionsPerCheck = _max;
        emit MaxPositionsPerCheckUpdated(oldMax, _max);
    }

    /// @notice Set minimum gas reserve for the gasleft() guard in performUpkeep
    /// @param _reserve New reserve (1–5000000)
    function setMinGasReserve(uint256 _reserve) external onlyOwner {
        if (_reserve == 0 || _reserve > 5_000_000) revert InvalidParameter();
        uint256 oldReserve = minGasReserve;
        minGasReserve = _reserve;
        emit MinGasReserveUpdated(oldReserve, _reserve);
    }

    /// @notice Update the minimum time between performUpkeep calls
    /// @param _interval New interval in seconds (between 1 minute and 1 hour)
    function setCheckInterval(uint256 _interval) external onlyOwner {
        if (_interval < MIN_CHECK_INTERVAL || _interval > MAX_CHECK_INTERVAL) {
            revert IntervalOutOfRange();
        }
        uint256 oldInterval = checkInterval;
        checkInterval = _interval;
        emit CheckIntervalUpdated(oldInterval, _interval);
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // FEE TOKEN MANAGEMENT
    // ═══════════════════════════════════════════════════════════════════════════

    /// @notice Add a token to the auto-distribute list
    /// @param token ERC20 token address to distribute
    function addFeeToken(address token) external onlyOwner {
        if (token == address(0)) revert FeeTokenNotFound();
        if (feeTokens.length >= MAX_FEE_TOKENS) revert TooManyFeeTokens();
        for (uint256 i = 0; i < feeTokens.length; i++) {
            if (feeTokens[i] == token) revert FeeTokenAlreadyAdded();
        }
        feeTokens.push(token);
        emit FeeTokenAdded(token);
    }

    /// @notice Remove a token from the auto-distribute list
    /// @param token ERC20 token address to remove
    function removeFeeToken(address token) external onlyOwner {
        for (uint256 i = 0; i < feeTokens.length; i++) {
            if (feeTokens[i] == token) {
                feeTokens[i] = feeTokens[feeTokens.length - 1];
                feeTokens.pop();
                emit FeeTokenRemoved(token);
                return;
            }
        }
        revert FeeTokenNotFound();
    }

    /// @notice Get all fee tokens configured for distribution
    function getFeeTokens() external view returns (address[] memory) {
        return feeTokens;
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // INTERNAL
    // ═══════════════════════════════════════════════════════════════════════════

    /// @dev Linear scan through TWAP cache. Fine for 30-50 unique pools.
    function _findInCache(
        CachedTWAP[] memory cache,
        uint256 size,
        address pool
    ) internal pure returns (bool found, int24 tick) {
        for (uint256 i = 0; i < size; i++) {
            if (cache[i].pool == pool) return (true, cache[i].tick);
        }
        return (false, int24(0));
    }
}
Chain explorer2150msChain node79ms