Run a keeper
Four permissionless functions move value between protocol contracts. None of them requires a role, an allowlist entry, or any prior setup. Two pay the caller a small ETH reward; the other two pay nothing but keep the pipeline flowing. Anyone can run a keeper against them.
| Call | Contract | Reward | What it does |
|---|---|---|---|
sweep() |
LiveBidAdapter | 0.5% of forwarded ETH, max 0.01 ETH | Meters buffered ETH into the live bid |
executeStep(minOut) |
BuybackBurner | 0.5% of the step, max 0.01 ETH | Swaps queued ETH for $111 and burns it |
settle(punkId) |
ReturnAuctionModule | none | Resolves an ended return auction |
sweep() |
ProtocolFeePhaseAdapter | none | Claims the protocol fee leg and forwards it |
For every call: simulate first (eth_call / viem simulateContract / cast call), then send. Gas cost varies with state, so measure via eth_estimateGas rather than assuming a number, and compare against the reward before sending the rewarded calls.
LiveBidAdapter.sweep()
function sweep() external returns (uint256 ethForwarded)Address: 0x8C72FBc2bB32e76aa54243F76745266a0F92CD01
The adapter buffers every ETH inflow that funds the live bid and meters it into Patron. sweep() forwards buffered ETH in one of two modes, keyed on Patron's balance versus activationThreshold():
- Fast mode (
patron.balance < activationThreshold()): no cooldown, no per-call cap. The forward is clamped so the live bid lands exactly at the threshold; any excess stays buffered - Throttled mode (
patron.balance >= activationThreshold()): at mostmaxSweepWei()per call, and the call revertsSweepTooEarly(nextBlock)beforenextSweepBlock(). The cooldown clock (lastSweepBlock) is shared with the hook-drivenstreamForward()path
Reward semantics, from source: the reward is forwarded × KEEPER_REWARD_BPS / 10_000 (KEEPER_REWARD_BPS = 50, so 0.5%), capped at KEEPER_REWARD_CAP = 0.01 ether. In the fast-mode fill-to-threshold case the reward is paid out of the buffered remainder (capped at that remainder) so the bid still lands exactly at the threshold; otherwise it is carved off the forward. If the reward send to you fails, the call does not revert; the ETH stays buffered and KeeperRewardFailed is emitted. An empty buffer returns 0 without reverting and does not consume the cooldown.
Be aware of the competing path: the official pool's hook calls streamForward() on the adapter before swaps, which forwards the same buffer with no reward (it no-ops below MIN_STREAM_WEI = 0.01 ETH and on cooldown). During active trading the buffer may drain before your sweep() lands.
Readiness (view calls):
cast call 0x8C72FBc2bB32e76aa54243F76745266a0F92CD01 "bufferedEth()(uint256)" --rpc-url https://ethereum-rpc.publicnode.com
cast call 0x8C72FBc2bB32e76aa54243F76745266a0F92CD01 "nextSweepBlock()(uint256)" --rpc-url https://ethereum-rpc.publicnode.com
cast call 0x8C72FBc2bB32e76aa54243F76745266a0F92CD01 "activationThreshold()(uint256)" --rpc-url https://ethereum-rpc.publicnode.com
cast balance 0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c --rpc-url https://ethereum-rpc.publicnode.comActionable when bufferedEth() > 0 and either Patron's balance is below activationThreshold() (fast mode, any block) or block.number >= nextSweepBlock() (throttled mode).
BuybackBurner.executeStep(minOut)
function executeStep(uint256 minOut) externalAddress: 0xf8a2D6F8c58626eE3BcDb4638F2a2f30Fe021242
Swaps up to maxStepWei() of queued ETH for $111 on the official pool and burns the output. Pacing: reverts StepTooEarly(nextBlock) before nextExecutableBlock(), and NothingToBurn() when remainingEth() is 0. The reward is 0.5% of the step (EXEC_REWARD_BPS = 50), capped at EXEC_REWARD_CAP = 0.01 ether, and pro-rated to the ETH actually spent on a partial fill.
Picking minOut. The contract's own sandwich guard is a fixed per-call price-impact cap: maxSlippageBps = 500 (5%), a compile-time constant enforced through the V4 sqrtPriceLimitX96. When the limit binds, V4 partial-fills the swap and the unspent ETH stays queued. Because that cap is enforced on-chain, minOut = 0 is safe and is what the protocol's own keeper sends. If you want a stricter per-call bound, quote the pool (via poolKey() and a V4 quoter) and set minOut below the quoted output, but note that a tight minOut will revert InsufficientOutput(received, required) on legitimate partial fills that the impact cap produces by design.
Readiness (view calls):
cast call 0xf8a2D6F8c58626eE3BcDb4638F2a2f30Fe021242 "remainingEth()(uint256)" --rpc-url https://ethereum-rpc.publicnode.com
cast call 0xf8a2D6F8c58626eE3BcDb4638F2a2f30Fe021242 "quoteStepAmount()(uint256)" --rpc-url https://ethereum-rpc.publicnode.com
cast call 0xf8a2D6F8c58626eE3BcDb4638F2a2f30Fe021242 "nextExecutableBlock()(uint256)" --rpc-url https://ethereum-rpc.publicnode.comActionable when remainingEth() > 0 and block.number >= nextExecutableBlock(). quoteStepAmount() returns min(remainingEth, maxStepWei), the ETH the next step would draw (reward included).
ReturnAuctionModule.settle(punkId)
function settle(uint16 punkId) externalAddress: 0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe
Resolves a return auction once block.timestamp >= endsAt(punkId). Reverts SaleLive(punkId) before then, SaleMissing(punkId) for a Punk with no sale, and AlreadySettled(punkId) after. Two branches:
- Cleared (a high bid exists): the Punk goes to the winning bidder and the proceeds split executes
- Vault path (no bids): the Punk enters PunkVault permanently, the recorded target trait is collected, and the Proof NFT mints if this is the trait's first vaulting
There is no caller reward on either branch. Settlement is still necessary: the winning bidder's ETH is locked and the Punk undelivered until someone settles, and on the vault path the trait is not collected until settle runs. Mission-aligned keepers should include it.
Watch endsAt(punkId) rather than caching a deadline: a bid inside the final 15 minutes extends the auction by 1 hour, with no cap on extensions.
Readiness (view calls): collect candidate punkIds from ReturnAuctionStarted event logs (never scan all 10,000 ids), then confirm each:
cast call 0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe "isSettleable(uint16)(bool)" 8348 --rpc-url https://ethereum-rpc.publicnode.comisSettleable(punkId) is true exactly when settle(punkId) would succeed.
ProtocolFeePhaseAdapter.sweep()
function sweep() externalAddress: 0xed3E9D3Bf693372060b7ce62aDB49650145b2ba9
The hook deposits the protocol fee leg into the artcoins fee escrow under this adapter's address on every swap. sweep() claims it from the escrow and forwards the full balance to the protocol fee controller. No reward. A failed escrow claim is non-fatal (ClaimFailed is emitted and the call continues with whatever balance is already held); a rejected forward reverts ForwardFailed() and the balance stays for retry. An empty balance returns without reverting.
Readiness (view calls): read the escrow address once, then check the pending balance under the adapter (token address(0) = native ETH):
ESCROW=$(cast call 0xed3E9D3Bf693372060b7ce62aDB49650145b2ba9 "feeEscrow()(address)" --rpc-url https://ethereum-rpc.publicnode.com)
cast call $ESCROW "availableFees(address,address)(uint256)" 0xed3E9D3Bf693372060b7ce62aDB49650145b2ba9 0x0000000000000000000000000000000000000000 --rpc-url https://ethereum-rpc.publicnode.com
cast balance 0xed3E9D3Bf693372060b7ce62aDB49650145b2ba9 --rpc-url https://ethereum-rpc.publicnode.comActionable when either value is above zero.
A minimal viem keeper loop
The sketch below checks readiness for all four calls each pass, simulates every candidate, and sends the rewarded ones only when the reward covers the estimated gas. Fill in your own transport and account handling.
import {createPublicClient, createWalletClient, http, parseAbi} from 'viem';
import {privateKeyToAccount} from 'viem/accounts';
import {mainnet} from 'viem/chains';
const rpc = http('https://ethereum-rpc.publicnode.com');
const client = createPublicClient({chain: mainnet, transport: rpc});
const account = privateKeyToAccount(process.env.KEEPER_KEY as `0x${string}`);
const wallet = createWalletClient({account, chain: mainnet, transport: rpc});
const adapter = '0x8C72FBc2bB32e76aa54243F76745266a0F92CD01' as const;
const burner = '0xf8a2D6F8c58626eE3BcDb4638F2a2f30Fe021242' as const;
const auction = '0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe' as const;
const feeAdapter = '0xed3E9D3Bf693372060b7ce62aDB49650145b2ba9' as const;
const patron = '0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c' as const;
const adapterAbi = parseAbi([
'function sweep() returns (uint256)',
'function bufferedEth() view returns (uint256)',
'function nextSweepBlock() view returns (uint256)',
'function activationThreshold() view returns (uint256)',
]);
const burnerAbi = parseAbi([
'function executeStep(uint256 minOut)',
'function remainingEth() view returns (uint256)',
'function nextExecutableBlock() view returns (uint256)',
]);
const auctionAbi = parseAbi([
'function settle(uint16 punkId)',
'function isSettleable(uint16 punkId) view returns (bool)',
'event ReturnAuctionStarted(uint16 indexed punkId, uint128 acquisitionCost, uint128 reserveWei, uint64 startedAt, uint64 endsAt)',
]);
const feeAbi = parseAbi(['function sweep()']);
interface Candidate {
address: `0x${string}`;
abi: readonly unknown[];
functionName: string;
args?: readonly unknown[];
rewarded: boolean;
}
async function findCandidates(): Promise<Candidate[]> {
const block = await client.getBlockNumber();
const out: Candidate[] = [];
// 1. LiveBidAdapter.sweep()
const [buffered, nextSweep, threshold, patronBal] = await Promise.all([
client.readContract({address: adapter, abi: adapterAbi, functionName: 'bufferedEth'}),
client.readContract({address: adapter, abi: adapterAbi, functionName: 'nextSweepBlock'}),
client.readContract({address: adapter, abi: adapterAbi, functionName: 'activationThreshold'}),
client.getBalance({address: patron}),
]);
const fastMode = patronBal < threshold;
if (buffered > 0n && (fastMode || block >= nextSweep)) {
out.push({address: adapter, abi: adapterAbi, functionName: 'sweep', rewarded: true});
}
// 2. BuybackBurner.executeStep(0): the on-chain 5% impact cap bounds slippage
const [queued, nextStep] = await Promise.all([
client.readContract({address: burner, abi: burnerAbi, functionName: 'remainingEth'}),
client.readContract({address: burner, abi: burnerAbi, functionName: 'nextExecutableBlock'}),
]);
if (queued > 0n && block >= nextStep) {
out.push({address: burner, abi: burnerAbi, functionName: 'executeStep', args: [0n], rewarded: true});
}
// 3. ReturnAuctionModule.settle(punkId): candidates from event history
const started = await client.getContractEvents({
address: auction, abi: auctionAbi, eventName: 'ReturnAuctionStarted',
fromBlock: 25270161n, // PermanentCollection.deployedAtBlock
});
const punkIds = [...new Set(started.map((l) => l.args.punkId!))];
for (const id of punkIds) {
const ready = await client.readContract({
address: auction, abi: auctionAbi, functionName: 'isSettleable', args: [id],
});
if (ready) out.push({address: auction, abi: auctionAbi, functionName: 'settle', args: [id], rewarded: false});
}
// 4. ProtocolFeePhaseAdapter.sweep(): cheap enough to just simulate
out.push({address: feeAdapter, abi: feeAbi, functionName: 'sweep', rewarded: false});
return out;
}
async function pass() {
for (const c of await findCandidates()) {
try {
// Simulate first: a revert here costs nothing.
const {request} = await client.simulateContract({...c, account} as never);
// For rewarded calls, compare estimated gas cost against the
// reward (<= 0.01 ETH) before sending.
const gas = await client.estimateContractGas({...c, account} as never);
const gasPrice = await client.getGasPrice();
if (c.rewarded && gas * gasPrice > 10n ** 16n) continue; // reward can never exceed 0.01 ETH
const hash = await wallet.writeContract(request);
console.log(`${c.functionName} -> https://evm.now/tx/${hash}?chainId=1`);
} catch {
// Not actionable right now (cooldown, empty buffer, raced by another keeper).
}
}
}
setInterval(() => void pass(), 60_000);The protocol's own dashboard at /debug/distribution evaluates the same readiness predicates per call, so it is a convenient way to cross-check what your keeper sees.