Skip to main content

enhance-ported-test

Clean up and future-proof a ported static test.

ソース情報

リポジトリ
ethereum/execution-specs
ソースの最終更新活動
2026年9月23日 09:33
検出された SKILL.md の言語
英語
スター
1,197
フォーク
520

インストール方法

デフォルトでは、最初にソースを確認する Prompt が選択されています。直接コマンドに切り替えるか、ローカルコピーをダウンロードすることもできます。

ソースファイルを確認

インストールを決める前に、SKILL.md と SkillsMP に表示されている付属ファイルをお読みください。

SKILL.md を表示中

SKILL.md
ソースの指示 · 読み取り専用プレビュー
name
enhance-ported-test
description
Clean up and future-proof a ported static test.
# Enhance Ported Test Future-proof and clean up a test under `tests/ported_static/`. These tests were machine-ported from the legacy `ethereum/tests` static fillers (YAML/JSON) and carry a lot of boilerplate, hardcoded values, and weak/incomplete post-state checks. This skill is the ordered methodology for turning one into idiomatic, robust Python. This skill is a **living document**: it captures the cases we have validated so far. Real tests will hit shapes not covered here — that is expected. When you find one, solve it, then add the new case/step to this file. ## Goal The end state is a test that **passes on every fork from its `valid_from` onward** (not just the baseline), expresses its intent explicitly, and has no fragile hardcoded constants. "Future-proof" = a later fork that re-prices gas, adds state costs, or changes account rules should not silently break it. ## Core loop (subtractive) Most of the work is **removing** boilerplate one piece at a time and proving the test still passes after each removal: 1. **Baseline first.** Before touching anything, fill the test and confirm it is green: `uv run fill <path> --fork=<valid_from-fork> -q --clean`. 2. Make **one** change. 3. Fill again (same fast command). Green → keep, move on. 4. **Red → roll back that one change and analyze.** A break is information: it tells you the thing you removed was load-bearing. Understand *why* before deciding whether to keep it, replace it with a dynamic equivalent, or leave it. Never paste a new expected value just to make red go green without understanding the change (see "Re-pinning" below). Do low-risk, independent removals in small batches if you like, but anything that can plausibly interact (addresses, contracts, gas) goes **one at a time** so a failure is attributable. ## Verification cadence - **Iterating:** `--fork=<baseline>` (usually Cancun) — fast. - **Checkpoint / done:** fill the whole `valid_from` range (omit `--fork`) so all deployed forks are exercised. - **Probe the future fork:** explicitly `--fork Amsterdam` (or the latest fork that enables new EIPs). A gas/state-cost change there is the most likely future breakage. (Historical note: broken tests used to be parked in a `tests/ported_static/amsterdam_skip_list.txt` consumed by a local conftest; the list was emptied and both were removed. If a future fork's repricing breaks tests en masse, the same parking pattern — a substring-matched skip list plus a `pytest_collection_modifyitems` hook — is in git history.) - **`fill` output:** writes to `./fixtures` (`--clean` resets it), or pass `--output <dir>` for a scratch location. Do **not** use `-o` — that is pytest's `--override-ini`, not the output dir. ## Ordered steps Do them roughly in this order. Earlier steps unblock later ones (notably: audit the bytecode *before* touching gas, since restoring elided opcodes moves the budget; and max out gas *before* strengthening post-state, so added opcodes don't hit a gas ceiling). ### 0. Audit the bytecode against its `# Source: yul` comment The `# Source: yul` blocks are the *filler's* source; the bytecode beside them is what **solc emitted**, and the optimizer is free to delete operations the test depends on. A port that faithfully reproduces the compiled bytecode therefore faithfully reproduces the *hole* the optimizer left. Do this **first** — restoring elided operations changes gas, so it must precede any budget work (steps 2 / 10). **The canonical fold: a self-cancelling `SSTORE` pair.** Refund tests set a slot then clear it (`sstore(k, 1); sstore(k, 0)`) to earn a refund. In a fresh `CREATE` frame slot `k` is already zero, so solc folds the pair down to `sstore(k, 0)` — a no-op that generates **no refund at all**, leaving the test vacuous while still passing. Validated on `test_create_oog_from_call_refunds`, where 2 of 24 init codes had lost their `sstore(1, 1)`: the OoG arms assert the sender's balance reaches exactly zero, which *is* the "refund earned inside a reverted frame must be discarded" check — and it was asserting nothing. **How to check.** Disassemble every bytecode blob and diff it against the comment above it. Comparing opcode *counts* per mnemonic (`sstore(` in the Yul vs. `SSTORE` in the asm) catches the whole class in one pass. A throwaway script that `ast`-parses the test, `eval`s each `Op...` assignment against a namespace of the test's constants, and walks `bytes(...)` through a `PUSH*`-aware opcode table is enough — there is no disassembler in `execution_testing`. **Tells in the ported source.** Dense `DUP`/`SWAP` juggling (`Op.SSTORE(key=Op.DUP2, value=Op.DUP2)`, a bare `Op.PUSH1[0x1] + Op.PUSH1[0x0]` prologue, a trailing argument-less `Op.RETURN`) is solc's stack reuse — the shape most likely to hide a fold, and unreadable regardless. Rewrite those from the Yul into explicit `Op.SSTORE(key=..., value=...)` / `Op.RETURN(offset=..., size=...)` form: it restores the intent and makes the next audit trivial. **Benign deviations — do not "fix" them.** solc drops a `POP` before a terminator (`pop(call(...)); return(0, 1)` compiles without the `POP`, as `RETURN` ignores leftover stack) and encodes repeated literal zeros as `DUP1` chains (`Op.CALL(..., args_offset=Op.DUP1, ...)`). Both are semantically identical to the Yul. Only a **missing or added state-changing operation** is a real deviation. **Expect to re-budget afterwards.** Restoring an elided op adds its cost — a zero->non-zero `SSTORE` is ~22.1k pre-EIP-8037 and ~97.9k of *state* gas on Amsterdam — so a test with a hardcoded `gas_limit` may now OOG. That is usually not a regression you introduced: it reveals that the sibling cases which never lost their op were *already* failing on the future fork for the same reason. Establish this before re-budgeting by copying the pre-change file aside under a different test name, filling both, and diffing the failure sets — in the validated case that separated 12 pre-existing Amsterdam failures from the 3 the fix added. **Verify the restoration is observable, not merely green.** Fill before and after and reconcile the gas delta. Above, consumption moved 77731 -> 97857 (the added cold `SSTORE`, minus the reset dropping to a warm 100) and the 19900 refund was capped by EIP-3529 at `97857 // 5 = 19571`, giving the reported 78286 exactly. A delta you cannot account for means the rewrite changed the program (see "Re-pinning" below). ### 1. Remove `env` Delete the `Environment(...)` block, the `env=env` arg to `state_test`, and any now-orphaned vars (`coinbase`) and the `Environment` import. The framework supplies sensible defaults. **Keep `env` only if** the post asserts on the coinbase/`fee_recipient` balance, or the bytecode reads block fields (`NUMBER`, `TIMESTAMP`, `PREVRANDAO`, `BASEFEE`, `GASLIMIT`, `COINBASE`). `fee_recipient=sender` alone is not a reason to keep it. ### 2. Remove `gas_limit` from the transaction (if gas is not the subject) This is the common case and belongs early. Omitting `gas_limit` maxes out the gas the tx receives, so the body executes fully. See the [Transactions section] (../write-test/SKILL.md#transactions) of the `write-test` skill. - **Remove it** when the test is about *behavior* and just needs to run to completion. This also lets you delete any per-fork gas band-aids (e.g. `fork.is_eip_enabled(8037)` budget bumps) and often the `fork` param itself. - **Keep it** only for genuinely gas-sensitive tests (OOG boundaries, intrinsic-gas, code-deposit limits, or gas metering) — see step 10. - **Gas-snapshot tests are gas-sensitive.** If the post asserts a stored `GAS` reading or a `SUB(@gas_before, GAS)` delta (legacy slots `0` / `0x64`), the test *measures gas* — handle it under step 10 (preserve via `CodeGasMeasure`), do not just strip `gas_limit`. This was the dominant skip-list shape: the stored gas value is exactly what EIP-8037 re-prices and breaks. - **EIP-8037 caveat:** when you omit `gas_limit` on a test that *measures* an operation incurring **state gas** (account creation, storage writes), add `state_gas_reservoir=0` to the tx, or that state gas is silently dropped from the measurement on EIP-8037 forks (see step 10). Pure-execution opcodes (e.g. `PUSH0`, arithmetic) have no state gas and do not need it. - Do **not** add a comment explaining the absence of `gas_limit`; omission is the default. ### 3. Remove hardcoded contract `nonce` Drop `nonce=0` from `pre.deploy_contract(...)`. If a `compute_create_address(..., nonce=N)` in the post depends on it, keep them consistent. ### 4. Remove hardcoded addresses (one contract at a time) Two sub-cases: - **Value discarded:** a `contract = Address(0x...)` literal that is immediately overwritten by `pre.deploy_contract(...)` (no `address=`). Just delete the literal; the deploy returns a `fill`-generated address. - **Value passed to `address=`:** remove both the literal *and* the `address=` argument, per contract, filling after each. - **No-op case:** `to=None` creation tests often have no hardcoded address at all (the created address is `compute_create_address(sender, nonce=0)`). Confirm by grepping for `Address(0x` / `address=`. - **On break:** some bytecode hardcodes that address as a CALL/CREATE target (or the tx `to`/`data`). Thread the dynamic address through the caller and the tx entry point instead. - **Self-reference:** a contract that hardcodes its *own* deploy address (e.g. `Op.BALANCE(0xF172…)` where `0xF172…` is its own `address=`). Threading a `fill`-generated address in is impossible (chicken-and-egg), so replace the self-reference with the opcode that yields it at runtime — `Op.BALANCE(Op. ADDRESS)`. Don't substitute a *different* opcode that happens to be shorter (e.g. `Op.SELFBALANCE`) if it changes what the test exercises. - **Remove `@pytest.mark.pre_alloc_mutable`** once the test no longer hardcodes addresses/nonces or assigns `pre[...]` directly — i.e. all allocation now goes through `fund_eoa` / `deploy_contract` / `nonexistent_account`. Fill to confirm. ### 5. Remove easy boilerplate values Independent and usually safe (batchable): `pre.fund_eoa(amount=...)` → `fund_eoa()`; tx `value`; tx `data` when it is empty (`Bytes("")`); explicit gas price fields. Keep any of these that the post actually checks or that triggers the behavior under test. - **Drop opcode args that just pass their default.** Ported bytecode often spells out zero operands that are already the default, e.g. `Op.CALL(..., args_offset=0, args_size=0, ret_offset=0, ret_size=0)` — all four are `0` by default. Removing them is a no-op on the assembled bytecode (verify once with `bytes(a) == bytes(b)`) and cuts noise. Applies to any opcode arg equal to its default. - **Drop the hardcoded subcall `gas` operand — this is a correctness fix, not cosmetics.** `Op.CALL`/`CALLCODE`/`DELEGATECALL`/`STATICCALL` default `gas` to `Op.GAS` (forward all remaining). Ported fillers hardcode a constant (`gas=0xEA60`, `gas=0x186A0`) that was sized for the *old* gas schedule; once EIP-8037 inflates the callee's state gas (e.g. a zero→non-zero SSTORE jumps to ~97920), that fixed budget no longer covers the callee and the subcall OOGs on Amsterdam — a common reason a pure-behavior test lands on the skip list. Omit the operand so it forwards everything. **Caveat:** forwarding all gas via `Op.GAS` misbehaves on **pre-EIP-150 (Homestead)** — the sweep (step 11) fails only there, so such tests floor at **TangerineWhistle**. Keep an explicit `gas` operand *only* when the amount forwarded is the subject (an OOG-boundary test). **Budget vs. subject:** before dropping the operand, ask *why* the constant has its value. A mid-sized constant (`0xEA60`) is a *budget* sized for the old schedule — drop it. An absurd or boundary constant (`2**256 - 20`) is the *subject*: it exercises the 63/64 clamp on an oversized ask (a client that computed e.g. `requested + stipend` in wrapping arithmetic would forward almost nothing and fail). Keep it, name it (`OVERSIZED_GAS_ASK`), and state the intent in a comment. Validated on `test_make_money`. - **A codeless / absent call target is `pre.nonexistent_account()`**, not `pre.fund_eoa(amount=0)`. It yields an address guaranteed to hold no code and no state, which is what "call an empty contract" tests mean. - **Drop a stale `# noqa: F841`** on `contract = pre.deploy_contract(...)` once the variable is actually used (in `to=` / the post); leaving it triggers `RUF100`. ### 6. (Parametrized tests) Analyze what the `data` parameter is Look at `tx.data` / `tx.to`: - **Scenario A — data is a target contract address:** the tx lands in a thin entry-point contract that just `CALL`s the address from calldata. Usually you can **delete the entry-point** and call the target directly, and the N targets are near-identical → replace N bytecode copies with a **dynamic generator** parameterized by the small difference. When the targets are *gas-measurement* contracts differing only by the measured opcode, the dedup collapses all the way to a single `CodeGasMeasure(code=opcode)` parametrized on the opcode (step 10) — the entry-point's `CALL` was only a delivery mechanism. Validated on `test_push0_gas2` (PUSH0 vs PUSH1 0x00). - **Scenario B — data is initcode:** spotted by **`to=None`**. Decide whether running inside initcode is *required* by the test (e.g. the test is about initcode-context behavior, per its title/docstring) or just an artifact of the static-filler format (most common — then the logic can move to a normal deployed contract). If required, convert the `tx_data` array into an `initcode(d)` **generator function**: even when variants are genuinely different programs, the function form lets each branch be labeled by intent, surfacing the one thing that varies. ### 7. (Parametrized tests) Simplify `expect_entries_` / `resolve_expect_post` **First, identify which index actually discriminates — it is *not* always `d`.** Ported tests also key on `g` (gas) or `v` (value); check both the `expect_entries_` `indexes` (which axis is non-`-1`) and which of `tx_data[d]`/`tx_gas[g]`/`tx_value[v]` is the list with >1 entry. The other two indexes are pinned/wildcard. (Example: `test_add_non_const` varies `v` — `d`/`g` are fixed at 0 and the `indexes` match on `"value"`.) **Precondition** (to collapse to a per-case form): every entry's `network` is implied by `valid_from` and there is no `expect_exception`. Then the post is a pure function of the discriminating index. - Convert `expect_entries_` into a plain **list of `result` dicts indexed by the discriminator** — duplicating identical entries (e.g. data `[0,1]` → two slots) is fine and preferred; an explicit flat list is easiest to reason about. - **When the discriminator is a real quantity** (the tx `value` or `gas`), parametrize *directly on that quantity* (`parametrize("tx_value", [0, 1])`) rather than an opaque index, feed it straight into the `Transaction`, and express the post as a function of it. A clean closed form is ideal — e.g. `Account(storage={0: 2 * tx_value})` for a contract that stores `ADD(BALANCE, BALANCE)` of a balance equal to the sent value (this is the "encode relationships" idea from step 9 applied to the post). - Cascade: delete the `resolve_expect_post` import, the `_exc` it returned, and the tx's `error=_exc`. - **Optionally merge** the data-generator and the post-list into **one `if/elif/else` on `d`** that sets both `initcode` and `post` per case. This co-locates each case's bytecode with its expected state — the strongest readability win, and it tends to *reveal* incomplete verification. Use a final `else` so every branch binds both vars; declare `initcode: Bytecode` and `post: dict` above the switch. Prefer the array form when cases are many or the switch would be unwieldy; this is a judgment call. - **Clean up the `parametrize` signature.** The ported `"d, g, v"` triple is usually overkill: drop the pinned/unused indexes from both the `parametrize` and the function signature, keep the discriminator, and rename it to something meaningful (and `fork` too, if no longer used). Parametrize on the renamed axis: - **String values** (e.g. `parametrize("opcode", ["calldataload", "calldatacopy", "codecopy"])`) read best when the cases are distinct programs; pytest derives the test ids straight from the strings (matching the old `id=`s), and the switch branches become `if opcode == "calldataload"`.
GitHubで見る
この SKILL.md は非常に大きいため、SkillsMP では最初のセクションだけを表示しています。 GitHubで見る