Pre-launch. RC4 code-frozen for external audit (rc4-audit-candidate, aca5fcd). Report pending. Status, updated 2026-10-08
GENESpecimen 001

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

  1. Catch up evolution. If currentEpoch - evolveCursor > AUTO_EVOLVE_MAX, call evolve(n) until the cursor reaches the current epoch (GeneLens.sol:100-115). Each call processes at most n epochs.
  2. metabolize(). It harvests ETH fees into ethReserve, and into regenReserve once 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).
  3. regenerate(). It spends only regenReserve (GeneHook.sol:563), which grows only through fees credited inside metabolize(), by its harvest or when a placement tops up an existing band (GeneHook.sol:554-559, 707-716). Calling it after metabolize() 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_TICKS in 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).