Files
zeekayandHanzo Dev 8e4f9488d8 Say what the differential settled, and what it cost to settle
The precompile section described a failing run with numbers from an older
corpus, and PRECOMPILE.md still told the reader that a Lux module shadows
wherever it is registered and that a declined charge cannot be recovered.
Both were true when written and neither is now.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 00:11:04 -07:00

6.3 KiB

The precompile differential

One corpus of calls — an address, a gas limit, and input bytes — handed to three implementations of the EVM precompiles, whose answers are compared field by field. It is the same machine as the chain differential next to it, with a different subject and the same runner.

make precompiles

Why a differential and not a test suite

Each runtime already tests its own precompiles, and each of them passes. That is the problem: a suite written against an implementation agrees with the implementation it was written against. The question here is not whether ecrecover works, it is whether the three runtimes charge the same price and return the same bytes for the same call — and no suite that lives inside one of them can ask that.

The corpus

corpus/precompile_vectors.tsv, one call per line:

V	<id>	<address>	<gas-limit>	<input-hex>

The address is twenty bytes of lower-case hex with no leading 0x. Input is lower-case hex, or - for no bytes. Most of the corpus is borrowed from geth's own core/vm/testdata/precompiles: an input the reference tests itself against is an input the other two should survive. It is read out of the geth the Go evaluator's module requires, located with go list -m, so the corpus and the reference cannot be two different versions of geth — they were, and the corpus was two patch releases behind the requirement with nothing to say so.

The rest covers what that data does not reach — the three precompiles it ships no vectors for, an empty input to every address, addresses nobody serves, the boundary between paying and not paying, and Lux's own precompiles, which geth has no data for because they are not geth's: inference at 0x0300…0003, AI mining at 0x0300…0000, p256 at 0x…0100, and the post-quantum block of LP-4200 — ML-KEM 0x…012201, ML-DSA 0x…012202, SLH-DSA 0x…012203, Pulsar 0x…012204, P3Q 0x…012205, Corona 0x…012206 and X-Wing 0x…2221. An empty input is a defined call to every one of them, so what those rows settle is not how each parses its arguments; it is which implementations are at the address at all.

The answer

Each implementation prints one line per vector:

R	<id>	<status>	<gas-charged>	<output-hex>	<note>

Status is one of:

OK it ran; the output is the bytes it returned
FAILED it read the input and refused it
OOG the gas offered was below what it charges
ABSENT nothing serves this address here
STATE something serves it, and answering needs a chain

Gas is what was charged, never what was left. The three runtimes disagree about which of those they return — Go hands back remaining gas, the C++ result carries gas left and zeroes it on failure, the Rust result carries a revm Gas — and a wire format that let each send its own convention would compare two different quantities and call the difference a bug. Convert at the edge.

On OOG and ABSENT the charge is 0 and the output is -, because there is no charge to report and a number nobody agrees to means the column stops comparing. On FAILED the charge stands: a precompile that read the input and refused it did the work of reading it.

An implementation that cannot know the charge writes SKIPPED rather than a number, since zero would be an answer and a wrong one. The runner counts a SKIPPED field, prints it under DECLINED, and never scores it as agreement — a run can still pass, because two other implementations answered, which is exactly why a column going quiet has to be printed rather than inferred.

Little reaches it now. revm computes a precompile's price inside the function that does the work, so its error carries no charge away, where Go and the C++ tree separate price from work and report one. But the charge is recoverable without either of them: a price is the smallest offer a precompile accepts, so the Rust evaluator bisects the offer until the answer stops being out of gas. That reads the price off the implementation under test rather than off a copy of revm's numbers — which revm keeps private in any case. What is left for SKIPPED is a precompile that cannot run at all, where no offer produces an answer and there is nothing to measure.

The note is not compared. An error string is a fact about a codebase, not about a precompile, so it travels where it explains without being weighed.

Which implementation answers at an address

A Lux module the chain enables shadows the stock precompile at its address, because that is what LuxPrecompileOverrider does before the standard table is consulted. The Go reference asks in that order.

Enabled, not registered — the two are a long way apart and the difference is invisible from inside one runtime. Importing luxfi/precompile/registry links fifty-six modules into a binary. PrecompileOverride answers from GetExtrasRules(...).Precompiles, which holds the keys the chain's precompileUpgrades name, and it never asks the registry whether the code is linked. A module no network names is not a cheaper precompile at that address; it is no precompile at that address.

The stock table is likewise the one the chain runs, and that is Cancun. luxfi/evm maps its Quasar fork to CancunTime and defines nothing after it — there is no OsakaTime on a Lux chain config for a network to set, and none sets one.

0x…0100 is where both halves of that showed. Two implementations are written for that address and the chain reaches neither: the Lux secp256r1 module charges 3450 and secp256r1Config appears in no upgrade.json, and geth's p256verify charges 6900 and arrives at Osaka. So all three evaluators answer ABSENT there and the corpus records ABSENT, because that is what a call to 0x…0100 meets on a Lux chain: an empty account. 0x0300…0003 is the same finding a second time — aiInferenceConfig is enabled nowhere, and the AI key the C-Chain does enable, aiMiningConfig, is at another address.

Adding an implementation

Write a program that takes the corpus path as its one argument and prints the result lines above to standard output. It does not need to answer every address — but it must print a row for every vector, ABSENT included, because the runner fails a run where an implementation stayed silent. Then add it to the precompiles target as another -eval.