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
- Creator
- 0x6aC51A7065…80858e25FF
- 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