tl;dr

  • Claude bumps the versions.
  • Claude finds missing opcodes.
  • Claude finds even more missing opcodes I didn’t know about.
  • Claude correctly handles compiler warnings that indicate cascading changes.
  • Claude correctly handles what files are auto-generated and how to regenerate them.
  • Stretch Goal #1: Release via CI to Hackage and GitHub
  • Stretch Goal #2: Validate hpack pre-push

The goal

I maintain an open source library uploaded to a package repository called evm-opcodes. It just exposes an enum with the assembly instructions (opcodes) for Ethereum’s VM, in case you want to build a smart contract compiler in Haskell.

Every few years one of evm-opcodes’ few dependencies, containers, releases a new “major” version, and the tight dependency version bounds would cause evm-opcodes to fall out of the Stackage stable Haskell package set for not supporting the latest version. I got tagged in GitHub issues here and here because my GitHub username can be derived from the package’s git source location.

Some Commercial Haskell users rely on packages that are proven to work together, and keeping tight version bounds encourages individual package maintainers to assure that the newer version still works with their package.

Bumping versions is a tedious chore, surely Claude Code can do this for me.

I use the opportunity to check if there’s been any additions to the EVM opcode set. Since I don’t actively use the package right now, I haven’t been doing this regularly. Ethereum has had some major changes, most notably the switch to proof-of-stake in 2022. I usually read the Ethereum Yellow Paper and jump to the opcode section and scan through to see if there were any new instructions. This is a manual and quite error prone approach. I’ll just cut out the section of the PDF and ask Claude to do that, too!

Uncovering more

❯ Read eth-opcodes-2026-01-14.pdf and verify if this Haskell library is up-to-date wrt. the available opcodes and their arguments in src/EVM/Opcode/Internal.hs

⏺ I'll read both the PDF and the Haskell source file to compare them.

⏺ Read(eth-opcodes-2026-01-14.pdf)
  ⎿  Read PDF (402KB)

⏺ Read(src/EVM/Opcode/Internal.hs)
  ⎿  Read 516 lines

⏺ Based on my comparison of the PDF and the Haskell library, here's my analysis:

  Comparison Results

  Missing Opcode (in PDF but not in library)

  PUSH0 (0x5f) - The PDF shows on page 6:
  - 0x5f PUSH0 with α=0, δ=1 - "Place 0 on the stack"

  The library only handles PUSH1-PUSH32 (0x60-0x7f):
  | PUSH !Word256     -- ^ 0x60 - 0x7f (PUSH1 - PUSH32)

  PUSH0 was introduced in EIP-3855 (Shanghai upgrade, April 2023) and is missing from the library.

This is exactly the type of update I was looking for.

And I clearly missed it when I did this in October 2024.

  All Other Opcodes Match

  All other opcodes in the PDF are present in the library with correct:
  - Hex encodings (0x00-0xff ranges)
  - Stack effects (α = items removed, δ = items added)
  - Mnemonics

  [...]

  Note on Cancun Opcodes

  Neither the PDF nor the library include the newer Cancun upgrade opcodes (March 2024):
  - BLOBHASH (0x49)
  - BLOBBASEFEE (0x4a)
  - TLOAD (0x5c)
  - TSTORE (0x5d)
  - MCOPY (0x5e)

✻ Brewed for 49s

Huh! There’s a whole set of opcodes the Yellow Paper doesn’t mention.

I’m clearly out of touch. I know about EIPs (Ethereum Improvement Proposals).

But I didn’t know that the Yellow Paper is a piece of history and it’s not an exhaustive list of opcodes.

❯ Where are the Cancun upgrades documented if not the yellowpaper?

⏺ The Cancun upgrade opcodes are documented in their respective EIPs (Ethereum Improvement Proposals):
  ┌─────────────┬──────┬──────────┐
  │   Opcode    │ Hex  │   EIP    │
  ├─────────────┼──────┼──────────┤
  │ BLOBHASH    │ 0x49 │ EIP-4844 │
  ├─────────────┼──────┼──────────┤
  │ BLOBBASEFEE │ 0x4a │ EIP-7516 │
  ├─────────────┼──────┼──────────┤
  │ TLOAD       │ 0x5c │ EIP-1153 │
  ├─────────────┼──────┼──────────┤
  │ TSTORE      │ 0x5d │ EIP-1153 │
  ├─────────────┼──────┼──────────┤
  │ MCOPY       │ 0x5e │ EIP-5656 │
  └─────────────┴──────┴──────────┘
  You can find these at:
  - https://eips.ethereum.org/EIPS/eip-4844 (Shard Blob Transactions)
  - https://eips.ethereum.org/EIPS/eip-7516 (BLOBBASEFEE opcode)
  - https://eips.ethereum.org/EIPS/eip-1153 (Transient storage opcodes)
  - https://eips.ethereum.org/EIPS/eip-5656 (MCOPY instruction)

The Yellow Paper tends to lag behind hard forks. For the most current opcode reference, the https://github.com/ethereum/execution-specs repository is the canonical source - it’s effectively the “executable Yellow Paper” maintained in Python. The opcode definitions live in files like ethereum/cancun/vm/instructions/.

Another good reference is https://www.evm.codes/ which provides an interactive opcode reference that’s typically up-to-date with the latest forks.

OK, TIL.

❯ Add all of these to the Haskell library; reference the right EIPs in comments in a similar fashion as was done for prior additions.

⏺ I'll add all the missing opcodes to the library. Let me make the necessary edits to src/EVM/Opcode/Internal.hs.

⏺ Update(src/EVM/Opcode/Internal.hs)
  ⎿  Added 2 lines
      92    | CHAINID           -- ^ 0x46, https://eips.ethereum.org/EIPS/eip-1344
      93    | SELFBALANCE       -- ^ 0x47, https://eips.ethereum.org/EIPS/eip-1884
      94    | BASEFEE           -- ^ 0x48, https://eips.ethereum.org/EIPS/eip-3198
      95 +  | BLOBHASH          -- ^ 0x49, https://eips.ethereum.org/EIPS/eip-4844
      96 +  | BLOBBASEFEE       -- ^ 0x4a, https://eips.ethereum.org/EIPS/eip-7516
      97
      98    -- 50s: Stack, Memory, Storage and Flow Operations
      99    | POP               -- ^ 0x50

⏺ Update(src/EVM/Opcode/Internal.hs)
  ⎿  Added 4 lines, removed 1 line
      108    | MSIZE             -- ^ 0x59
      109    | GAS               -- ^ 0x5a
      110    | JUMPDEST j        -- ^ 0x5b
      111 +  | TLOAD             -- ^ 0x5c, https://eips.ethereum.org/EIPS/eip-1153
      112 +  | TSTORE            -- ^ 0x5d, https://eips.ethereum.org/EIPS/eip-1153
      113 +  | MCOPY             -- ^ 0x5e, https://eips.ethereum.org/EIPS/eip-5656
      114
      112 -  -- 60s & 70s: Push Operations
      115 +  -- 5f, 60s & 70s: Push Operations
      116    | PUSH !Word256     -- ^ 0x60 - 0x7f (PUSH1 - PUSH32)
      117
      118    -- 80s: Duplication Operations (DUP)

⏺ Update(src/EVM/Opcode/Internal.hs)
  ⎿  Added 1 line
      113    | MCOPY             -- ^ 0x5e, https://eips.ethereum.org/EIPS/eip-5656
      114
      115    -- 5f, 60s & 70s: Push Operations
      116 +  | PUSH0             -- ^ 0x5f, https://eips.ethereum.org/EIPS/eip-3855
      117    | PUSH !Word256     -- ^ 0x60 - 0x7f (PUSH1 - PUSH32)
      118
      119    -- 80s: Duplication Operations (DUP)

⏺ Update(src/EVM/Opcode/Internal.hs)
  ⎿  Added 2 lines
      282    CHAINID        -> OpcodeSpec 0x46 0 1 "chainid"
      283    SELFBALANCE    -> OpcodeSpec 0x47 0 1 "selfbalance"
      284    BASEFEE        -> OpcodeSpec 0x48 0 1 "basefee"
      285 +  BLOBHASH       -> OpcodeSpec 0x49 1 1 "blobhash"
      286 +  BLOBBASEFEE    -> OpcodeSpec 0x4a 0 1 "blobbasefee"
      287
      288    -- 50s: Stack, Memory, Storage and Flow Operations
      289    --                    Hex  α δ

⏺ Read(src/EVM/Opcode/Internal.hs)
  ⎿  Read 30 lines

⏺ Update(src/EVM/Opcode/Internal.hs)
  ⎿  Added 5 lines, removed 1 line
      299    MSIZE          -> OpcodeSpec 0x59 0 1 "msize"
      300    GAS            -> OpcodeSpec 0x5a 0 1 "gas"
      301    JUMPDEST{}     -> OpcodeSpec 0x5b 0 0 "jumpdest"
      302 +  TLOAD          -> OpcodeSpec 0x5c 1 1 "tload"
      303 +  TSTORE         -> OpcodeSpec 0x5d 2 0 "tstore"
      304 +  MCOPY          -> OpcodeSpec 0x5e 3 0 "mcopy"
      305
      303 -  -- 60s & 70s: Push Operations
      306 +  -- 5f, 60s & 70s: Push Operations
      307 +  PUSH0          -> OpcodeSpec 0x5f 0 1 "push0"
      308    PUSH n ->
      309      let (pushHex, pushConst) = push' n
      310      in OpcodeSpec { opcodeEncoding = pushHex

The Claude Code harness makes sure to test that the code compiles.

The default.nix I made October 2024 makes sure that my compiler toolchain works out of the box.

I’ve not worked on a single Haskell project on this laptop; all I need is the Nix package manager, and direnv.

When adding another variant to an enum in Haskell, cascading changes in cases where that enum is pattern matched shows warnings when compiling. Claude Code picks up on those cases and fills in the gaps like I would have.

This is a super power of strongly typed, functional languages like Haskell. Rust can do it, too.

⏺ Bash(cabal build 2>&1)
  ⎿  Resolving dependencies...
     Build profile: -w ghc-9.10.3 -O1
     In order, the following will be built (use -v for more details):
     … +35 lines (ctrl+o to expand)

⏺ The library builds, but there's a warning about incomplete pattern matches in EVM.Opcode.Traversal. Let me fix that.

⏺ Read(src/EVM/Opcode/Traversal.hs)
  ⎿  Read 143 lines

⏺ I need to add the new opcodes to the pattern matching. Let me make the edits.

⏺ Update(src/EVM/Opcode/Traversal.hs)
  ⎿  Added 2 lines
       98    CHAINID     -> mapOnOther' CHAINID CHAINID
       99    SELFBALANCE -> mapOnOther' SELFBALANCE SELFBALANCE
      100    BASEFEE     -> mapOnOther' BASEFEE BASEFEE
      101 +  BLOBHASH    -> mapOnOther' BLOBHASH BLOBHASH
      102 +  BLOBBASEFEE -> mapOnOther' BLOBBASEFEE BLOBBASEFEE
      103
      104    -- 50s: Stack, Memory, Storage and Flow Operations
      105    POP       -> mapOnOther' POP POP

⏺ Update(src/EVM/Opcode/Traversal.hs)
  ⎿  Added 5 lines, removed 1 line
      111    PC        -> mapOnOther' PC PC
      112    MSIZE     -> mapOnOther' MSIZE MSIZE
      113    GAS       -> mapOnOther' GAS GAS
      114 +  TLOAD     -> mapOnOther' TLOAD TLOAD
      115 +  TSTORE    -> mapOnOther' TSTORE TSTORE
      116 +  MCOPY     -> mapOnOther' MCOPY MCOPY
      117
      115 -  -- 60s & 70s: Push Operations
      118 +  -- 5f, 60s & 70s: Push Operations
      119 +  PUSH0     -> mapOnOther' PUSH0 PUSH0
      120    PUSH n    -> mapOnOther' (PUSH n) (PUSH n)
      121
      122    -- 80s: Duplication Operations (DUP)

⏺ Now let me rebuild to verify everything compiles cleanly.

⏺ Bash(cabal build 2>&1)
  ⎿  Build profile: -w ghc-9.10.3 -O1
     In order, the following will be built (use -v for more details):
      - evm-opcodes-0.2.0 (lib) (file src/EVM/Opcode/Traversal.hs changed)
     … +11 lines (ctrl+o to expand)

⏺ The build succeeds with no new warnings (just a pre-existing unused import warning). Let me also run the tests to verify everything works.

⏺ Bash(cabal test 2>&1)
  ⎿  Build profile: -w ghc-9.10.3 -O1
     In order, the following will be built (use -v for more details):
      - evm-opcodes-0.2.0 (test:test) (first run)
     … +160 lines (ctrl+o to expand)

⏺ All 132 tests pass. Let me update the todo list and summarize the changes.

⏺ Done. I've added all 6 missing opcodes to the library:

  Summary of Changes

  src/EVM/Opcode/Internal.hs

  New opcodes added to Opcode' data type:
  ┌─────────────┬──────┬─────┬─────┬──────────┐
  │   Opcode    │ Hex  │  α  │  δ  │   EIP    │
  ├─────────────┼──────┼─────┼─────┼──────────┤
  │ BLOBHASH    │ 0x49 │ 1   │ 1   │ EIP-4844 │
  ├─────────────┼──────┼─────┼─────┼──────────┤
  │ BLOBBASEFEE │ 0x4a │ 0   │ 1   │ EIP-7516 │
  ├─────────────┼──────┼─────┼─────┼──────────┤
  │ TLOAD       │ 0x5c │ 1   │ 1   │ EIP-1153 │
  ├─────────────┼──────┼─────┼─────┼──────────┤
  │ TSTORE      │ 0x5d │ 2   │ 0   │ EIP-1153 │
  ├─────────────┼──────┼─────┼─────┼──────────┤
  │ MCOPY       │ 0x5e │ 3   │ 0   │ EIP-5656 │
  ├─────────────┼──────┼─────┼─────┼──────────┤
  │ PUSH0       │ 0x5f │ 0   │ 1   │ EIP-3855 │
  └─────────────┴──────┴─────┴─────┴──────────┘
  Each opcode includes an EIP reference in the comments, following the existing style.

  src/EVM/Opcode/Traversal.hs

  Added pattern match cases for all new opcodes in mapOpcodeM to fix the incomplete pattern warning.

  Verification

  - Library builds cleanly (only pre-existing unused import warning)
  - All 132 tests pass

✻ Brewed for 2m 21s

The chore

As for the version bumping, I thought I’d increase the copyright bounds, too.

Claude correctly guesses that it needs to change versions in Markdown and YAML files, but the .cabal project file is generated using hpack; an experienced Haskell developer might derive this from the presence of package.yaml,

❯ Grep for “Copyright” and update the bound to 2026 everywhere. Increase the dependency bound for containers to include 0.8 (so < 0.9). Bump the package version to 0.3.0.

⏺ Search(pattern: "Copyright", output_mode: "content")
  ⎿  Found 7 lines (ctrl+o to expand)

⏺ Search(pattern: "containers|^version:", path: "~/Projects/evm-opcodes", glob: "*.cabal", output_mode: "content")
  ⎿  Found 4 lines (ctrl+o to expand)

[...]

⏺ Now let me regenerate the .cabal file using hpack.

⏺ Bash(hpack)
  ⎿  generated evm-opcodes.cabal

⏺ Let me verify by building and running tests.

⏺ Bash(cabal build && cabal test 2>&1 | tail -20)
  ⎿  Resolving dependencies...
     Build profile: -w ghc-9.10.3 -O1
     In order, the following will be built (use -v for more details):
     … +41 lines (ctrl+o to expand)

⏺ Done. All changes applied successfully:

  Summary

  Version bump: 0.2.0 → 0.3.0

  Copyright updated to 2018-2026 in:
  - src/EVM/Opcode/Internal.hs
  - src/EVM/Opcode.hs
  - src/EVM/Opcode/Positional.hs
  - src/EVM/Opcode/Labelled.hs
  - src/EVM/Opcode/Traversal.hs
  - LICENSE
  - LICENSE.md
  - package.yaml (and regenerated evm-opcodes.cabal)

  Dependency update: containers >= 0.6 && < 0.8 → containers >= 0.6 && < 0.9

  Build succeeds as evm-opcodes-0.3.0 and all 132 tests pass.

✻ Cooked for 1m 25s

Stretch Goal #1: Release via CI to Hackage and GitHub

As a stretch goal, because I release this package so rarely, I always forget the commands.

Let’s just make that part of CI.

Now all I need to do is git tag vX.Y.Z; git push --tags.

Then CI packages and uploads to Hackage and GitHub releases!

name: Haskell CI

on:
  push:
    branches:
      - main
    tags:
      - 'v*'
  pull_request:
    branches:
      - main

jobs:
  # ...

  release:
    name: Release to Hackage
    runs-on: ubuntu-latest
    needs: build
    if: startsWith(github.ref, 'refs/tags/v')

    steps:
      - name: Checkout
        uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1, 2025-12-02

      - name: Setup Haskell
        uses: haskell-actions/setup@dc63c94789664bb2910876ec3dfeeaa24d23b96b # v2.10.2, 2026-01-11
        with:
          ghc-version: "9.10"
          cabal-version: "3.16"

      - name: Update Cabal
        run: cabal update

      - name: Generate sdist
        run: cabal sdist

      - name: Upload to Hackage
        run: cabal upload --publish --token "${{ secrets.HACKAGE_TOKEN }}" dist-newstyle/sdist/evm-opcodes-*.tar.gz

      - name: Create GitHub Release
        uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0, 2025-12-01
        with:
          files: evm-opcodes-*.tar.gz
          generate_release_notes: true

Stretch Goal #2: Validate hpack pre-push

As an example of forgetting the commands, I bumped the version in package.yaml, but unlike Claude, I forgot to run hpack to propagate the changes to the .cabal file.

I will forget this again.

So I’ll make a git hook that checks if hpack was run and fails if it produces any changes that weren’t committed.

This git hook can be added both as a CI step and as a pre-push hook using pre-commit, a tool for installing git hooks. I’ll wire this into default.nix so that entering the directory with direnv, the hook is automatically installed.

The hook itself looks like:

#!/usr/bin/env bash
set -euo pipefail

hpack

if ! git diff --exit-code evm-opcodes.cabal; then
  echo "Error: evm-opcodes.cabal is out of sync with package.yaml"
  echo "Run 'hpack' and commit the changes."
  exit 1
fi

echo "evm-opcodes.cabal is in sync with package.yaml"

Its integration with CI is a simple step:

- name: Check cabal file is in sync
  if: matrix.ghc == '9.12'
  run: |
    cabal install hpack
    .github/hooks/check-cabal-sync.sh

The if: matrix.ghc == '9.12' is to prevent this step from executing twice.

It only needs to be released once, even if the test matrix is bigger than that.

As for the direnv integration, the file .envrc contains the line use nix.

And the file default.nix looks like this:

let
  # Use nixpkgs available on the system
  pkgs = import <nixpkgs> {};
  haskellDependencies = with pkgs.haskellPackages; [
    stack
    cabal-install
    haskell-language-server
    hlint
    ghcid
    hpack
  ];

  # Build a Haskell package with those dependencies
  addBuildTools = pkgs.haskell.lib.addBuildTools;
  package = pkgs.haskellPackages.developPackage {
    root = ./.;
    modifier = drv: addBuildTools drv haskellDependencies;
  };

  # Extend the build environment with pre-commit installed and activated
  withPreCommitHook = old: {
    nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.pre-commit ];
    shellHook = (old.shellHook or "") + ''
      if [ -d .git ]; then
        pre-commit install --hook-type pre-push
      fi
    '';
  };
in
  package.overrideAttrs withPreCommitHook