Golden on-chain parity tests¶
Problem¶
A large family of tests assert that a local pool-calculation function returns a
value exactly equal to the value returned by the deployed contract on-chain
(Uniswap V2/V3/V4 quoter, Curve get_dy/get_dy_underlying, Camelot / Aerodrome
getAmountOut, etc.). Today these tests drive a live RPC (via an Anvil fork at an
unpinned tip, or an external archive node URI from tests.env), which is:
slow — fork spin-up + per-amount
eth_calls round-trip the RPC;flaky — external public RPCs rate-limit / drop connections;
unsafe for CI/CD —
tests.envcarries API keys that must not leak; andnon-deterministic — many tests fork at tip (
fork_mainnet_fullwith nofork_block), so the “golden” value changes every run.
We want these tests to assert the same exact-equality contract but read the on-chain truth from a recorded file, so CI runs with zero RPC and zero secrets.
Existing assets we build on¶
OfflineProvider(src/degenbot/provider/offline_provider.py) — already serves pre-recordedeth_callresponses from per-block JSON files undertests/fixtures/chain_data/. Replay-only; no recorder exists yet.I/O-free pool tests (
tests/pool_companion/test_io_free_construction.py,tests/pool_companion/test_io_free_behavior.py) — already build pools from hardcoded constants + recorded tick data, no RPC. These are the architectural template (and align with ADR-005: pools are I/O-free; builders fetch state, pools receive values).tests/uniswap/v3/test_v3_offline.py— the proven offline-replay wiring (OfflineProvider.from_json_file→raw_call→ I/O-free pool).
The gap: the offline tests dropped the exact quoter parity assertion
(replaced with loose ranges), and the recorder that would populate a cassette
was never generalised — the existing chain_data files were hand-produced.
The Curve cassette recorder¶
The one L1 recorder that exists is scripts/record_curve_tripool_cassette.py:
it forks mainnet at --block via the archive URI, builds the Curve tripool
through the production Bot.build_pool path, and serializes the pool’s
constructor inputs into tests/fixtures/chain_data/1/curve_tripool_block_<N>.json
(committed cassette: curve_tripool_block_14900000.json), which
tests/curve/test_curve_stableswap_pool.py::test_a_ramping replays offline.
Re-record only when the pinned regression block changes —
uv run --no-sync python scripts/record_curve_tripool_cassette.py --block <N>
(requires a reachable archive node serving that block and the anvil binary).
Generalizing this recorder to other pools remains the deferred non-goal below.
Two layers, one goal¶
On-chain parity tests need two kinds of off-chain truth:
Layer |
What it records |
Mechanism |
Status |
|---|---|---|---|
L1 — pool state |
slot0 / reserves / balances / tick bitmap+data / |
|
exists (replay); needs a recorder |
L2 — oracle truth |
the quoter / |
|
new (this doc) |
L1 and L2 compose. A converted test builds the pool I/O-free (L1 or hardcoded
constants) and asserts calc == golden.check(…) (L2). Neither touches a live
RPC in CI.
Why a separate L2 instead of folding the quoter into the L1 cassette?¶
The quoter call in existing tests goes through a test-local contract interface — its own provider connection, not the cassette-bound provider the pool under test uses. A provider-level cassette would not capture it without re-routing every quoter call through the same provider instance. L2 intercepts the oracle call at the test boundary, so:
the oracle truth is a small file of plain ints (trivially reviewable in a PR);
it is decoupled from quoter-ABI / calldata churn;
it works uniformly across V2/V3/V4/Curve/Aerodrome regardless of how the test currently invokes the on-chain contract.
Identification method¶
A test is an on-chain parity candidate iff it satisfies all three:
It builds a pool and calls a local calculation method (
calculate_tokens_out_from_tokens_in,calculate_tokens_in_from_tokens_out,calc_withdraw_one_coin,calc_token_amount,simulate_*, …).It calls the deployed contract — quoter /
get_dy/getAmountOut/ raweth_calltoquoteExactInputSingle, etc. — and binds the result to a variable named likecontract_amount(_out|_in),quoter_amount_*.It asserts
local == contract(exact equality, not a range /pytest.approx).
Discovery (heuristic)¶
rg -n --type py \
'quoter|quoteExact|get_dy|getAmountOut|\.functions\..*\.call\(\)' tests \
| rg -v '__pycache__'
then cherry-pick lines co-located with fork_ fixtures and an assert .* == .*amount whose RHS came from step 2.
Durable indexing (the real index)¶
Apply a registered pytest marker to every converted test:
@pytest.mark.onchain_oracle
def test_cached_calculations(golden_factory, ...): ...
onchain_oracle is registered in [tool.pytest.ini_options].markers (see
pyproject.toml). The marker set is the inventory; pytest --markers | rg onchain_oracle or pytest -m onchain_oracle --collect-only -q lists every
converted test. The grep above is only the initial triage.
Exclusion rule — when NOT to convert¶
Do not convert a test if its purpose is discovery, not regression — i.e. it iterates a live registry to surface new or broken pools, where the set of pools is unknown ahead of time and the test’s value is catching the unexpected. Converting would freeze the pool set and silently stop discovering new failures.
In this repo:
tests/curve/test_curve_stableswap_pool.py::test_factory_stableswap_poolstests/curve/test_curve_stableswap_pool.py::test_base_registry_poolstests/uniswap/v3/…::test_first_200_pools/test_first_200_pools_with_snapshot(unless you also freeze the pool list + block — see “partial” below)tests/uniswap/v4/…::test_first_200_pools/…_with_snapshot
These tests catch arbitrary exceptions and re-raise them as AssertionError
with a “Reproduce with test_single_pool @ <addr>” hint — that is a discovery
contract, not a fixed-truth assertion. They must stay on a live fork (mark them
slow + the chain marker and exclude from CI via -m).
Partial conversion (optional, for the first_200_* tests): if the pool list
comes from a committed JSON (testing_pools() fixture), you may pin a block +
convert each pool’s quoter results to L2 — but keep the live-registry variant
(test_*_registry_pools) as the discovery gate that runs out-of-band.
The L2 scaffold — tests/golden/oracle.py¶
GoldenOracle is the record/replay seam for oracle truth.
File layout (one JSON per test function, derived from nodeid):
tests/golden/data/<module path>/<TestName>.json
e.g. tests/golden/data/uniswap/v3/test_uniswap_v3_liquidity_pool/test_cached_calculations.json.
Format (human-readable, sorted keys):
{
"chain_id": 1,
"block_number": 17600000,
"recorded_at": "2026-06-27T12:00:00Z",
"entries": {
"0xbEbc…|quoteExactInputSingle|token0→token1|100000000": { "value": 15808930695950518795 },
"0xbEbc…|quoteExactOutputSingle|…": { "reverted": true, "exception": "ContractLogicError" }
}
}
Reverts are recorded as entries too, so the replay reproduces the test’s
try/except ContractLogicError: continue (skip) path faithfully — a revert at
record time stays a skip at replay time.
API¶
def test_cached_calculations(golden_factory, offline_wbtc_weth_v3_pool):
pool = offline_wbtc_weth_v3_pool # built I/O-free (L1/constants)
golden = golden_factory(chain_id=1, block_number=17_600_000) # L2
for amount in GOLDEN_AMOUNTS: # fixed list, NOT hypothesis
for token_in, token_out in [(pool.token0, pool.token1), (pool.token1, pool.token0)]:
key = f"{pool.address}|quoteExactInputSingle|{token_in.address}→{token_out.address}|{amount}"
oracle = golden.check(
key,
contract=lambda: quoter.functions.quoteExactInputSingle(
token_in.address, token_out.address, pool.fee, amount, <limit>,
).call(),
)
if oracle.reverted: # reproduce the record-time skip
continue
amount_out = pool.calculate_tokens_out_from_tokens_in(token_in, amount)
assert amount_out == oracle.value
Record mode (
--golden-mode=record):golden.checkinvokescontract()against the live fork, captures the value or the exception, writes/updates the JSON, and returns aGoldenResult. The test’s ownassertstill runs, so a record run is also a live parity run — it fails loudly if calc ≠ oracle at record time. Write incrementally so a partial run still persists what it saw.Replay mode (default, CI):
golden.checknever callscontract(); it returns the recordedGoldenResult. Thecontract=callable is required purely as the source of truth for the next record run (and as living documentation of which on-chain call the int came from). Theassertruns against the recorded int. No RPC. No secrets.
Drift & missing-entry semantics¶
Missing key in replay →
GoldenError("no golden entry for <key>; re-record")(fails the test, tells you what to run).Mismatch in replay → the test’s own
assert … == oracle.valuefails with a clear diff. The scaffold does not add a second assertion.Updating a stale golden value intentionally → re-run with
--golden-mode=recordagainst a fork pinned to the same block recorded in the file header.
Conversion recipe (per test)¶
Pin the block. Replace
fork_mainnet_full(tip) with a pinned-fork fixture:@pytest.mark.parametrize("fork_mainnet_archive", [N], indirect=True). Use the sameNfor record + replay (the L2 file header records it; the L2 scaffold asserts the replay block matches the recorded block).Make pool construction RPC-free. Preferred (ADR-005-aligned): build the pool I/O-free from constants + recorded tick data (copy the
offline_wbtc_weth_v3_poolfixture pattern; record the tick data into the existingtests/fixtures/chain_data/cassette). Fallback: wrap the fork in an L1 recorder →OfflineProvidercassette.Freeze the inputs. Replace
@hypothesis.givenamounts with a fixed, parametrized list committed alongside the golden file. Hypothesis-generated amounts are non-deterministic and cannot be replayed against recorded oracles.Route the oracle call through
golden.check(key, contract=…). Keep the test’sassert local == oracle.valueand itstry/except … continuerevert handling intact (L2 preserves the revert as a recorded entry).Mark it.
@pytest.mark.onchain_oracle.Record once.
just record-golden -- <test nodeid>against an anvil fork. Committests/golden/data/…/*.json(+ the L1 cassette if you added one).Replay.
just test-python— the converted test runs offline in CI.
Hypothesis tests (test_cached_calculations V3/V4)¶
These are the trickiest. They use @hypothesis.given(integers(1, MAX_INT256)) to
explore many amounts against the quoter. Strategy:
Keep hypothesis as a separate, online-only property test (mark
slow + ethereum), ORReplace it with a curated fixed list of amounts drawn from a real hypothesis run (the shrunk failing examples + representative spans) and convert that list to L2. The fixed list becomes the regression gate that runs in CI. The hypothesis variant remains the deep-exploration gate that runs on-demand.
Recommendation: replace with a fixed list + L2 for CI; keep the hypothesis
variant slow-marked for periodic deep runs. Do not try to record an
unbounded hypothesis search into L2.
Central-plumbing changes¶
pyproject.toml— registeronchain_oraclemarker (and, as a separate rollout decision, anonline_rpcmarker added to the default-mexclusion list so unconverted live-RPC tests are skipped in CI instead of failing).tests/conftest.py—--golden-mode/--golden-rootoptions + thegolden_factoryfixture.justfile—record-golden(runs pytest with--golden-mode=record+ the fork fixtures, requires a working RPC/anvil) andtest-online-parity(runs only-m onchain_oraclein replay, fully offline).
Rollout order (smallest blast radius first)¶
Land scaffold:
tests/golden/oracle.py+ conftest options + marker + justfilea self-contained deterministic scaffold test (
tests/golden/test_oracle.py).
Tracer bullet (single pool, single assert): convert
test_create_camelot_v2_pool(CamelotgetAmountOut, one assertion) — proves record + replay + revert handling end to end.Fixed-block, fixed-amounts: convert V3/V4
test_cached_calculations(replace hypothesis with fixed amounts) + thetest_calculate_tokens_*_with_overridefamily.Multi-direction Curve: convert
test_tripool,test_base_pool,test_single_pool,test_tricrypto_pool,test_metapool_*(L2 forget_dy/get_dy_underlying/calc_withdraw_one_coin/calc_token_amount).Aerodrome / Pancake / V2 router:
test_calculation_volatile/stable,test_pancakeswap_calculations,test_calculate_tokens_out_from_ratio_out.Leave as live discovery: the
test_*_registry_pools/test_factory_stableswap_poolsregistry sweeps (exclusion rule above).
Each step is one commit; the marker set is the progress dashboard.
Non-goals¶
Recording full RPC cassettes for arbitrary chains of
eth_calls (L1 recorder) is intentionally deferred — it is its own concern and only needed where a pool cannot yet be built I/O-free. Track it as a follow-up (likely anergotask).We do not change the calculation behaviour of pools; only the test oracle.
We do not remove the live-fork fixtures — record mode still needs them.