The operator
Exit codes, the file lock, how a partial epoch closes itself, the runbook, and which of it a reader can check without any access.
One script, epoch.mjs, fired by a systemd timer at *-*-* *:00:00 UTC with five seconds of accuracy and no randomised delay. It runs, it exits with a code, and the code is the whole status report. Nothing is daemonised, nothing holds state in memory between hours, and a run that dies is designed to be closed by the next one rather than repaired by hand.
This page is what an operator does and what a reader can check without being one. The second part is shorter than you would expect, which is the point.
Three exit codes
| Code | Meaning | What it implies |
|---|---|---|
0 | the epoch closed, or there was nothing to do | includes a recovery run that finished a previous epoch's journal record and published nothing |
2 | partial: money moved and the run gave up before writing its closing record | a partial record is already in the journal. The next run reconciles it. Shows as a failed unit in systemctl status on purpose, as a signal to read the log, not as something to fix |
3 | refused or failed before any money moved | nothing was sent. Safe to retry once the cause is fixed |
Exit 3 covers a specific list, and it is worth reading as a list of things the script refuses to do rather than things that went wrong: the split not summing to exactly 10000 basis points, a non-integer or negative threshold, an unreachable RPC, the lock held by a live process, RESERVE_START_WEI still zero on the first epoch, openEpoch estimating above 90% of the gas cap, a balanceOf that failed for any holder, a Transfer replay whose balances do not sum to totalSupply, and an epoch id that already has a published root on chain with no matching journal record.
Neither the burn nor the sweep can produce a 2 or a 3. A missing router, a reverting swap, gas that is too expensive, a failing sweep: all handled as a skip, never as a refusal to publish the root. Holders' money is not held hostage to the parts of the hour that are optional.
The lock
An atomic file lock at .epoch.lock in the working directory, containing the holder's pid. Acquisition writes the pid to a temp file and link(2)s it into place, which is all-or-nothing and fails with EEXIST if the lock exists; the naive alternative of open-then-write leaves a window where a concurrent acquirer reads an empty file and concludes the holder is dead.
A lock is stale, and gets cleared, if either the pid is not running or the file's mtime is at least 30 minutes old. The second condition matters as much as the first: a wedged but technically alive run is cleared after half an hour rather than wedging the timer forever. A lock file with unparseable contents is conservatively assumed to be held by a live process until it is old enough to be stale by age alone.
TimeoutStartSec=900 backs this up from the systemd side. If a run hangs past fifteen minutes, systemd sends it a SIGTERM, which is safe for the reason the next section explains.
Intent first, then money
Before the first transaction of a run, two things go to disk: a partial record in the journal describing what this epoch is about to commit to, and the proofs file for the tree. A SIGKILL from that moment on leaves evidence of what was intended and, more importantly, leaves the tree itself, which cannot be rebuilt from the chain because only the root goes on chain.
The journal record is rewritten after each step, so a killed run's last line says how far it got: whether a swap was broadcast, whether it confirmed, whether a sweep landed, whether the publication was in flight and under which hash.
Recovery is the first thing the next run does. If the journal says a run started publishing epoch N and the chain says N is published, there is nothing to replay, because openEpoch sets the root, the money and the epoch counter in a single transaction. The recovery reads the epoch record back off the chain, stamps the proofs file with the real publication timestamp, re-derives burned from the recorded swap hash's own logs if needed, records the measured reserve balance, closes the journal entry and returns 0 without publishing anything.
Two crash points get specific handling:
- A swap broadcast, then the process died. The hash was journaled before the receipt wait. The next run reuses that hash, confirms it by its own logs, and never sends a second swap. If the transaction cannot be found or did not succeed, the hour's burn is written off and its share stays on the reserve.
- The publication was in flight. The
inFlighthash is in the journal. Recovery reads the chain rather than trusting it, so a transaction that was broadcast but never mined leaves the epoch unpublished and the next run publishes it normally.
Two guards against paying an epoch twice
The contract's guard is if (epochId != epochCount + 1) revert BadEpoch(). Epochs can only be published in order, exactly once, and the counter is set in the same call as the funds. Nothing off chain can defeat that.
The operator's guard sits in front of it. Before doing anything, the run reads epochs(epochId) and refuses with exit 3 if that epoch already has a non-zero closedAt and the local journal knows nothing about it. The contract would have reverted anyway; refusing here means the operator never spends gas discovering it, and, more usefully, it means a deleted or stale journal gets a human's attention instead of quietly producing a run that thinks it is starting from scratch.
The gas ceiling
Robinhood Chain reports a block header gasLimit of 1125899906842624, which is 2^50 and an Arbitrum-style placeholder rather than a budget; real blocks were using around 10.3M when this was measured. The ceiling that actually binds is the Orbit per-transaction cap of 32,000,000, hardcoded in the script, and a publication estimating above 90% of it refuses with exit 3 rather than broadcasting a transaction that can never mine.
Since the move to Merkle claims neither that check nor the 10%-of-the-pool gas guard can realistically fire on the publication, because openEpoch costs about 118,000 gas regardless of how many wallets are in the tree, against roughly 11.4M for the 300-recipient push it replaced. Both checks stayed anyway. A guard that has stopped being able to fire is not the same as a guard that was wrong to write.
Sweeping
One expired epoch per run, oldest first. The scan starts from a cursor carried in the journal so a run never re-reads epochs it has already cleared, walks at most 500 records, and stops at the first epoch still inside its window, since publication timestamps are monotonic and everything after it is also still claimable.
The sweep runs before this epoch's reserve figure is taken, deliberately. A remainder returning to the reserve after the measurement would be read as fresh income an hour later and redistributed, which would pay holders twice out of money that was already theirs once.
What a reader can verify without trusting the operator
This is the part that matters to somebody who is not running anything.
- That a root was funded.
openEpochrequiresmsg.value == total, so the publication transaction's value is the epoch's total. Both are in the transaction on Blockscout. - That the epoch sequence is unbroken.
epochCountand theEpochlogs are indexed by id, and the contract will not accept a gap. - What is left in an epoch.
epochs(id)returnsroot,total,claimed,closedAtandsweptto anyone who calls it. - Whether a given wallet claimed. After deployment,
claimed(id, address)and theClaimedevents can be checked on the distributor explorer page. Wallet lookup is not available on the current homepage. - That a proof matches the root. Hash the leaf, fold the proof, compare. No operator involvement, no trust required.
- How much has been burned. Count
Transferlogs into the dead address on the token contract. That number is independent of anything the operator emits. - The income subtraction.
eth_getBalanceon the reserve at the snapshot block, against the previous epoch's recorded reserve figure.
One caveat on that last item, because the code and the earlier plan disagree and the code is what runs. The reserveAfter field published in the Epoch event is taken before the publication transaction is sent, as the balance minus the epoch's total; the operator's own baseline for the next hour is the balance measured after the transaction confirms, which is what goes in the journal. The two differ by exactly that transaction's gas. So repeating the subtraction from the event lands a hair high, and the bias is always in the same direction: the next hour's measured income is understated, never overstated.
What a reader cannot verify is the tree's fairness. The snapshot was taken by the operator at a block only the operator chose, and reconstructing it means replaying the token's Transfer logs yourself from the token's creation block to that snapshot block and checking the balances sum to totalSupply — which is exactly what the operator does, and refuses to publish when it fails, but it is work, not a glance.
The runbook, compressed
The log is /var/log/mevhood/epoch.log, both streams appended. A partial epoch shows up either as a failed unit in journalctl -u mevhood-epoch or as a "status":"partial" line in epochs.jsonl, and the correct response is to do nothing, because the next hourly run closes it. Investigate only if the same epoch stays partial across more than one run.
node epoch.mjs --dry-run prints the share table and writes a dry record, sending nothing. It is read-only by design, which includes not performing recovery: with an outstanding partial epoch, a dry run does nothing at all.
Three files on the operator's machine are not interchangeable. epochs.jsonl is the record of what was published and burned. holders.json is a cache and can be deleted; it rebuilds from the token's logs. proofs/ cannot be rebuilt from anything, by anyone, and is the one directory that must be backed up — lose it and holders whose money is sitting in the contract have no way to construct the proof that releases it.
A truncated last line in the journal, which a run killed mid-write can leave, is logged as a warning and skipped in favour of the previous record. That is safe because every journal record is a cumulative snapshot and recovery re-reads the root, total and timestamp from the chain rather than trusting the file.