Documentation · Integrate
Keepers
How to call evolve(), metabolize() and regenerate(): who may call them, the cooldown rules, the auto-evolve limit and the order that avoids wasted calls.
GeneHook has three permissionless entry points. Nobody is designated to call them, and no caller is paid: the hook never moves claims or tokens to any address (GeneHook.sol:30-31), so a keeper pays gas and receives nothing. After launch(), no function checks the caller (GeneHook.sol:33). The calls below take HOOK_ADDRESS, the hook address listed on addresses.
#The three entry points
| Function | Requires | Cooldown | What it does | Source |
|---|---|---|---|---|
evolve(uint256 maxEpochs) |
launched | none | Touches the block (closing an elapsed epoch if needed), then folds up to maxEpochs closed epochs into the gene state machines. evolve(0) only touches the block |
GeneHook.sol:312-329 |
metabolize() |
launched; METABOLISM expressed | COOLDOWN_BLOCKS after every non-reverting call |
Auto-evolves up to AUTO_EVOLVE_MAX epochs, harvests fees into reserves, places ETH (IMMUNITY, bids, churn tier) if evolution is caught up, places asks |
GeneHook.sol:281-291, 451-476 |
regenerate() |
launched; REGENERATION expressed | COOLDOWN_BLOCKS, started only by a call that spends its whole allotment |
Bounded buyback from regenReserve, then burns the GENE bought |
GeneHook.sol:296-309, 561-591 |
Reverts: NotLaunched(), GeneNotExpressed(uint8 gene), Cooldown(uint256 readyAtBlock) (GeneHook.sol:283-285, 298-300, 1000). Constants: parameters. Errors: errors.
#Cooldowns
metabolize(). lastMetabolizeBlock is written before any work, on every call that passes the expression and cooldown checks (GeneHook.sol:284-286). The cooldown therefore starts on every call that does not revert, whether or not it places anything. A call that reverts writes nothing and starts no cooldown.
regenerate(), rule R1. The cooldown is written after the buyback, and only when the call spent its whole allotment, meaning the buy was not stopped early by the price limit (GeneHook.sol:302-307, 590). A call that spends nothing, has no allotment, or is stopped early by the price limit starts no cooldown, so anyone can call again, in the same block if needed. Every call stays bounded by the per-call cap, the per-epoch cap, the net-flow allowance and the price limit (GeneHook.sol:563-575).
evolve(). No cooldown (GeneHook.sol:312-316).
Read the cooldown state with GeneLens lastMetabolizeBlock(hook) and lastRegenerateBlock(hook) (GeneLens.sol:121-127). A call is ready when the stored block is 0 or block.number >= last + COOLDOWN_BLOCKS (GeneHook.sol:284-285, 299-300).
#The auto-evolve limit
metabolize() folds at most AUTO_EVOLVE_MAX pending closed epochs (GeneHook.sol:289; Genome.sol:54). It places ETH only when the evolution cursor has reached the current epoch (GeneHook.sol:465-469). Asks are attempted either way (GeneHook.sol:471).
Consequence: with more than AUTO_EVOLVE_MAX epochs pending, a metabolize() call evolves part of the way, places no ETH, and still starts its cooldown (GeneHook.sol:284-290, 465). Call evolve() first until the cursor is caught up.
metabolize() checks that METABOLISM is expressed before it auto-evolves (GeneHook.sol:283, 289). The first metabolize() therefore needs a prior evolve() that expresses METABOLISM.
#Ordering
- Catch up evolution. If
currentEpoch - evolveCursor > AUTO_EVOLVE_MAX, callevolve(n)until the cursor reaches the current epoch (GeneLens.sol:100-115). Each call processes at mostnepochs. - metabolize(). It harvests ETH fees into
ethReserve, and intoregenReserveonce REGENERATION is expressed (GeneHook.sol:707-716). Inside the call the order is: harvest, IMMUNITY (if the deployment gate is open), METABOLISM bids, churn tier, asks, settle (GeneHook.sol:458-476). - regenerate(). It spends only
regenReserve(GeneHook.sol:563), which grows only through fees credited insidemetabolize(), by its harvest or when a placement tops up an existing band (GeneHook.sol:554-559, 707-716). Calling it aftermetabolize()lets it use fees harvested in that call. Its allotment is at most the REGENERATION share of the previous epoch's net-flow bid budget (GeneHook.sol:565-570). It buys nothing when the price already sits at or above the limit,atlTick - REGEN_PREMIUM_TICKSin ticks (GeneHook.sol:574-577).
The deployment gate for IMMUNITY is immunityActive(): IMMUNITY expressed, the last IMM_CONFIRM processed epochs each deployment-stressed, and evolution caught up (GeneHook.sol:802-805). It is a view; only a mined MassAdded with kind 2 shows a deployment.
#Simulate, then send
Simulating first avoids paying for a call that reverts. Remember that a non-reverting metabolize() starts the cooldown even when it places nothing. A metabolize() that reverts, including with InvariantBroken, rolls back as a whole and starts no cooldown, so it can be retried in a later block (GeneHook.sol:284-286, 655-660).
// keeper.mjs (Node 18+, npm install viem)
// env: RPC_URL, HOOK_ADDRESS (after deployment), KEEPER_PRIVATE_KEY, LENS_ARTIFACT (frozen GeneLens build)
import { createPublicClient, createWalletClient, http, parseAbi } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { readFileSync } from "node:fs";
const { RPC_URL, HOOK_ADDRESS, KEEPER_PRIVATE_KEY } = process.env;
const LENS_CREATION_CODE = JSON.parse(readFileSync(process.env.LENS_ARTIFACT ?? "out/GeneLens.sol/GeneLens.json", "utf8")).bytecode.object;
const AUTO_EVOLVE_MAX = 8n; // Genome.sol:54; must match the Genome
const COOLDOWN_BLOCKS = 300n; // Genome.sol:44; must match the Genome
const hookAbi = parseAbi([
"function evolve(uint256 maxEpochs) returns (uint256 processed)",
"function metabolize() returns (uint256 ethToBids, uint256 ethToImmunity, uint256 geneToAsks)",
"function regenerate() returns (uint256 ethSpent, uint256 geneBurned)",
"error NotLaunched()",
"error GeneNotExpressed(uint8 gene)",
"error Cooldown(uint256 readyAtBlock)",
"error InvariantBroken()",
]);
const lensAbi = parseAbi([
"function currentEpoch(address hook) view returns (uint32)",
"function evolveCursor(address hook) view returns (uint32)",
"function lastMetabolizeBlock(address hook) view returns (uint40)",
"function lastRegenerateBlock(address hook) view returns (uint40)",
"function geneStatus(address hook, uint8 g) view returns ((uint8 state, uint8 history, uint32 count, uint32 firstEvidenceEpoch, uint32 expressedEpoch, bytes32 evidenceHash))",
]);
const account = privateKeyToAccount(KEEPER_PRIVATE_KEY);
const pub = createPublicClient({ transport: http(RPC_URL) });
const wallet = createWalletClient({ account, transport: http(RPC_URL) });
const lens = (functionName, args = []) =>
pub.readContract({ code: LENS_CREATION_CODE, abi: lensAbi, functionName, args: [HOOK_ADDRESS, ...args] });
async function send(functionName, args = []) {
const { request, result } = await pub.simulateContract({ account, address: HOOK_ADDRESS, abi: hookAbi, functionName, args });
const hash = await wallet.writeContract(request);
const receipt = await pub.waitForTransactionReceipt({ hash });
console.log(functionName, receipt.status, result);
return receipt;
}
const block = await pub.getBlockNumber();
const ready = (last) => last === 0n || block >= last + COOLDOWN_BLOCKS;
const expressed = async (g) => (await lens("geneStatus", [g])).state === 3;
// 1. catch up evolution so that metabolize() can place ETH
const pending = BigInt(await lens("currentEpoch")) - BigInt(await lens("evolveCursor"));
if (pending > AUTO_EVOLVE_MAX || (pending > 0n && !(await expressed(1)))) await send("evolve", [pending]);
// 2. metabolize, when METABOLISM is expressed and the cooldown has passed
if ((await expressed(1)) && ready(BigInt(await lens("lastMetabolizeBlock")))) await send("metabolize");
// 3. regenerate, when REGENERATION is expressed and the cooldown has passed
if ((await expressed(2)) && ready(BigInt(await lens("lastRegenerateBlock")))) await send("regenerate");A very long backlog can make one evolve(n) call expensive; split it into several calls with a smaller n.
#Gas
Measured on the RC4 production code:
| Call | Gas |
|---|---|
metabolize(): harvest, bid and ask |
734,259 |
regenerate() that spends |
140,686 |
regenerate() that buys nothing |
64,235 |
#Audit scope notes
A metabolize() call reverts as a whole if an ETH placement would need GENE (GeneHook.sol:655-660). A reverted call writes nothing and starts no cooldown (GeneHook.sol:284-286), so a keeper can retry in a later block. regenerate() buys while the price is no more than about 1% above the all-time-low price; otherwise it spends nothing and starts no cooldown (GeneHook.sol:574-577, 302-307).
Audit scope note: whether a caller can make metabolize() place less while still starting its cooldown, metabolize() reverting InvariantBroken when the price sits at or beyond the bid band, and reachability of the REGENERATION window (see Security).
Related: read state, regeneration, epochs.
Sources (12)
- src/GeneHook.sol:25-33
- src/GeneHook.sol:276-329
- src/GeneHook.sol:451-476
- src/GeneHook.sol:479-552
- src/GeneHook.sol:561-591
- src/GeneHook.sol:655-660
- src/GeneHook.sol:707-718
- src/GeneHook.sol:728-730
- src/GeneHook.sol:802-805
- src/Genome.sol:44
- src/Genome.sol:54
- src/GeneLens.sol:100-127
Paths are relative to the GENE repository at tag rc4-audit-candidate (aca5fcd).