Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4bee640
feat(nuts): add update-nuts command
maurelian Mar 9, 2026
81801b5
fix(nuts): remove bundle generation from update-nuts
maurelian Mar 10, 2026
7e8b858
docs(nuts): add README documenting NUT bundle workflow
maurelian Mar 10, 2026
c56b564
Simplify README
maurelian Mar 10, 2026
ef2621f
Apply suggestions from code review
maurelian Mar 10, 2026
4bf25a6
refactor(nuts): extract shared ForkLockEntry into op-core/nuts package
maurelian Mar 11, 2026
ca33700
fix(nuts): restrict bundle file permissions to 0600
maurelian Apr 1, 2026
ad0a8d7
feat(nuts): add fork locking mechanism
maurelian Apr 1, 2026
c9b6483
refactor(nuts): rename scripts for consistency
maurelian Apr 1, 2026
4dfa9db
fix(nuts): regenerate karst bundle and populate commit field
maurelian Apr 1, 2026
b75a8ad
revert(nuts): keep check-nut-locks name to minimize diff
maurelian Apr 2, 2026
5908e9f
docs(nuts): remove contracts-bedrock concerns from README
maurelian Apr 2, 2026
63acba7
refactor(nuts): remove locking mechanism
maurelian Apr 2, 2026
9a2f3bc
fix(nuts): update lock file comments and add provenance CI
maurelian Apr 2, 2026
6a92a23
fix(ci): only verify provenance for changed forks
maurelian Apr 2, 2026
542ebff
docs(ci): add comment explaining provenance verify logic
maurelian Apr 2, 2026
84910a0
fix(nuts): move reviewer comment to bottom of fork_lock.toml
maurelian Apr 2, 2026
5c9c41f
fix: move part of comment to top
maurelian Apr 2, 2026
0c6b670
fix(nuts): verify lock file commits exist in git history
maurelian Apr 2, 2026
ace326a
broken demo: show that a manually edited bundle breaks check-nut-locks
maurelian Apr 2, 2026
bc967a9
Revert "broken demo: show that a manually edited bundle breaks check-…
maurelian Apr 2, 2026
debc9ef
broken demo: show that a manually edited lock commit breaks check-nut…
maurelian Apr 2, 2026
b7b76f5
Revert "broken demo: show that a manually edited lock commit breaks c…
maurelian Apr 2, 2026
85745bc
docs(op-core/nuts): clarify that just nut-provenance-verify does not …
maurelian Apr 2, 2026
d173d60
fix(nuts): record merge-base commit to survive squash-merge
maurelian Apr 2, 2026
faccb27
docs(nuts): rearrange lock file comments, document merge-base workflow
maurelian Apr 2, 2026
a498641
refactor(ci): extract provenance verify logic to bash script
maurelian Apr 2, 2026
6afd773
test(nuts): add tests for check-nut-locks validation
maurelian Apr 2, 2026
b7b7c7c
test(nuts): add tests for nut-provenance-verify
maurelian Apr 2, 2026
15c22b2
docs(ctb): warn against generate-nut-bundle rename
maurelian Apr 2, 2026
0da7f52
fix(nuts): fix goimports formatting in provenance test
maurelian Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .circleci/continue/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1673,6 +1673,20 @@ jobs:
command: |
go run ./ops/scripts/check-nut-locks

nut-provenance-verify:
docker:
- image: <<pipeline.parameters.c-default_docker_image>>
resource_class: 2xlarge
steps:
- utils/checkout-with-mise:
enable-mise-cache: true
- install-contracts-dependencies
- check-changed:
patterns: op-core/nuts
- run:
name: verify NUT bundle provenance
command: ./ops/scripts/nut-provenance-verify-changed.sh

go-tests:
parameters:
notify:
Expand Down Expand Up @@ -2615,6 +2629,9 @@ workflows:
- check-nut-locks:
context:
- circleci-repo-readonly-authenticated-github-token
- nut-provenance-verify:
context:
- circleci-repo-readonly-authenticated-github-token
- fuzz-golang:
name: fuzz-golang-<<matrix.package_name>>
on_changes: <<matrix.package_name>>
Expand Down
11 changes: 10 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ PYTHON := env('PYTHON', 'python3')

TEST_TIMEOUT := env('TEST_TIMEOUT', '10m')

TEST_PKGS := "./op-alt-da/... ./op-batcher/... ./op-chain-ops/... ./op-node/... ./op-proposer/... ./op-challenger/... ./op-faucet/... ./op-dispute-mon/... ./op-conductor/... ./op-program/... ./op-service/... ./op-supervisor/... ./op-test-sequencer/... ./op-fetcher/... ./op-e2e/system/... ./op-e2e/e2eutils/... ./op-e2e/opgeth/... ./op-e2e/interop/... ./op-e2e/actions/altda ./op-e2e/actions/batcher ./op-e2e/actions/derivation ./op-e2e/actions/helpers ./op-e2e/actions/interop ./op-e2e/actions/proofs ./op-e2e/actions/proposer ./op-e2e/actions/safedb ./op-e2e/actions/sequencer ./op-e2e/actions/sync ./op-e2e/actions/upgrades ./packages/contracts-bedrock/scripts/checks/... ./op-dripper/... ./op-devstack/... ./op-deployer/pkg/deployer/artifacts/... ./op-deployer/pkg/deployer/broadcaster/... ./op-deployer/pkg/deployer/clean/... ./op-deployer/pkg/deployer/integration_test/ ./op-deployer/pkg/deployer/integration_test/cli/... ./op-deployer/pkg/deployer/standard/... ./op-deployer/pkg/deployer/state/... ./op-deployer/pkg/deployer/verify/... ./op-sync-tester/... ./op-supernode/..."
TEST_PKGS := "./op-alt-da/... ./op-batcher/... ./op-chain-ops/... ./op-node/... ./op-proposer/... ./op-challenger/... ./op-faucet/... ./op-dispute-mon/... ./op-conductor/... ./op-program/... ./op-service/... ./op-supervisor/... ./op-test-sequencer/... ./op-fetcher/... ./op-e2e/system/... ./op-e2e/e2eutils/... ./op-e2e/opgeth/... ./op-e2e/interop/... ./op-e2e/actions/altda ./op-e2e/actions/batcher ./op-e2e/actions/derivation ./op-e2e/actions/helpers ./op-e2e/actions/interop ./op-e2e/actions/proofs ./op-e2e/actions/proposer ./op-e2e/actions/safedb ./op-e2e/actions/sequencer ./op-e2e/actions/sync ./op-e2e/actions/upgrades ./packages/contracts-bedrock/scripts/checks/... ./ops/scripts/... ./op-dripper/... ./op-devstack/... ./op-deployer/pkg/deployer/artifacts/... ./op-deployer/pkg/deployer/broadcaster/... ./op-deployer/pkg/deployer/clean/... ./op-deployer/pkg/deployer/integration_test/ ./op-deployer/pkg/deployer/integration_test/cli/... ./op-deployer/pkg/deployer/standard/... ./op-deployer/pkg/deployer/state/... ./op-deployer/pkg/deployer/verify/... ./op-sync-tester/... ./op-supernode/..."

FRAUD_PROOF_TEST_PKGS := "./op-e2e/faultproofs/..."

Expand Down Expand Up @@ -352,6 +352,15 @@ build-rust-release:
check-nut-locks:
go run ./ops/scripts/check-nut-locks

# Snapshots current-upgrade-bundle.json as a fork's NUT bundle and updates the lock file.
nut-snapshot-for fork:
go run ./ops/scripts/nut-snapshot-for {{fork}}

# Verifies a fork's NUT bundle was correctly built from its recorded commit.
nut-provenance-verify fork:
go run ./ops/scripts/nut-provenance-verify {{fork}}


# Checks that TODO comments have corresponding issues.
todo-checker:
./ops/scripts/todo-checker.sh
Expand Down
56 changes: 56 additions & 0 deletions op-core/nuts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# NUT Bundles

Network Upgrade Transaction (NUT) bundles define the L2 deposit transactions that activate a hardfork. Each bundle is a JSON file containing ordered transactions (implementation deployments, proxy upgrades, etc.) that the rollup node embeds and executes at the fork activation block.

## Files

| File | Purpose |
|------|---------|
| `fork_lock.toml` | Lock file mapping fork names to bundle paths, sha256 hashes, and source commits |
| `op-node/rollup/derive/<fork>_nut_bundle.json` | Embedded bundle consumed by op-node at fork activation |

## Workflow

### Generating a bundle

```bash
cd packages/contracts-bedrock
just generate-nut-bundle
```

### Snapshotting a bundle for a fork

```bash
just nut-snapshot-for <fork>
```

This copies `current-upgrade-bundle.json` to `op-node/rollup/derive/<fork>_nut_bundle.json` and updates `fork_lock.toml` with the sha256 hash and the merge-base commit with `origin/develop`.

**Important:** The recorded commit is the merge-base with develop, not HEAD. This ensures the commit survives squash-merge. Contract changes must be merged to develop in a separate PR *before* snapshotting the bundle.


### Verifying a bundle

```bash
just nut-provenance-verify <fork>
```

Checks that:
1. The bundle file exists and its sha256 matches the lock
2. Creates a temporary worktree at the recorded commit, regenerates the bundle, and compares byte-for-byte

Requires `forge` for the provenance check (step 2).

### CI checks

- **`check-nut-locks`** — Verifies all bundle hashes match their lock entries, all entries have a commit, and every `*_nut_bundle.json` file has a corresponding lock entry. Runs in CI on every PR.

## fork_lock.toml schema

```toml
[<fork-name>]
bundle = "op-node/rollup/derive/<fork>_nut_bundle.json" # repo-relative path
hash = "sha256:<hex>" # sha256 of bundle contents
commit = "<full-sha>" # commit that produced the bundle
```

12 changes: 8 additions & 4 deletions op-core/nuts/fork_lock.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# NUT Bundle Fork Lock
# To update a locked bundle, update both the bundle file and this hash in the same PR.
# To update a fork's bundle, run: just nut-snapshot-for <fork>
# Contract changes must be merged to develop before snapshotting.

[karst]
bundle = "op-node/rollup/derive/karst_nut_bundle.json"
hash = "sha256:b9c610d09ca05ab24ef84ea38e4f563d71401f592f9eff13fa97dac879bee600"
bundle = "op-node/rollup/derive/karst_nut_bundle.json"
hash = "sha256:6145f900384f0aa2cdc63f2267a747b8c958bf1c09bacce8c29037c3eaa75d44"
commit = "cba7aba0c98aae22720b21c3a023990a486cb6e0"

# REVIEWER NOTE: Changes to this file affect which NUT bundles are embedded
# into op-node for hardfork activations. Review carefully.
74 changes: 74 additions & 0 deletions op-core/nuts/lock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package nuts

import (
"fmt"
"os"
"path/filepath"

"github.com/BurntSushi/toml"

opservice "github.com/ethereum-optimism/optimism/op-service"
)

// ForkLockEntry represents a single fork's entry in fork_lock.toml.
type ForkLockEntry struct {
Bundle string `toml:"bundle"`
Hash string `toml:"hash"`
Commit string `toml:"commit"`
}

// ForkLock is the full contents of fork_lock.toml, keyed by fork name.
type ForkLock map[string]ForkLockEntry

// LockFilePath returns the absolute path to fork_lock.toml relative to the given directory.
func LockFilePath(dir string) (string, error) {
root, err := opservice.FindMonorepoRoot(dir)
if err != nil {
return "", fmt.Errorf("finding monorepo root: %w", err)
}
return filepath.Join(root, "op-core", "nuts", "fork_lock.toml"), nil
}

// ReadLockFile reads and parses fork_lock.toml from the monorepo root.
func ReadLockFile(dir string) (ForkLock, string, error) {
lockPath, err := LockFilePath(dir)
if err != nil {
return nil, "", err
}
var locks ForkLock
if _, err := toml.DecodeFile(lockPath, &locks); err != nil {
return nil, "", fmt.Errorf("reading fork lock file: %w", err)
}
return locks, lockPath, nil
}

// WriteLockFile writes fork_lock.toml back to disk with a header comment.
func WriteLockFile(lockPath string, locks ForkLock) error {
f, err := os.Create(lockPath)
if err != nil {
return fmt.Errorf("opening fork lock file for writing: %w", err)
}
defer f.Close()

_, err = fmt.Fprint(f, `# To update a fork's bundle, run: just nut-snapshot-for <fork>
# Contract changes must be merged to develop before snapshotting.

`)
if err != nil {
return err
}

enc := toml.NewEncoder(f)
if err := enc.Encode(locks); err != nil {
return fmt.Errorf("writing fork lock file: %w", err)
}

_, err = fmt.Fprint(f, `
# REVIEWER NOTE: Changes to this file affect which NUT bundles are embedded
# into op-node for hardfork activations. Review carefully.
`)
if err != nil {
return err
}
return nil
}
229 changes: 227 additions & 2 deletions op-node/rollup/derive/karst_nut_bundle.json

Large diffs are not rendered by default.

58 changes: 40 additions & 18 deletions ops/scripts/check-nut-locks/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import (
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/BurntSushi/toml"

"github.com/ethereum-optimism/optimism/op-core/nuts"
opservice "github.com/ethereum-optimism/optimism/op-service"
)

Expand Down Expand Up @@ -44,9 +44,36 @@ func checkAllBundlesLocked(root string, lockedBundles map[string]bool) error {
return nil
}

type forkLockEntry struct {
Bundle string `toml:"bundle"`
Hash string `toml:"hash"`
// validateEntry checks a single fork lock entry against its bundle file content.
func validateEntry(fork string, entry nuts.ForkLockEntry, bundleContent []byte) error {
hash := sha256.Sum256(bundleContent)
actual := "sha256:" + hex.EncodeToString(hash[:])

expectedHash := strings.TrimSpace(entry.Hash)
if actual != expectedHash {
return fmt.Errorf(
"bundle hash mismatch for fork %s: expected=%s actual=%s. "+
"If this change is intentional, update the hash in op-core/nuts/fork_lock.toml",
fork, expectedHash, actual,
)
}

if entry.Commit == "" {
return fmt.Errorf("fork %s has no commit recorded; "+
"run 'just nut-snapshot-for %s' to populate the commit field", fork, fork)
}

return nil
}

// checkCommitAncestry verifies that a commit is an ancestor of origin/develop.
func checkCommitAncestry(root, fork string, commit string) error {
cmd := exec.Command("git", "merge-base", "--is-ancestor", commit, "origin/develop")
cmd.Dir = root
if err := cmd.Run(); err != nil {
return fmt.Errorf("fork %s: commit %s is not an ancestor of origin/develop", fork, commit[:12])
}
return nil
}

func main() {
Expand All @@ -62,10 +89,9 @@ func run(dir string) error {
return fmt.Errorf("finding monorepo root: %w", err)
}

lockPath := filepath.Join(root, "op-core", "nuts", "fork_lock.toml")
var locks map[string]forkLockEntry
if _, err := toml.DecodeFile(lockPath, &locks); err != nil {
return fmt.Errorf("reading fork lock file: %w", err)
locks, _, err := nuts.ReadLockFile(dir)
if err != nil {
return err
}

lockedBundles := make(map[string]bool)
Expand All @@ -78,16 +104,12 @@ func run(dir string) error {
return fmt.Errorf("fork %s: reading bundle %s: %w", fork, entry.Bundle, err)
}

hash := sha256.Sum256(content)
actual := "sha256:" + hex.EncodeToString(hash[:])
if err := validateEntry(fork, entry, content); err != nil {
return err
}

locked := strings.TrimSpace(entry.Hash)
if actual != locked {
return fmt.Errorf(
"bundle hash mismatch for fork %s: locked=%s actual=%s. "+
"If this change is intentional, update the hash in op-core/nuts/fork_lock.toml",
fork, locked, actual,
)
if err := checkCommitAncestry(root, fork, entry.Commit); err != nil {
return err
}

fmt.Printf("fork %s: bundle hash OK\n", fork)
Expand Down
60 changes: 60 additions & 0 deletions ops/scripts/check-nut-locks/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package main

import (
"crypto/sha256"
"encoding/hex"
"testing"

"github.com/ethereum-optimism/optimism/op-core/nuts"
"github.com/stretchr/testify/require"
)

func hashOf(data []byte) string {
h := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(h[:])
}

func TestValidateEntry_MatchingHash(t *testing.T) {
content := []byte(`{"transactions":[]}`)
entry := nuts.ForkLockEntry{
Bundle: "op-node/rollup/derive/test_nut_bundle.json",
Hash: hashOf(content),
Commit: "abc123",
}
err := validateEntry("test", entry, content)
require.NoError(t, err)
}

func TestValidateEntry_HashMismatch(t *testing.T) {
content := []byte(`{"transactions":[]}`)
entry := nuts.ForkLockEntry{
Bundle: "op-node/rollup/derive/test_nut_bundle.json",
Hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
Commit: "abc123",
}
err := validateEntry("test", entry, content)
require.ErrorContains(t, err, "bundle hash mismatch")
}

func TestValidateEntry_EmptyCommit(t *testing.T) {
content := []byte(`{"transactions":[]}`)
entry := nuts.ForkLockEntry{
Bundle: "op-node/rollup/derive/test_nut_bundle.json",
Hash: hashOf(content),
Commit: "",
}
err := validateEntry("test", entry, content)
require.ErrorContains(t, err, "no commit recorded")
}

func TestValidateEntry_ModifiedBundle(t *testing.T) {
original := []byte(`{"transactions":[{"intent":"deploy"}]}`)
modified := []byte(`{"transactions":[{"intent":"modified"}]}`)
entry := nuts.ForkLockEntry{
Bundle: "op-node/rollup/derive/test_nut_bundle.json",
Hash: hashOf(original),
Commit: "abc123",
}
err := validateEntry("test", entry, modified)
require.ErrorContains(t, err, "bundle hash mismatch")
}
19 changes: 19 additions & 0 deletions ops/scripts/nut-provenance-verify-changed.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail

# Verifies provenance for all forks whose bundle hash changed vs develop.
# For each fork whose hash changed, checks out the recorded commit,
# regenerates the bundle, and verifies it matches byte-for-byte.
# Unchanged forks are skipped to avoid expensive forge rebuilds.

git show origin/develop:op-core/nuts/fork_lock.toml > /tmp/base_lock.toml 2>/dev/null || true
for fork in $(yq -p toml -o json op-core/nuts/fork_lock.toml | jq -r 'keys[]'); do
base_hash=$(yq -p toml ".${fork}.hash" /tmp/base_lock.toml 2>/dev/null || echo "")
curr_hash=$(yq -p toml ".${fork}.hash" op-core/nuts/fork_lock.toml)
if [ "$base_hash" != "$curr_hash" ]; then
echo "Verifying $fork (hash changed)..."
go run ./ops/scripts/nut-provenance-verify "$fork"
else
echo "Skipping $fork (unchanged)"
fi
done
Loading
Loading