Keeper Network
A decentralized automation protocol on Base. Bonded operators watch your contracts and execute jobs the moment conditions are met — no centralized servers, no single points of failure.
The problem with smart contract automation
Smart contracts are passive by nature. They execute only when called — they cannot wake themselves up. Yet most non-trivial protocols depend on time-sensitive external triggers: liquidating undercollateralized positions, compounding yield, distributing rewards, or rebalancing vaults.
The standard industry answer is a company-run server with a cron job. This works until it doesn't. An outage, a compromised key, or a simple deployment error leaves critical functions uncalled — sometimes costing users real money while the engineering team scrambles to restore service.
Keeper Network replaces that centralized bot with a permissionless network of bonded operators. Anyone can join as a keeper by posting ETH as collateral. Honest execution earns a reward. Malicious or lazy execution gets that collateral slashed. The incentive design makes the system self-regulating.
No protocol team needs to run infrastructure. No single server can fail. The system continues running as long as there are keepers — and keepers show up as long as there are jobs paying rewards.
System architecture
The protocol is split across three contracts, each with a single, clearly-bounded responsibility. This separation is not just good engineering hygiene — it's a security requirement. A bug or upgrade in one layer cannot touch the invariants of the others.
IAutomatable —checkUpkeep() and performUpkeep()The Execution Engine is the only contract that calls external code. Everything it touches is wrapped in a try/catch boundary. A malicious target can intentionally throw — the engine catches it, emits a failure event, and the keeper moves on. One bad job cannot stall the queue.
The engine itself holds zero ETH at any point. All value lives in the JobManager's escrow. Even if the engine were somehow compromised, there would be nothing in it to steal.
KeeperRegistry
0xcEa37b9C…4D61D3The registry is the protocol's trust anchor. Every operator who wants to execute jobs must post a minimum ETH bond here first. The bond is not a deposit — it is active collateral that the protocol holds against future misbehavior.
Keeper state is stored in a packed two-slot struct: bond amount, registration timestamp, unbonding timestamp, total executions, total slashes, reputation score, and lifecycle status. The packing is deliberate — every read of a keeper's state costs a single SLOAD.
Slashing
When the Execution Engine determines a keeper behaved badly, it callsslash(keeper, amount). The registry immediately deducts the slash from the keeper's bond and routes the ETH to treasury. The slash counter increments. If the counter reaches the jail threshold (default: 3), or if the remaining bond drops below the minimum, the registry automatically moves the keeper to the Jailed state without any human intervention.
The 3-day unbonding cooldown
A keeper who wants to exit calls initiateUnbond(), which moves them to Exiting and starts a strict 3-day timer. Only after the timer elapses can they call withdrawBond() to receive their ETH back.
This cooldown is not arbitrary bureaucracy. It gives the protocol time to process any executions the keeper made in their final active period, including any slashes those executions might earn. Without it, a keeper could execute maliciously and immediately extract their bond before the slash pipeline catches up.
Reputation
Every successful execution increments the keeper's reputation score by 5 points, bounded at a hard ceiling of 1000. The score can be decreased by governance if needed. The math is handled by the KeeperMath pure library, which clamps every operation — the score can never overflow or underflow regardless of inputs.
JobManager
0xBAa2B4c2…27718CJobManager is the accounting ledger and scheduling engine. Every automation intent registered on the protocol lives here as a Job struct, with an associated reward pool in escrow.
Registering a job
Calling registerJob(target, rewardPerExec, interval, maxBaseFee)with attached ETH creates a new job and locks the sent ETH as the reward pool. The four parameters do exactly what their names suggest:
target— the contract address whoseperformUpkeepwill be called.rewardPerExec— how much ETH a keeper earns for each successful execution. Must be less than or equal to the amount sent.interval— minimum seconds between executions. Set to 0 for a one-time job that self-completes after a single run.maxBaseFee— ifblock.basefeeexceeds this value, the job is considered unready and execution is skipped. Protects job owners from paying wildly elevated gas compensation during network spikes.
The O(1) active job list
The contract maintains an array of active job IDs that keepers can enumerate offchain. Adding a job appends to the array. Removing a job — on cancellation, pause, or completion — uses a swap-and-pop technique backed by a 1-indexed position mapping. The job being removed swaps with the last element, then the array is popped. This keeps removal at constant gas cost regardless of queue length.
Pull-payment fees
When a keeper executes a job, the protocol fee is not immediately pushed to the treasury address. Instead, it accumulates in a s_accumulatedFeescounter. The treasury must call withdrawFees() to collect.
This is a deliberate security choice. Push-payment designs — where the contract actively sends ETH on every settlement — are vulnerable to a class of denial-of-service attacks where the treasury address is a contract that intentionally reverts on receive, causing every execution to fail. Pull-payment makes that attack impossible.
Pausing and resuming jobs
Job owners can call pauseJob(jobId) to temporarily remove a job from the active queue without canceling it or losing the reward pool. CallingresumeJob(jobId) puts it back — as long as the reward pool still has enough ETH to cover at least one execution.
ExecutionEngine
0x388665c3…Ed80e9The engine is the narrow bridge between keepers and target contracts. Its job is simple: validate that the keeper is eligible and the job is ready, call the target, and settle atomically. It has no storage of its own beyond the addresses of the Registry and Manager. It holds no ETH.
executeJob vs executeBatch
executeJob(jobId, performData) handles a single job.executeBatch(jobIds[], performDatas[]) handles an array of jobs in one transaction. Both require the caller to be an active keeper.
In batch mode, each job runs inside its own _executeSingle call, which wraps the target invocation in a try/catch. If a job is not ready (wrong interval, empty pool, gas ceiling exceeded), it is silently skipped rather than reverting. If the target's performUpkeep throws, the failure is caught and emitted as a JobExecutionFailed event — the batch continues.
Keepers are expected to simulate the batch via eth_call before submitting. A keeper who sends a batch with many broken jobs wastes their own gas — the protocol does not compensate for failed executions.Atomic settlement
On a successful execution, the engine calls recordExecution on the JobManager in the same transaction. The manager handles everything from there: verifying the interval has elapsed, splitting the reward from the pool, transferring the keeper's share, accumulating the fee, and updating the job state. The keeper's reputation increment happens in the same call via the Registry. By the time the transaction commits, the entire state is consistent.
Admin controls
The protocol owner can call slashKeeper or jailKeeperdirectly on the engine, which delegates to the Registry. In Phase 1, this is a trusted admin function. Optimistic dispute resolution with challenge windows is planned for Phase 2 — when this ships, admin slashing will be removed.
Keeper lifecycle
A keeper address lives in exactly one state at any given block. The engine checks this before routing any job.
Unregistered
Default state for every address. No bond posted. The address is invisible to the protocol — it cannot be assigned jobs or call any execution function.
Active
Bond meets or exceeds the minimum. The keeper appears inisActive() checks. Eligible to route executions, earn rewards, and accumulate reputation. This is the only state from which execution is possible.
Exiting
initiateUnbond() was called. A 3-day cooldown is running. The keeper is blocked from execution. After the cooldown,withdrawBond() burns the record and returns ETH.
Jailed
Triggered automatically: three slashes accumulated, or bond dropped below the minimum floor. Execution is completely blocked. The protocol owner can call unjail() to reinstate — but only if the bond is still at or above the minimum.
Transition rules
register(), meets minimuminitiateUnbond() calledwithdrawBond() calledunjail() called by owner, bond ≥ minimumExecution flow
Every execution follows the same four-step sequence. The first step costs no gas. The final three happen atomically in a single transaction.
Offchain simulation
The keeper calls checkUpkeep() on the target viaeth_call. This is a read-only simulation that costs zero gas. If it returns false, the keeper ignores the job and moves on. No wasted gas, no failed transactions. The function also returns performData — an encoded payload the keeper will pass to the next step.
Onchain validation
The keeper submits executeJob(jobId, performData)to the engine. The engine checks: is the keeper active? Is the job ready? Is the base fee within the job's ceiling? Is the reward pool funded? Any check failing reverts immediately.
Isolated execution
The engine calls target.performUpkeep(performData)inside a try/catch. Your contract's logic runs here. If it reverts for any reason, the engine catches the error, emitsJobExecutionFailed, and the transaction ends without reverting. The keeper loses only the gas for the attempt.
Atomic settlement
On success, recordExecution fires: the job's last-executed timestamp updates, the reward is split between keeper and protocol fee, the keeper's share transfers immediately, and reputation increments by 5. All in one commit.
Security model
The security model starts from an adversarial assumption: every keeper might eventually misbehave, and every target contract might try to exploit the engine. Every mechanism below exists because someone has already tried the attack it prevents.
Checks-Effects-Interactions, without exception
Every state-changing function across all three contracts follows the Checks-Effects-Interactions pattern strictly. Validation happens first. Storage writes happen second. ETH transfers — the only external interaction that matters — happen last. Reentrancy guards wrap the functions that transfer ETH. There is no code path where an external call precedes a storage write.
The engine holds no funds
The ExecutionEngine is a pure router. It has no balance. On successful execution, reward transfers flow through the JobManager — the engine never touches ETH directly. This means a compromised engine has nothing to drain.
Fault isolation in batch execution
In executeBatch, each job is wrapped in an independent try/catch. A target contract that intentionally reverts — to trap gas or stall the queue — is caught silently. The failure is logged. The batch continues. One bad actor cannot deny service to legitimate jobs.
Gas price ceilings
Every job specifies a maxBaseFee. If block.basefeeexceeds this at execution time, the job is not considered ready. This protects job owners from paying inflated keeper compensation during network congestion. Jobs naturally pause during spikes and resume when fees normalize.
Protocol invariants
These invariants were verified with stateful invariant testing across more than 500,000 simulated transactions. None of them broke.
Integrating with Keeper Network
Integration requires two things: implementing the IAutomatableinterface in your contract, and registering a job with enough ETH to fund executions. There is no SDK, no proprietary library, no inherited base contract.
The IAutomatable interface
Your contract must implement exactly two functions:
checkUpkeep(bytes calldata)— a view function that returns(bool upkeepNeeded, bytes memory performData). This runs offchain. It can be arbitrarily complex — time checks, price oracle reads, vault threshold comparisons — because no gas is spent here.performUpkeep(bytes calldata performData)— the state-changing function called onchain by the engine. This is where your actual automation logic runs.
Access control is your responsibility
Your performUpkeep function must check thatmsg.sender is the Execution Engine address. Without this check, anyone can call your function and force execution at an arbitrary time.
msg.sender check in performUpkeep. Store the engine address as an immutable at construction time and revert withAutomatable__NotExecutionEngine() if the caller is anyone else.Double-check your conditions onchain
checkUpkeep runs offchain and is not a guarantee of state. Between when a keeper simulates and when the transaction lands, the chain may have moved. Always re-validate your conditions insideperformUpkeep and revert withAutomatable__UpkeepNotNeeded() if they are no longer met. This prevents front-running and protects the reward pool from being drained on redundant executions.
Funding the job
Call registerJob on the JobManager with enough ETH to cover at least one execution. The reward pool is the pool of ETH keepers draw from. When it empties, the job automatically becomes "not ready" and keepers stop executing it. Top it up anytime with depositReward.
Setting maxBaseFee
Set this to a realistic ceiling for your network conditions. On Base, normal base fees are very low — a value of 0.1 gwei is usually sufficient. If you set it too low, keepers will never execute your job during any network activity. If you set it too high, you pay inflated keeper rewards during congestion.
Pre-launch checklist
checkUpkeep and performUpkeepmsg.sender === executionEngine in performUpkeepperformUpkeep, not just in checkUpkeepcheckUpkeep locally before expecting keepers to pick up the jobmaxBaseFee to a realistic value for your chain's conditionsDeployments
All three contracts are live on Base Mainnet (chain ID 8453) and verified on Basescan. Always verify addresses against the official repository before routing funds.
0xcEa37b9C…4D61D30xBAa2B4c2…27718C0x388665c3…Ed80e9Audit summary
Ready to register a job?
Fund a reward pool and let the network handle execution from here.