Three chores, foundry-tested, copy-paste-deploy. Every template implements the same two-function interface - pick the chore that matches your contract.
Vendor IAutoJob.sol from the open repo ↗ (MIT), paste the adapter into your Foundry project, deploy with the registry as the constructor arg (0xb012fe01…98b4643d), then register exactly as in the walkthrough. The repo also ships the three required Foundry tests for each chore - copy those too.
Run anything on a clock. The condition is the schedule: flip 1 hours to whatever cadence you want, drop your maintenance call where the comment says. This is the exact contract the ten-minute walkthrough teaches - and the pattern behind PeekPeak's own hourly demo job.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IAutoJob {
function checkJob() external view returns (bool canExec, bytes memory execPayload);
function performJob(bytes calldata execPayload) external;
}
contract MyKeeper is IAutoJob {
address public immutable REGISTRY_ADDRESS;
uint256 public nextRun = block.timestamp + 1 hours;
error NotRegistry();
constructor(address registry) { REGISTRY_ADDRESS = registry; }
function checkJob() external view returns (bool, bytes memory) {
return (block.timestamp >= nextRun, "");
}
function performJob(bytes calldata) external {
if (msg.sender != REGISTRY_ADDRESS) revert NotRegistry();
if (block.timestamp < nextRun) return; // idempotent
nextRun = block.timestamp + 1 hours; // effect first
// ...your maintenance call here...
}
}Constructor: constructor(registry) - the registry address for your chain.
Roll ve(3,3) epochs: calls voter.distribute() and/or gauge.harvest() once per epoch. Built for Pharaoh DEX ↗ but the shape fits any Solidly fork. Deploy one keeper per vault/gauge; a failed leg never rolls the epoch back.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IAutoJob} from "../IAutoJob.sol";
/// @notice Minimal interface surface PeekPeak needs from a Solidly-fork voter.
interface IPharaohVoter {
function distribute() external;
}
/// @notice Minimal interface surface PeekPeak needs from a Solidly-fork gauge.
interface IPharaohGauge {
function harvest() external;
}
/// @title PharaohEpochKeeper - reference `IAutoJob` adapter for Pharaoh DEX (phar.gg).
/// @notice Rolls ve(3,3) gauge epochs: calls `voter.distribute()` and/or
/// `gauge.harvest()` once per epoch (SPEC-8). Deploy one keeper per
/// vault/gauge; PeekPeak funds it via the registry escrow.
/// @dev `performJob` is restricted to the immutable `REGISTRY_ADDRESS`. The
/// `nextEpochTimestamp` state advances BEFORE external calls (RULE-1), so a
/// mid-call revert cannot cause double-distribution on retry within the same
/// epoch - `checkJob` is already false for the block after any successful poke.
contract PharaohEpochKeeper is IAutoJob {
uint256 public constant EPOCH = 1 weeks;
address public immutable REGISTRY_ADDRESS;
IPharaohVoter public immutable voter;
IPharaohGauge public immutable gauge;
uint256 public nextEpochTimestamp;
event EpochRolled(uint256 newEpochTimestamp, bool voterOk, bool gaugeOk);
error NotRegistry();
/// @param registry Address of the deployed PeekRegistry that may call performJob.
/// @param _voter Pharaoh voter (may be address(0) if only harvesting).
/// @param _gauge Pharaoh gauge (may be address(0) if only distributing).
/// @param firstEpochTimestamp Unix time of the first epoch rollover to perform.
constructor(address registry, address _voter, address _gauge, uint256 firstEpochTimestamp) {
REGISTRY_ADDRESS = registry;
voter = IPharaohVoter(_voter);
gauge = IPharaohGauge(_gauge);
nextEpochTimestamp = firstEpochTimestamp;
}
/// @notice Ready when the current epoch has elapsed.
function checkJob() external view returns (bool canExec, bytes memory) {
return (block.timestamp >= nextEpochTimestamp, "");
}
/// @notice Distribute/harvest and roll the epoch. Only the registry may call.
/// @dev Idempotent: if the epoch already rolled this block (or a concurrent
/// executor won the race), returns quietly - never re-distributes.
/// External calls are wrapped in try/catch: if one leg fails after the
/// epoch advanced, the failure is recorded (not reverted) so a retry can
/// never double-distribute the leg that already succeeded.
function performJob(bytes calldata) external {
if (msg.sender != REGISTRY_ADDRESS) revert NotRegistry();
uint256 ts = nextEpochTimestamp;
if (block.timestamp < ts) return;
nextEpochTimestamp = ts + EPOCH; // effect first (RULE-1, RULE-12)
bool voterOk;
bool gaugeOk;
if (address(voter) != address(0)) {
// swallow-on-purpose: a failed leg must not roll the epoch back
// (no double-distribution on retry); the outcome is recorded in
// the EpochRolled flags and caught up at the next epoch.
try voter.distribute() {
voterOk = true;
} catch {
voterOk = false;
}
}
if (address(gauge) != address(0)) {
try gauge.harvest() {
gaugeOk = true;
} catch {
gaugeOk = false;
}
}
emit EpochRolled(ts + EPOCH, voterOk, gaugeOk);
}
}
Constructor: constructor(registry, voter, gauge, firstEpochTimestamp) - pass address(0) for the leg you don't use.
The thinnest useful automation: call harvest() (or claim()) on any target at a fixed cadence - Solidly gauges, MasterChef-style pools, reward vaults. If it exposes a parameterless harvest, this runs it.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IAutoJob} from "../IAutoJob.sol";
/// @notice Minimal interface surface PeekPeak needs from a claimer. Any
/// Solidly-style gauge, MasterChef-style pool, or reward vault that
/// exposes a parameterless `harvest()`/`claim()` fits.
interface IHarvester {
function harvest() external;
}
/// @title HarvestKeeper - thin harvest/claim `IAutoJob` template.
/// @notice Calls `harvest()` on a target on a fixed cadence - the simplest
/// useful automation: "claim my rewards every N hours". Deploy one
/// keeper per target; PeekPeak funds it via the registry escrow.
/// @dev `performJob` is restricted to the immutable `REGISTRY_ADDRESS` and
/// advances `nextRun` BEFORE the external call (SKILL.md rule 4), so a
/// reverting harvest can never be retried into a double-claim window -
/// the miss is recorded in the `Harvested` event and caught up at the
/// next interval.
contract HarvestKeeper is IAutoJob {
address public immutable REGISTRY_ADDRESS;
IHarvester public immutable harvester;
uint256 public immutable interval;
uint256 public nextRun;
uint256 public harvestCount;
event Harvested(uint256 indexed run, uint256 nextScheduled, bool ok);
error NotRegistry();
error IntervalOutOfBounds();
/// @param registry Address of the deployed PeekRegistry that may call performJob.
/// @param harvester_ Contract whose `harvest()` is called when due.
/// @param interval_ Cadence in seconds (>= 1 minute, <= 1 year).
constructor(address registry, address harvester_, uint256 interval_) {
if (interval_ < 1 minutes || interval_ > 365 days) revert IntervalOutOfBounds();
REGISTRY_ADDRESS = registry;
harvester = IHarvester(harvester_);
interval = interval_;
nextRun = block.timestamp + interval_;
}
/// @notice Ready when the cadence has elapsed.
function checkJob() external view returns (bool canExec, bytes memory) {
return (block.timestamp >= nextRun, "");
}
/// @notice Harvest and reschedule. Only the registry may call.
/// @dev Idempotent within an interval; a failing harvest still advances
/// the schedule (flagged `ok = false`) - never re-harvest the same
/// window on retry.
function performJob(bytes calldata) external {
if (msg.sender != REGISTRY_ADDRESS) revert NotRegistry();
uint256 ts = nextRun;
if (block.timestamp < ts) return; // idempotent: nothing due
harvestCount += 1; // effect first
nextRun = ts + interval; // schedule from the PLANNED run - no drift
bool ok;
// swallow-on-purpose: a failed harvest must not roll the schedule
// back (no double-claim retry storm); the outcome is recorded in the
// Harvested flags and caught up at the next interval.
try harvester.harvest() {
ok = true;
} catch {
ok = false;
}
emit Harvested(harvestCount, nextRun, ok);
}
}
Constructor: constructor(registry, harvester, interval) - interval bounded to 1 minute - 365 days.