From 906727089b1b96d601a87fdf83efed577a4c2743 Mon Sep 17 00:00:00 2001 From: Sahil Sojitra <88416181+Sahil-4555@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:40:34 +0530 Subject: [PATCH 01/24] p2p/discover: optimize findnodeByID (#33348) This PR adds an optimization to the `findNodeByID` function in `p2p/discover`. There is already an open PR (#33205) for similar improvements, and I have further optimized the function to get better performance. I have attached the benchmark results comparing the current `main` branch with my `optimized version`, and the results show clear improvements. --------- Co-authored-by: Csaba Kiraly --- p2p/discover/table.go | 27 ++++++++------ p2p/discover/table_test.go | 74 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/p2p/discover/table.go b/p2p/discover/table.go index 016a2d1af3..ca29e51979 100644 --- a/p2p/discover/table.go +++ b/p2p/discover/table.go @@ -290,28 +290,33 @@ func (tab *Table) refresh() <-chan struct{} { // preferLive is true and the table contains any verified nodes, the result will not // contain unverified nodes. However, if there are no verified nodes at all, the result // will contain unverified nodes. -func (tab *Table) findnodeByID(target enode.ID, nresults int, preferLive bool) *nodesByDistance { +func (tab *Table) findnodeByID(target enode.ID, nresults int, preferLive bool) (nodes nodesByDistance) { + nodes.target = target tab.mutex.Lock() defer tab.mutex.Unlock() // Scan all buckets. There might be a better way to do this, but there aren't that many // buckets, so this solution should be fine. The worst-case complexity of this loop // is O(tab.len() * nresults). - nodes := &nodesByDistance{target: target} - liveNodes := &nodesByDistance{target: target} - for _, b := range &tab.buckets { - for _, n := range b.entries { - nodes.push(n.Node, nresults) - if preferLive && n.isValidatedLive { - liveNodes.push(n.Node, nresults) + if preferLive { + for _, b := range &tab.buckets { + for _, n := range b.entries { + if n.isValidatedLive { + nodes.push(n.Node, nresults) + } } } + if len(nodes.entries) > 0 { + return + } } - if preferLive && len(liveNodes.entries) > 0 { - return liveNodes + for _, b := range &tab.buckets { + for _, n := range b.entries { + nodes.push(n.Node, nresults) + } } - return nodes + return } // appendBucketNodes adds nodes at the given distance to the result slice. diff --git a/p2p/discover/table_test.go b/p2p/discover/table_test.go index a16b4d9cab..fcc4697047 100644 --- a/p2p/discover/table_test.go +++ b/p2p/discover/table_test.go @@ -597,3 +597,77 @@ func newkey() *ecdsa.PrivateKey { } return key } + +// BenchmarkTable_findnodeByID exercises findnodeByID across table sizes, result +// counts, and liveness ratios. The _PreferLive_NoLive cases cover the fallback +// path where preferLive is requested but no validated-live nodes exist. +func BenchmarkTable_findnodeByID(b *testing.B) { + benchmarks := []struct { + name string + tableSize int + nresults int + preferLive bool + liveRatio float64 // fraction of nodes marked validated-live + }{ + {"SmallTable_5Results_NoPreferLive", 50, 5, false, 0.0}, + {"SmallTable_16Results_NoPreferLive", 50, 16, false, 0.0}, + {"SmallTable_5Results_PreferLive_AllLive", 50, 5, true, 1.0}, + {"SmallTable_16Results_PreferLive_AllLive", 50, 16, true, 1.0}, + {"SmallTable_5Results_PreferLive_HalfLive", 50, 5, true, 0.5}, + {"SmallTable_16Results_PreferLive_HalfLive", 50, 16, true, 0.5}, + {"SmallTable_5Results_PreferLive_NoLive", 50, 5, true, 0.0}, + {"SmallTable_16Results_PreferLive_NoLive", 50, 16, true, 0.0}, + + {"MediumTable_5Results_NoPreferLive", 200, 5, false, 0.0}, + {"MediumTable_16Results_NoPreferLive", 200, 16, false, 0.0}, + {"MediumTable_5Results_PreferLive_AllLive", 200, 5, true, 1.0}, + {"MediumTable_16Results_PreferLive_AllLive", 200, 16, true, 1.0}, + {"MediumTable_5Results_PreferLive_HalfLive", 200, 5, true, 0.5}, + {"MediumTable_16Results_PreferLive_HalfLive", 200, 16, true, 0.5}, + {"MediumTable_5Results_PreferLive_NoLive", 200, 5, true, 0.0}, + {"MediumTable_16Results_PreferLive_NoLive", 200, 16, true, 0.0}, + + {"FullTable_5Results_NoPreferLive", nBuckets * bucketSize, 5, false, 0.0}, + {"FullTable_16Results_NoPreferLive", nBuckets * bucketSize, 16, false, 0.0}, + {"FullTable_5Results_PreferLive_AllLive", nBuckets * bucketSize, 5, true, 1.0}, + {"FullTable_16Results_PreferLive_AllLive", nBuckets * bucketSize, 16, true, 1.0}, + {"FullTable_5Results_PreferLive_HalfLive", nBuckets * bucketSize, 5, true, 0.5}, + {"FullTable_16Results_PreferLive_HalfLive", nBuckets * bucketSize, 16, true, 0.5}, + {"FullTable_5Results_PreferLive_NoLive", nBuckets * bucketSize, 5, true, 0.0}, + {"FullTable_16Results_PreferLive_NoLive", nBuckets * bucketSize, 16, true, 0.0}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + tab, db := newTestTable(newPingRecorder(), Config{}) + defer db.Close() + defer tab.close() + <-tab.initDone + + self := tab.self().ID() + nodes := make([]*enode.Node, bm.tableSize) + for i := range nodes { + // Spread across buckets by varying log-distance in the valid range. + d := bucketMinDistance + 1 + i%nBuckets + nodes[i] = nodeAtDistance(self, d, intIP(i)) + } + + liveCount := int(float64(len(nodes)) * bm.liveRatio) + if liveCount > 0 { + fillTable(tab, nodes[:liveCount], true) + } + if liveCount < len(nodes) { + fillTable(tab, nodes[liveCount:], false) + } + + var target enode.ID + rand.Read(target[:]) + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = tab.findnodeByID(target, bm.nresults, bm.preferLive) + } + }) + } +} From d93dda49c8e5b0530fbddc16d61592ecf7e32b78 Mon Sep 17 00:00:00 2001 From: Sina M <1591639+s1na@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:31:15 +0200 Subject: [PATCH 02/24] internal/memlimit: respect cgroup memory cap (#34947) Currently geth ignores the docker `--memory` directive and doesn't adjust its cache size downward when necessary, potentially running into OOM. while gopsutil has functions like `docker/CgroupMem()` they are rather for reading cgroup memory limit of a container from the host. --- cmd/utils/flags.go | 24 ++-- internal/memlimit/probe.go | 59 +++++++++ internal/memlimit/probe_linux.go | 155 +++++++++++++++++++++++ internal/memlimit/probe_linux_test.go | 172 ++++++++++++++++++++++++++ internal/memlimit/probe_other.go | 25 ++++ internal/memlimit/probe_test.go | 28 +++++ 6 files changed, 452 insertions(+), 11 deletions(-) create mode 100644 internal/memlimit/probe.go create mode 100644 internal/memlimit/probe_linux.go create mode 100644 internal/memlimit/probe_linux_test.go create mode 100644 internal/memlimit/probe_other.go create mode 100644 internal/memlimit/probe_test.go diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index 6bb54dc780..a248d4fa8a 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -58,6 +58,7 @@ import ( "github.com/ethereum/go-ethereum/graphql" "github.com/ethereum/go-ethereum/internal/ethapi" "github.com/ethereum/go-ethereum/internal/flags" + "github.com/ethereum/go-ethereum/internal/memlimit" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/metrics" "github.com/ethereum/go-ethereum/metrics/exp" @@ -74,7 +75,6 @@ import ( "github.com/ethereum/go-ethereum/triedb/hashdb" "github.com/ethereum/go-ethereum/triedb/pathdb" pcsclite "github.com/gballet/go-libpcsclite" - gopsutil "github.com/shirou/gopsutil/mem" "github.com/urfave/cli/v2" ) @@ -1726,16 +1726,18 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) { setMiner(ctx, &cfg.Miner) setRequiredBlocks(ctx, cfg) - // Cap the cache allowance and tune the garbage collector - mem, err := gopsutil.VirtualMemory() - if err == nil { - if 32<<(^uintptr(0)>>63) == 32 && mem.Total > 2*1024*1024*1024 { - log.Warn("Lowering memory allowance on 32bit arch", "available", mem.Total/1024/1024, "addressable", 2*1024) - mem.Total = 2 * 1024 * 1024 * 1024 + // Cap the cache allowance and tune the garbage collector against + // the effective memory limit (cgroup-imposed when running in a + // container, total system memory otherwise). + total, source := memlimit.Limit() + if total > 0 { + if 32<<(^uintptr(0)>>63) == 32 && total > 2*1024*1024*1024 { + log.Warn("Lowering memory allowance on 32bit arch", "available", total/1024/1024, "addressable", 2*1024) + total = 2 * 1024 * 1024 * 1024 } - allowance := int(mem.Total / 1024 / 1024 / 3) + allowance := int(total / 1024 / 1024 / 3) if cache := ctx.Int(CacheFlag.Name); cache > allowance { - log.Warn("Sanitizing cache to Go's GC limits", "provided", cache, "updated", allowance) + log.Warn("Sanitizing cache to Go's GC limits", "source", source, "provided", cache, "updated", allowance) ctx.Set(CacheFlag.Name, strconv.Itoa(allowance)) } } @@ -1750,14 +1752,14 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) { cfg.SyncMode = ethconfig.FullSync // dev sync target forces full sync } else if ctx.IsSet(SyncModeFlag.Name) { value := ctx.String(SyncModeFlag.Name) - if err = cfg.SyncMode.UnmarshalText([]byte(value)); err != nil { + if err := cfg.SyncMode.UnmarshalText([]byte(value)); err != nil { Fatalf("--%v: %v", SyncModeFlag.Name, err) } } if ctx.IsSet(ChainHistoryFlag.Name) { value := ctx.String(ChainHistoryFlag.Name) - if err = cfg.HistoryMode.UnmarshalText([]byte(value)); err != nil { + if err := cfg.HistoryMode.UnmarshalText([]byte(value)); err != nil { Fatalf("--%s: %v", ChainHistoryFlag.Name, err) } } diff --git a/internal/memlimit/probe.go b/internal/memlimit/probe.go new file mode 100644 index 0000000000..d5b7652d3b --- /dev/null +++ b/internal/memlimit/probe.go @@ -0,0 +1,59 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +// Package memlimit detects the effective memory limit of the current +// process. On Linux the cgroup limit is consulted first, with total +// system memory as the fallback on all platforms. +package memlimit + +import ( + gopsutil "github.com/shirou/gopsutil/mem" +) + +// Source identifies which mechanism produced the limit value. +type Source int + +const ( + SourceUnknown Source = iota + SourceCgroupV2 + SourceCgroupV1 + SourceSystem +) + +func (s Source) String() string { + switch s { + case SourceCgroupV2: + return "cgroup-v2" + case SourceCgroupV1: + return "cgroup-v1" + case SourceSystem: + return "system" + default: + return "unknown" + } +} + +// Limit returns the memory limit visible to this process in bytes and +// the source that produced it. +func Limit() (bytes uint64, source Source) { + if v, src, ok := platformLimit(); ok { + return v, src + } + if mem, err := gopsutil.VirtualMemory(); err == nil { + return mem.Total, SourceSystem + } + return 0, SourceUnknown +} diff --git a/internal/memlimit/probe_linux.go b/internal/memlimit/probe_linux.go new file mode 100644 index 0000000000..58eb9de182 --- /dev/null +++ b/internal/memlimit/probe_linux.go @@ -0,0 +1,155 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +//go:build linux + +package memlimit + +import ( + "os" + "path" + "strconv" + "strings" +) + +// cgroupV1UnlimitedThreshold detects the v1 "no limit" sentinel, which +// is LONG_MAX rounded down to the kernel page size. Anything above 1<<62 +// is treated as unlimited regardless of page size. +const cgroupV1UnlimitedThreshold = uint64(1) << 62 + +// fileReader abstracts os.ReadFile for testing. +type fileReader func(path string) ([]byte, error) + +// platformLimit returns the cgroup limit (v2 memory.max or v1 +// memory.limit_in_bytes) of the current process. The cgroup limit is +// the authoritative budget in a container, where /proc/meminfo +// reports the host's RAM. +func platformLimit() (uint64, Source, bool) { + if v, ok := cgroupV2Limit(os.ReadFile); ok { + return v, SourceCgroupV2, true + } + if v, ok := cgroupV1Limit(os.ReadFile); ok { + return v, SourceCgroupV1, true + } + return 0, SourceUnknown, false +} + +// cgroupV2Limit reads the cgroup v2 memory.max for the current process. +// It probes /sys/fs/cgroup directly first (the effective root inside a +// cgroup-namespaced container), then the path from /proc/self/cgroup +// for the bare-metal case where the limit sits on a systemd slice. +func cgroupV2Limit(read fileReader) (uint64, bool) { + if v, ok := readCgroupV2At("/sys/fs/cgroup", "/", read); ok { + return v, true + } + procPath, ok := readProcSelfCgroupV2(read) + if !ok || procPath == "/" { + return 0, false + } + return readCgroupV2At("/sys/fs/cgroup", procPath, read) +} + +// readCgroupV2At reads memory.max under root+rel, walking up parents +// until a numeric value is found or the path bottoms out. +func readCgroupV2At(root, rel string, read fileReader) (uint64, bool) { + // cgroup.controllers exists only on v2; if absent, v2 is not mounted here. + if _, err := read(path.Join(root, "cgroup.controllers")); err != nil { + return 0, false + } + for { + raw, err := read(path.Join(root, rel, "memory.max")) + if err == nil { + s := strings.TrimSpace(string(raw)) + if s != "max" { + // Zero is legal to write but degenerate; treat it like + // "max" and keep walking up. + if n, err := strconv.ParseUint(s, 10, 64); err == nil && n != 0 { + return n, true + } + } + } + if rel == "/" || rel == "" { + return 0, false + } + rel = path.Dir(rel) + } +} + +// readProcSelfCgroupV2 returns the cgroup path from the v2 line +// ("0::") of /proc/self/cgroup. +func readProcSelfCgroupV2(read fileReader) (string, bool) { + raw, err := read("/proc/self/cgroup") + if err != nil { + return "", false + } + for line := range strings.SplitSeq(strings.TrimSpace(string(raw)), "\n") { + // v2 unified line: "0::" + if strings.HasPrefix(line, "0::") { + return strings.TrimPrefix(line, "0::"), true + } + } + return "", false +} + +// cgroupV1Limit reads memory.limit_in_bytes from the v1 memory +// controller, walking up parents when a node reports the unlimited +// sentinel. +func cgroupV1Limit(read fileReader) (uint64, bool) { + rel, ok := readProcSelfCgroupV1Memory(read) + if !ok { + return 0, false + } + root := "/sys/fs/cgroup/memory" + if _, err := read(path.Join(root, "memory.limit_in_bytes")); err != nil { + return 0, false + } + for { + raw, err := read(path.Join(root, rel, "memory.limit_in_bytes")) + if err == nil { + if n, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64); err == nil { + if n != 0 && n < cgroupV1UnlimitedThreshold { + return n, true + } + } + } + if rel == "/" || rel == "" { + return 0, false + } + rel = path.Dir(rel) + } +} + +// readProcSelfCgroupV1Memory parses /proc/self/cgroup for the v1 memory +// controller line (":memory:" or ":...,memory,...:"). +func readProcSelfCgroupV1Memory(read fileReader) (string, bool) { + raw, err := read("/proc/self/cgroup") + if err != nil { + return "", false + } + for line := range strings.SplitSeq(strings.TrimSpace(string(raw)), "\n") { + // Format: "::" + parts := strings.SplitN(line, ":", 3) + if len(parts) != 3 { + continue + } + for ctrl := range strings.SplitSeq(parts[1], ",") { + if ctrl == "memory" { + return parts[2], true + } + } + } + return "", false +} diff --git a/internal/memlimit/probe_linux_test.go b/internal/memlimit/probe_linux_test.go new file mode 100644 index 0000000000..51432b9e3e --- /dev/null +++ b/internal/memlimit/probe_linux_test.go @@ -0,0 +1,172 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +//go:build linux + +package memlimit + +import ( + "os" + "testing" +) + +// fakeFS is a fileReader backed by an in-memory map. Missing keys +// return os.ErrNotExist. +type fakeFS map[string]string + +func (f fakeFS) read(path string) ([]byte, error) { + v, ok := f[path] + if !ok { + return nil, os.ErrNotExist + } + return []byte(v), nil +} + +func TestCgroupV2Container(t *testing.T) { + // Namespaced container (Docker default since 20.10): memory.max + // sits directly at the cgroup root. + fs := fakeFS{ + "/sys/fs/cgroup/cgroup.controllers": "memory cpu io", + "/sys/fs/cgroup/memory.max": "536870912", + "/proc/self/cgroup": "0::/", + } + bytes, ok := cgroupV2Limit(fs.read) + if !ok || bytes != 536870912 { + t.Errorf("got (%d, %v), want (536870912, true)", bytes, ok) + } +} + +func TestCgroupV2Unlimited(t *testing.T) { + fs := fakeFS{ + "/sys/fs/cgroup/cgroup.controllers": "memory cpu io", + "/sys/fs/cgroup/memory.max": "max", + "/proc/self/cgroup": "0::/", + } + if _, ok := cgroupV2Limit(fs.read); ok { + t.Errorf("expected ok=false for fully unlimited v2 hierarchy") + } +} + +func TestCgroupV2LimitOnAncestor(t *testing.T) { + // Bare-metal systemd: the leaf has no limit but the containing + // slice does. + fs := fakeFS{ + "/sys/fs/cgroup/cgroup.controllers": "memory cpu io", + "/sys/fs/cgroup/memory.max": "max", + "/sys/fs/cgroup/system.slice/memory.max": "8589934592", + "/sys/fs/cgroup/system.slice/geth.service/memory.max": "max", + "/proc/self/cgroup": "0::/system.slice/geth.service", + } + bytes, ok := cgroupV2Limit(fs.read) + if !ok || bytes != 8589934592 { + t.Errorf("got (%d, %v), want (8589934592, true)", bytes, ok) + } +} + +func TestCgroupV2PrefersDirectRoot(t *testing.T) { + // In a namespaced container /proc/self/cgroup may show a host-side + // path; the direct probe at the root must win. + fs := fakeFS{ + "/sys/fs/cgroup/cgroup.controllers": "memory cpu io", + "/sys/fs/cgroup/memory.max": "536870912", + "/sys/fs/cgroup/system.slice/memory.max": "max", + "/proc/self/cgroup": "0::/system.slice/docker-abc.scope", + } + bytes, ok := cgroupV2Limit(fs.read) + if !ok || bytes != 536870912 { + t.Errorf("got (%d, ok=%v), want (536870912, true)", bytes, ok) + } +} + +func TestCgroupV2ZeroWalksUp(t *testing.T) { + // memory.max="0" is legal but degenerate; walk up like "max". + fs := fakeFS{ + "/sys/fs/cgroup/cgroup.controllers": "memory cpu io", + "/sys/fs/cgroup/memory.max": "max", + "/sys/fs/cgroup/system.slice/memory.max": "536870912", + "/sys/fs/cgroup/system.slice/geth.service/memory.max": "0", + "/proc/self/cgroup": "0::/system.slice/geth.service", + } + bytes, ok := cgroupV2Limit(fs.read) + if !ok || bytes != 536870912 { + t.Errorf("got (%d, ok=%v), want (536870912, true)", bytes, ok) + } +} + +func TestCgroupV1(t *testing.T) { + fs := fakeFS{ + // no v2 hallmark file + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712", + "/sys/fs/cgroup/memory/docker/abc/memory.limit_in_bytes": "1073741824", + "/proc/self/cgroup": "12:memory:/docker/abc\n11:cpu:/docker/abc", + } + if _, ok := cgroupV2Limit(fs.read); ok { + t.Errorf("expected v2 probe to fail on a v1-only host") + } + bytes, ok := cgroupV1Limit(fs.read) + if !ok || bytes != 1073741824 { + t.Errorf("got (%d, %v), want (1073741824, true)", bytes, ok) + } +} + +func TestCgroupV1Unlimited(t *testing.T) { + fs := fakeFS{ + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712", + "/sys/fs/cgroup/memory/docker/abc/memory.limit_in_bytes": "9223372036854771712", + "/proc/self/cgroup": "12:memory:/docker/abc", + } + if _, ok := cgroupV1Limit(fs.read); ok { + t.Errorf("expected ok=false when v1 reports the unlimited sentinel everywhere") + } +} + +func TestCgroupV1NonDefaultPageSize(t *testing.T) { + // The unlimited sentinel is LONG_MAX aligned to the local page + // size, so it differs on 16 KiB and 64 KiB page kernels. + fs := fakeFS{ + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854710272", // 64 KiB-page sentinel + "/sys/fs/cgroup/memory/foo/memory.limit_in_bytes": "9223372036854767616", // 16 KiB-page sentinel + "/proc/self/cgroup": "12:memory:/foo", + } + if _, ok := cgroupV1Limit(fs.read); ok { + t.Errorf("expected ok=false: both values are page-aligned LONG_MAX") + } +} + +func TestCgroupV1CombinedControllers(t *testing.T) { + // Some kernels list multiple controllers per line. + fs := fakeFS{ + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712", + "/sys/fs/cgroup/memory/foo/memory.limit_in_bytes": "2147483648", + "/proc/self/cgroup": "8:cpu,memory,blkio:/foo", + } + bytes, ok := cgroupV1Limit(fs.read) + if !ok || bytes != 2147483648 { + t.Errorf("got (%d, ok=%v), want (2147483648, true)", bytes, ok) + } +} + +func TestNoCgroup(t *testing.T) { + fs := fakeFS{ + "/proc/self/cgroup": "0::/", + } + if _, ok := cgroupV2Limit(fs.read); ok { + t.Errorf("expected v2 ok=false when v2 is not mounted") + } + if _, ok := cgroupV1Limit(fs.read); ok { + t.Errorf("expected v1 ok=false when v1 is not mounted") + } +} diff --git a/internal/memlimit/probe_other.go b/internal/memlimit/probe_other.go new file mode 100644 index 0000000000..56d0122349 --- /dev/null +++ b/internal/memlimit/probe_other.go @@ -0,0 +1,25 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +//go:build !linux + +package memlimit + +// platformLimit reports no platform-specific limit on non-Linux +// systems; the caller falls back to total system memory. +func platformLimit() (uint64, Source, bool) { + return 0, SourceUnknown, false +} diff --git a/internal/memlimit/probe_test.go b/internal/memlimit/probe_test.go new file mode 100644 index 0000000000..b86e95629a --- /dev/null +++ b/internal/memlimit/probe_test.go @@ -0,0 +1,28 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package memlimit + +import "testing" + +// TestLimitSmoke asserts that Limit() returns a non-zero value, +// exercising the real probe and the gopsutil fallback. +func TestLimitSmoke(t *testing.T) { + bytes, src := Limit() + if bytes == 0 { + t.Errorf("Limit() returned 0 bytes (source=%s); expected non-zero on any sane host", src) + } +} From 9059157eba227b6a4d4cc2e147e65f9919294360 Mon Sep 17 00:00:00 2001 From: Marius van der Wijden Date: Fri, 12 Jun 2026 14:29:03 +0200 Subject: [PATCH 03/24] core: implement EIP-8037, state creation gas cost increase (#33601) Implements https://eips.ethereum.org/EIPS/eip-8037 mainly done in order to judge the complexity of the EIP and to act as a jumping off point, since the eip will likely change. --------- Co-authored-by: Gary Rong --- cmd/evm/internal/t8ntool/execution.go | 17 +- cmd/evm/internal/t8ntool/transaction.go | 2 +- core/bal_test.go | 78 +-- core/bench_test.go | 2 +- core/bintrie_witness_test.go | 6 +- core/blockchain.go | 8 + core/evm.go | 25 +- core/gaspool.go | 74 ++- core/state_processor.go | 30 +- core/state_transition.go | 479 ++++++++++++------ core/state_transition_test.go | 105 +++- .../tracing/gen_gas_change_reason_stringer.go | 7 +- core/tracing/hooks.go | 4 + core/txpool/validation.go | 9 +- core/vm/common.go | 1 - core/vm/contract.go | 49 +- core/vm/contracts.go | 3 +- core/vm/contracts_fuzz_test.go | 2 +- core/vm/contracts_test.go | 8 +- core/vm/eips.go | 15 +- core/vm/evm.go | 255 ++++++---- core/vm/gas_table.go | 161 +++++- core/vm/gas_table_test.go | 12 +- core/vm/gascosts.go | 269 ++++++++-- core/vm/instructions.go | 71 +-- core/vm/interpreter.go | 16 +- core/vm/interpreter_test.go | 4 +- core/vm/jump_table.go | 6 +- core/vm/operations_acl.go | 18 +- core/vm/runtime/env.go | 24 +- core/vm/runtime/runtime.go | 22 +- eth/tracers/js/tracer_test.go | 2 +- eth/tracers/logger/logger_test.go | 2 +- params/protocol_params.go | 8 + tests/state_test.go | 6 +- tests/transaction_test_util.go | 4 +- 36 files changed, 1257 insertions(+), 547 deletions(-) diff --git a/cmd/evm/internal/t8ntool/execution.go b/cmd/evm/internal/t8ntool/execution.go index 85a581eba6..f35b28510c 100644 --- a/cmd/evm/internal/t8ntool/execution.go +++ b/cmd/evm/internal/t8ntool/execution.go @@ -183,14 +183,15 @@ func (pre *Prestate) Apply(vmConfig vm.Config, chainConfig *params.ChainConfig, blockAccessList = bal.NewConstructionBlockAccessList() ) vmContext := vm.BlockContext{ - CanTransfer: core.CanTransfer, - Transfer: core.Transfer, - Coinbase: pre.Env.Coinbase, - BlockNumber: new(big.Int).SetUint64(pre.Env.Number), - Time: pre.Env.Timestamp, - Difficulty: pre.Env.Difficulty, - GasLimit: pre.Env.GasLimit, - GetHash: getHash, + CanTransfer: core.CanTransfer, + Transfer: core.Transfer, + Coinbase: pre.Env.Coinbase, + BlockNumber: new(big.Int).SetUint64(pre.Env.Number), + Time: pre.Env.Timestamp, + Difficulty: pre.Env.Difficulty, + GasLimit: pre.Env.GasLimit, + GetHash: getHash, + CostPerStateByte: params.CostPerStateByte, } if pre.Env.SlotNumber != nil { vmContext.SlotNum = *pre.Env.SlotNumber diff --git a/cmd/evm/internal/t8ntool/transaction.go b/cmd/evm/internal/t8ntool/transaction.go index ca19ae3386..9eb1bdbf5f 100644 --- a/cmd/evm/internal/t8ntool/transaction.go +++ b/cmd/evm/internal/t8ntool/transaction.go @@ -133,7 +133,7 @@ func Transaction(ctx *cli.Context) error { } // Check intrinsic gas rules := chainConfig.Rules(common.Big0, true, 0) - cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai, rules.IsAmsterdam) + cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, params.CostPerStateByte) if err != nil { r.Error = err results = append(results, r) diff --git a/core/bal_test.go b/core/bal_test.go index d20108b96c..b27901c083 100644 --- a/core/bal_test.go +++ b/core/bal_test.go @@ -153,6 +153,14 @@ func assertEmpty(t *testing.T, aa *bal.AccountAccess) { } } +// txGasNewAccount covers the base tx cost plus the EIP-8037 account-creation +// state-gas charge (STATE_BYTES_PER_NEW_ACCOUNT × CPSB ≈ 183,600) that is +// incurred when value is transferred to a non-existent account under Amsterdam. +// params.TxGas (21,000) alone is insufficient: the transfer would run out of +// gas, the credit would revert, and the recipient would never get a balance +// change recorded in the BAL. +const txGasNewAccount = 250_000 + // --- tx builders --- func (e *balTestEnv) tx(nonce uint64, to *common.Address, value *big.Int, gas uint64, tipGwei int64, data []byte) *types.Transaction { @@ -177,7 +185,7 @@ func TestBALTxSenderAndRecipient(t *testing.T) { env := newBALTestEnv(nil) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &to, big.NewInt(1000), params.TxGas, 0, nil)) + g.AddTx(env.tx(0, &to, big.NewInt(1000), txGasNewAccount, 0, nil)) }) sender := assertPresent(t, b, env.from) @@ -260,7 +268,7 @@ func TestBALSystemAddressIncludedWhenTouched(t *testing.T) { env := newBALTestEnv(nil) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &sys, big.NewInt(1000), params.TxGas, 0, nil)) + g.AddTx(env.tx(0, &sys, big.NewInt(1000), txGasNewAccount, 0, nil)) }) aa := assertPresent(t, b, sys) @@ -283,7 +291,7 @@ func TestBALPrecompileInvokedFromContractIncluded(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) }) aa := assertPresent(t, b, identity) @@ -315,7 +323,7 @@ func TestBALPrecompileValueTransferRecordsBalance(t *testing.T) { env := newBALTestEnv(nil) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &identity, big.NewInt(5), 50_000, 0, nil)) + g.AddTx(env.tx(0, &identity, big.NewInt(5), txGasNewAccount, 0, nil)) }) aa := assertPresent(t, b, identity) @@ -334,7 +342,7 @@ func TestBALBalanceProbeOnNonExistent(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) }) assertEmpty(t, assertPresent(t, b, probe)) @@ -350,7 +358,7 @@ func TestBALExtCodeSizeProbeOnNonExistent(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) }) assertEmpty(t, assertPresent(t, b, probe)) @@ -366,7 +374,7 @@ func TestBALExtCodeHashProbeOnNonExistent(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) }) assertEmpty(t, assertPresent(t, b, probe)) @@ -385,7 +393,7 @@ func TestBALExtCodeCopyProbeOnNonExistent(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) }) assertEmpty(t, assertPresent(t, b, probe)) @@ -447,7 +455,7 @@ func TestBALCallTargetWithEmptyChangeSet(t *testing.T) { }) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &target, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &target, big.NewInt(0), 1_000_000, 0, nil)) }) assertEmpty(t, assertPresent(t, b, target)) @@ -465,7 +473,7 @@ func TestBALCallCodeTargetIncluded(t *testing.T) { }) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &caller, big.NewInt(0), 200_000, 0, nil)) + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) }) assertPresent(t, b, caller) @@ -483,7 +491,7 @@ func TestBALDelegateCallTargetIncluded(t *testing.T) { }) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &caller, big.NewInt(0), 200_000, 0, nil)) + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) }) assertPresent(t, b, caller) @@ -501,7 +509,7 @@ func TestBALStaticCallTargetIncluded(t *testing.T) { }) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &caller, big.NewInt(0), 200_000, 0, nil)) + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) }) assertPresent(t, b, caller) @@ -519,7 +527,7 @@ func TestBALRevertedTxStillIncluded(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{reverter: {Code: revertCode, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &reverter, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &reverter, big.NewInt(0), 1_000_000, 0, nil)) }) assertEmpty(t, assertPresent(t, b, reverter)) @@ -533,7 +541,7 @@ func TestBALSenderRecordedOnRevert(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{reverter: {Code: revertCode, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &reverter, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &reverter, big.NewInt(0), 1_000_000, 0, nil)) }) sender := assertPresent(t, b, env.from) @@ -557,7 +565,7 @@ func TestBALStorageWriteRecorded(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{contract: {Code: code, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &contract, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) }) aa := assertPresent(t, b, contract) @@ -578,7 +586,7 @@ func TestBALStorageSloadOnly(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{contract: {Code: code, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &contract, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) }) aa := assertPresent(t, b, contract) @@ -604,7 +612,7 @@ func TestBALStorageReadThenWriteOnlyInWrites(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{contract: {Code: code, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &contract, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) }) aa := assertPresent(t, b, contract) @@ -632,7 +640,7 @@ func TestBALNoOpSSTOREDemotesToRead(t *testing.T) { }) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &contract, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) }) aa := assertPresent(t, b, contract) @@ -660,7 +668,7 @@ func TestBALStorageWriteZeroIsAWrite(t *testing.T) { }) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &contract, big.NewInt(0), 100_000, 0, nil)) + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) }) aa := assertPresent(t, b, contract) @@ -687,7 +695,7 @@ func TestBALCreateDeploysCode(t *testing.T) { init := []byte{0x60, 0x00, 0x60, 0x00, 0x53, 0x60, 0x01, 0x60, 0x00, 0xf3} b, receipts := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, nil, big.NewInt(7), 200_000, 0, init)) + g.AddTx(env.tx(0, nil, big.NewInt(7), 1_000_000, 0, init)) }) created := receipts[0].ContractAddress @@ -711,7 +719,7 @@ func TestBALCreateEmptyRuntimeNoCodeEntry(t *testing.T) { init := []byte{0x60, 0x00, 0x60, 0x00, 0xf3} b, receipts := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, nil, big.NewInt(0), 200_000, 0, init)) + g.AddTx(env.tx(0, nil, big.NewInt(0), 1_000_000, 0, init)) }) created := receipts[0].ContractAddress @@ -732,7 +740,7 @@ func TestBALCreateInitRevertEmptyChangeSet(t *testing.T) { init := []byte{0x60, 0x00, 0x60, 0x00, 0xfd} b, receipts := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, nil, big.NewInt(0), 200_000, 0, init)) + g.AddTx(env.tx(0, nil, big.NewInt(0), 1_000_000, 0, init)) }) created := receipts[0].ContractAddress @@ -743,11 +751,13 @@ func TestBALCreateInitRevertEmptyChangeSet(t *testing.T) { // the deployed address in the BAL with an empty change set. func TestBALCreateInitOOGEmptyChangeSet(t *testing.T) { env := newBALTestEnv(nil) - // Infinite loop: JUMPDEST PUSH1 0 JUMP — burns gas until OOG. + // Infinite loop: JUMPDEST PUSH1 0 JUMP — burns gas until OOG. The + // gas budget must cover EIP-8037 intrinsic state gas (account creation) + // so the tx is accepted; OOG must happen inside the init code. init := []byte{0x5b, 0x60, 0x00, 0x56} b, receipts := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, nil, big.NewInt(0), 60_000, 0, init)) + g.AddTx(env.tx(0, nil, big.NewInt(0), 220_000, 0, init)) }) created := receipts[0].ContractAddress @@ -771,7 +781,7 @@ func TestBALCreateAddressCollisionStillIncluded(t *testing.T) { // Init code doesn't matter — execution never starts. init := []byte{0x60, 0x00, 0x60, 0x00, 0xf3} b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, nil, big.NewInt(0), 200_000, 0, init)) + g.AddTx(env.tx(0, nil, big.NewInt(0), 1_000_000, 0, init)) }) aa := assertPresent(t, b, collide) @@ -799,7 +809,7 @@ func TestBALInEVMCreatePreAccessAbortDestinationExcluded(t *testing.T) { }) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &factory, big.NewInt(0), 200_000, 0, nil)) + g.AddTx(env.tx(0, &factory, big.NewInt(0), 1_000_000, 0, nil)) }) // The address that WOULD have been deployed had the create succeeded. @@ -842,7 +852,7 @@ func TestBALInEVMCreateDeploysContract(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{factory: {Code: code, Balance: common.Big0, Nonce: 1}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &factory, big.NewInt(0), 300_000, 0, nil)) + g.AddTx(env.tx(0, &factory, big.NewInt(0), 1_000_000, 0, nil)) }) // Deployed address depends on the factory's nonce at the moment of CREATE, @@ -870,7 +880,7 @@ func TestBALSelfDestructBeneficiaryWithZeroBalance(t *testing.T) { init = append(init, 0xff) b, receipts := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, nil, big.NewInt(0), 200_000, 0, init)) + g.AddTx(env.tx(0, nil, big.NewInt(0), 1_000_000, 0, init)) }) created := receipts[0].ContractAddress @@ -896,7 +906,7 @@ func TestBALSelfDestructBeneficiaryWithValueTransfer(t *testing.T) { init = append(init, 0xff) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, nil, big.NewInt(100), 200_000, 0, init)) + g.AddTx(env.tx(0, nil, big.NewInt(100), 1_000_000, 0, init)) }) ben := assertPresent(t, b, beneficiary) @@ -921,7 +931,7 @@ func TestBALSelfDestructPreExistingContract(t *testing.T) { }) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &suicidal, big.NewInt(0), 200_000, 0, nil)) + g.AddTx(env.tx(0, &suicidal, big.NewInt(0), 1_000_000, 0, nil)) }) aa := assertPresent(t, b, suicidal) @@ -966,7 +976,7 @@ func TestBALMidTxBalanceRoundTrip(t *testing.T) { env := newBALTestEnv(types.GenesisAlloc{bouncer: {Code: code, Balance: common.Big0}}) b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &bouncer, big.NewInt(1234), 200_000, 0, nil)) + g.AddTx(env.tx(0, &bouncer, big.NewInt(1234), 1_000_000, 0, nil)) }) aa := assertPresent(t, b, bouncer) @@ -1072,7 +1082,7 @@ func TestBALAuthorityIncludedOnSetCodeTx(t *testing.T) { Nonce: 0, To: env.from, Value: new(uint256.Int), - Gas: 200_000, + Gas: 1_000_000, GasFeeCap: uint256.NewInt(uint64(newGwei(10).Int64())), GasTipCap: new(uint256.Int), AuthList: []types.SetCodeAuthorization{auth}, @@ -1112,7 +1122,7 @@ func TestBALDelegationTargetNotIncludedOnAuthOnly(t *testing.T) { Nonce: 0, To: env.from, // tx.to is an EOA with no code: delegate is never called Value: new(uint256.Int), - Gas: 200_000, + Gas: 1_000_000, GasFeeCap: uint256.NewInt(uint64(newGwei(10).Int64())), GasTipCap: new(uint256.Int), AuthList: []types.SetCodeAuthorization{auth}, @@ -1131,7 +1141,7 @@ func (e *balTestEnv) newSetCodeTx(t *testing.T, nonce uint64, to common.Address, Nonce: nonce, To: to, Value: new(uint256.Int), - Gas: 400_000, + Gas: 1_000_000, GasFeeCap: uint256.NewInt(uint64(newGwei(10).Int64())), GasTipCap: new(uint256.Int), AuthList: auths, diff --git a/core/bench_test.go b/core/bench_test.go index 65179c54d4..fe66aeae0d 100644 --- a/core/bench_test.go +++ b/core/bench_test.go @@ -89,7 +89,7 @@ func genValueTx(nbytes int) func(int, *BlockGen) { data := make([]byte, nbytes) return func(i int, gen *BlockGen) { toaddr := common.Address{} - cost, _ := IntrinsicGas(data, nil, nil, false, false, false, false, false) + cost, _ := IntrinsicGas(data, nil, nil, false, params.Rules{}, params.CostPerStateByte) signer := gen.Signer() gasPrice := big.NewInt(0) if gen.header.BaseFee != nil { diff --git a/core/bintrie_witness_test.go b/core/bintrie_witness_test.go index b49ac83bb5..e151a72801 100644 --- a/core/bintrie_witness_test.go +++ b/core/bintrie_witness_test.go @@ -64,12 +64,12 @@ var ( func TestProcessUBT(t *testing.T) { var ( code = common.FromHex(`6060604052600a8060106000396000f360606040526008565b00`) - intrinsicContractCreationGas, _ = IntrinsicGas(code, nil, nil, true, true, true, true, false) + intrinsicContractCreationGas, _ = IntrinsicGas(code, nil, nil, true, params.Rules{IsHomestead: true, IsIstanbul: true, IsShanghai: true}, 0) // A contract creation that calls EXTCODECOPY in the constructor. Used to ensure that the witness // will not contain that copied data. // Source: https://gist.github.com/gballet/a23db1e1cb4ed105616b5920feb75985 codeWithExtCodeCopy = common.FromHex(`0x60806040526040516100109061017b565b604051809103906000f08015801561002c573d6000803e3d6000fd5b506000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555034801561007857600080fd5b5060008067ffffffffffffffff8111156100955761009461024a565b5b6040519080825280601f01601f1916602001820160405280156100c75781602001600182028036833780820191505090505b50905060008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690506020600083833c81610101906101e3565b60405161010d90610187565b61011791906101a3565b604051809103906000f080158015610133573d6000803e3d6000fd5b50600160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550505061029b565b60d58061046783390190565b6102068061053c83390190565b61019d816101d9565b82525050565b60006020820190506101b86000830184610194565b92915050565b6000819050602082019050919050565b600081519050919050565b6000819050919050565b60006101ee826101ce565b826101f8846101be565b905061020381610279565b925060208210156102435761023e7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8360200360080261028e565b831692505b5050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b600061028582516101d9565b80915050919050565b600082821b905092915050565b6101bd806102aa6000396000f3fe608060405234801561001057600080fd5b506004361061002b5760003560e01c8063f566852414610030575b600080fd5b61003861004e565b6040516100459190610146565b60405180910390f35b6000600160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166381ca91d36040518163ffffffff1660e01b815260040160206040518083038186803b1580156100b857600080fd5b505afa1580156100cc573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906100f0919061010a565b905090565b60008151905061010481610170565b92915050565b6000602082840312156101205761011f61016b565b5b600061012e848285016100f5565b91505092915050565b61014081610161565b82525050565b600060208201905061015b6000830184610137565b92915050565b6000819050919050565b600080fd5b61017981610161565b811461018457600080fd5b5056fea2646970667358221220a6a0e11af79f176f9c421b7b12f441356b25f6489b83d38cc828a701720b41f164736f6c63430008070033608060405234801561001057600080fd5b5060b68061001f6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c8063ab5ed15014602d575b600080fd5b60336047565b604051603e9190605d565b60405180910390f35b60006001905090565b6057816076565b82525050565b6000602082019050607060008301846050565b92915050565b600081905091905056fea26469706673582212203a14eb0d5cd07c277d3e24912f110ddda3e553245a99afc4eeefb2fbae5327aa64736f6c63430008070033608060405234801561001057600080fd5b5060405161020638038061020683398181016040528101906100329190610063565b60018160001c6100429190610090565b60008190555050610145565b60008151905061005d8161012e565b92915050565b60006020828403121561007957610078610129565b5b60006100878482850161004e565b91505092915050565b600061009b826100f0565b91506100a6836100f0565b9250827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff038211156100db576100da6100fa565b5b828201905092915050565b6000819050919050565b6000819050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b600080fd5b610137816100e6565b811461014257600080fd5b50565b60b3806101536000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806381ca91d314602d575b600080fd5b60336047565b604051603e9190605a565b60405180910390f35b60005481565b6054816073565b82525050565b6000602082019050606d6000830184604d565b92915050565b600081905091905056fea26469706673582212209bff7098a2f526de1ad499866f27d6d0d6f17b74a413036d6063ca6a0998ca4264736f6c63430008070033`) - intrinsicCodeWithExtCodeCopyGas, _ = IntrinsicGas(codeWithExtCodeCopy, nil, nil, true, true, true, true, false) + intrinsicCodeWithExtCodeCopyGas, _ = IntrinsicGas(codeWithExtCodeCopy, nil, nil, true, params.Rules{IsHomestead: true, IsIstanbul: true, IsShanghai: true}, 0) signer = types.LatestSigner(testUBTChainConfig) testKey, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") bcdb = rawdb.NewMemoryDatabase() // Database for the blockchain @@ -201,7 +201,7 @@ func TestProcessParentBlockHash(t *testing.T) { if isUBT { chainConfig = testUBTChainConfig } - vmContext := NewEVMBlockContext(header, nil, new(common.Address)) + vmContext := NewEVMBlockContext(header, &BlockChain{chainConfig: chainConfig}, new(common.Address)) evm := vm.NewEVM(vmContext, statedb, chainConfig, vm.Config{}) ProcessParentBlockHash(header.ParentHash, evm, bal.NewConstructionBlockAccessList()) } diff --git a/core/blockchain.go b/core/blockchain.go index c914a6dd81..acf2da1921 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -2316,6 +2316,14 @@ func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, stats.Validation = vtime - (statedb.AccountHashes + statedb.AccountUpdates + statedb.StorageUpdates) // The time spent on block validation stats.CrossValidation = xvtime // The time spent on stateless cross validation + // Attach the computed block access list so it gets persisted alongside the + // block. The validator has already verified the hash matches the header. + // BAL is only meaningful from Amsterdam onward; skip pre-Amsterdam blocks + // to avoid persisting and serving empty BALs over the network. + if res.Bal != nil && block.AccessList() == nil && bc.chainConfig.IsAmsterdam(block.Number(), block.Time()) { + block = block.WithAccessListUnsafe(res.Bal.ToEncodingObj()) + } + // Write the block to the chain and get the status. var status WriteStatus if config.WriteState { diff --git a/core/evm.go b/core/evm.go index 73e4c01a99..fdea63e469 100644 --- a/core/evm.go +++ b/core/evm.go @@ -68,18 +68,19 @@ func NewEVMBlockContext(header *types.Header, chain ChainContext, author *common } return vm.BlockContext{ - CanTransfer: CanTransfer, - Transfer: Transfer, - GetHash: GetHashFn(header, chain), - Coinbase: beneficiary, - BlockNumber: new(big.Int).Set(header.Number), - Time: header.Time, - Difficulty: new(big.Int).Set(header.Difficulty), - BaseFee: baseFee, - BlobBaseFee: blobBaseFee, - GasLimit: header.GasLimit, - Random: random, - SlotNum: slotNum, + CanTransfer: CanTransfer, + Transfer: Transfer, + GetHash: GetHashFn(header, chain), + Coinbase: beneficiary, + BlockNumber: new(big.Int).Set(header.Number), + Time: header.Time, + Difficulty: new(big.Int).Set(header.Difficulty), + BaseFee: baseFee, + BlobBaseFee: blobBaseFee, + GasLimit: header.GasLimit, + Random: random, + SlotNum: slotNum, + CostPerStateByte: params.CostPerStateByte, } } diff --git a/core/gaspool.go b/core/gaspool.go index 14f5abd93c..83420c7640 100644 --- a/core/gaspool.go +++ b/core/gaspool.go @@ -27,6 +27,10 @@ type GasPool struct { remaining uint64 initial uint64 cumulativeUsed uint64 + + // After 8037 Block gas used is max(cumulativeRegular, cumulativeState). + cumulativeRegular uint64 + cumulativeState uint64 } // NewGasPool initializes the gasPool with the given amount. @@ -37,9 +41,9 @@ func NewGasPool(amount uint64) *GasPool { } } -// SubGas deducts the given amount from the pool if enough gas is +// CheckGasLegacy deducts the given amount from the pool if enough gas is // available and returns an error otherwise. -func (gp *GasPool) SubGas(amount uint64) error { +func (gp *GasPool) CheckGasLegacy(amount uint64) error { if gp.remaining < amount { return ErrGasLimitReached } @@ -47,41 +51,73 @@ func (gp *GasPool) SubGas(amount uint64) error { return nil } -// ReturnGas adds the refunded gas back to the pool and updates +// CheckGasAmsterdam performs the EIP-8037 per-tx 2D block-inclusion check: +// the worst-case regular contribution must fit in the regular dimension and +// the worst-case state contribution must fit in the state dimension +func (gp *GasPool) CheckGasAmsterdam(regularReservation, stateReservation uint64) error { + if gp.initial-gp.cumulativeRegular < regularReservation { + return ErrGasLimitReached + } + if gp.initial-gp.cumulativeState < stateReservation { + return ErrGasLimitReached + } + return nil +} + +// ChargeGasLegacy adds the refunded gas back to the pool and updates // the cumulative gas usage accordingly. -func (gp *GasPool) ReturnGas(returned uint64, gasUsed uint64) error { +func (gp *GasPool) ChargeGasLegacy(returned uint64, gasUsed uint64) error { if gp.remaining > math.MaxUint64-returned { return fmt.Errorf("%w: remaining: %d, returned: %d", ErrGasLimitOverflow, gp.remaining, returned) } - // The returned gas calculation differs across forks. - // - // - Pre-Amsterdam: - // returned = purchased - remaining (refund included) - // - // - Post-Amsterdam: - // returned = purchased - gasUsed (refund excluded) + // returned = purchased - remaining (refund included) gp.remaining += returned // gasUsed = max(txGasUsed - gasRefund, calldataFloorGasCost) - // regardless of Amsterdam is activated or not. gp.cumulativeUsed += gasUsed return nil } +// ChargeGasAmsterdam calculates the new remaining gas in the pool after the +// execution of a message. Previously we subtracted and re-added gas to the +// gaspool. After Amsterdam we only check if we can include the transaction +// and charge the gaspool at the end. +func (gp *GasPool) ChargeGasAmsterdam(txRegular, txState, receiptGasUsed uint64) error { + cumulativeRegular := gp.cumulativeRegular + txRegular + cumulativeState := gp.cumulativeState + txState + blockUsed := max(cumulativeRegular, cumulativeState) + if gp.initial < blockUsed { + return fmt.Errorf("%w: block gas overflow: initial %d, used %d (regular: %d, state: %d)", + ErrGasLimitReached, gp.initial, blockUsed, cumulativeRegular, cumulativeState) + } + gp.cumulativeRegular = cumulativeRegular + gp.cumulativeState = cumulativeState + gp.cumulativeUsed += receiptGasUsed + // TODO(rjl, marius), the semantics of this counter is slightly different + // in the context of Amsterdam, the API Gas() should be reworked. + gp.remaining = gp.initial - gp.cumulativeRegular + return nil +} + // Gas returns the amount of gas remaining in the pool. func (gp *GasPool) Gas() uint64 { return gp.remaining } -// CumulativeUsed returns the amount of cumulative consumed gas (refunded included). +// CumulativeUsed returns the cumulative gas consumed for receipt tracking. func (gp *GasPool) CumulativeUsed() uint64 { return gp.cumulativeUsed } // Used returns the amount of consumed gas. func (gp *GasPool) Used() uint64 { + // After 8037, return max(sum_regular, sum_state) + if gp.cumulativeRegular > 0 || gp.cumulativeState > 0 { + return max(gp.cumulativeRegular, gp.cumulativeState) + } + // Before 8037, return initial-remaining if gp.initial < gp.remaining { - panic("gas used underflow") + panic(fmt.Sprintf("gas used underflow: %v %v", gp.initial, gp.remaining)) } return gp.initial - gp.remaining } @@ -89,9 +125,11 @@ func (gp *GasPool) Used() uint64 { // Snapshot returns the deep-copied object as the snapshot. func (gp *GasPool) Snapshot() *GasPool { return &GasPool{ - initial: gp.initial, - remaining: gp.remaining, - cumulativeUsed: gp.cumulativeUsed, + initial: gp.initial, + remaining: gp.remaining, + cumulativeUsed: gp.cumulativeUsed, + cumulativeRegular: gp.cumulativeRegular, + cumulativeState: gp.cumulativeState, } } @@ -100,6 +138,8 @@ func (gp *GasPool) Set(other *GasPool) { gp.initial = other.initial gp.remaining = other.remaining gp.cumulativeUsed = other.cumulativeUsed + gp.cumulativeRegular = other.cumulativeRegular + gp.cumulativeState = other.cumulativeState } func (gp *GasPool) String() string { diff --git a/core/state_processor.go b/core/state_processor.go index 5f43206eb4..f40aee0301 100644 --- a/core/state_processor.go +++ b/core/state_processor.go @@ -271,6 +271,21 @@ func ApplyTransaction(evm *vm.EVM, gp *GasPool, statedb *state.StateDB, header * return ApplyTransactionWithEVM(msg, gp, statedb, header.Number, header.Hash(), header.Time, tx, evm) } +// systemCallGasBudget returns the gas budget for system calls. +func systemCallGasBudget(evm *vm.EVM) (gasLimit uint64, gasBudget vm.GasBudget) { + if !evm.GetRules().IsAmsterdam { + gasLimit = 30_000_000 + gasBudget = vm.NewGasBudget(gasLimit, 0) + } else { + // SYSTEM_MAX_SSTORES_PER_CALL = 16 is the upper bound on the number of + // new storage slots a single system call is expected to write. + stateBudget := params.SystemMaxSStoresPerCall * evm.Context.CostPerStateByte * params.StorageCreationSize + gasLimit = 30_000_000 + gasBudget = vm.NewGasBudget(gasLimit, stateBudget) + } + return gasLimit, gasBudget +} + // ProcessBeaconBlockRoot applies the EIP-4788 system call to the beacon block root // contract. This method is exported to be used in tests. func ProcessBeaconBlockRoot(beaconRoot common.Hash, evm *vm.EVM, blockAccessList *bal.ConstructionBlockAccessList) { @@ -280,9 +295,10 @@ func ProcessBeaconBlockRoot(beaconRoot common.Hash, evm *vm.EVM, blockAccessList defer tracer.OnSystemCallEnd() } } + gasLimit, gasBudget := systemCallGasBudget(evm) msg := &Message{ From: params.SystemAddress, - GasLimit: 30_000_000, + GasLimit: gasLimit, GasPrice: uint256.NewInt(0), GasFeeCap: uint256.NewInt(0), GasTipCap: uint256.NewInt(0), @@ -293,7 +309,7 @@ func ProcessBeaconBlockRoot(beaconRoot common.Hash, evm *vm.EVM, blockAccessList evm.StateDB.Prepare(evm.GetRules(), common.Address{}, common.Address{}, nil, nil, nil) evm.StateDB.SetTxContext(common.Hash{}, 0, 0) evm.StateDB.AddAddressToAccessList(params.BeaconRootsAddress) - _, _, _ = evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000), common.U2560) + _, _, _ = evm.Call(msg.From, *msg.To, msg.Data, gasBudget, common.U2560) if evm.StateDB.AccessEvents() != nil { evm.StateDB.AccessEvents().Merge(evm.AccessEvents) } @@ -309,9 +325,10 @@ func ProcessParentBlockHash(prevHash common.Hash, evm *vm.EVM, blockAccessList * defer tracer.OnSystemCallEnd() } } + gasLimit, gasBudget := systemCallGasBudget(evm) msg := &Message{ From: params.SystemAddress, - GasLimit: 30_000_000, + GasLimit: gasLimit, GasPrice: uint256.NewInt(0), GasFeeCap: uint256.NewInt(0), GasTipCap: uint256.NewInt(0), @@ -322,7 +339,7 @@ func ProcessParentBlockHash(prevHash common.Hash, evm *vm.EVM, blockAccessList * evm.StateDB.Prepare(evm.GetRules(), common.Address{}, common.Address{}, nil, nil, nil) evm.StateDB.SetTxContext(common.Hash{}, 0, 0) evm.StateDB.AddAddressToAccessList(params.HistoryStorageAddress) - _, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000), common.U2560) + _, _, err := evm.Call(msg.From, *msg.To, msg.Data, gasBudget, common.U2560) if err != nil { panic(err) } @@ -351,9 +368,10 @@ func processRequestsSystemCall(requests *[][]byte, rules params.Rules, evm *vm.E defer tracer.OnSystemCallEnd() } } + gasLimit, gasBudget := systemCallGasBudget(evm) msg := &Message{ From: params.SystemAddress, - GasLimit: 30_000_000, + GasLimit: gasLimit, GasPrice: uint256.NewInt(0), GasFeeCap: uint256.NewInt(0), GasTipCap: uint256.NewInt(0), @@ -363,7 +381,7 @@ func processRequestsSystemCall(requests *[][]byte, rules params.Rules, evm *vm.E evm.StateDB.Prepare(rules, common.Address{}, common.Address{}, nil, nil, nil) evm.StateDB.SetTxContext(common.Hash{}, 0, blockAccessIndex) evm.StateDB.AddAddressToAccessList(addr) - ret, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000), common.U2560) + ret, _, err := evm.Call(msg.From, *msg.To, msg.Data, gasBudget, common.U2560) if evm.StateDB.AccessEvents() != nil { evm.StateDB.AccessEvents().Merge(evm.AccessEvents) } diff --git a/core/state_transition.go b/core/state_transition.go index 724b9963f8..dac8123530 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -68,13 +68,27 @@ func (result *ExecutionResult) Revert() []byte { } // IntrinsicGas computes the 'intrinsic gas' for a message with the given data. -func IntrinsicGas(data []byte, accessList types.AccessList, authList []types.SetCodeAuthorization, isContractCreation, isHomestead, isEIP2028, isEIP3860, isAmsterdam bool) (vm.GasCosts, error) { +func IntrinsicGas(data []byte, accessList types.AccessList, authList []types.SetCodeAuthorization, isContractCreation bool, rules params.Rules, costPerStateByte uint64) (vm.GasCosts, error) { // Set the starting gas for the raw transaction - var gas uint64 - if isContractCreation && isHomestead { - gas = params.TxGasContractCreation + var gas vm.GasCosts + if isContractCreation && rules.IsHomestead { + if rules.IsAmsterdam { + gas.RegularGas = params.TxGas + params.CreateGasAmsterdam + gas.StateGas = params.AccountCreationSize * costPerStateByte + } else { + gas.RegularGas = params.TxGasContractCreation + } } else { - gas = params.TxGas + gas.RegularGas = params.TxGas + } + // Add gas for authorizations + if authList != nil { + if rules.IsAmsterdam { + gas.RegularGas += uint64(len(authList)) * params.TxAuthTupleRegularGas + gas.StateGas += uint64(len(authList)) * (params.AuthorizationCreationSize + params.AccountCreationSize) * costPerStateByte + } else { + gas.RegularGas += uint64(len(authList)) * params.CallNewAccountGas + } } dataLen := uint64(len(data)) // Bump the required gas by the amount of transactional data @@ -85,59 +99,56 @@ func IntrinsicGas(data []byte, accessList types.AccessList, authList []types.Set // Make sure we don't exceed uint64 for all data combinations nonZeroGas := params.TxDataNonZeroGasFrontier - if isEIP2028 { + if rules.IsIstanbul { nonZeroGas = params.TxDataNonZeroGasEIP2028 } - if (math.MaxUint64-gas)/nonZeroGas < nz { + if (math.MaxUint64-gas.RegularGas)/nonZeroGas < nz { return vm.GasCosts{}, ErrGasUintOverflow } - gas += nz * nonZeroGas + gas.RegularGas += nz * nonZeroGas - if (math.MaxUint64-gas)/params.TxDataZeroGas < z { + if (math.MaxUint64-gas.RegularGas)/params.TxDataZeroGas < z { return vm.GasCosts{}, ErrGasUintOverflow } - gas += z * params.TxDataZeroGas + gas.RegularGas += z * params.TxDataZeroGas - if isContractCreation && isEIP3860 { + if isContractCreation && rules.IsShanghai { lenWords := toWordSize(dataLen) - if (math.MaxUint64-gas)/params.InitCodeWordGas < lenWords { + if (math.MaxUint64-gas.RegularGas)/params.InitCodeWordGas < lenWords { return vm.GasCosts{}, ErrGasUintOverflow } - gas += lenWords * params.InitCodeWordGas + gas.RegularGas += lenWords * params.InitCodeWordGas } } if accessList != nil { addresses := uint64(len(accessList)) storageKeys := uint64(accessList.StorageKeys()) - if (math.MaxUint64-gas)/params.TxAccessListAddressGas < addresses { + if (math.MaxUint64-gas.RegularGas)/params.TxAccessListAddressGas < addresses { return vm.GasCosts{}, ErrGasUintOverflow } - gas += addresses * params.TxAccessListAddressGas - if (math.MaxUint64-gas)/params.TxAccessListStorageKeyGas < storageKeys { + gas.RegularGas += addresses * params.TxAccessListAddressGas + if (math.MaxUint64-gas.RegularGas)/params.TxAccessListStorageKeyGas < storageKeys { return vm.GasCosts{}, ErrGasUintOverflow } - gas += storageKeys * params.TxAccessListStorageKeyGas + gas.RegularGas += storageKeys * params.TxAccessListStorageKeyGas // EIP-7981: access list data is charged in addition to the base charge. - if isAmsterdam { + if rules.IsAmsterdam { const ( addressCost = common.AddressLength * params.TxCostFloorPerToken7976 * params.TxTokenPerNonZeroByte storageKeyCost = common.HashLength * params.TxCostFloorPerToken7976 * params.TxTokenPerNonZeroByte ) - if (math.MaxUint64-gas)/addressCost < addresses { + if (math.MaxUint64-gas.RegularGas)/addressCost < addresses { return vm.GasCosts{}, ErrGasUintOverflow } - gas += addresses * addressCost - if (math.MaxUint64-gas)/storageKeyCost < storageKeys { + gas.RegularGas += addresses * addressCost + if (math.MaxUint64-gas.RegularGas)/storageKeyCost < storageKeys { return vm.GasCosts{}, ErrGasUintOverflow } - gas += storageKeys * storageKeyCost + gas.RegularGas += storageKeys * storageKeyCost } } - if authList != nil { - gas += uint64(len(authList)) * params.CallNewAccountGas - } - return vm.GasCosts{RegularGas: gas}, nil + return gas, nil } // FloorDataGas computes the minimum gas required for a transaction based on its data tokens (EIP-7623). @@ -338,12 +349,11 @@ func ApplyMessage(evm *vm.EVM, msg *Message, gp *GasPool) (*ExecutionResult, err // 5. Run Script section // 6. Derive new state root type stateTransition struct { - gp *GasPool - msg *Message - initialBudget vm.GasBudget - gasRemaining vm.GasBudget - state vm.StateDB - evm *vm.EVM + gp *GasPool + msg *Message + gasRemaining vm.GasBudget + state vm.StateDB + evm *vm.EVM } // newStateTransition initialises and returns a new state transition object. @@ -364,6 +374,24 @@ func (st *stateTransition) to() common.Address { return *st.msg.To } +// buyGas pre-pays gas from the sender's balance and initializes the +// transaction's gas budget. It is invoked at the tail of preCheck. +// +// The balance requirement is the worst-case ETH the tx may need to lock +// up: `msg.GasLimit × max(msg.GasPrice, msg.GasFeeCap) + msg.Value`, +// plus `blobGas × msg.BlobGasFeeCap` under Cancun. Insufficient balance +// returns ErrInsufficientFunds. After the check, the sender is actually +// debited `msg.GasLimit × msg.GasPrice` (plus `blobGas × blobBaseFee` +// under Cancun), the cap-vs-tip differential is settled at tx end. +// +// The gas budget is seeded into both `initialBudget` (frozen snapshot +// for tx-end accounting) and `gasRemaining` (live running balance): +// +// - Pre-Amsterdam: one-dimensional regular budget equal to +// `msg.GasLimit`; the state-gas reservoir is zero. +// - Amsterdam+ (EIP-8037): two-dimensional budget. Regular gas is +// capped at `MaxTxGas` (EIP-7825, 16_777_216); any excess from +// `msg.GasLimit` above that cap becomes the state-gas reservoir. func (st *stateTransition) buyGas() error { mgval := new(uint256.Int).SetUint64(st.msg.GasLimit) _, overflow := mgval.MulOverflow(mgval, st.msg.GasPrice) @@ -416,22 +444,53 @@ func (st *stateTransition) buyGas() error { if have, want := st.state.GetBalance(st.msg.From), balanceCheck; have.Cmp(want) < 0 { return fmt.Errorf("%w: address %v have %v want %v", ErrInsufficientFunds, st.msg.From.Hex(), have, want) } - if err := st.gp.SubGas(st.msg.GasLimit); err != nil { + isAmsterdam := st.evm.ChainConfig().IsAmsterdam(st.evm.Context.BlockNumber, st.evm.Context.Time) + + // Reserve the gas budget in the block gas pool + var err error + if isAmsterdam { + err = st.gp.CheckGasAmsterdam(min(st.msg.GasLimit, params.MaxTxGas), st.msg.GasLimit) + } else { + err = st.gp.CheckGasLegacy(st.msg.GasLimit) + } + if err != nil { return err } - if st.evm.Config.Tracer.HasGasHook() { - empty := vm.GasBudget{} - initial := vm.NewGasBudget(st.msg.GasLimit) - st.evm.Config.Tracer.EmitGasChange(empty.AsTracing(), initial.AsTracing(), tracing.GasChangeTxInitialBalance) + // After Amsterdam we limit the regular gas to 16M, the data gas to the transaction limit + limit := st.msg.GasLimit + if isAmsterdam { + limit = min(st.msg.GasLimit, params.MaxTxGas) } - st.gasRemaining = vm.NewGasBudget(st.msg.GasLimit) - st.initialBudget = st.gasRemaining.Copy() + st.gasRemaining = vm.NewGasBudget(limit, st.msg.GasLimit-limit) + if st.evm.Config.Tracer.HasGasHook() { + st.evm.Config.Tracer.EmitGasChange(tracing.Gas{}, st.gasRemaining.AsTracing(), tracing.GasChangeTxInitialBalance) + } + // Deduct the gas cost from the sender's balance st.state.SubBalance(st.msg.From, mgval, tracing.BalanceDecreaseGasBuy) return nil } +// preCheck performs all pre-execution validation that does not require +// the EVM to run, then ends by calling buyGas to lock in the gas budget. +// It returns a consensus error if any of the following fail: +// +// - Sender nonce matches state and is not at 2^64-1 (EIP-2681). +// - EIP-7825 per-tx gas-limit cap on Osaka chains pre-Amsterdam +// (the cap also bounds the regular dimension after Amsterdam, but +// it is enforced there via the two-dimensional budget in buyGas). +// - EIP-3607 sender-is-EOA, allowing accounts whose only code is an +// EIP-7702 delegation designator. +// - EIP-1559 fee-cap, tip-cap and base-fee constraints (London+). +// - Blob-tx structural checks: non-nil `To`, non-empty hash list, +// valid KZG versioned hashes, count below `BlobTxMaxBlobs` (Osaka+). +// - Blob fee-cap not below the current blob base fee (Cancun+). +// - EIP-7702 set-code-tx shape: non-nil `To` and non-empty +// authorization list. +// +// The SkipNonceChecks / SkipTransactionChecks / NoBaseFee flags bypass +// subsets of these checks for simulation paths (eth_call, eth_estimateGas). func (st *stateTransition) preCheck() error { // Only check transactions that are not fake msg := st.msg @@ -449,11 +508,13 @@ func (st *stateTransition) preCheck() error { msg.From.Hex(), stNonce) } } - isOsaka := st.evm.ChainConfig().IsOsaka(st.evm.Context.BlockNumber, st.evm.Context.Time) - isAmsterdam := st.evm.ChainConfig().IsAmsterdam(st.evm.Context.BlockNumber, st.evm.Context.Time) + var ( + isOsaka = st.evm.ChainConfig().IsOsaka(st.evm.Context.BlockNumber, st.evm.Context.Time) + isAmsterdam = st.evm.ChainConfig().IsAmsterdam(st.evm.Context.BlockNumber, st.evm.Context.Time) + ) if !msg.SkipTransactionChecks { // Verify tx gas limit does not exceed EIP-7825 cap. - if isOsaka && !isAmsterdam && msg.GasLimit > params.MaxTxGas { + if !isAmsterdam && isOsaka && msg.GasLimit > params.MaxTxGas { return fmt.Errorf("%w (cap: %d, tx: %d)", ErrGasLimitTooHigh, params.MaxTxGas, msg.GasLimit) } // Make sure the sender is an EOA @@ -527,40 +588,33 @@ func (st *stateTransition) preCheck() error { return st.buyGas() } -// execute will transition the state by applying the current message and -// returning the evm execution result with following fields. +// execute transitions the state by applying the current message and +// returns the EVM execution result with the following fields: // -// - used gas: total gas used (including gas being refunded) -// - returndata: the returned data from evm -// - concrete execution error: various EVM errors which abort the execution, e.g. -// ErrOutOfGas, ErrExecutionReverted +// - used gas: total gas used, including gas refunded +// - peak used gas: maximum gas used before applying refunds +// - returndata: data returned by the EVM +// - execution error: EVM-level errors that abort execution, such as +// ErrOutOfGas or ErrExecutionReverted // -// However if any consensus issue encountered, return the error directly with -// nil evm execution result. +// If a consensus error is encountered, it is returned directly with a +// nil EVM execution result. func (st *stateTransition) execute() (*ExecutionResult, error) { - // First check this message satisfies all consensus rules before - // applying the message. The rules include these clauses - // - // 1. the nonce of the message caller is correct - // 2. caller has enough balance to cover transaction fee(gaslimit * gasprice) - // 3. the amount of gas required is available in the block - // 4. the purchased gas is enough to cover intrinsic usage - // 5. there is no overflow when calculating intrinsic gas - // 6. caller has enough balance to cover asset transfer for **topmost** call - - // Check clauses 1-3, buy gas if everything is correct + // Validate the message and pre-pay gas. if err := st.preCheck(); err != nil { return nil, err } + // Charge intrinsic gas (with overflow detection inside IntrinsicGas). + // Under Amsterdam the cost is two-dimensional and Charge debits both + // regular and state in one step. var ( msg = st.msg rules = st.evm.ChainConfig().Rules(st.evm.Context.BlockNumber, st.evm.Context.Random != nil, st.evm.Context.Time) contractCreation = msg.To == nil floorDataGas uint64 ) - // Check clauses 4-5, subtract intrinsic gas if everything is correct - cost, err := IntrinsicGas(msg.Data, msg.AccessList, msg.SetCodeAuthorizations, contractCreation, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai, rules.IsAmsterdam) + cost, err := IntrinsicGas(msg.Data, msg.AccessList, msg.SetCodeAuthorizations, contractCreation, rules, st.evm.Context.CostPerStateByte) if err != nil { return nil, err } @@ -571,15 +625,24 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { if st.evm.Config.Tracer.HasGasHook() { st.evm.Config.Tracer.EmitGasChange(prior.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxIntrinsicGas) } - // Gas limit suffices for the floor data cost (EIP-7623) + + // Validate the EIP-7623 calldata floor against the gas limit. The floor inflates + // the total gas usage at tx end, so the gas limit must be sufficient to cover that. if rules.IsPrague { floorDataGas, err = FloorDataGas(rules, msg.Data, msg.AccessList) if err != nil { return nil, err } + // Make sure the transaction has sufficient gas allowance to + // pay the floor cost. if msg.GasLimit < floorDataGas { return nil, fmt.Errorf("%w: have %d, want %d", ErrFloorDataGas, msg.GasLimit, floorDataGas) } + // In Amsterdam, the transaction gas limit is allowed to exceed + // params.MaxTxGas, but the calldata floor cost is capped by it. + if rules.IsAmsterdam && max(cost.RegularGas, floorDataGas) > params.MaxTxGas { + return nil, fmt.Errorf("%w: regular intrisic cost %v, floor: %v", ErrFloorDataGas, cost.RegularGas, floorDataGas) + } } if rules.IsEIP4762 { @@ -590,7 +653,8 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { } } - // Check clause 6 + // Top-call affordability, the sender must still be able to cover the value + // transfer of the top frame after gas pre-pay. value := msg.Value if value == nil { value = new(uint256.Int) @@ -599,36 +663,43 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { return nil, fmt.Errorf("%w: address %v", ErrInsufficientFundsForTransfer, msg.From.Hex()) } - // Check whether the init code size has been exceeded. - if contractCreation { - if err := vm.CheckMaxInitCodeSize(&rules, uint64(len(msg.Data))); err != nil { - return nil, err - } - } - // Execute the preparatory steps for state transition which includes: // - prepare accessList(post-berlin) // - reset transient storage(EIP-1153) // - enable block-level accessList construction (EIP-7928) st.state.Prepare(rules, msg.From, st.evm.Context.Coinbase, msg.To, vm.ActivePrecompiles(rules), msg.AccessList) + // Execute the top-most frame var ( - ret []byte - vmerr error // vm errors do not effect consensus and are therefore not assigned to err + ret []byte + vmerr error // vm errors do not effect consensus and are therefore not assigned to err + result vm.GasBudget + + // Capture the forwarded regular-gas amount BEFORE ForwardAll consumes + // it, so Absorb can back out state-gas spillover from UsedRegularGas + // per EIP-8037. + forwarded = st.gasRemaining.RegularGas ) if contractCreation { - ret, _, st.gasRemaining, vmerr = st.evm.Create(msg.From, msg.Data, st.gasRemaining, value) + // Check whether the init code size has been exceeded. + if err := vm.CheckMaxInitCodeSize(&rules, uint64(len(msg.Data))); err != nil { + return nil, err + } + // Execute the transaction's creation. + ret, _, result, vmerr = st.evm.Create(msg.From, msg.Data, st.gasRemaining.ForwardAll(), value) + st.gasRemaining.Absorb(result, forwarded) + + // If the contract creation failed, refund the account-creation state + // gas pre-charged in IntrinsicGas. + if rules.IsAmsterdam && vmerr != nil { + st.gasRemaining.RefundState(params.AccountCreationSize * st.evm.Context.CostPerStateByte) + } } else { // Increment the nonce for the next transaction. st.state.SetNonce(msg.From, st.state.GetNonce(msg.From)+1, tracing.NonceChangeEoACall) // Apply EIP-7702 authorizations. - if msg.SetCodeAuthorizations != nil { - for _, auth := range msg.SetCodeAuthorizations { - // Note errors are ignored, we simply skip invalid authorizations here. - st.applyAuthorization(&auth) - } - } + st.applyAuthorizations(rules, msg.SetCodeAuthorizations) // Perform convenience warming of sender's delegation target. Although the // sender is already warmed in Prepare(..), it's possible a delegation to @@ -638,44 +709,18 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { if addr, ok := types.ParseDelegation(st.state.GetCode(*msg.To)); ok { st.state.AddAddressToAccessList(addr) } - // Execute the transaction's call. - ret, st.gasRemaining, vmerr = st.evm.Call(msg.From, st.to(), msg.Data, st.gasRemaining, value) + ret, result, vmerr = st.evm.Call(msg.From, st.to(), msg.Data, st.gasRemaining.ForwardAll(), value) + st.gasRemaining.Absorb(result, forwarded) } - // Record the gas used excluding gas refunds. This value represents the actual - // gas allowance required to complete execution. - peakGasUsed := st.gasUsed() - - // Compute refund counter, capped to a refund quotient. - st.gasRemaining.Refund(st.calcRefund()) - - if rules.IsPrague { - // After EIP-7623: Data-heavy transactions pay the floor gas. - if used := st.gasUsed(); used < floorDataGas { - prior, _ := st.gasRemaining.Charge(vm.GasCosts{RegularGas: floorDataGas - used}) - if st.evm.Config.Tracer.HasGasHook() { - st.evm.Config.Tracer.EmitGasChange(prior.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxDataFloor) - } - } - if peakGasUsed < floorDataGas { - peakGasUsed = floorDataGas - } - } - // Return gas to the user - st.returnGas() - - // Return gas to the gas pool - if rules.IsAmsterdam { - // Refund is excluded for returning - err = st.gp.ReturnGas(st.initialBudget.RegularGas-peakGasUsed, st.gasUsed()) - } else { - // Refund is included for returning - err = st.gp.ReturnGas(st.gasRemaining.RegularGas, st.gasUsed()) - } + // Settle down the gas usage and refund the ETH back if any remaining + gasUsed, peakUsed, err := st.settleGas(rules, floorDataGas) if err != nil { return nil, err } + + // Pay the effective transaction fee to the specific coinbase effectiveTip := msg.GasPrice if rules.IsLondon { baseFee, overflow := uint256.FromBig(st.evm.Context.BaseFee) @@ -684,13 +729,12 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { } effectiveTip = new(uint256.Int).Sub(msg.GasPrice, baseFee) } - if st.evm.Config.NoBaseFee && msg.GasFeeCap.Sign() == 0 && msg.GasTipCap.Sign() == 0 { // Skip fee payment when NoBaseFee is set and the fee fields // are 0. This avoids a negative effectiveTip being applied to // the coinbase when simulating calls. } else { - fee := new(uint256.Int).SetUint64(st.gasUsed()) + fee := new(uint256.Int).SetUint64(gasUsed) fee.Mul(fee, effectiveTip) st.state.AddBalance(st.evm.Context.Coinbase, fee, tracing.BalanceIncreaseRewardTransactionFee) @@ -699,19 +743,105 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { st.evm.AccessEvents.AddAccount(st.evm.Context.Coinbase, true, math.MaxUint64) } } + + // EIP-7708: Emit the ETH-burn logs if rules.IsAmsterdam { for _, log := range st.evm.StateDB.LogsForBurnAccounts() { st.evm.StateDB.AddLog(log) } } return &ExecutionResult{ - UsedGas: st.gasUsed(), - MaxUsedGas: peakGasUsed, + UsedGas: gasUsed, + MaxUsedGas: peakUsed, Err: vmerr, ReturnData: ret, }, nil } +// settleGas finalizes the per-tx gas accounting after EVM execution: +// +// - Snapshots the EIP-8037 block-level 2D figures (tx_regular_gas, +// tx_state_gas) before any refund or floor: +// +// tx_gas_used_before_refund = tx.gas - gas_left - state_gas_reservoir +// tx_state_gas = state_gas_used +// tx_regular_gas = tx_gas_used_before_refund - tx_state_gas +// +// - Computes the receipt scalar tx_gas_used by applying the EIP-3529 +// refund (capped at tx_gas_used_before_refund/5) and the EIP-7623 +// calldata floor: +// +// tx_gas_used = max(tx_gas_used_before_refund - tx_gas_refund, calldata_floor) +// +// - Charges the block gas pool (2D under Amsterdam, scalar pre-Amsterdam). +// +// - Refunds the leftover gas to the sender as ETH. +// +// Returns the receipt-level tx_gas_used and the pre-refund peak (consumed +// by gas-estimation callers via ExecutionResult.MaxUsedGas). UsedStateGas +// should never become negative in the top-most frame, since state-gas +// refunds occur only when state creation is reverted within the same +// transaction and clearing pre-existing state is never refunded. +func (st *stateTransition) settleGas(rules params.Rules, floorDataGas uint64) (gasUsed, peakUsed uint64, err error) { + if st.gasRemaining.UsedStateGas < 0 { + return 0, 0, fmt.Errorf("negative topmost frame state gas usage, %d", st.gasRemaining.UsedStateGas) + } + txStateGas := uint64(st.gasRemaining.UsedStateGas) + + // EIP-8037: + // tx_gas_used_before_refund = tx.gas - tx_output.gas_left - tx_output.state_gas_reservoir + // tx_state_gas = intrinsic_state_gas + tx_output.execution_state_gas_used + // tx_regular_gas = tx_gas_used_before_refund - tx_state_gas + gasLeft := st.gasRemaining.RegularGas + st.gasRemaining.StateGas + gasUsedBeforeRefund := st.msg.GasLimit - gasLeft + + if gasUsedBeforeRefund < txStateGas { + return 0, 0, fmt.Errorf("negative topmost frame regular gas usage, total: %d, state: %d", gasUsedBeforeRefund, txStateGas) + } + txRegularGas := gasUsedBeforeRefund - txStateGas + + // EIP-3529: tx_gas_refund = min(tx_gas_used_before_refund/5, refund_counter). + refund := st.calcRefund(gasUsedBeforeRefund) + if st.evm.Config.Tracer.HasGasHook() { + st.evm.Config.Tracer.EmitGasChange(tracing.Gas{Regular: gasLeft}, tracing.Gas{Regular: gasLeft + refund}, tracing.GasChangeTxRefunds) + } + gasLeft += refund + gasUsed = gasUsedBeforeRefund - refund + + // EIP-7623: tx_gas_used = max(tx_gas_used_after_refund, calldata_floor). + peakUsed = gasUsedBeforeRefund + if rules.IsPrague && gasUsed < floorDataGas { + diff := floorDataGas - gasUsed + if st.evm.Config.Tracer.HasGasHook() { + st.evm.Config.Tracer.EmitGasChange(tracing.Gas{Regular: gasLeft}, tracing.Gas{Regular: gasLeft - diff}, tracing.GasChangeTxDataFloor) + } + gasLeft -= diff + gasUsed = floorDataGas + peakUsed = max(peakUsed, floorDataGas) + } + + if rules.IsAmsterdam { + if err = st.gp.ChargeGasAmsterdam(txRegularGas, txStateGas, gasUsed); err != nil { + return 0, 0, err + } + } else { + if err = st.gp.ChargeGasLegacy(gasLeft, gasUsed); err != nil { + return 0, 0, err + } + } + + // Refund leftover gas to the sender as ETH. + if gasLeft > 0 { + refund := new(uint256.Int).Mul(uint256.NewInt(gasLeft), st.msg.GasPrice) + st.state.AddBalance(st.msg.From, refund, tracing.BalanceIncreaseGasReturn) + + if st.evm.Config.Tracer.HasGasHook() { + st.evm.Config.Tracer.EmitGasChange(tracing.Gas{Regular: gasLeft}, tracing.Gas{}, tracing.GasChangeTxLeftOverReturned) + } + } + return gasUsed, peakUsed, nil +} + // validateAuthorization validates an EIP-7702 authorization against the state. func (st *stateTransition) validateAuthorization(auth *types.SetCodeAuthorization) (authority common.Address, err error) { // Verify chain ID is null or equal to current chain ID. @@ -743,72 +873,93 @@ func (st *stateTransition) validateAuthorization(auth *types.SetCodeAuthorizatio return authority, nil } -// applyAuthorization applies an EIP-7702 code delegation to the state. -func (st *stateTransition) applyAuthorization(auth *types.SetCodeAuthorization) error { +// applyAuthorization applies an EIP-7702 code delegation to the state and, +// under EIP-8037, reconciles the per-authorization intrinsic state-gas pre- +// charge so that, per authority: +// +// - the account portion (AccountCreationSize × CPSB) is charged at most +// once, and only when the account did not exist before the tx +// +// - the delegation-indicator portion (AuthorizationCreationSize × CPSB) is +// charged at most once, and only when the authority ends the tx delegated +// having started it undelegated. +func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.SetCodeAuthorization, delegates map[common.Address]bool) error { authority, err := st.validateAuthorization(auth) if err != nil { + if rules.IsAmsterdam { + st.gasRemaining.RefundState((params.AccountCreationSize + params.AuthorizationCreationSize) * st.evm.Context.CostPerStateByte) + } return err } + prevDelegation, curDelegated := types.ParseDelegation(st.state.GetCode(authority)) - // If the account already exists in state, refund the new account cost - // charged in the intrinsic calculation. - if st.state.Exist(authority) { - st.state.AddRefund(params.CallNewAccountGas - params.TxAuthTupleGas) + if !rules.IsAmsterdam { + if st.state.Exist(authority) { + st.state.AddRefund(params.CallNewAccountGas - params.TxAuthTupleGas) + } + } else { + if st.state.Exist(authority) { + st.gasRemaining.RefundState(params.AccountCreationSize * st.evm.Context.CostPerStateByte) + } + authBase := params.AuthorizationCreationSize * st.evm.Context.CostPerStateByte + + preDelegated, ok := delegates[authority] + if !ok { + preDelegated = curDelegated + delegates[authority] = preDelegated + } + if auth.Address == (common.Address{}) { + // Clearing writes no indicator, refill this auth's state charge. + st.gasRemaining.RefundState(authBase) + + // The indicator was created by an earlier auth within the same + // transaction, refill the state charge as it's no longer justified. + if curDelegated && !preDelegated { + st.gasRemaining.RefundState(authBase) + } + } else if curDelegated || preDelegated { + // The 23-byte slot is already occupied, overwriting it writes no + // new bytes, refill the state charge. + st.gasRemaining.RefundState(authBase) + } } // Update nonce and account code. st.state.SetNonce(authority, auth.Nonce+1, tracing.NonceChangeAuthorization) + + // Delegation to zero address means clear. if auth.Address == (common.Address{}) { - // Delegation to zero address means clear. - st.state.SetCode(authority, nil, tracing.CodeChangeAuthorizationClear) + if curDelegated { + st.state.SetCode(authority, nil, tracing.CodeChangeAuthorizationClear) + } return nil } - - // Otherwise install delegation to auth.Address. - st.state.SetCode(authority, types.AddressToDelegation(auth.Address), tracing.CodeChangeAuthorization) - + // Install delegation to auth.Address if the delegation changed + if !curDelegated || auth.Address != prevDelegation { + st.state.SetCode(authority, types.AddressToDelegation(auth.Address), tracing.CodeChangeAuthorization) + } return nil } -// calcRefund computes refund counter, capped to a refund quotient. -func (st *stateTransition) calcRefund() vm.GasBudget { - var refund uint64 - if !st.evm.ChainConfig().IsLondon(st.evm.Context.BlockNumber) { - // Before EIP-3529: refunds were capped to gasUsed / 2 - refund = st.gasUsed() / params.RefundQuotient - } else { - // After EIP-3529: refunds are capped to gasUsed / 5 - refund = st.gasUsed() / params.RefundQuotientEIP3529 +// applyAuthorizations applies an EIP-7702 code delegation to the state. +func (st *stateTransition) applyAuthorizations(rules params.Rules, auths []types.SetCodeAuthorization) { + preDelegated := make(map[common.Address]bool) + for _, auth := range auths { + st.applyAuthorization(rules, &auth, preDelegated) } +} + +// calcRefund computes the EIP-3529 refund cap against tx_gas_used_before_refund. +func (st *stateTransition) calcRefund(gasUsedBeforeRefund uint64) uint64 { + quotient := params.RefundQuotient + if st.evm.ChainConfig().IsLondon(st.evm.Context.BlockNumber) { + quotient = params.RefundQuotientEIP3529 + } + refund := gasUsedBeforeRefund / quotient if refund > st.state.GetRefund() { refund = st.state.GetRefund() } - if refund > 0 && st.evm.Config.Tracer.HasGasHook() { - after := st.gasRemaining - after.RegularGas += refund - - st.evm.Config.Tracer.EmitGasChange(st.gasRemaining.AsTracing(), after.AsTracing(), tracing.GasChangeTxRefunds) - } - return vm.NewGasBudget(refund) -} - -// returnGas returns ETH for remaining gas, -// exchanged at the original rate. -func (st *stateTransition) returnGas() { - remaining := uint256.NewInt(st.gasRemaining.RegularGas) - remaining.Mul(remaining, st.msg.GasPrice) - st.state.AddBalance(st.msg.From, remaining, tracing.BalanceIncreaseGasReturn) - - if st.gasRemaining.RegularGas > 0 && st.evm.Config.Tracer.HasGasHook() { - after := st.gasRemaining - after.RegularGas = 0 - st.evm.Config.Tracer.EmitGasChange(st.gasRemaining.AsTracing(), after.AsTracing(), tracing.GasChangeTxLeftOverReturned) - } -} - -// gasUsed returns the amount of gas used up by the state transition. -func (st *stateTransition) gasUsed() uint64 { - return st.gasRemaining.Used(st.initialBudget) + return refund } // blobGasUsed returns the amount of blob gas used by the message. diff --git a/core/state_transition_test.go b/core/state_transition_test.go index 8aab016123..be2de7f511 100644 --- a/core/state_transition_test.go +++ b/core/state_transition_test.go @@ -155,50 +155,50 @@ func TestIntrinsicGas(t *testing.T) { isEIP2028 bool isEIP3860 bool isAmsterdam bool - want uint64 + want vm.GasCosts }{ { name: "frontier/empty-call", - want: params.TxGas, + want: vm.GasCosts{RegularGas: params.TxGas}, }, { name: "frontier/contract-creation-pre-homestead", creation: true, isHomestead: false, // pre-homestead, contract creation still uses TxGas - want: params.TxGas, + want: vm.GasCosts{RegularGas: params.TxGas}, }, { name: "homestead/contract-creation", creation: true, isHomestead: true, - want: params.TxGasContractCreation, + want: vm.GasCosts{RegularGas: params.TxGasContractCreation}, }, { name: "frontier/non-zero-data", data: bytes.Repeat([]byte{0xff}, 100), // 100 nz bytes * 68 (frontier) - want: params.TxGas + 100*params.TxDataNonZeroGasFrontier, + want: vm.GasCosts{RegularGas: params.TxGas + 100*params.TxDataNonZeroGasFrontier}, }, { name: "istanbul/non-zero-data", data: bytes.Repeat([]byte{0xff}, 100), isEIP2028: true, // 100 nz bytes * 16 (post-EIP2028) - want: params.TxGas + 100*params.TxDataNonZeroGasEIP2028, + want: vm.GasCosts{RegularGas: params.TxGas + 100*params.TxDataNonZeroGasEIP2028}, }, { name: "istanbul/zero-data", data: bytes.Repeat([]byte{0x00}, 100), isEIP2028: true, // 100 zero bytes * 4 - want: params.TxGas + 100*params.TxDataZeroGas, + want: vm.GasCosts{RegularGas: params.TxGas + 100*params.TxDataZeroGas}, }, { name: "istanbul/mixed-data", data: append(bytes.Repeat([]byte{0x00}, 50), bytes.Repeat([]byte{0xff}, 50)...), isEIP2028: true, - want: params.TxGas + 50*params.TxDataZeroGas + 50*params.TxDataNonZeroGasEIP2028, + want: vm.GasCosts{RegularGas: params.TxGas + 50*params.TxDataZeroGas + 50*params.TxDataNonZeroGasEIP2028}, }, { name: "shanghai/init-code-word-gas", @@ -208,7 +208,7 @@ func TestIntrinsicGas(t *testing.T) { isEIP2028: true, isEIP3860: true, // TxGasContractCreation + 64 zero bytes * 4 + 2 words * 2 - want: params.TxGasContractCreation + 64*params.TxDataZeroGas + 2*params.InitCodeWordGas, + want: vm.GasCosts{RegularGas: params.TxGasContractCreation + 64*params.TxDataZeroGas + 2*params.InitCodeWordGas}, }, { name: "shanghai/init-code-non-multiple-of-32", @@ -217,7 +217,7 @@ func TestIntrinsicGas(t *testing.T) { isHomestead: true, isEIP2028: true, isEIP3860: true, - want: params.TxGasContractCreation + 33*params.TxDataZeroGas + 2*params.InitCodeWordGas, + want: vm.GasCosts{RegularGas: params.TxGasContractCreation + 33*params.TxDataZeroGas + 2*params.InitCodeWordGas}, }, { name: "berlin/access-list", @@ -227,7 +227,7 @@ func TestIntrinsicGas(t *testing.T) { }, isEIP2028: true, // 2 addrs * 2400 + 3 keys * 1900 - want: params.TxGas + 2*params.TxAccessListAddressGas + 3*params.TxAccessListStorageKeyGas, + want: vm.GasCosts{RegularGas: params.TxGas + 2*params.TxAccessListAddressGas + 3*params.TxAccessListStorageKeyGas}, }, { name: "amsterdam/access-list-extra-cost", @@ -238,9 +238,9 @@ func TestIntrinsicGas(t *testing.T) { isEIP2028: true, isAmsterdam: true, // base access-list charge + EIP-7981 extra - want: params.TxGas + + want: vm.GasCosts{RegularGas: params.TxGas + 2*params.TxAccessListAddressGas + 3*params.TxAccessListStorageKeyGas + - 2*amsterdamAddressCost + 3*amsterdamStorageKeyCost, + 2*amsterdamAddressCost + 3*amsterdamStorageKeyCost}, }, { name: "prague/auth-list", @@ -250,8 +250,54 @@ func TestIntrinsicGas(t *testing.T) { {Address: addr1}, }, isEIP2028: true, - // 3 auths * 25000 - want: params.TxGas + 3*params.CallNewAccountGas, + // 3 auths * 25000 (pre-Amsterdam: CallNewAccountGas per auth tuple) + want: vm.GasCosts{RegularGas: params.TxGas + 3*params.CallNewAccountGas}, + }, + { + name: "amsterdam/contract-creation-empty", + creation: true, + isHomestead: true, + isEIP2028: true, + isAmsterdam: true, + // EIP-8037: creation regular gas is TxGas + CreateGasAmsterdam (not TxGasContractCreation), + // and account-creation cost is moved to state gas. + want: vm.GasCosts{ + RegularGas: params.TxGas + params.CreateGasAmsterdam, + StateGas: params.AccountCreationSize * params.CostPerStateByte, + }, + }, + { + name: "amsterdam/contract-creation-init-code", + data: bytes.Repeat([]byte{0x00}, 64), // 2 words of init code + creation: true, + isHomestead: true, + isEIP2028: true, + isEIP3860: true, // Shanghai gates init-code word gas + isAmsterdam: true, + want: vm.GasCosts{ + RegularGas: params.TxGas + params.CreateGasAmsterdam + + 64*params.TxDataZeroGas + 2*params.InitCodeWordGas, + StateGas: params.AccountCreationSize * params.CostPerStateByte, + }, + }, + { + name: "amsterdam/contract-creation-with-access-list", + data: bytes.Repeat([]byte{0xff}, 32), // 1 word of non-zero init code + accessList: types.AccessList{ + {Address: addr1, StorageKeys: []common.Hash{key1}}, + }, + creation: true, + isHomestead: true, + isEIP2028: true, + isEIP3860: true, + isAmsterdam: true, + want: vm.GasCosts{ + RegularGas: params.TxGas + params.CreateGasAmsterdam + + 32*params.TxDataNonZeroGasEIP2028 + 1*params.InitCodeWordGas + + 1*params.TxAccessListAddressGas + 1*params.TxAccessListStorageKeyGas + + 1*amsterdamAddressCost + 1*amsterdamStorageKeyCost, + StateGas: params.AccountCreationSize * params.CostPerStateByte, + }, }, { name: "amsterdam/combined", @@ -264,23 +310,34 @@ func TestIntrinsicGas(t *testing.T) { }, isEIP2028: true, isAmsterdam: true, - want: params.TxGas + - 100*params.TxDataNonZeroGasEIP2028 + - 1*params.TxAccessListAddressGas + 1*params.TxAccessListStorageKeyGas + - 1*amsterdamAddressCost + 1*amsterdamStorageKeyCost + - 1*params.CallNewAccountGas, + // EIP-8037 splits the auth-tuple charge into regular + state gas: + // regular: TxAuthTupleRegularGas (7500) per auth + // state: (AuthorizationCreationSize + AccountCreationSize) * CostPerStateByte per auth + want: vm.GasCosts{ + RegularGas: params.TxGas + + 100*params.TxDataNonZeroGasEIP2028 + + 1*params.TxAccessListAddressGas + 1*params.TxAccessListStorageKeyGas + + 1*amsterdamAddressCost + 1*amsterdamStorageKeyCost + + 1*params.TxAuthTupleRegularGas, + StateGas: 1 * (params.AuthorizationCreationSize + params.AccountCreationSize) * params.CostPerStateByte, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + rules := params.Rules{ + IsHomestead: tt.isHomestead, + IsIstanbul: tt.isEIP2028, + IsShanghai: tt.isEIP3860, + IsAmsterdam: tt.isAmsterdam, + } got, err := IntrinsicGas(tt.data, tt.accessList, tt.authList, - tt.creation, tt.isHomestead, tt.isEIP2028, tt.isEIP3860, tt.isAmsterdam) + tt.creation, rules, params.CostPerStateByte) if err != nil { t.Fatalf("unexpected error: %v", err) } - want := vm.GasCosts{RegularGas: tt.want} - if got != want { - t.Fatalf("gas mismatch: got %+v, want %+v", got, want) + if got != tt.want { + t.Fatalf("gas mismatch: got %+v, want %+v", got, tt.want) } }) } diff --git a/core/tracing/gen_gas_change_reason_stringer.go b/core/tracing/gen_gas_change_reason_stringer.go index e64be781a6..3d3aa96fad 100644 --- a/core/tracing/gen_gas_change_reason_stringer.go +++ b/core/tracing/gen_gas_change_reason_stringer.go @@ -28,21 +28,22 @@ func _() { _ = x[GasChangeWitnessCodeChunk-17] _ = x[GasChangeWitnessContractCollisionCheck-18] _ = x[GasChangeTxDataFloor-19] + _ = x[GasChangeAccountCreation-20] _ = x[GasChangeIgnored-255] } const ( - _GasChangeReason_name_0 = "UnspecifiedTxInitialBalanceTxIntrinsicGasTxRefundsTxLeftOverReturnedCallInitialBalanceCallLeftOverReturnedCallLeftOverRefundedCallContractCreationCallContractCreation2CallCodeStorageCallOpCodeCallPrecompiledContractCallStorageColdAccessCallFailedExecutionWitnessContractInitWitnessContractCreationWitnessCodeChunkWitnessContractCollisionCheckTxDataFloor" + _GasChangeReason_name_0 = "UnspecifiedTxInitialBalanceTxIntrinsicGasTxRefundsTxLeftOverReturnedCallInitialBalanceCallLeftOverReturnedCallLeftOverRefundedCallContractCreationCallContractCreation2CallCodeStorageCallOpCodeCallPrecompiledContractCallStorageColdAccessCallFailedExecutionWitnessContractInitWitnessContractCreationWitnessCodeChunkWitnessContractCollisionCheckTxDataFloorAccountCreation" _GasChangeReason_name_1 = "Ignored" ) var ( - _GasChangeReason_index_0 = [...]uint16{0, 11, 27, 41, 50, 68, 86, 106, 126, 146, 167, 182, 192, 215, 236, 255, 274, 297, 313, 342, 353} + _GasChangeReason_index_0 = [...]uint16{0, 11, 27, 41, 50, 68, 86, 106, 126, 146, 167, 182, 192, 215, 236, 255, 274, 297, 313, 342, 353, 368} ) func (i GasChangeReason) String() string { switch { - case i <= 19: + case i <= 20: return _GasChangeReason_name_0[_GasChangeReason_index_0[i]:_GasChangeReason_index_0[i+1]] case i == 255: return _GasChangeReason_name_1 diff --git a/core/tracing/hooks.go b/core/tracing/hooks.go index 6ea3f7ebbf..78b85fafea 100644 --- a/core/tracing/hooks.go +++ b/core/tracing/hooks.go @@ -472,6 +472,10 @@ const ( // transaction data. This change will always be a negative change. GasChangeTxDataFloor GasChangeReason = 19 + // GasChangeAccountCreation represents the state gas charging for account + // creation inside the call/create frame. + GasChangeAccountCreation GasChangeReason = 20 + // GasChangeIgnored is a special value that can be used to indicate that the gas change should be ignored as // it will be "manually" tracked by a direct emit of the gas change event. GasChangeIgnored GasChangeReason = 0xFF diff --git a/core/txpool/validation.go b/core/txpool/validation.go index c87bba31ac..3b30dd30ef 100644 --- a/core/txpool/validation.go +++ b/core/txpool/validation.go @@ -125,7 +125,7 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types } // Ensure the transaction has more gas than the bare minimum needed to cover // the transaction metadata - intrGas, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, true, rules.IsIstanbul, rules.IsShanghai, rules.IsAmsterdam) + intrGas, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, params.CostPerStateByte) if err != nil { return err } @@ -138,9 +138,16 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types if err != nil { return err } + // Make sure the transaction has sufficient gas allowance to + // pay the floor cost. if tx.Gas() < floorDataGas { return fmt.Errorf("%w: gas %v, minimum needed %v", core.ErrFloorDataGas, tx.Gas(), floorDataGas) } + // In Amsterdam, the transaction gas limit is allowed to exceed + // params.MaxTxGas, but the calldata floor cost is capped by it. + if rules.IsAmsterdam && max(intrGas.RegularGas, floorDataGas) > params.MaxTxGas { + return fmt.Errorf("%w: regular intrisic cost %v, floor: %v", core.ErrFloorDataGas, intrGas.RegularGas, floorDataGas) + } } // Ensure the gasprice is high enough to cover the requirement of the calling pool if tx.GasTipCapIntCmp(opts.MinTip) < 0 { diff --git a/core/vm/common.go b/core/vm/common.go index 2d631f8a55..5059b4af37 100644 --- a/core/vm/common.go +++ b/core/vm/common.go @@ -112,6 +112,5 @@ func toWordSize(size uint64) uint64 { if size > math.MaxUint64-31 { return math.MaxUint64/32 + 1 } - return (size + 31) / 32 } diff --git a/core/vm/contract.go b/core/vm/contract.go index 45c879c80f..9155e9f84a 100644 --- a/core/vm/contract.go +++ b/core/vm/contract.go @@ -42,6 +42,8 @@ type Contract struct { IsDeployment bool IsSystemCall bool + // Gas carries the unified gas state for this frame: running balance, + // reservoir, and per-frame usage accumulators. See GasBudget. Gas GasBudget value *uint256.Int } @@ -113,7 +115,6 @@ func (c *Contract) GetOp(n uint64) OpCode { if n < uint64(len(c.Code)) { return OpCode(c.Code[n]) } - return STOP } @@ -125,9 +126,10 @@ func (c *Contract) Caller() common.Address { return c.caller } -// UseGas attempts the use gas and subtracts it and returns true on success -func (c *Contract) UseGas(cost GasCosts, logger *tracing.Hooks, reason tracing.GasChangeReason) (ok bool) { - prior, ok := c.Gas.Charge(cost) +// chargeRegular deducts regular gas only, with tracer integration. +// Returns false on OOG. Delegates the arithmetic to GasBudget.ChargeRegular. +func (c *Contract) chargeRegular(r uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) bool { + prior, ok := c.Gas.ChargeRegular(r) if !ok { return false } @@ -137,15 +139,44 @@ func (c *Contract) UseGas(cost GasCosts, logger *tracing.Hooks, reason tracing.G return true } -// RefundGas refunds the leftover gas budget back to the contract. -func (c *Contract) RefundGas(refund GasBudget, logger *tracing.Hooks, reason tracing.GasChangeReason) { - prior, changed := c.Gas.Refund(refund) - if !changed { - return +// chargeState deducts state gas (spilling into regular when the reservoir is +// exhausted), with tracer integration. Returns false on OOG. +func (c *Contract) chargeState(s uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) bool { + prior, ok := c.Gas.ChargeState(s) + if !ok { + return false } if logger.HasGasHook() && reason != tracing.GasChangeIgnored { logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) } + return true +} + +// refundGas absorbs a sub-call's leftover GasBudget into this contract's gas state. +func (c *Contract) refundGas(child GasBudget, forwarded uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) { + prior := c.Gas + c.Gas.Absorb(child, forwarded) + if logger.HasGasHook() && reason != tracing.GasChangeIgnored { + logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) + } +} + +// forwardGas drains `regular` regular gas and the entire state reservoir +// from this contract's running budget and returns the initial GasBudget for +// a child frame. The caller's UsedRegularGas is bumped by the forwarded +// amount so that the absorb-on-return path correctly reclaims the unused +// portion. Thin wrapper around GasBudget.Forward with tracer integration. +// +// Caller must ensure `regular` is no larger than the running balance (the +// opcode's dynamic gas table is expected to validate that before invoking +// the opcode handler). +func (c *Contract) forwardGas(regular uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) GasBudget { + prior := c.Gas + child := c.Gas.Forward(regular) + if logger.HasGasHook() && reason != tracing.GasChangeIgnored { + logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) + } + return child } // Address returns the contracts address diff --git a/core/vm/contracts.go b/core/vm/contracts.go index 71cfdbc527..6908ffeba1 100644 --- a/core/vm/contracts.go +++ b/core/vm/contracts.go @@ -264,9 +264,8 @@ func ActivePrecompiles(rules params.Rules) []common.Address { // - any error that occurred func RunPrecompiledContract(stateDB StateDB, p PrecompiledContract, address common.Address, input []byte, gas GasBudget, logger *tracing.Hooks, rules params.Rules) (ret []byte, remaining GasBudget, err error) { gasCost := p.RequiredGas(input) - prior, ok := gas.Charge(GasCosts{RegularGas: gasCost}) + prior, ok := gas.ChargeRegular(gasCost) if !ok { - gas.Exhaust() return nil, gas, ErrOutOfGas } if logger.HasGasHook() { diff --git a/core/vm/contracts_fuzz_test.go b/core/vm/contracts_fuzz_test.go index 988cdb91f2..4d28df6a6a 100644 --- a/core/vm/contracts_fuzz_test.go +++ b/core/vm/contracts_fuzz_test.go @@ -37,7 +37,7 @@ func FuzzPrecompiledContracts(f *testing.F) { return } inWant := string(input) - RunPrecompiledContract(nil, p, a, input, NewGasBudget(gas), nil, params.Rules{}) + RunPrecompiledContract(nil, p, a, input, NewGasBudget(gas, 0), nil, params.Rules{}) if inHave := string(input); inWant != inHave { t.Errorf("Precompiled %v modified input data", a) } diff --git a/core/vm/contracts_test.go b/core/vm/contracts_test.go index e7841c8552..c6975bd0a6 100644 --- a/core/vm/contracts_test.go +++ b/core/vm/contracts_test.go @@ -100,7 +100,7 @@ func testPrecompiled(addr string, test precompiledTest, t *testing.T) { in := common.Hex2Bytes(test.Input) gas := p.RequiredGas(in) t.Run(fmt.Sprintf("%s-Gas=%d", test.Name, gas), func(t *testing.T) { - if res, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas), nil, params.Rules{}); err != nil { + if res, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas, 0), nil, params.Rules{}); err != nil { t.Error(err) } else if common.Bytes2Hex(res) != test.Expected { t.Errorf("Expected %v, got %v", test.Expected, common.Bytes2Hex(res)) @@ -122,7 +122,7 @@ func testPrecompiledOOG(addr string, test precompiledTest, t *testing.T) { gas := test.Gas - 1 t.Run(fmt.Sprintf("%s-Gas=%d", test.Name, gas), func(t *testing.T) { - _, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas), nil, params.Rules{}) + _, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas, 0), nil, params.Rules{}) if err.Error() != "out of gas" { t.Errorf("Expected error [out of gas], got [%v]", err) } @@ -139,7 +139,7 @@ func testPrecompiledFailure(addr string, test precompiledFailureTest, t *testing in := common.Hex2Bytes(test.Input) gas := p.RequiredGas(in) t.Run(test.Name, func(t *testing.T) { - _, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas), nil, params.Rules{}) + _, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas, 0), nil, params.Rules{}) if err.Error() != test.ExpectedError { t.Errorf("Expected error [%v], got [%v]", test.ExpectedError, err) } @@ -170,7 +170,7 @@ func benchmarkPrecompiled(addr string, test precompiledTest, bench *testing.B) { start := time.Now() for bench.Loop() { copy(data, in) - res, _, err = RunPrecompiledContract(nil, p, common.HexToAddress(addr), data, NewGasBudget(reqGas), nil, params.Rules{}) + res, _, err = RunPrecompiledContract(nil, p, common.HexToAddress(addr), data, NewGasBudget(reqGas, 0), nil, params.Rules{}) } elapsed := uint64(time.Since(start)) if elapsed < 1 { diff --git a/core/vm/eips.go b/core/vm/eips.go index 33af8fd4fd..ba7cbd7461 100644 --- a/core/vm/eips.go +++ b/core/vm/eips.go @@ -43,6 +43,7 @@ var activators = map[int]func(*JumpTable){ 7939: enable7939, 8024: enable8024, 7843: enable7843, + 8037: enable8037, } // EnableEIP enables the given EIP on the config. @@ -377,7 +378,7 @@ func opExtCodeCopyEIP4762(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, er code := evm.StateDB.GetCode(addr) paddedCodeCopy, copyOffset, nonPaddedCopyLength := getDataAndAdjustedBounds(code, uint64CodeOffset, length.Uint64()) consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(addr, copyOffset, nonPaddedCopyLength, uint64(len(code)), false, scope.Contract.Gas.RegularGas) - scope.Contract.UseGas(GasCosts{RegularGas: consumed}, evm.Config.Tracer, tracing.GasChangeUnspecified) + scope.Contract.chargeRegular(consumed, evm.Config.Tracer, tracing.GasChangeUnspecified) if consumed < wanted { return nil, ErrOutOfGas } @@ -403,7 +404,7 @@ func opPush1EIP4762(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // advanced past this boundary. contractAddr := scope.Contract.Address() consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(contractAddr, *pc+1, uint64(1), uint64(len(scope.Contract.Code)), false, scope.Contract.Gas.RegularGas) - scope.Contract.UseGas(GasCosts{RegularGas: wanted}, evm.Config.Tracer, tracing.GasChangeUnspecified) + scope.Contract.chargeRegular(wanted, evm.Config.Tracer, tracing.GasChangeUnspecified) if consumed < wanted { return nil, ErrOutOfGas } @@ -430,7 +431,7 @@ func makePushEIP4762(size uint64, pushByteSize int) executionFunc { if !scope.Contract.IsDeployment && !scope.Contract.IsSystemCall { contractAddr := scope.Contract.Address() consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(contractAddr, uint64(start), uint64(pushByteSize), uint64(len(scope.Contract.Code)), false, scope.Contract.Gas.RegularGas) - scope.Contract.UseGas(GasCosts{RegularGas: consumed}, evm.Config.Tracer, tracing.GasChangeUnspecified) + scope.Contract.chargeRegular(consumed, evm.Config.Tracer, tracing.GasChangeUnspecified) if consumed < wanted { return nil, ErrOutOfGas } @@ -590,3 +591,11 @@ func enable7843(jt *JumpTable) { maxStack: maxStack(0, 1), } } + +// enable8037 enables the multidimensional-metering as specified in EIP-8037. +func enable8037(jt *JumpTable) { + jt[CREATE].constantGas = params.CreateGasAmsterdam + jt[CREATE2].constantGas = params.CreateGasAmsterdam + jt[SELFDESTRUCT].dynamicGas = gasSelfdestruct8037 + jt[SSTORE].dynamicGas = gasSStore8037 +} diff --git a/core/vm/evm.go b/core/vm/evm.go index 832306b9a0..50d9e8ab0c 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -67,6 +67,8 @@ type BlockContext struct { BlobBaseFee *big.Int // Provides information for BLOBBASEFEE (0 if vm runs with NoBaseFee flag and 0 blob gas price) Random *common.Hash // Provides information for PREVRANDAO SlotNum uint64 // Provides information for SLOTNUM + + CostPerStateByte uint64 // CostPerByte for new state after EIP-8037 } // TxContext provides the EVM with information about a transaction. @@ -245,13 +247,13 @@ func isSystemCall(caller common.Address) bool { // parameters. It also handles any necessary value transfer required and takse // the necessary steps to create accounts and reverses the state in case of an // execution error or failed value transfer. -func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, leftOverGas GasBudget, err error) { +func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, result GasBudget, err error) { // Capture the tracer start/end events in debug mode if evm.Config.Tracer != nil { - evm.captureBegin(evm.depth, CALL, caller, addr, input, gas.RegularGas, value.ToBig()) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) + evm.captureBegin(evm.depth, CALL, caller, addr, input, gas, value.ToBig()) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) } // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { @@ -263,7 +265,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g if !syscall && !value.IsZero() && !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance } - snapshot := evm.StateDB.Snapshot() + snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas p, isPrecompile := evm.precompile(addr) if !evm.StateDB.Exist(addr) { if !isPrecompile && evm.chainRules.IsEIP4762 && !isSystemCall(caller) { @@ -275,10 +277,9 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g // hash leaf to the access list, then account creation will proceed unimpaired. // Thus, only pay for the creation of the code hash leaf here. wgas := evm.AccessEvents.CodeHashGas(addr, true, gas.RegularGas, false) - if _, ok := gas.Charge(GasCosts{RegularGas: wgas}); !ok { + if _, ok := gas.ChargeRegular(wgas); !ok { evm.StateDB.RevertToSnapshot(snapshot) - gas.Exhaust() - return nil, gas, ErrOutOfGas + return nil, gas.ExitHalt(reservoir), ErrOutOfGas } } @@ -288,6 +289,16 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } evm.StateDB.CreateAccount(addr) } + if evm.chainRules.IsAmsterdam && !value.IsZero() && evm.StateDB.Empty(addr) { + prev, ok := gas.ChargeState(params.AccountCreationSize * evm.Context.CostPerStateByte) + if !ok { + evm.StateDB.RevertToSnapshot(snapshot) + return nil, gas.ExitHalt(reservoir), ErrOutOfGas + } + if evm.Config.Tracer.HasGasHook() { + evm.Config.Tracer.EmitGasChange(prev.AsTracing(), gas.AsTracing(), tracing.GasChangeAccountCreation) + } + } // Perform the value transfer only in non-syscall mode. // Calling this is required even for zero-value transfers, // to ensure the state clearing mechanism is applied. @@ -311,22 +322,19 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g gas = contract.Gas } } - // When an error was returned by the EVM or when setting the creation code - // above we revert to the snapshot and consume any gas remaining. Additionally, - // when we're in homestead this also counts for code storage gas errors. + + // Calculate the remaining gas at the end of frame + exitGas := gas.Exit(err, reservoir) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) + if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), exitGas.AsTracing(), tracing.GasChangeCallFailedExecution) } - gas.Exhaust() } - // TODO: consider clearing up unused snapshots: - //} else { - // evm.StateDB.DiscardSnapshot(snapshot) } - return ret, gas, err + return ret, exitGas, err } // CallCode executes the contract associated with the addr with the given input @@ -336,26 +344,23 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g // // CallCode differs from Call in the sense that it executes the given address' // code with the caller as context. -func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, leftOverGas GasBudget, err error) { +func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, result GasBudget, err error) { // Invoke tracer hooks that signal entering/exiting a call frame if evm.Config.Tracer != nil { - evm.captureBegin(evm.depth, CALLCODE, caller, addr, input, gas.RegularGas, value.ToBig()) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) + evm.captureBegin(evm.depth, CALLCODE, caller, addr, input, gas, value.ToBig()) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) } // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { return nil, gas, ErrDepth } // Fail if we're trying to transfer more than the available balance - // Note although it's noop to transfer X ether to caller itself. But - // if caller doesn't have enough balance, it would be an error to allow - // over-charging itself. So the check here is necessary. if !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance } - var snapshot = evm.StateDB.Snapshot() + snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { @@ -368,16 +373,19 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt ret, err = evm.Run(contract, input, false) gas = contract.Gas } + + // Calculate the remaining gas at the end of frame + exitGas := gas.Exit(err, reservoir) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) + if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), exitGas.AsTracing(), tracing.GasChangeCallFailedExecution) } - gas.Exhaust() } } - return ret, gas, err + return ret, exitGas, err } // DelegateCall executes the contract associated with the addr with the given input @@ -385,56 +393,56 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt // // DelegateCall differs from CallCode in the sense that it executes the given address' // code with the caller as context and the caller is set to the caller of the caller. -func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, leftOverGas GasBudget, err error) { +func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, result GasBudget, err error) { // Invoke tracer hooks that signal entering/exiting a call frame if evm.Config.Tracer != nil { // DELEGATECALL inherits value from parent call - evm.captureBegin(evm.depth, DELEGATECALL, caller, addr, input, gas.RegularGas, value.ToBig()) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) + evm.captureBegin(evm.depth, DELEGATECALL, caller, addr, input, gas, value.ToBig()) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) } // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { return nil, gas, ErrDepth } - var snapshot = evm.StateDB.Snapshot() + snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { - // Initialise a new contract and make initialise the delegate values - // - // Note: The value refers to the original value from the parent call. contract := NewContract(originCaller, caller, value, gas, evm.jumpDests) contract.SetCallCode(evm.resolveCodeHash(addr), evm.resolveCode(addr)) ret, err = evm.Run(contract, input, false) gas = contract.Gas } + + // Calculate the remaining gas at the end of frame + exitGas := gas.Exit(err, reservoir) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) + if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), exitGas.AsTracing(), tracing.GasChangeCallFailedExecution) } - gas.Exhaust() } } - return ret, gas, err + return ret, exitGas, err } // StaticCall executes the contract associated with the addr with the given input // as parameters while disallowing any modifications to the state during the call. // Opcodes that attempt to perform such modifications will result in exceptions // instead of performing the modifications. -func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []byte, gas GasBudget) (ret []byte, leftOverGas GasBudget, err error) { +func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []byte, gas GasBudget) (ret []byte, result GasBudget, err error) { // Invoke tracer hooks that signal entering/exiting a call frame if evm.Config.Tracer != nil { - evm.captureBegin(evm.depth, STATICCALL, caller, addr, input, gas.RegularGas, nil) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) + evm.captureBegin(evm.depth, STATICCALL, caller, addr, input, gas, nil) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) } // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { @@ -445,7 +453,7 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b // after all empty accounts were deleted, so this is not required. However, if we omit this, // then certain tests start failing; stRevertTest/RevertPrecompiledTouchExactOOG.json. // We could change this, but for now it's left for legacy reasons - var snapshot = evm.StateDB.Snapshot() + snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas // We do an AddBalance of zero here, just in order to trigger a touch. // This doesn't matter on Mainnet, where all empties are gone at the time of Byzantium, @@ -456,58 +464,59 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b if p, isPrecompile := evm.precompile(addr); isPrecompile { ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { - // Initialise a new contract and set the code that is to be used by the EVM. - // The contract is a scoped environment for this execution context only. contract := NewContract(caller, addr, new(uint256.Int), gas, evm.jumpDests) contract.SetCallCode(evm.resolveCodeHash(addr), evm.resolveCode(addr)) - - // When an error was returned by the EVM or when setting the creation code - // above we revert to the snapshot and consume any gas remaining. Additionally - // when we're in Homestead this also counts for code storage gas errors. ret, err = evm.Run(contract, input, true) gas = contract.Gas } + + // Calculate the remaining gas at the end of frame + exitGas := gas.Exit(err, reservoir) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), exitGas.AsTracing(), tracing.GasChangeCallFailedExecution) } - gas.Exhaust() } } - return ret, gas, err + return ret, exitGas, err } // create creates a new contract using code as deployment code. -func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int, address common.Address, typ OpCode) (ret []byte, createAddress common.Address, leftOverGas GasBudget, err error) { - if evm.Config.Tracer != nil { - evm.captureBegin(evm.depth, typ, caller, address, code, gas.RegularGas, value.ToBig()) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) - } +func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int, address common.Address, typ OpCode) (ret []byte, createAddress common.Address, result GasBudget, err error) { // Depth check execution. Fail if we're trying to execute above the // limit. + var nonce uint64 if evm.depth > int(params.CallCreateDepth) { - return nil, common.Address{}, gas, ErrDepth + err = ErrDepth + } else if !evm.Context.CanTransfer(evm.StateDB, caller, value) { + err = ErrInsufficientBalance + } else { + nonce = evm.StateDB.GetNonce(caller) + if nonce+1 < nonce { + err = ErrNonceUintOverflow + } } - if !evm.Context.CanTransfer(evm.StateDB, caller, value) { - return nil, common.Address{}, gas, ErrInsufficientBalance + if evm.Config.Tracer != nil { + evm.captureBegin(evm.depth, typ, caller, address, code, gas, value.ToBig()) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) } - nonce := evm.StateDB.GetNonce(caller) - if nonce+1 < nonce { - return nil, common.Address{}, gas, ErrNonceUintOverflow + if err != nil { + return nil, common.Address{}, gas, err } + // Increment the caller's nonce after passing all validations evm.StateDB.SetNonce(caller, nonce+1, tracing.NonceChangeContractCreator) + reservoir := gas.StateGas // Charge the contract creation init gas in verkle mode if evm.chainRules.IsEIP4762 { statelessGas := evm.AccessEvents.ContractCreatePreCheckGas(address, gas.RegularGas) prior, ok := gas.Charge(GasCosts{RegularGas: statelessGas}) if !ok { - gas.Exhaust() - return nil, common.Address{}, gas, ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas } if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(prior.AsTracing(), gas.AsTracing(), tracing.GasChangeWitnessContractCollisionCheck) @@ -528,11 +537,13 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != types.EmptyCodeHash) || // non-empty code isEIP7610RejectedAccount(evm.ChainConfig().ChainID, address, evm.chainRules.IsEIP158) { + halt := gas.ExitHalt(reservoir) if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), halt.AsTracing(), tracing.GasChangeCallFailedExecution) } - gas.Exhaust() - return nil, common.Address{}, gas, ErrContractAddressCollision + // EIP-8037 collision rule: the state reservoir is fully preserved on + // address collision while regular gas is burnt. + return nil, common.Address{}, halt, ErrContractAddressCollision } // Create a new account on the state only if the object was not present. // It might be possible the contract code is deployed to a pre-existent @@ -540,6 +551,18 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value snapshot := evm.StateDB.Snapshot() if !evm.StateDB.Exist(address) { evm.StateDB.CreateAccount(address) + + if evm.chainRules.IsAmsterdam && evm.depth > 0 { + // Only charge state gas if we are not doing a create transaction. + // Prevents double charging with IntrinsicGas. + prev, ok := gas.ChargeState(params.AccountCreationSize * evm.Context.CostPerStateByte) + if !ok { + return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas + } + if evm.Config.Tracer.HasGasHook() { + evm.Config.Tracer.EmitGasChange(prev.AsTracing(), gas.AsTracing(), tracing.GasChangeAccountCreation) + } + } } // CreateContract means that regardless of whether the account previously existed // in the state trie or not, it _now_ becomes created as a _contract_ account. @@ -554,8 +577,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.chainRules.IsEIP4762 { consumed, wanted := evm.AccessEvents.ContractCreateInitGas(address, gas.RegularGas) if consumed < wanted { - gas.Exhaust() - return nil, common.Address{}, gas, ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas } prior, _ := gas.Charge(GasCosts{RegularGas: consumed}) if evm.Config.Tracer.HasGasHook() { @@ -574,13 +596,23 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value contract.IsDeployment = true ret, err = evm.initNewContract(contract, address) + + // Special case: ErrCodeStoreOutOfGas pre-Homestead does NOT roll back + // state and gas is preserved (i.e., treated as success). if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) { evm.StateDB.RevertToSnapshot(snapshot) + + exit := contract.Gas.Exit(err, reservoir) if err != ErrExecutionReverted { - contract.UseGas(GasCosts{RegularGas: contract.Gas.RegularGas}, evm.Config.Tracer, tracing.GasChangeCallFailedExecution) + if evm.Config.Tracer.HasGasHook() { + evm.Config.Tracer.EmitGasChange(contract.Gas.AsTracing(), exit.AsTracing(), tracing.GasChangeCallFailedExecution) + } } + return ret, address, exit, err } - return ret, address, contract.Gas, err + // Either success, or pre-Homestead ErrCodeStoreOutOfGas (gas preserved). + // Both packaged as a success-form GasBudget. + return ret, address, contract.Gas.ExitSuccess(), err } // initNewContract runs a new contract's creation code, performs checks on the @@ -590,30 +622,45 @@ func (evm *EVM) initNewContract(contract *Contract, address common.Address) ([]b if err != nil { return ret, err } - - // Check whether the max code size has been exceeded, assign err if the case. - if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { - return ret, err - } - + // Check prefix before gas calculation. // Reject code starting with 0xEF if EIP-3541 is enabled. if len(ret) >= 1 && ret[0] == 0xEF && evm.chainRules.IsLondon { return ret, ErrInvalidCode } - - if !evm.chainRules.IsEIP4762 { - createDataGas := uint64(len(ret)) * params.CreateDataGas - if !contract.UseGas(GasCosts{RegularGas: createDataGas}, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { - return ret, ErrCodeStoreOutOfGas - } - } else { + if evm.chainRules.IsEIP4762 { consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(address, 0, uint64(len(ret)), uint64(len(ret)), true, contract.Gas.RegularGas) - contract.UseGas(GasCosts{RegularGas: consumed}, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk) + contract.chargeRegular(consumed, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk) if len(ret) > 0 && (consumed < wanted) { return ret, ErrCodeStoreOutOfGas } + if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { + return ret, err + } + } else if evm.chainRules.IsAmsterdam { + // Check max code size BEFORE charging gas so over-max code + // does not consume state gas (which would inflate tx_state). + if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { + return ret, err + } + // Charge regular gas (hash cost) before state gas. + regularCost := toWordSize(uint64(len(ret))) * params.Keccak256WordGas + if !contract.chargeRegular(regularCost, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { + return ret, ErrCodeStoreOutOfGas + } + // Charge state gas (code-deposit) afterwards. + stateCost := uint64(len(ret)) * evm.Context.CostPerStateByte + if !contract.chargeState(stateCost, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { + return ret, ErrCodeStoreOutOfGas + } + } else { + createDataCost := uint64(len(ret)) * params.CreateDataGas + if !contract.chargeRegular(createDataCost, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { + return ret, ErrCodeStoreOutOfGas + } + if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { + return ret, err + } } - if len(ret) > 0 { evm.StateDB.SetCode(address, ret, tracing.CodeChangeContractCreation) } @@ -621,7 +668,7 @@ func (evm *EVM) initNewContract(contract *Contract, address common.Address) ([]b } // Create creates a new contract using code as deployment code. -func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int) (ret []byte, contractAddr common.Address, leftOverGas GasBudget, err error) { +func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int) (ret []byte, contractAddr common.Address, result GasBudget, err error) { contractAddr = crypto.CreateAddress(caller, evm.StateDB.GetNonce(caller)) return evm.create(caller, code, gas, value, contractAddr, CREATE) } @@ -630,7 +677,7 @@ func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value // // The different between Create2 with Create is Create2 uses keccak256(0xff ++ msg.sender ++ salt ++ keccak256(init_code))[12:] // instead of the usual sender-and-nonce-hash as the address where the contract is initialized at. -func (evm *EVM) Create2(caller common.Address, code []byte, gas GasBudget, endowment *uint256.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, leftOverGas GasBudget, err error) { +func (evm *EVM) Create2(caller common.Address, code []byte, gas GasBudget, endowment *uint256.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, result GasBudget, err error) { inithash := crypto.Keccak256Hash(code) contractAddr = crypto.CreateAddress2(caller, salt.Bytes32(), inithash[:]) return evm.create(caller, code, gas, endowment, contractAddr, CREATE2) @@ -668,22 +715,20 @@ func (evm *EVM) resolveCodeHash(addr common.Address) common.Hash { // ChainConfig returns the environment's chain configuration func (evm *EVM) ChainConfig() *params.ChainConfig { return evm.chainConfig } -func (evm *EVM) captureBegin(depth int, typ OpCode, from common.Address, to common.Address, input []byte, startGas uint64, value *big.Int) { +func (evm *EVM) captureBegin(depth int, typ OpCode, from common.Address, to common.Address, input []byte, startGas GasBudget, value *big.Int) { tracer := evm.Config.Tracer if tracer.OnEnter != nil { - tracer.OnEnter(depth, byte(typ), from, to, input, startGas, value) + tracer.OnEnter(depth, byte(typ), from, to, input, startGas.RegularGas, value) } if tracer.HasGasHook() { - initial := NewGasBudget(startGas) - tracer.EmitGasChange(tracing.Gas{}, initial.AsTracing(), tracing.GasChangeCallInitialBalance) + tracer.EmitGasChange(tracing.Gas{}, startGas.AsTracing(), tracing.GasChangeCallInitialBalance) } } -func (evm *EVM) captureEnd(depth int, startGas uint64, leftOverGas uint64, ret []byte, err error) { +func (evm *EVM) captureEnd(depth int, startGas GasBudget, leftOverGas GasBudget, ret []byte, err error) { tracer := evm.Config.Tracer - if leftOverGas != 0 && tracer.HasGasHook() { - leftover := NewGasBudget(leftOverGas) - tracer.EmitGasChange(leftover.AsTracing(), tracing.Gas{}, tracing.GasChangeCallLeftOverReturned) + if !leftOverGas.IsZero() && tracer.HasGasHook() { + tracer.EmitGasChange(leftOverGas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallLeftOverReturned) } var reverted bool if err != nil { @@ -693,7 +738,7 @@ func (evm *EVM) captureEnd(depth int, startGas uint64, leftOverGas uint64, ret [ reverted = false } if tracer.OnExit != nil { - tracer.OnExit(depth, ret, startGas-leftOverGas, VMErrorFromErr(err), reverted) + tracer.OnExit(depth, ret, startGas.RegularGas-leftOverGas.RegularGas, VMErrorFromErr(err), reverted) } } diff --git a/core/vm/gas_table.go b/core/vm/gas_table.go index 046311f9cc..550375c9c0 100644 --- a/core/vm/gas_table.go +++ b/core/vm/gas_table.go @@ -291,10 +291,19 @@ var ( gasMLoad = pureMemoryGascost gasMStore8 = pureMemoryGascost gasMStore = pureMemoryGascost - gasCreate = pureMemoryGascost ) +func gasCreate(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + return pureMemoryGascost(evm, contract, stack, mem, memorySize) +} + func gasCreate2(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } gas, err := memoryGasCost(mem, memorySize) if err != nil { return GasCosts{}, err @@ -313,6 +322,9 @@ func gasCreate2(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memoryS } func gasCreateEip3860(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } gas, err := memoryGasCost(mem, memorySize) if err != nil { return GasCosts{}, err @@ -331,7 +343,11 @@ func gasCreateEip3860(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m } return GasCosts{RegularGas: gas}, nil } + func gasCreate2Eip3860(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } gas, err := memoryGasCost(mem, memorySize) if err != nil { return GasCosts{}, err @@ -384,17 +400,17 @@ var ( gasStaticCall = makeCallVariantGasCost(gasStaticCallIntrinsic) ) -func makeCallVariantGasCost(intrinsicFunc gasFunc) gasFunc { +func makeCallVariantGasCost(intrinsicFunc intrinsicGasFunc) gasFunc { return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { intrinsic, err := intrinsicFunc(evm, contract, stack, mem, memorySize) if err != nil { return GasCosts{}, err } - evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, intrinsic.RegularGas, stack.back(0)) + evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, intrinsic, stack.back(0)) if err != nil { return GasCosts{}, err } - gas, overflow := math.SafeAdd(intrinsic.RegularGas, evm.callGasTemp) + gas, overflow := math.SafeAdd(intrinsic, evm.callGasTemp) if overflow { return GasCosts{}, ErrGasUintOverflow } @@ -402,19 +418,19 @@ func makeCallVariantGasCost(intrinsicFunc gasFunc) gasFunc { } } -func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { +func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { var ( gas uint64 transfersValue = !stack.back(2).IsZero() address = common.Address(stack.back(1).Bytes20()) ) if evm.readOnly && transfersValue { - return GasCosts{}, ErrWriteProtection + return 0, ErrWriteProtection } // Stateless check memoryGas, err := memoryGasCost(mem, memorySize) if err != nil { - return GasCosts{}, err + return 0, err } var transferGas uint64 if transfersValue && !evm.chainRules.IsEIP4762 { @@ -422,12 +438,12 @@ func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m } var overflow bool if gas, overflow = math.SafeAdd(memoryGas, transferGas); overflow { - return GasCosts{}, ErrGasUintOverflow + return 0, ErrGasUintOverflow } // Terminate the gas measurement if the leftover gas is not sufficient, // it can effectively prevent accessing the states in the following steps. if contract.Gas.RegularGas < gas { - return GasCosts{}, ErrOutOfGas + return 0, ErrOutOfGas } // Stateful check var stateGas uint64 @@ -439,15 +455,15 @@ func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m stateGas += params.CallNewAccountGas } if gas, overflow = math.SafeAdd(gas, stateGas); overflow { - return GasCosts{}, ErrGasUintOverflow + return 0, ErrGasUintOverflow } - return GasCosts{RegularGas: gas}, nil + return gas, nil } -func gasCallCodeIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { +func gasCallCodeIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { memoryGas, err := memoryGasCost(mem, memorySize) if err != nil { - return GasCosts{}, err + return 0, err } var ( gas uint64 @@ -457,38 +473,36 @@ func gasCallCodeIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memor gas += params.CallValueTransferGas } if gas, overflow = math.SafeAdd(gas, memoryGas); overflow { - return GasCosts{}, ErrGasUintOverflow + return 0, ErrGasUintOverflow } - return GasCosts{RegularGas: gas}, nil + return gas, nil } -func gasDelegateCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { +func gasDelegateCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { gas, err := memoryGasCost(mem, memorySize) if err != nil { - return GasCosts{}, err + return 0, err } - return GasCosts{RegularGas: gas}, nil + return gas, nil } -func gasStaticCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { +func gasStaticCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { gas, err := memoryGasCost(mem, memorySize) if err != nil { - return GasCosts{}, err + return 0, err } - return GasCosts{RegularGas: gas}, nil + return gas, nil } func gasSelfdestruct(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { if evm.readOnly { return GasCosts{}, ErrWriteProtection } - var gas uint64 // EIP150 homestead gas reprice fork: if evm.chainRules.IsEIP150 { gas = params.SelfdestructGasEIP150 var address = common.Address(stack.back(0).Bytes20()) - if evm.chainRules.IsEIP158 { // if empty and transfers value if evm.StateDB.Empty(address) && evm.StateDB.GetBalance(contract.Address()).Sign() != 0 { @@ -504,3 +518,104 @@ func gasSelfdestruct(evm *EVM, contract *Contract, stack *Stack, mem *Memory, me } return GasCosts{RegularGas: gas}, nil } + +func gasSelfdestruct8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + var ( + gas GasCosts + address = common.Address(stack.peek().Bytes20()) + ) + if !evm.StateDB.AddressInAccessList(address) { + // If the caller cannot afford the cost, this change will be rolled back + evm.StateDB.AddAddressToAccessList(address) + gas.RegularGas = params.ColdAccountAccessCostEIP2929 + } + // Check we have enough regular gas before we add the address to the BAL + if contract.Gas.RegularGas < gas.RegularGas { + return gas, ErrOutOfGas + } + // Important: use StateDB.Empty instead of !StateDB.Exist. An account may exist + // in the current state yet still be considered non-existent by EIP-161 if its + // nonce, balance, and code are all zero. Such accounts can appear temporarily + // during execution (e.g. via SELFDESTRUCT) and are removed at tx end. + // + // Funding such an account makes it permanent state growth and must be charged. + if evm.StateDB.Empty(address) && evm.StateDB.GetBalance(contract.Address()).Sign() != 0 { + gas.StateGas += params.AccountCreationSize * evm.Context.CostPerStateByte + } + return gas, nil +} + +func gasSStore8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + // If we fail the minimum gas availability invariant, fail (0) + if contract.Gas.RegularGas <= params.SstoreSentryGasEIP2200 { + return GasCosts{}, errors.New("not enough gas for reentrancy sentry") + } + // Gas sentry honoured, do the actual gas calculation based on the stored value + var ( + y, x = stack.back(1), stack.peek() + slot = common.Hash(x.Bytes32()) + current, original = evm.StateDB.GetStateAndCommittedState(contract.Address(), slot) + cost GasCosts + ) + // Check slot presence in the access list + if _, slotPresent := evm.StateDB.SlotInAccessList(contract.Address(), slot); !slotPresent { + cost = GasCosts{RegularGas: params.ColdSloadCostEIP2929} + // If the caller cannot afford the cost, this change will be rolled back + evm.StateDB.AddSlotToAccessList(contract.Address(), slot) + } + value := common.Hash(y.Bytes32()) + + if current == value { // noop (1) + // EIP 2200 original clause: + // return params.SloadGasEIP2200, nil + return GasCosts{RegularGas: cost.RegularGas + params.WarmStorageReadCostEIP2929}, nil // SLOAD_GAS + } + if original == current { + if original == (common.Hash{}) { // create slot (2.1.1) + return GasCosts{ + RegularGas: cost.RegularGas + params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929, + StateGas: params.StorageCreationSize * evm.Context.CostPerStateByte, + }, nil + } + if value == (common.Hash{}) { // delete slot (2.1.2b) + evm.StateDB.AddRefund(params.SstoreClearsScheduleRefundEIP3529) + } + // EIP-2200 original clause: + // return params.SstoreResetGasEIP2200, nil // write existing slot (2.1.2) + return GasCosts{RegularGas: cost.RegularGas + params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929}, nil // write existing slot (2.1.2) + } + if original != (common.Hash{}) { + if current == (common.Hash{}) { // recreate slot (2.2.1.1) + evm.StateDB.SubRefund(params.SstoreClearsScheduleRefundEIP3529) + } else if value == (common.Hash{}) { // delete slot (2.2.1.2) + evm.StateDB.AddRefund(params.SstoreClearsScheduleRefundEIP3529) + } + } + if original == value { + if original == (common.Hash{}) { // reset to original inexistent slot (2.2.2.1) + // EIP-8037 point (2): refund state gas directly to the reservoir + // at the SSTORE restoration point (0→x→0 in same tx); not to the + // refund counter, which is capped at gas_used/5. + contract.Gas.RefundState(params.StorageCreationSize * evm.Context.CostPerStateByte) + + // Regular portion of the refund still goes through the refund counter. + evm.StateDB.AddRefund(params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929 - params.WarmStorageReadCostEIP2929) + } else { // reset to original existing slot (2.2.2.2) + // EIP 2200 Original clause: + // evm.StateDB.AddRefund(params.SstoreResetGasEIP2200 - params.SloadGasEIP2200) + // - SSTORE_RESET_GAS redefined as (5000 - COLD_SLOAD_COST) + // - SLOAD_GAS redefined as WARM_STORAGE_READ_COST + // Final: (5000 - COLD_SLOAD_COST) - WARM_STORAGE_READ_COST + evm.StateDB.AddRefund((params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929) - params.WarmStorageReadCostEIP2929) + } + } + // EIP-2200 original clause: + //return params.SloadGasEIP2200, nil // dirty update (2.2) + return GasCosts{RegularGas: cost.RegularGas + params.WarmStorageReadCostEIP2929}, nil // dirty update (2.2) +} diff --git a/core/vm/gas_table_test.go b/core/vm/gas_table_test.go index 16ce651a7d..4bf936d07d 100644 --- a/core/vm/gas_table_test.go +++ b/core/vm/gas_table_test.go @@ -97,12 +97,12 @@ func TestEIP2200(t *testing.T) { Transfer: func(StateDB, common.Address, common.Address, *uint256.Int, *params.Rules) {}, } evm := NewEVM(vmctx, statedb, params.AllEthashProtocolChanges, Config{ExtraEips: []int{2200}}) - initialGas := NewGasBudget(tt.gaspool) - _, leftOver, err := evm.Call(common.Address{}, address, nil, initialGas.Copy(), new(uint256.Int)) + initialGas := NewGasBudget(tt.gaspool, 0) + _, result, err := evm.Call(common.Address{}, address, nil, initialGas, new(uint256.Int)) if !errors.Is(err, tt.failure) { t.Errorf("test %d: failure mismatch: have %v, want %v", i, err, tt.failure) } - if used := leftOver.Used(initialGas); used != tt.used { + if used := result.Used(initialGas); used != tt.used { t.Errorf("test %d: gas used mismatch: have %v, want %v", i, used, tt.used) } if refund := evm.StateDB.GetRefund(); refund != tt.refund { @@ -157,12 +157,12 @@ func TestCreateGas(t *testing.T) { } evm := NewEVM(vmctx, statedb, chainConfig, config) - initialGas := NewGasBudget(uint64(testGas)) - ret, leftOver, err := evm.Call(common.Address{}, address, nil, initialGas.Copy(), new(uint256.Int)) + initialGas := NewGasBudget(uint64(testGas), 0) + ret, result, err := evm.Call(common.Address{}, address, nil, initialGas, new(uint256.Int)) if err != nil { return false } - gasUsed = leftOver.Used(initialGas) + gasUsed = result.Used(initialGas) if len(ret) != 32 { t.Fatalf("test %d: expected 32 bytes returned, have %d", i, len(ret)) } diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index ed938ae41f..b1756ab5fe 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -20,11 +20,11 @@ import ( "fmt" "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/log" ) -// GasCosts denotes a vector of gas costs in the -// multidimensional metering paradigm. It represents the cost -// charged by an individual operation. +// GasCosts denotes a vector of gas costs in the multidimensional metering +// paradigm. It represents the cost charged by an individual operation. type GasCosts struct { RegularGas uint64 StateGas uint64 @@ -40,67 +40,254 @@ func (g GasCosts) String() string { return fmt.Sprintf("<%v,%v>", g.RegularGas, g.StateGas) } -// GasBudget denotes a vector of remaining gas allowances available -// for EVM execution in the multidimensional metering paradigm. -// Unlike GasCosts which represents the price of an operation, -// GasBudget tracks how much gas is left to spend. +// GasBudget is the unified gas-state structure used throughout the EVM. +// It carries two pairs of fields: +// +// - RegularGas / StateGas: the running balance during execution, or the +// leftover balance the caller must absorb after a sub-call. +// - UsedRegularGas / UsedStateGas: per-frame accumulators tracking gross +// consumption. UsedStateGas is signed so it can be decremented by inline +// state-gas refunds (e.g., SSTORE 0->A->0). +// +// The same struct serves three roles: +// +// - During execution: Charge / ChargeRegular / ChargeState / RefundState +// and RefundRegular mutate the running balance and the usage accumulators +// in lockstep. +// +// - At frame exit: ExitSuccess / ExitRevert / ExitHalt produce a new +// GasBudget in "leftover" form that packages the result for the caller. +// +// - At absorption: the caller's Absorb method merges the child's leftover +// budget into its own running budget. type GasBudget struct { - RegularGas uint64 // The leftover gas for execution and state gas usage - StateGas uint64 // The state gas reservoir + RegularGas uint64 // remaining regular-gas balance (or leftover for caller to absorb) + StateGas uint64 // remaining state-gas reservoir (or leftover for caller to absorb) + UsedRegularGas uint64 // gross regular gas consumed in this frame + UsedStateGas int64 // signed net state-gas consumed in this frame } -// NewGasBudget creates a GasBudget with the given initial regular gas allowance. -func NewGasBudget(gas uint64) GasBudget { - return GasBudget{RegularGas: gas} +// NewGasBudget initializes a fresh GasBudget for execution / forwarding, +// with both usage accumulators set to zero. +func NewGasBudget(regular, state uint64) GasBudget { + return GasBudget{RegularGas: regular, StateGas: state} } -// Used returns the amount of regular gas consumed so far. +// Used returns the total scalar gas consumed relative to an initial budget +// (= (initial.regular + initial.state) − (current.regular + current.state)). +// This is the payment scalar (EIP-8037's tx_gas_used_before_refund). func (g GasBudget) Used(initial GasBudget) uint64 { - return initial.RegularGas - g.RegularGas + return (initial.RegularGas + initial.StateGas) - (g.RegularGas + g.StateGas) } -// Exhaust sets all remaining gas to zero, preserving the initial amount -// for usage tracking. -func (g *GasBudget) Exhaust() { - g.RegularGas = 0 - g.StateGas = 0 -} - -func (g *GasBudget) Copy() GasBudget { - return GasBudget{RegularGas: g.RegularGas, StateGas: g.StateGas} -} - -// String returns a visual representation of the gas budget vector. +// String returns a visual representation of the budget. func (g GasBudget) String() string { - return fmt.Sprintf("<%v,%v>", g.RegularGas, g.StateGas) + return fmt.Sprintf("<%v,%v,used=<%v,%v>>", g.RegularGas, g.StateGas, g.UsedRegularGas, g.UsedStateGas) } -// CanAfford reports whether the budget has sufficient gas to cover the cost. +// CanAfford reports whether the running balance can cover the given cost. +// State-gas charges that exceed the reservoir spill into regular gas. func (g GasBudget) CanAfford(cost GasCosts) bool { - return g.RegularGas >= cost.RegularGas + if g.RegularGas < cost.RegularGas { + return false + } + if cost.StateGas > g.StateGas { + spillover := cost.StateGas - g.StateGas + if spillover > g.RegularGas-cost.RegularGas { + return false + } + } + return true } -// Charge deducts the given gas cost from the budget. It returns the -// pre-charge budget and false if the budget does not have sufficient -// gas to cover the cost. +// Charge deducts a combined regular+state cost from the running balance and +// updates the usage accumulators. State-gas in excess of the reservoir spills +// into regular_gas. func (g *GasBudget) Charge(cost GasCosts) (GasBudget, bool) { prior := *g - if g.RegularGas < cost.RegularGas { + if !g.CanAfford(cost) { return prior, false } + // Charge regular gas g.RegularGas -= cost.RegularGas - return prior, true -} + g.UsedRegularGas += cost.RegularGas -// Refund adds the given gas budget back. It returns the pre-refund budget -// and whether the budget was actually changed. -func (g *GasBudget) Refund(other GasBudget) (GasBudget, bool) { - prior := *g - g.RegularGas += other.RegularGas - return prior, g.RegularGas != prior.RegularGas + // Charge state gas + if cost.StateGas > g.StateGas { + spillover := cost.StateGas - g.StateGas + g.StateGas = 0 + g.RegularGas -= spillover + } else { + g.StateGas -= cost.StateGas + } + g.UsedStateGas += int64(cost.StateGas) + return prior, true } // AsTracing converts the GasBudget into the tracing-facing Gas vector. func (g GasBudget) AsTracing() tracing.Gas { return tracing.Gas{Regular: g.RegularGas, State: g.StateGas} } + +// ChargeRegular is a convenience that deducts a regular-only cost. +func (g *GasBudget) ChargeRegular(r uint64) (GasBudget, bool) { + return g.Charge(GasCosts{RegularGas: r}) +} + +// ChargeState is a convenience that deducts a state-only cost (spills to +// regular when the reservoir is exhausted). Returns false on OOG. +func (g *GasBudget) ChargeState(s uint64) (GasBudget, bool) { + return g.Charge(GasCosts{StateGas: s}) +} + +// IsZero returns an indicator if the gas budget has been exhausted. +func (g *GasBudget) IsZero() bool { + return g.RegularGas == 0 && g.StateGas == 0 +} + +// RefundState applies an inline state-gas refund (e.g., SSTORE 0->A->0). +// The reservoir is credited and the signed usage counter is decremented +// in lockstep, preserving the per-frame invariant: +// +// StateGas + UsedStateGas == initialStateGas + spillover_so_far +// +// which the revert path relies on for the correct gross refund. +func (g *GasBudget) RefundState(s uint64) { + g.StateGas += s + g.UsedStateGas -= int64(s) +} + +// Forward drains `regular` regular gas and the entire state reservoir from +// the parent's running budget and returns the initial GasBudget for a child +// frame. The parent's UsedRegularGas is bumped by the forwarded amount so +// that the absorb-on-return path correctly reclaims the unused portion. +// +// Used by frame boundaries where the regular forward has NOT been pre- +// deducted: tx-level dispatch (state_transition) and CREATE / CREATE2. The +// CALL family pre-deducts the forward via the dynamic gas table for tracer- +// reporting reasons and therefore constructs its child budget directly. +// +// Caller must ensure `regular` does not exceed the running balance and +// apply any EIP-150 1/64 retention before calling Forward. +func (g *GasBudget) Forward(regular uint64) GasBudget { + g.RegularGas -= regular + g.UsedRegularGas += regular + + child := GasBudget{ + RegularGas: regular, + StateGas: g.StateGas, + } + g.StateGas = 0 + return child +} + +// ForwardAll forwards the parent's full remaining budget (both regular and +// state) to a child frame. Equivalent to Forward(g.RegularGas) — used at +// the tx boundary where there is no 1/64 retention. +func (g *GasBudget) ForwardAll() GasBudget { + return g.Forward(g.RegularGas) +} + +// ============================================================================ +// Exit-form constructors. These take a post-execution running budget and +// produce a new GasBudget in "leftover form", the value the caller should +// absorb to update its own state. +// ============================================================================ + +// ExitSuccess produces the leftover form for a successful frame. Inline +// state-gas refunds have already been folded into StateGas / UsedStateGas +// during execution; the running budget IS the exit budget on success. +func (g GasBudget) ExitSuccess() GasBudget { + return g +} + +// ExitRevert produces the leftover for a REVERT exit. Per EIP-8037, all state +// gas charged by the reverted frame is refunded to the caller's reservoir: +// +// leftover.StateGas = StateGas + UsedStateGas +// +// UsedStateGas is reset since the frame's state changes are discarded. +func (g GasBudget) ExitRevert() GasBudget { + reservoir := int64(g.StateGas) + g.UsedStateGas + if reservoir < 0 { + // Reservoir should never be negative. By construction it equals + // the initial state-gas allocation plus any spillover to regular + // gas. + reservoir = 0 + log.Warn("Negative reservoir at revert", "remaining", g.StateGas, "used", g.UsedStateGas) + } + return GasBudget{ + RegularGas: g.RegularGas, + StateGas: uint64(reservoir), + UsedRegularGas: g.UsedRegularGas, + UsedStateGas: 0, + } +} + +// ExitHalt produces the leftover for an exceptional halt. +// +// - state_gas_reservoir is reset back to its value at the start of the child frame +// - the gas_left initially given to the child is consumed (set to zero) +func (g GasBudget) ExitHalt(initStateReservoir uint64) GasBudget { + reservoir := int64(g.StateGas) + g.UsedStateGas + if reservoir < 0 { + // Reservoir should never be negative. By construction it equals + // the initial state-gas allocation plus any spillover to regular + // gas. + reservoir = 0 + log.Warn("Negative reservoir at halt", "remaining", g.StateGas, "used", g.UsedStateGas) + } + // The portion of state gas charged from regular gas is also burned + // together with the regular gas, rather than being returned to the + // parent's state-gas reservoir. + var spilled uint64 + if uint64(reservoir) > initStateReservoir { + spilled = uint64(reservoir) - initStateReservoir + } + return GasBudget{ + RegularGas: 0, + StateGas: initStateReservoir, + UsedRegularGas: g.UsedRegularGas + g.RegularGas + spilled, + UsedStateGas: 0, + } +} + +// Exit dispatches on err to the appropriate exit-form constructor +// for the post-evm.Run path: +// +// - err == nil → ExitSuccess +// - err == ErrExecutionReverted → ExitRevert +// - any other err → ExitHalt +// +// Soft validation failures (occurring BEFORE evm.Run) should call Preserved +// directly instead of going through this dispatcher. +func (g GasBudget) Exit(err error, initStateReservoir uint64) GasBudget { + switch { + case err == nil: + return g.ExitSuccess() + case err == ErrExecutionReverted: + return g.ExitRevert() + default: + return g.ExitHalt(initStateReservoir) + } +} + +// Absorb merges a sub-call's leftover GasBudget into this (caller's) running +// budget. Additionally, it does an EIP-8037 spillover correction: +// state-gas that spilled into the regular pool inside the child frame is +// excluded from the UsedRegularGas. +// +// spillover = forwarded - child.RegularGas - child.UsedRegularGas +// +// forwarded is the regular-gas amount that was passed to the child at call +// entry (i.e., the regular initial of the child's GasBudget). +func (g *GasBudget) Absorb(child GasBudget, forwarded uint64) { + spillover := forwarded - child.RegularGas - child.UsedRegularGas + + g.UsedRegularGas -= child.RegularGas + g.RegularGas += child.RegularGas + g.StateGas = child.StateGas + g.UsedStateGas += child.UsedStateGas + + g.UsedRegularGas -= spillover +} diff --git a/core/vm/instructions.go b/core/vm/instructions.go index 4b05092cc7..92c363a356 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -647,25 +647,22 @@ func opSwap16(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - if evm.readOnly { - return nil, ErrWriteProtection - } var ( value = scope.Stack.pop() offset, size = scope.Stack.pop(), scope.Stack.pop() input = scope.Memory.GetCopy(offset.Uint64(), size.Uint64()) - gas = scope.Contract.Gas.RegularGas ) + // Apply EIP-150 to the regular gas left after the state charge. + forward := scope.Contract.Gas.RegularGas if evm.chainRules.IsEIP150 { - gas -= gas / 64 + forward -= forward / 64 } // reuse size int for stackvalue stackvalue := size - scope.Contract.UseGas(GasCosts{RegularGas: gas}, evm.Config.Tracer, tracing.GasChangeCallContractCreation) - - res, addr, returnGas, suberr := evm.Create(scope.Contract.Address(), input, NewGasBudget(gas), &value) + child := scope.Contract.forwardGas(forward, evm.Config.Tracer, tracing.GasChangeCallContractCreation) + res, addr, result, suberr := evm.Create(scope.Contract.Address(), input, child, &value) // Push item on the stack based on the returned error. If the ruleset is // homestead we must check for CodeStoreOutOfGasError (homestead only // rule) and treat as an error, if the ruleset is frontier we must @@ -679,7 +676,8 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } scope.Stack.push(&stackvalue) - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + // Refund the leftover gas back to current frame + scope.Contract.refundGas(result, forward, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer @@ -690,24 +688,20 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - if evm.readOnly { - return nil, ErrWriteProtection - } var ( endowment = scope.Stack.pop() offset, size = scope.Stack.pop(), scope.Stack.pop() salt = scope.Stack.pop() input = scope.Memory.GetCopy(offset.Uint64(), size.Uint64()) - gas = scope.Contract.Gas.RegularGas ) + // Apply EIP-150 to the regular gas left after the state charge. + forward := scope.Contract.Gas.RegularGas + forward -= forward / 64 - // Apply EIP150 - gas -= gas / 64 - scope.Contract.UseGas(GasCosts{RegularGas: gas}, evm.Config.Tracer, tracing.GasChangeCallContractCreation2) // reuse size int for stackvalue stackvalue := size - res, addr, returnGas, suberr := evm.Create2(scope.Contract.Address(), input, NewGasBudget(gas), - &endowment, &salt) + child := scope.Contract.forwardGas(forward, evm.Config.Tracer, tracing.GasChangeCallContractCreation2) + res, addr, result, suberr := evm.Create2(scope.Contract.Address(), input, child, &endowment, &salt) // Push item on the stack based on the returned error. if suberr != nil { stackvalue.Clear() @@ -715,7 +709,9 @@ func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { stackvalue.SetBytes(addr.Bytes()) } scope.Stack.push(&stackvalue) - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + + // Refund the leftover gas back to current frame + scope.Contract.refundGas(result, forward, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer @@ -743,7 +739,12 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if !value.IsZero() { gas += params.CallStipend } - ret, returnGas, err := evm.Call(scope.Contract.Address(), toAddr, args, NewGasBudget(gas), &value) + + // Regular gas for the forward was already pre-deducted by the dynamic + // gas table (see makeCallVariantGasCallEIP*); only the state reservoir + // needs to be handed off to the child here. + childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) + ret, result, err := evm.Call(scope.Contract.Address(), toAddr, args, childBudget, &value) if err != nil { temp.Clear() @@ -751,11 +752,11 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { temp.SetOne() } stack.push(&temp) + if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -776,8 +777,11 @@ func opCallCode(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if !value.IsZero() { gas += params.CallStipend } - - ret, returnGas, err := evm.CallCode(scope.Contract.Address(), toAddr, args, NewGasBudget(gas), &value) + // Regular gas for the forward was already pre-deducted by the dynamic + // gas table, only the state reservoir needs to be handed off to the + // child here. + childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) + ret, result, err := evm.CallCode(scope.Contract.Address(), toAddr, args, childBudget, &value) if err != nil { temp.Clear() } else { @@ -788,7 +792,7 @@ func opCallCode(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -806,7 +810,11 @@ func opDelegateCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // Get arguments from the memory. args := scope.Memory.GetPtr(inOffset.Uint64(), inSize.Uint64()) - ret, returnGas, err := evm.DelegateCall(scope.Contract.Caller(), scope.Contract.Address(), toAddr, args, NewGasBudget(gas), scope.Contract.value) + // Regular gas for the forward was already pre-deducted by the dynamic + // gas table, only the state reservoir needs to be handed off to the + // child here. + childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) + ret, result, err := evm.DelegateCall(scope.Contract.Caller(), scope.Contract.Address(), toAddr, args, childBudget, scope.Contract.value) if err != nil { temp.Clear() } else { @@ -816,8 +824,7 @@ func opDelegateCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -835,7 +842,11 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // Get arguments from the memory. args := scope.Memory.GetPtr(inOffset.Uint64(), inSize.Uint64()) - ret, returnGas, err := evm.StaticCall(scope.Contract.Address(), toAddr, args, NewGasBudget(gas)) + // Regular gas for the forward was already pre-deducted by the dynamic + // gas table, only the state reservoir needs to be handed off to the + // child here. + childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) + ret, result, err := evm.StaticCall(scope.Contract.Address(), toAddr, args, childBudget) if err != nil { temp.Clear() } else { @@ -846,7 +857,7 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil diff --git a/core/vm/interpreter.go b/core/vm/interpreter.go index 3994327247..ca33670163 100644 --- a/core/vm/interpreter.go +++ b/core/vm/interpreter.go @@ -174,7 +174,7 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte // associated costs. contractAddr := contract.Address() consumed, wanted := evm.TxContext.AccessEvents.CodeChunksRangeGas(contractAddr, pc, 1, uint64(len(contract.Code)), false, contract.Gas.RegularGas) - contract.UseGas(GasCosts{RegularGas: consumed}, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk) + contract.chargeRegular(consumed, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk) if consumed < wanted { return nil, ErrOutOfGas } @@ -192,10 +192,8 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte return nil, &ErrStackOverflow{stackLen: sLen, limit: operation.maxStack} } // for tracing: this gas consumption event is emitted below in the debug section. - if contract.Gas.RegularGas < cost { + if !contract.chargeRegular(cost, nil, tracing.GasChangeIgnored) { return nil, ErrOutOfGas - } else { - contract.Gas.RegularGas -= cost } // All ops with a dynamic memory usage also has a dynamic gas cost. @@ -224,11 +222,13 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte if err != nil { return nil, fmt.Errorf("%w: %v", ErrOutOfGas, err) } - // for tracing: this gas consumption event is emitted below in the debug section. - if contract.Gas.RegularGas < dynamicCost.RegularGas { + // EIP-8037: charge regular gas before state gas. The state charge + // is a no-op when dynamicCost.StateGas == 0 (e.g., pre-Amsterdam). + if !contract.chargeRegular(dynamicCost.RegularGas, nil, tracing.GasChangeIgnored) { + return nil, ErrOutOfGas + } + if !contract.chargeState(dynamicCost.StateGas, nil, tracing.GasChangeIgnored) { return nil, ErrOutOfGas - } else { - contract.Gas.RegularGas -= dynamicCost.RegularGas } } diff --git a/core/vm/interpreter_test.go b/core/vm/interpreter_test.go index 868cb12d04..42530b83b7 100644 --- a/core/vm/interpreter_test.go +++ b/core/vm/interpreter_test.go @@ -55,7 +55,7 @@ func TestLoopInterrupt(t *testing.T) { timeout := make(chan bool) go func(evm *EVM) { - _, _, err := evm.Call(common.Address{}, address, nil, NewGasBudget(math.MaxUint64), new(uint256.Int)) + _, _, err := evm.Call(common.Address{}, address, nil, NewGasBudget(math.MaxUint64, 0), new(uint256.Int)) errChannel <- err }(evm) @@ -85,7 +85,7 @@ func BenchmarkInterpreter(b *testing.B) { value = uint256.NewInt(0) stack = newStackForTesting() mem = NewMemory() - contract = NewContract(common.Address{}, common.Address{}, value, NewGasBudget(startGas), nil) + contract = NewContract(common.Address{}, common.Address{}, value, NewGasBudget(startGas, 0), nil) ) stack.push(uint256.NewInt(123)) stack.push(uint256.NewInt(123)) diff --git a/core/vm/jump_table.go b/core/vm/jump_table.go index 82fc43ec13..9ea8349e3a 100644 --- a/core/vm/jump_table.go +++ b/core/vm/jump_table.go @@ -23,8 +23,9 @@ import ( ) type ( - executionFunc func(pc *uint64, evm *EVM, callContext *ScopeContext) ([]byte, error) - gasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (GasCosts, error) // last parameter is the requested memory size as a uint64 + executionFunc func(pc *uint64, evm *EVM, callContext *ScopeContext) ([]byte, error) + gasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (GasCosts, error) // last parameter is the requested memory size as a uint64 + intrinsicGasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (uint64, error) // last parameter is the requested memory size as a uint64 // memorySizeFunc returns the required size, and whether the operation overflowed a uint64 memorySizeFunc func(*Stack) (size uint64, overflow bool) ) @@ -97,6 +98,7 @@ func newAmsterdamInstructionSet() JumpTable { instructionSet := newOsakaInstructionSet() enable7843(&instructionSet) // EIP-7843 (SLOTNUM opcode) enable8024(&instructionSet) // EIP-8024 (Backward compatible SWAPN, DUPN, EXCHANGE) + enable8037(&instructionSet) // EIP-8037 (State creation gas cost increase) return validate(instructionSet) } diff --git a/core/vm/operations_acl.go b/core/vm/operations_acl.go index 86ac262a93..2206fb95fa 100644 --- a/core/vm/operations_acl.go +++ b/core/vm/operations_acl.go @@ -168,7 +168,7 @@ func makeCallVariantGasCallEIP2929(oldCalculator gasFunc, addressPosition int) g evm.StateDB.AddAddressToAccessList(addr) // Charge the remaining difference here already, to correctly calculate available // gas for call - if !contract.UseGas(GasCosts{RegularGas: coldCost}, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + if !contract.chargeRegular(coldCost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { return GasCosts{}, ErrOutOfGas } } @@ -276,7 +276,7 @@ func gasCallEIP7702(evm *EVM, contract *Contract, stack *Stack, mem *Memory, mem return innerGasCallEIP7702(evm, contract, stack, mem, memorySize) } -func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { +func makeCallVariantGasCallEIP7702(intrinsicFunc intrinsicGasFunc) gasFunc { return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { var ( eip2929Cost uint64 @@ -295,7 +295,7 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { // Charge the remaining difference here already, to correctly calculate // available gas for call - if !contract.UseGas(GasCosts{RegularGas: eip2929Cost}, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + if !contract.chargeRegular(eip2929Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { return GasCosts{}, ErrOutOfGas } } @@ -312,7 +312,7 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { // Terminate the gas measurement if the leftover gas is not sufficient, // it can effectively prevent accessing the states in the following steps. // It's an essential safeguard before any stateful check. - if contract.Gas.RegularGas < intrinsicCost.RegularGas { + if contract.Gas.RegularGas < intrinsicCost { return GasCosts{}, ErrOutOfGas } @@ -324,13 +324,13 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { evm.StateDB.AddAddressToAccessList(target) eip7702Cost = params.ColdAccountAccessCostEIP2929 } - if !contract.UseGas(GasCosts{RegularGas: eip7702Cost}, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + if !contract.chargeRegular(eip7702Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { return GasCosts{}, ErrOutOfGas } } // Calculate the gas budget for the nested call. The costs defined by // EIP-2929 and EIP-7702 have already been applied. - evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, intrinsicCost.RegularGas, stack.back(0)) + evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, intrinsicCost, stack.back(0)) if err != nil { return GasCosts{}, err } @@ -339,6 +339,10 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { // part of the dynamic gas. This will ensure it is correctly reported to // tracers. contract.Gas.RegularGas += eip2929Cost + eip7702Cost + // Undo the RegularGasUsed increments from the direct UseGas charges, + // since this gas will be re-charged via the returned cost. + contract.Gas.UsedRegularGas -= eip2929Cost + contract.Gas.UsedRegularGas -= eip7702Cost // Aggregate the gas costs from all components, including EIP-2929, EIP-7702, // the CALL opcode itself, and the cost incurred by nested calls. @@ -349,7 +353,7 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { if totalCost, overflow = math.SafeAdd(eip2929Cost, eip7702Cost); overflow { return GasCosts{}, ErrGasUintOverflow } - if totalCost, overflow = math.SafeAdd(totalCost, intrinsicCost.RegularGas); overflow { + if totalCost, overflow = math.SafeAdd(totalCost, intrinsicCost); overflow { return GasCosts{}, ErrGasUintOverflow } if totalCost, overflow = math.SafeAdd(totalCost, evm.callGasTemp); overflow { diff --git a/core/vm/runtime/env.go b/core/vm/runtime/env.go index 4181e08ee8..de3e8d4ae2 100644 --- a/core/vm/runtime/env.go +++ b/core/vm/runtime/env.go @@ -19,6 +19,7 @@ package runtime import ( "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/params" "github.com/holiman/uint256" ) @@ -29,17 +30,18 @@ func NewEnv(cfg *Config) *vm.EVM { BlobHashes: cfg.BlobHashes, } blockContext := vm.BlockContext{ - CanTransfer: core.CanTransfer, - Transfer: core.Transfer, - GetHash: cfg.GetHashFn, - Coinbase: cfg.Coinbase, - BlockNumber: cfg.BlockNumber, - Time: cfg.Time, - Difficulty: cfg.Difficulty, - GasLimit: cfg.GasLimit, - BaseFee: cfg.BaseFee, - BlobBaseFee: cfg.BlobBaseFee, - Random: cfg.Random, + CanTransfer: core.CanTransfer, + Transfer: core.Transfer, + GetHash: cfg.GetHashFn, + Coinbase: cfg.Coinbase, + BlockNumber: cfg.BlockNumber, + Time: cfg.Time, + Difficulty: cfg.Difficulty, + GasLimit: cfg.GasLimit, + BaseFee: cfg.BaseFee, + BlobBaseFee: cfg.BlobBaseFee, + Random: cfg.Random, + CostPerStateByte: params.CostPerStateByte, } evm := vm.NewEVM(blockContext, cfg.State, cfg.ChainConfig, cfg.EVMConfig) diff --git a/core/vm/runtime/runtime.go b/core/vm/runtime/runtime.go index 4fafdf3a50..34dec1bd4a 100644 --- a/core/vm/runtime/runtime.go +++ b/core/vm/runtime/runtime.go @@ -144,15 +144,15 @@ func Execute(code, input []byte, cfg *Config) ([]byte, *state.StateDB, error) { // set the receiver's (the executing contract) code for execution. cfg.State.SetCode(address, code, tracing.CodeChangeUnspecified) // Call the code with the given configuration. - ret, leftOverGas, err := vmenv.Call( + ret, result, err := vmenv.Call( cfg.Origin, common.BytesToAddress([]byte("contract")), input, - vm.NewGasBudget(cfg.GasLimit), + vm.NewGasBudget(cfg.GasLimit, 0), uint256.MustFromBig(cfg.Value), ) if cfg.EVMConfig.Tracer != nil && cfg.EVMConfig.Tracer.OnTxEnd != nil { - cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - leftOverGas.RegularGas}, err) + cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - result.RegularGas}, err) } return ret, cfg.State, err } @@ -179,16 +179,16 @@ func Create(input []byte, cfg *Config) ([]byte, common.Address, uint64, error) { // - reset transient storage(eip 1153) cfg.State.Prepare(rules, cfg.Origin, cfg.Coinbase, nil, vm.ActivePrecompiles(rules), nil) // Call the code with the given configuration. - code, address, leftOverGas, err := vmenv.Create( + code, address, result, err := vmenv.Create( cfg.Origin, input, - vm.NewGasBudget(cfg.GasLimit), + vm.NewGasBudget(cfg.GasLimit, 0), uint256.MustFromBig(cfg.Value), ) if cfg.EVMConfig.Tracer != nil && cfg.EVMConfig.Tracer.OnTxEnd != nil { - cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - leftOverGas.RegularGas}, err) + cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - result.RegularGas}, err) } - return code, address, leftOverGas.RegularGas, err + return code, address, result.RegularGas, err } // Call executes the code given by the contract's address. It will return the @@ -213,15 +213,15 @@ func Call(address common.Address, input []byte, cfg *Config) ([]byte, uint64, er statedb.Prepare(rules, cfg.Origin, cfg.Coinbase, &address, vm.ActivePrecompiles(rules), nil) // Call the code with the given configuration. - ret, leftOverGas, err := vmenv.Call( + ret, result, err := vmenv.Call( cfg.Origin, address, input, - vm.NewGasBudget(cfg.GasLimit), + vm.NewGasBudget(cfg.GasLimit, 0), uint256.MustFromBig(cfg.Value), ) if cfg.EVMConfig.Tracer != nil && cfg.EVMConfig.Tracer.OnTxEnd != nil { - cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - leftOverGas.RegularGas}, err) + cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - result.RegularGas}, err) } - return ret, leftOverGas.RegularGas, err + return ret, result.RegularGas, err } diff --git a/eth/tracers/js/tracer_test.go b/eth/tracers/js/tracer_test.go index 6570d73575..2fefa46492 100644 --- a/eth/tracers/js/tracer_test.go +++ b/eth/tracers/js/tracer_test.go @@ -55,7 +55,7 @@ func runTrace(tracer *tracers.Tracer, vmctx *vmContext, chaincfg *params.ChainCo gasLimit uint64 = 31000 startGas uint64 = 10000 value = uint256.NewInt(0) - contract = vm.NewContract(common.Address{}, common.Address{}, value, vm.NewGasBudget(startGas), nil) + contract = vm.NewContract(common.Address{}, common.Address{}, value, vm.NewGasBudget(startGas, 0), nil) ) evm.SetTxContext(vmctx.txCtx) contract.Code = []byte{byte(vm.PUSH1), 0x1, byte(vm.PUSH1), 0x1, 0x0} diff --git a/eth/tracers/logger/logger_test.go b/eth/tracers/logger/logger_test.go index decdf588e1..73868d22e0 100644 --- a/eth/tracers/logger/logger_test.go +++ b/eth/tracers/logger/logger_test.go @@ -47,7 +47,7 @@ func TestStoreCapture(t *testing.T) { var ( logger = NewStructLogger(nil) evm = vm.NewEVM(vm.BlockContext{}, &dummyStatedb{}, params.TestChainConfig, vm.Config{Tracer: logger.Hooks()}) - contract = vm.NewContract(common.Address{}, common.Address{}, new(uint256.Int), vm.NewGasBudget(100000), nil) + contract = vm.NewContract(common.Address{}, common.Address{}, new(uint256.Int), vm.NewGasBudget(100000, 0), nil) ) contract.Code = []byte{byte(vm.PUSH1), 0x1, byte(vm.PUSH1), 0x0, byte(vm.SSTORE)} var index common.Hash diff --git a/params/protocol_params.go b/params/protocol_params.go index 3e36b83547..69e10fa5d9 100644 --- a/params/protocol_params.go +++ b/params/protocol_params.go @@ -88,6 +88,7 @@ const ( LogTopicGas uint64 = 375 // Multiplied by the * of the LOG*, per LOG transaction. e.g. LOG0 incurs 0 * c_txLogTopicGas, LOG4 incurs 4 * c_txLogTopicGas. CreateGas uint64 = 32000 // Once per CREATE operation & contract-creation transaction. Create2Gas uint64 = 32000 // Once per CREATE2 operation + CreateGasAmsterdam uint64 = 9000 // Regular gas portion of CREATE in Amsterdam (EIP-8037); state gas is charged separately. CreateNGasEip4762 uint64 = 1000 // Once per CREATEn operations post-verkle SelfdestructRefundGas uint64 = 24000 // Refunded following a selfdestruct operation. MemoryGas uint64 = 3 // Times the address of the (highest referenced byte in memory + 1). NOTE: referencing happens on read, write and in instructions such as RETURN and CALL. @@ -100,6 +101,7 @@ const ( TxAccessListAddressGas uint64 = 2400 // Per address specified in EIP 2930 access list TxAccessListStorageKeyGas uint64 = 1900 // Per storage key specified in EIP 2930 access list TxAuthTupleGas uint64 = 12500 // Per auth tuple code specified in EIP-7702 + TxAuthTupleRegularGas uint64 = 7500 // Per auth tuple regular gas specified in EIP-8037 // These have been changed during the course of the chain CallGasFrontier uint64 = 40 // Once per CALL operation & message call transaction. @@ -196,6 +198,12 @@ const ( // the bound has a small safety margin for system-contract accesses that // don't consume block gas. BALItemCost uint64 = 2000 + + AccountCreationSize = 120 + StorageCreationSize = 64 + AuthorizationCreationSize = 23 + CostPerStateByte = 1530 + SystemMaxSStoresPerCall = 16 ) // Bls12381G1MultiExpDiscountTable is the gas discount table for BLS12-381 G1 multi exponentiation operation diff --git a/tests/state_test.go b/tests/state_test.go index cf1d4bce4c..09c5cad40e 100644 --- a/tests/state_test.go +++ b/tests/state_test.go @@ -325,10 +325,10 @@ func runBenchmark(b *testing.B, t *StateTest) { b.StartTimer() start := time.Now() - initialGas := vm.NewGasBudget(msg.GasLimit) + initialGas := vm.NewGasBudget(msg.GasLimit, 0) // Execute the message. - _, leftOverGas, err := evm.Call(sender.Address(), *msg.To, msg.Data, initialGas.Copy(), msg.Value) + _, result, err := evm.Call(sender.Address(), *msg.To, msg.Data, initialGas, msg.Value) if err != nil { b.Error(err) return @@ -337,7 +337,7 @@ func runBenchmark(b *testing.B, t *StateTest) { b.StopTimer() elapsed += uint64(time.Since(start)) refund += state.StateDB.GetRefund() - gasUsed += leftOverGas.Used(initialGas) + gasUsed += result.Used(initialGas) state.StateDB.RevertToSnapshot(snapshot) } diff --git a/tests/transaction_test_util.go b/tests/transaction_test_util.go index 572c109f1e..91f7d6c3ec 100644 --- a/tests/transaction_test_util.go +++ b/tests/transaction_test_util.go @@ -80,8 +80,8 @@ func (tt *TransactionTest) Run() error { if err != nil { return } - // Intrinsic gas - cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai, rules.IsAmsterdam) + // Intrinsic cost + cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, params.CostPerStateByte) if err != nil { return } From 116314baf9157f102321ef458cefd5cbed19fd28 Mon Sep 17 00:00:00 2001 From: cui Date: Sat, 13 Jun 2026 01:38:45 +0800 Subject: [PATCH 04/24] core/vm: fix test error message (#35134) --- core/vm/instructions_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/vm/instructions_test.go b/core/vm/instructions_test.go index 354d2ce5ab..a143b7d01a 100644 --- a/core/vm/instructions_test.go +++ b/core/vm/instructions_test.go @@ -724,7 +724,7 @@ func TestRandom(t *testing.T) { ) opRandom(&pc, evm, &ScopeContext{nil, stack, nil}) if have, want := stack.len(), 1; have != want { - t.Errorf("test '%v': want %d item(s) on stack, have %d: ", tt.name, have, want) + t.Errorf("test '%v': want %d item(s) on stack, have %d: ", tt.name, want, have) } actual := stack.pop() expected, overflow := uint256.FromBig(new(big.Int).SetBytes(tt.random.Bytes())) From 23483010a479fea7aabfce9c8f6605d0ba62ee8d Mon Sep 17 00:00:00 2001 From: cui Date: Sat, 13 Jun 2026 05:09:54 +0800 Subject: [PATCH 05/24] cmd/geth: fix logging line count error (#35136) --- cmd/geth/logging_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/geth/logging_test.go b/cmd/geth/logging_test.go index d420c2d078..5131e6787b 100644 --- a/cmd/geth/logging_test.go +++ b/cmd/geth/logging_test.go @@ -96,7 +96,7 @@ func testConsoleLogging(t *testing.T, format string, tStart, tEnd int) { } } if len(haveLines) != len(wantLines) { - t.Errorf("format %v, want %d lines, have %d", format, len(haveLines), len(wantLines)) + t.Errorf("format %v, want %d lines, have %d", format, len(wantLines), len(haveLines)) } } From e2164cc78c690367e2ca0b24cae982c3ab44bb4e Mon Sep 17 00:00:00 2001 From: Jonny Rhea <5555162+jrhea@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:09:41 -0500 Subject: [PATCH 06/24] eth/downloader, eth/protocols/snap: freeze pivot once state is downloaded (#35155) --- core/rawdb/accessors_snapshot.go | 42 ------ core/rawdb/database.go | 2 - core/rawdb/schema.go | 9 -- eth/downloader/beaconsync.go | 6 +- eth/downloader/downloader.go | 28 +++- eth/protocols/snap/bal_apply.go | 6 +- eth/protocols/snap/bal_apply_test.go | 2 +- eth/protocols/snap/handler.go | 6 +- eth/protocols/snap/progress_test.go | 8 +- eth/protocols/snap/protocol.go | 9 +- eth/protocols/snap/syncer.go | 9 ++ eth/protocols/snap/syncv2.go | 192 +++++++++++++++++++-------- eth/protocols/snap/syncv2_test.go | 181 +++++++++++++++++-------- triedb/generate.go | 37 +----- triedb/generate_test.go | 91 ------------- 15 files changed, 317 insertions(+), 311 deletions(-) diff --git a/core/rawdb/accessors_snapshot.go b/core/rawdb/accessors_snapshot.go index 4573e08321..8872b00fc2 100644 --- a/core/rawdb/accessors_snapshot.go +++ b/core/rawdb/accessors_snapshot.go @@ -209,48 +209,6 @@ func WriteSnapshotSyncStatus(db ethdb.KeyValueWriter, status []byte) { } } -// ReadGenerateTriePartitionDone returns the raw subtree root blob for a -// partition that has previously completed. -func ReadGenerateTriePartitionDone(db ethdb.KeyValueReader, partition byte) ([]byte, bool) { - data, err := db.Get(generateTriePartitionDoneKey(partition)) - if err != nil { - return nil, false - } - if len(data) == 0 { - return nil, false - } - switch data[0] { - case 0x00: - // Partition is done and it is empty. - return nil, true - case 0x01: - // Partition is done and the blob follows. - return data[1:], true - default: - return nil, false - } -} - -// WriteGenerateTriePartitionDone records a completed partition. -func WriteGenerateTriePartitionDone(db ethdb.KeyValueWriter, partition byte, blob []byte) { - var value []byte - if blob == nil { - value = []byte{0x00} - } else { - value = append([]byte{0x01}, blob...) - } - if err := db.Put(generateTriePartitionDoneKey(partition), value); err != nil { - log.Crit("Failed to store generate-trie done marker", "err", err) - } -} - -// DeleteGenerateTriePartitionDone removes a partition's done marker. -func DeleteGenerateTriePartitionDone(db ethdb.KeyValueWriter, partition byte) { - if err := db.Delete(generateTriePartitionDoneKey(partition)); err != nil { - log.Crit("Failed to remove generate-trie done marker", "err", err) - } -} - // DeleteSnapshotSyncStatus removes the serialized sync status from the database. func DeleteSnapshotSyncStatus(db ethdb.KeyValueWriter) { if err := db.Delete(snapshotSyncStatusKey); err != nil { diff --git a/core/rawdb/database.go b/core/rawdb/database.go index bf19f41c44..8063bc6419 100644 --- a/core/rawdb/database.go +++ b/core/rawdb/database.go @@ -563,8 +563,6 @@ func InspectDatabase(db ethdb.Database, keyPrefix, keyStart []byte) error { } // Metadata keys - case bytes.HasPrefix(key, generateTriePartitionDonePrefix) && len(key) == len(generateTriePartitionDonePrefix)+1: - metadata.add(size) case slices.ContainsFunc(knownMetadataKeys, func(x []byte) bool { return bytes.Equal(x, key) }): metadata.add(size) diff --git a/core/rawdb/schema.go b/core/rawdb/schema.go index da5f9608bf..54c76143b4 100644 --- a/core/rawdb/schema.go +++ b/core/rawdb/schema.go @@ -104,10 +104,6 @@ var ( // snapSyncStatusFlagKey flags that status of snap sync. snapSyncStatusFlagKey = []byte("SnapSyncStatus") - // generateTriePartitionDonePrefix stores the subtree root hash of each - // triedb.GenerateTrie partition once it finishes. - generateTriePartitionDonePrefix = []byte("gtd") // generateTriePartitionDonePrefix + partition byte -> subtree root hash - // Data item prefixes (use single byte to avoid mixing data types, avoid `i`, used for indexes). headerPrefix = []byte("h") // headerPrefix + num (uint64 big endian) + hash -> header headerTDSuffix = []byte("t") // headerPrefix + num (uint64 big endian) + hash + headerTDSuffix -> td (deprecated) @@ -469,8 +465,3 @@ func trienodeHistoryIndexBlockKey(addressHash common.Hash, path []byte, blockID func transitionStateKey(hash common.Hash) []byte { return append(VerkleTransitionStatePrefix, hash.Bytes()...) } - -// generateTriePartitionDoneKey = generateTriePartitionDonePrefix + partition (single byte). -func generateTriePartitionDoneKey(partition byte) []byte { - return append(generateTriePartitionDonePrefix, partition) -} diff --git a/eth/downloader/beaconsync.go b/eth/downloader/beaconsync.go index 914e1dfada..23daafa8f6 100644 --- a/eth/downloader/beaconsync.go +++ b/eth/downloader/beaconsync.go @@ -297,9 +297,11 @@ func (d *Downloader) fetchHeaders(from uint64) error { return err } // If the pivot became stale (older than 2*64-8 (bit of wiggle room)), - // move it ahead to HEAD-64 + // move it ahead to HEAD-64. + // + // The state syncer is consulted first before the pivot movement. d.pivotLock.Lock() - if d.pivotHeader != nil { + if d.pivotHeader != nil && d.snapSyncer.FrozenPivot() == nil { if head.Number.Uint64() > d.pivotHeader.Number.Uint64()+2*uint64(fsMinFullBlocks)-8 { // Retrieve the next pivot header, either from skeleton chain // or the filled chain diff --git a/eth/downloader/downloader.go b/eth/downloader/downloader.go index e0a4ec6b6d..af4c4bb1f6 100644 --- a/eth/downloader/downloader.go +++ b/eth/downloader/downloader.go @@ -496,6 +496,18 @@ func (d *Downloader) syncToHead() (err error) { if mode == ethconfig.SnapSync && pivot == nil { pivot = d.blockchain.CurrentBlock() } + // If the snap syncer froze its pivot in a previous cycle, resume against + // the frozen header instead of a fresh one. + if mode == ethconfig.SnapSync && pivot != nil { + if frozen := d.snapSyncer.FrozenPivot(); frozen != nil { + if rawdb.ReadCanonicalHash(d.stateDB, frozen.Number.Uint64()) == frozen.Hash() { + log.Info("Resuming snap sync against frozen pivot", "number", frozen.Number, "hash", frozen.Hash()) + pivot = frozen + } else { + log.Warn("Frozen pivot is no longer canonical", "number", frozen.Number, "hash", frozen.Hash()) + } + } + } height := latest.Number.Uint64() // In beacon mode, use the skeleton chain for the ancestor lookup @@ -921,7 +933,9 @@ func (d *Downloader) processSnapSyncContent() error { // the results in the meantime. // // Note, there's no issue with memory piling up since after 64 blocks the - // pivot will forcefully move so these accumulators will be dropped. + // pivot will forcefully move so these accumulators will be dropped. The + // exception is snap/2 trie generation, where the pivot is frozen on + // purpose and results accumulate until the generation finishes. var ( oldPivot *fetchResult // Locked in pivot block, might change eventually oldTail []*fetchResult // Downloaded content after the pivot @@ -978,11 +992,15 @@ func (d *Downloader) processSnapSyncContent() error { return err } if P != nil { - // If new pivot block found, cancel old state retrieval and restart + // If new pivot block found, cancel old state retrieval and restart. if oldPivot != P { - sync.Cancel() - sync = d.syncState(P.Header) - go closeOnErr(sync) + // Skip the restart if the running sync already targets the + // pivot's root (e.g, no pivot block movement yet). + if sync.pivot.Root != P.Header.Root { + sync.Cancel() + sync = d.syncState(P.Header) + go closeOnErr(sync) + } oldPivot = P } // Wait for completion, occasionally checking for pivot staleness diff --git a/eth/protocols/snap/bal_apply.go b/eth/protocols/snap/bal_apply.go index 3e565662e7..d67015361b 100644 --- a/eth/protocols/snap/bal_apply.go +++ b/eth/protocols/snap/bal_apply.go @@ -93,7 +93,7 @@ func (s *syncerV2) isStorageFetched(accountHash, storageHash common.Hash) bool { // applyAccessList applies a single block's access list diffs to the flat state // in the database. For each account, it applies the post-block values (highest // TxIdx entry) for balance, nonce, code, and storage. The storageRoot field is -// intentionally left stale. It will be recomputed during the trie rebuild. +// intentionally left stale. It will be recomputed during the trie generation. func (s *syncerV2) applyAccessList(b *bal.BlockAccessList, batch ethdb.Batch) error { // Iterate over all accounts in the access list for _, access := range *b { @@ -113,7 +113,7 @@ func (s *syncerV2) applyAccessList(b *bal.BlockAccessList, batch ethdb.Batch) er rawdb.DeleteStorageSnapshot(batch, accountHash, storageHash) } else { // Store the slot in the same encoding the snapshot and the - // trie rebuild use: RLP of the minimal big-endian value + // trie generation use: RLP of the minimal big-endian value // (leading zeros trimmed), matching core/state's snapshot // writes. blob, _ := rlp.EncodeToBytes(value.Bytes()) @@ -176,7 +176,7 @@ func (s *syncerV2) applyAccessList(b *bal.BlockAccessList, batch ethdb.Batch) er case isEmpty && !isNew: // Existing account got fully drained (e.g., pre-funded // address that gets deployed to with init code that - // self-destructs). Delete the entry so the trie rebuild + // self-destructs). Delete the entry so the trie generation // doesn't pick it up as an empty leaf. rawdb.DeleteAccountSnapshot(batch, accountHash) default: diff --git a/eth/protocols/snap/bal_apply_test.go b/eth/protocols/snap/bal_apply_test.go index a9e7f789a5..e5c9188b1b 100644 --- a/eth/protocols/snap/bal_apply_test.go +++ b/eth/protocols/snap/bal_apply_test.go @@ -157,7 +157,7 @@ func TestAccessListApplication(t *testing.T) { // Verify storage updated. Slots are stored in the canonical snapshot // encoding (RLP of the value with leading zeros trimmed), the same form - // the download path writes and the trie rebuild consumes. + // the download path writes and the trie generation consumes. storageVal := rawdb.ReadStorageSnapshot(db, accountHash, slotHash) wantStorage, _ := rlp.EncodeToBytes(common.TrimLeftZeroes(common.HexToHash("0x02").Bytes())) if !bytes.Equal(storageVal, wantStorage) { diff --git a/eth/protocols/snap/handler.go b/eth/protocols/snap/handler.go index 7adff5dc0f..ff5564ca65 100644 --- a/eth/protocols/snap/handler.go +++ b/eth/protocols/snap/handler.go @@ -84,8 +84,10 @@ type Backend interface { // otherwise only the default (snap/1) versions are offered on the wire. func MakeProtocols(backend Backend, snapV2 bool) []p2p.Protocol { versions := ProtocolVersions - if snapV2 { - versions = append([]uint{SNAP2}, versions...) + if !snapV2 { + // snap/2 is not safe to advertise unconditionally yet, so it is gated + // behind a feature flag. + versions = []uint{SNAP1} } protocols := make([]p2p.Protocol, len(versions)) for i, version := range versions { diff --git a/eth/protocols/snap/progress_test.go b/eth/protocols/snap/progress_test.go index 21ec36c2b5..25b304038a 100644 --- a/eth/protocols/snap/progress_test.go +++ b/eth/protocols/snap/progress_test.go @@ -186,8 +186,8 @@ func TestSyncProgressV1Discarded(t *testing.T) { syncer := newSyncerV2(db, rawdb.HashScheme) syncer.loadSyncStatus() - if syncer.previousPivot != nil { - t.Fatalf("expected previousPivot nil after discarding old format, got %+v", syncer.previousPivot) + if syncer.pivot != nil { + t.Fatalf("expected pivot nil after discarding old format, got %+v", syncer.pivot) } if len(syncer.tasks) != accountConcurrency { t.Fatalf("expected fresh task split of %d, got %d", accountConcurrency, len(syncer.tasks)) @@ -258,8 +258,8 @@ func TestSyncProgressCorruptPayload(t *testing.T) { syncer := newSyncerV2(db, rawdb.HashScheme) syncer.loadSyncStatus() - if syncer.previousPivot != nil { - t.Fatalf("expected previousPivot nil after corrupt payload, got %+v", syncer.previousPivot) + if syncer.pivot != nil { + t.Fatalf("expected pivot nil after corrupt payload, got %+v", syncer.pivot) } if len(syncer.tasks) != accountConcurrency { t.Fatalf("expected fresh task split of %d, got %d", accountConcurrency, len(syncer.tasks)) diff --git a/eth/protocols/snap/protocol.go b/eth/protocols/snap/protocol.go index e14ca1283d..d46607ff22 100644 --- a/eth/protocols/snap/protocol.go +++ b/eth/protocols/snap/protocol.go @@ -35,11 +35,10 @@ const ( // devp2p capability negotiation. const ProtocolName = "snap" -// ProtocolVersions are the supported versions of the `snap` protocol advertised -// by default (first is primary). snap/2 is not safe to advertise unconditionally -// yet, so it is gated behind a feature flag and appended in MakeProtocols rather -// than listed here. -var ProtocolVersions = []uint{SNAP1} +// ProtocolVersions are all the `snap` protocol versions this node implements +// (first is primary). What's actually advertised on the wire is decided by +// MakeProtocols, which gates snap/2 behind a feature flag. +var ProtocolVersions = []uint{SNAP2, SNAP1} // protocolLengths are the number of implemented messages corresponding to // different protocol versions. snap/2 adds GetAccessLists/AccessLists (0x08/0x09). diff --git a/eth/protocols/snap/syncer.go b/eth/protocols/snap/syncer.go index 0c3ea5caf2..5d1aa67ea7 100644 --- a/eth/protocols/snap/syncer.go +++ b/eth/protocols/snap/syncer.go @@ -58,6 +58,10 @@ type Syncer interface { OnTrieNodes(peer SyncPeerV2, id uint64, trienodes [][]byte) error OnAccessLists(peer SyncPeerV2, id uint64, lists rlp.RawList[rlp.RawValue]) error + // FrozenPivot returns the pivot header the syncer is bound to, or nil if + // the pivot may still be chosen and moved freely. + FrozenPivot() *types.Header + // Version is the snap protocol version this syncer implements. Version() uint } @@ -122,6 +126,11 @@ func (syncerV1Adapter) OnAccessLists(SyncPeerV2, uint64, rlp.RawList[rlp.RawValu // Version is SNAP1 func (syncerV1Adapter) Version() uint { return SNAP1 } +// FrozenPivot is always nil for snap/1: the sync target must keep tracking +// the chain head, ensuring the state is available in the network, so the +// pivot is never frozen. +func (syncerV1Adapter) FrozenPivot() *types.Header { return nil } + // syncerV2Adapter adapts the snap/2 *syncerV2 to Syncer. Its peer-facing methods // already take SyncPeerV2 and its Sync already takes a header, so only Progress // (different return type) and OnTrieNodes (absent) need wrapping. diff --git a/eth/protocols/snap/syncv2.go b/eth/protocols/snap/syncv2.go index 70f78e3ec8..ac84008697 100644 --- a/eth/protocols/snap/syncv2.go +++ b/eth/protocols/snap/syncv2.go @@ -26,6 +26,7 @@ import ( "math/rand" "sort" "sync" + "sync/atomic" "time" "github.com/ethereum/go-ethereum/common" @@ -261,13 +262,34 @@ type storageTaskV2 struct { done bool // Flag whether the task can be removed } +// syncPhase tracks how far a snap/2 sync has progressed for the journaled +// pivot. The phases are strictly ordered: each one implies all previous +// ones have finished. +type syncPhase uint8 + +const ( + // phaseDownload covers the flat state (account, storage, bytecode) + // download. The requests target the pivot root, which remote peers + // only serve while it is recent, so the pivot must keep tracking the + // chain head (see FrozenPivot). + phaseDownload syncPhase = iota + + // phaseGenerate covers the local trie generation after the download + // has completed. It targets the exact pivot root it was started with, + // so pivot updates are refused from here on. + phaseGenerate + + // phaseComplete means the sync ran to completion for the pivot. + phaseComplete +) + // syncProgressV2 is a database entry to allow suspending and resuming a snapshot state // sync. Opposed to full and fast sync, there is no way to restart a suspended // snap sync without prior knowledge of the suspension point. type syncProgressV2 struct { - Pivot *types.Header // Pivot header being synced (for pivot move and reorg detection) - Tasks []*accountTaskV2 // The suspended account tasks (contract tasks within) - Complete bool // True once sync ran to completion for Pivot + Pivot *types.Header // Pivot header being synced (for pivot move and reorg detection) + Tasks []*accountTaskV2 // The suspended account tasks (contract tasks within) + Phase syncPhase // Phase is how far the sync has progressed for Pivot // Status report during syncing phase AccountSynced uint64 // Number of accounts downloaded @@ -313,7 +335,7 @@ type SyncPeerV2 interface { // syncerV2 is an Ethereum account and storage trie syncer based on the snap // protocol. It downloads all accounts, storage slots, and bytecodes from // remote peers as flat state, applies BAL diffs on pivot moves, -// and triggers a final trie rebuild once flat state is consistent. +// and triggers a final trie generation once flat state is consistent. // // Every network request has a variety of failure events: // - The peer disconnects after task assignment, failing to send the request @@ -322,14 +344,12 @@ type SyncPeerV2 interface { // - The peer delivers a stale response after a previous timeout // - The peer delivers a refusal to serve the requested state type syncerV2 struct { - db ethdb.Database // Database to store the trie nodes into (and dedup) - scheme string // Node scheme used in node database - - pivot *types.Header // Current pivot header being synced (lock needed) - previousPivot *types.Header // Pivot from previous sync run (for pivot move detection) - complete bool // Whether the persisted progress was a completed sync - tasks []*accountTaskV2 // Current account task set being synced - update chan struct{} // Notification channel for possible sync progression + db ethdb.Database // Database to store the trie nodes into (and dedup) + scheme string // Node scheme used in node database + pivot *types.Header // Current pivot header being synced (lock needed) + phase atomic.Uint32 // Current syncPhase; atomic so phase transitions are visible across goroutines + tasks []*accountTaskV2 // Current account task set being synced + update chan struct{} // Notification channel for possible sync progression peers map[string]SyncPeerV2 // Currently active peers to download from peerJoin *event.Feed // Event feed to react to peers joining @@ -370,7 +390,7 @@ type syncerV2 struct { // newSyncerV2 creates a new snapshot syncer to download the Ethereum state over the // snap protocol. func newSyncerV2(db ethdb.Database, scheme string) *syncerV2 { - return &syncerV2{ + s := &syncerV2{ db: db, scheme: scheme, @@ -393,6 +413,24 @@ func newSyncerV2(db ethdb.Database, scheme string) *syncerV2 { extProgress: new(syncProgressV2), } + if raw := rawdb.ReadSnapshotSyncStatus(db); len(raw) > 0 && raw[0] == syncProgressVersion { + var progress syncProgressV2 + if err := json.Unmarshal(raw[1:], &progress); err == nil { + s.pivot = progress.Pivot + s.setPhase(progress.Phase) + } + } + return s +} + +// getPhase returns the current sync phase. +func (s *syncerV2) getPhase() syncPhase { + return syncPhase(s.phase.Load()) +} + +// setPhase moves the sync to the given phase. +func (s *syncerV2) setPhase(phase syncPhase) { + s.phase.Store(uint32(phase)) } // Register injects a new data source into the syncer's peerset. @@ -452,19 +490,17 @@ func (s *syncerV2) Unregister(id string) error { // Sync starts (or resumes a previous) sync cycle to iterate over a state trie // with the given pivot header and reconstruct the nodes based on the snapshot // leaves. -func (s *syncerV2) Sync(pivot *types.Header, cancel chan struct{}) error { - if pivot == nil { +func (s *syncerV2) Sync(target *types.Header, cancel chan struct{}) error { + if target == nil { return errors.New("snap sync: pivot header is nil") } s.lock.Lock() - s.pivot = pivot - s.previousPivot = nil // loadSyncStatus overwrites when resuming from persisted progress s.statelessPeers = make(map[string]struct{}) s.lock.Unlock() if s.startTime.IsZero() { s.startTime = time.Now() } - root := pivot.Root + root := target.Root // Retrieve the previous sync status from DB. If there's no persisted // status, sync is either fresh or already complete. @@ -473,20 +509,24 @@ func (s *syncerV2) Sync(pivot *types.Header, cancel chan struct{}) error { // isPivotChanged is true when we have prior progress against a different // pivot. That means we need to roll forward via catchUp, or wipe and // restart if the prior pivot was reorged out. - isPivotChanged := s.previousPivot != nil && s.previousPivot.Hash() != s.pivot.Hash() + s.lock.RLock() + prevPivot := s.pivot + s.lock.RUnlock() + isPivotChanged := prevPivot != nil && prevPivot.Hash() != target.Hash() // Skip if we've already finished syncing this pivot. - if !isPivotChanged && s.complete { + if !isPivotChanged && s.getPhase() == phaseComplete { log.Info("Snap sync already complete for this pivot", "root", root) return nil } - // We're committing to running this sync. Clear the complete flag so a - // mid-run save (on cancel or error) doesn't persist a stale Complete=true - // status from a prior pivot. - s.lock.Lock() - s.complete = false - s.lock.Unlock() + // We're committing to running this sync. Demote a completed phase so a + // mid-run save (on cancel or error) doesn't persist a stale complete + // status from a prior pivot. The download remains done, only the trie + // generation must be redone against the new pivot. + if s.getPhase() == phaseComplete { + s.setPhase(phaseGenerate) + } defer func() { // Whether sync completed or not, disregard any future packets @@ -515,24 +555,25 @@ func (s *syncerV2) Sync(pivot *types.Header, cancel chan struct{}) error { // progress is still usable. If yes, roll forward via BAL catch-up. If not, // wipe everything and restart fresh. if isPivotChanged { - if isPivotReorged(s.db, s.previousPivot, s.pivot) { - log.Warn("Persisted progress unusable, restarting snap sync from scratch", - "number", s.previousPivot.Number, "oldHash", s.previousPivot.Hash()) + if isPivotReorged(s.db, prevPivot, target) { + log.Warn("Restarting snap sync from scratch", "oldnumber", prevPivot.Number, "oldHash", prevPivot.Hash()) s.resetSyncState() - } else if err := s.catchUp(cancel); err != nil { - return err + } else { + // A canonical pivot move past a frozen pivot should be impossible: + // the downloader both refuses moves (FrozenPivot) and resumes new + // cycles against the frozen header itself. Reaching this branch + // frozen indicates a bug on the downloader side; roll the flat + // state forward defensively and regenerate. + if s.getPhase() >= phaseGenerate { + log.Warn("Frozen pivot moved unexpectedly, rolling forward", "frozen", prevPivot.Number, "new", target.Number) + } + if err := s.catchUp(target, cancel); err != nil { + return err + } } } - - // Pin previousPivot to the current pivot before downloadState runs. - // This is what saveSyncStatus persists. If the download is interrupted - // and the next Sync gets a different pivot, this is how isPivotReorged - // recognizes the partial flat state belongs to the old pivot. Without - // it, isPivotReorged sees nil, skips the reorg branch, and downloadState - // would resume from the persisted task markers but mix the old pivot's - // already-downloaded accounts with the new pivot's data. s.lock.Lock() - s.previousPivot = s.pivot + s.pivot = target s.lock.Unlock() log.Info("Starting state download", "root", root) @@ -541,21 +582,47 @@ func (s *syncerV2) Sync(pivot *types.Header, cancel chan struct{}) error { } log.Info("State download complete", "root", root) + // Entering the generation phase makes the downloader stop moving the + // pivot (see FrozenPivot) until the pivot block is committed. The phase + // is persisted right away so the freeze also holds across a restart, + // before the generation has had a chance to finish. + if s.getPhase() < phaseGenerate { + s.setPhase(phaseGenerate) + s.saveSyncStatus() + } + log.Info("Starting trie generation", "root", root) + batch := s.db.NewBatch() + s.resetTrienodes(batch) + if err := batch.Write(); err != nil { + return err + } if _, err := triedb.GenerateTrie(s.db, s.scheme, root, cancel); err != nil { return err } log.Info("Trie generation complete", "root", root) - // Mark sync complete. The deferred saveSyncStatus persists this with - // Complete=true so a follow-up Sync call for the same pivot can skip - // the work entirely. - s.lock.Lock() - s.complete = true - s.lock.Unlock() + // Mark sync complete. The deferred saveSyncStatus persists this so a + // follow-up Sync call for the same pivot can skip the work entirely. + s.setPhase(phaseComplete) return nil } +// FrozenPivot returns the pivot header the sync is bound to, or nil while +// the pivot may still move freely. The pivot freezes once the state +// download completes. The remaining work (trie generation) and the pivot +// commit is purely local and targets the exact pivot root the download +// finished with, so from that point on the downloader must neither move the +// pivot nor start a new cycle against a different one. +func (s *syncerV2) FrozenPivot() *types.Header { + if s.getPhase() < phaseGenerate { + return nil + } + s.lock.RLock() + defer s.lock.RUnlock() + return s.pivot +} + // download runs the bulk flat-state download. It fetches // account ranges, storage slots, and bytecodes, writing flat state to disk. func (s *syncerV2) downloadState(cancel chan struct{}) error { @@ -660,10 +727,10 @@ func isPivotReorged(db ethdb.Database, prev, curr *types.Header) bool { // catchUp runs the BAL catch-up. When the pivot has moved, it fetches BALs // for the gap blocks, verifies them against block headers, and applies the // diffs to roll flat state forward. -func (s *syncerV2) catchUp(cancel chan struct{}) error { +func (s *syncerV2) catchUp(target *types.Header, cancel chan struct{}) error { s.lock.RLock() - from := s.previousPivot.Number.Uint64() + 1 - to := s.pivot.Number.Uint64() + from := s.pivot.Number.Uint64() + 1 + to := target.Number.Uint64() s.lock.RUnlock() log.Info("Starting BAL catch-up", "from", from, "to", to, "blocks", to-from+1) @@ -720,7 +787,7 @@ func (s *syncerV2) catchUp(cancel chan struct{}) error { // Persist incremental progress so a crash mid-catchUp can resume // from the next unapplied block. s.lock.Lock() - s.previousPivot = headers[hash] + s.pivot = headers[hash] s.lock.Unlock() s.saveSyncStatusWithDB(batch) @@ -952,8 +1019,8 @@ func (s *syncerV2) loadSyncStatus() { } task.StorageCompleted = nil } - s.previousPivot = progress.Pivot - s.complete = progress.Complete + s.pivot = progress.Pivot + s.setPhase(progress.Phase) s.accountSynced = progress.AccountSynced s.accountBytes = progress.AccountBytes s.bytecodeSynced = progress.BytecodeSynced @@ -1005,6 +1072,16 @@ func deleteRange(batch ethdb.Batch, prefix []byte) { } } +// resetTrienodes wipes all persisted trienodes if the path scheme is used. +// It's a defensive operation, ensuring all the leftover trie nodes are cleared +// before the new generation cycle. +func (s *syncerV2) resetTrienodes(batch ethdb.Batch) { + if s.scheme == rawdb.PathScheme { + deleteRange(batch, rawdb.TrieNodeAccountPrefix) + deleteRange(batch, rawdb.TrieNodeStoragePrefix) + } +} + // resetSyncState wipes all persisted snap-sync data (sync status, account // and storage snapshots) and re-initializes in-memory state with a fresh // chunking of the account hash range. @@ -1013,14 +1090,15 @@ func (s *syncerV2) resetSyncState() { rawdb.DeleteSnapshotSyncStatus(batch) deleteRange(batch, rawdb.SnapshotAccountPrefix) deleteRange(batch, rawdb.SnapshotStoragePrefix) + s.resetTrienodes(batch) batch.Write() s.lock.Lock() defer s.lock.Unlock() s.tasks = nil - s.previousPivot = nil - s.complete = false + s.pivot = nil + s.setPhase(phaseDownload) s.accountSynced, s.accountBytes = 0, 0 s.bytecodeSynced, s.bytecodeBytes = 0, 0 s.storageSynced, s.storageBytes = 0, 0 @@ -1069,9 +1147,9 @@ func (s *syncerV2) saveSyncStatusWithDB(db ethdb.KeyValueWriter) { } // Store the actual progress markers. progress := &syncProgressV2{ - Pivot: s.previousPivot, + Pivot: s.pivot, Tasks: s.tasks, - Complete: s.complete, + Phase: s.getPhase(), AccountSynced: s.accountSynced, AccountBytes: s.accountBytes, BytecodeSynced: s.bytecodeSynced, @@ -2028,7 +2106,7 @@ func (s *syncerV2) forwardAccountTask(task *accountTaskV2) { // Persist the received account segments. These flat state maybe // outdated during the sync, but it can be fixed later during the - // trie rebuild. + // trie generation. oldAccountBytes := s.accountBytes batch := ethdb.HookedBatch{ diff --git a/eth/protocols/snap/syncv2_test.go b/eth/protocols/snap/syncv2_test.go index d3c1b61800..aed7aac5f2 100644 --- a/eth/protocols/snap/syncv2_test.go +++ b/eth/protocols/snap/syncv2_test.go @@ -547,6 +547,65 @@ func testSyncV2(t *testing.T, scheme string) { verifyAdoptedSyncedState(scheme, syncer.db, sourceAccountTrie.Hash(), elems, t) } +// TestSyncV2FrozenPivot checks the pivot freeze signal around the sync +// lifecycle. The pivot is unfrozen while flat state is downloading, frozen +// once the download completes, stays frozen after the sync returns so the +// downloader resumes against it until the pivot block is committed, and +// unfreezes again after a state reset. +func TestSyncV2FrozenPivot(t *testing.T) { + t.Parallel() + testSyncV2FrozenPivot(t, rawdb.HashScheme) + testSyncV2FrozenPivot(t, rawdb.PathScheme) +} + +func testSyncV2FrozenPivot(t *testing.T, scheme string) { + var ( + once sync.Once + cancel = make(chan struct{}) + term = func() { once.Do(func() { close(cancel) }) } + ) + nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, scheme) + + source := newTestPeerV2("source", t, term) + source.accountTrie = sourceAccountTrie.Copy() + source.accountValues = elems + + syncer := setupSyncerV2(nodeScheme, source) + pivot := mkPivot(0, sourceAccountTrie.Hash()) + + // The handler runs while account ranges are still being served, so it + // can observe the signal mid download. + source.accountRequestV2Handler = func(p *testPeerV2, requestId uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { + if syncer.FrozenPivot() != nil { + t.Error("pivot frozen during flat state download") + } + return defaultAccountRequestHandlerV2(p, requestId, root, origin, limit, cap) + } + if syncer.FrozenPivot() != nil { + t.Fatal("pivot frozen before sync started") + } + if err := syncer.Sync(pivot, cancel); err != nil { + t.Fatalf("sync failed: %v", err) + } + if frozen := syncer.FrozenPivot(); frozen == nil || frozen.Hash() != pivot.Hash() { + t.Fatal("pivot not frozen at the synced header after download completed") + } + // A restart must not lose the freeze: a fresh syncer instance on the same + // database derives it from the persisted journal, before any Sync call. + restarted := newSyncerV2(syncer.db, nodeScheme) + if frozen := restarted.FrozenPivot(); frozen == nil || frozen.Hash() != pivot.Hash() { + t.Fatal("pivot freeze lost after restart") + } + syncer.resetSyncState() + if syncer.FrozenPivot() != nil { + t.Fatal("pivot still frozen after state reset") + } + // The reset deletes the journal, so a restarted instance is unfrozen too. + if restarted := newSyncerV2(syncer.db, nodeScheme); restarted.FrozenPivot() != nil { + t.Fatal("pivot still frozen after restart following a state reset") + } +} + // verifyAdoptedSyncedState exercises the snap/2 completion contract end-to-end: // after a real sync, opening a fresh triedb and calling AdoptSyncedState must // (a) succeed and (b) leave flat-state reads serving immediately, with no @@ -1326,9 +1385,9 @@ func TestIsPivotReorged(t *testing.T) { // canonical header at block 100 has a different hash. Sync is then called with // a new pivot at the same height. // -// If isPivotReorged works, loadSyncStatus restores previousPivot, the check -// flags it as reorged, resetSyncState clears previousPivot, catchUp is -// skipped, and the fresh download proceeds to completion. +// If isPivotReorged works, loadSyncStatus restores the persisted pivot, the +// check flags it as reorged, resetSyncState clears it, catchUp is skipped, +// and the fresh download proceeds to completion. // // If detection doesn't fire, the pivot-move check would call catchUp with // from = 101 and to = 100 — the inverted-range guard surfaces that as an @@ -1347,10 +1406,8 @@ func TestSyncDetectsPivotReorged(t *testing.T) { // and non-zero counter so the reset path has something to clean up. orphanPivot := mkPivot(100, common.HexToHash("0xdead")) seed := newSyncerV2(db, nodeScheme) - // previousPivot reflects where flat state matches and it is what - // saveSyncStatus persists. Set it to simulate a prior sync reaching - // orphanPivot. - seed.previousPivot = orphanPivot + // pivot reflects where flat state matches and it is what saveSyncStatus + // persists. Set it to simulate a prior sync reaching orphanPivot. seed.pivot = orphanPivot seed.accountSynced = 42 seed.tasks = []*accountTaskV2{{ @@ -1391,14 +1448,14 @@ func TestSyncDetectsPivotReorged(t *testing.T) { if err := syncer.Sync(newPivot, cancel); err != nil { t.Fatalf("sync failed (reorg detection likely broken): %v", err) } - // After successful completion, status should be marked Complete=true + // After successful completion, status should reach the complete phase // against the new (canonical) pivot. loader := newSyncerV2(db, nodeScheme) loader.loadSyncStatus() - if !loader.complete { - t.Fatal("sync status should be marked Complete=true after successful completion") + if loader.getPhase() != phaseComplete { + t.Fatal("sync status should reach the complete phase after successful completion") } - if loader.previousPivot == nil || loader.previousPivot.Hash() != newPivot.Hash() { + if loader.pivot == nil || loader.pivot.Hash() != newPivot.Hash() { t.Fatalf("expected persisted pivot to match new pivot") } if data := rawdb.ReadAccountSnapshot(db, orphanAccountHash); len(data) != 0 { @@ -1445,9 +1502,8 @@ func testInterruptedDownloadRecovery(t *testing.T, scheme string) { syncer1.Register(src1) src1.remote = syncer1 pivot := mkPivot(0, root) - syncer1.pivot = pivot - syncer1.previousPivot = pivot // Sync sets this before downloadState syncer1.loadSyncStatus() + syncer1.pivot = pivot // Sync pins this before downloadState syncer1.downloadState(cancel1) // Save progress @@ -1483,9 +1539,8 @@ func testInterruptedDownloadRecovery(t *testing.T, scheme string) { syncer2.Register(src2) src2.remote = syncer2 pivot2 := mkPivot(0, root) - syncer2.pivot = pivot2 - syncer2.previousPivot = pivot2 // Sync sets this before downloadState syncer2.loadSyncStatus() + syncer2.pivot = pivot2 // Sync pins this before downloadState if err := syncer2.downloadState(cancel2); err != nil { t.Fatalf("resumed download failed: %v", err) } @@ -1499,10 +1554,10 @@ func testInterruptedDownloadRecovery(t *testing.T, scheme string) { } // TestSyncPersistsPivotDuringDownload verifies that after a fresh Sync is -// interrupted mid-download, the persisted previousPivot equals the current -// pivot (not nil). Without this, a follow-up Sync at a different pivot -// would not see that the partial flat state belongs to the old pivot, and -// would mix old-pivot accounts with new-pivot data. +// interrupted mid-download, the persisted pivot equals the current pivot +// (not nil). Without this, a follow-up Sync at a different pivot would not +// see that the partial flat state belongs to the old pivot, and would mix +// old-pivot accounts with new-pivot data. func TestSyncPersistsPivotDuringDownload(t *testing.T) { t.Parallel() nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, rawdb.HashScheme) @@ -1532,15 +1587,15 @@ func TestSyncPersistsPivotDuringDownload(t *testing.T) { // Sync should be interrupted by the cancel after a couple of responses. _ = syncer.Sync(pivot, cancel) - // Persisted previousPivot must equal the pivot, so a follow-up Sync at a - // different pivot can recognize the partial flat state belongs to this one. + // Persisted pivot must equal the pivot, so a follow-up Sync at a different + // pivot can recognize the partial flat state belongs to this one. loader := newSyncerV2(db, nodeScheme) loader.loadSyncStatus() - if loader.previousPivot == nil { - t.Fatal("expected persisted previousPivot to be set after interrupted download, got nil") + if loader.pivot == nil { + t.Fatal("expected persisted pivot to be set after interrupted download, got nil") } - if loader.previousPivot.Hash() != pivot.Hash() { - t.Errorf("persisted previousPivot mismatch: got %v, want %v", loader.previousPivot.Hash(), pivot.Hash()) + if loader.pivot.Hash() != pivot.Hash() { + t.Errorf("persisted pivot mismatch: got %v, want %v", loader.pivot.Hash(), pivot.Hash()) } } @@ -1702,7 +1757,7 @@ func testPivotMovement(t *testing.T, scheme string, pivotMoves int) { } // TestCatchUpPersistsIncrementally verifies that catchUp updates and persists -// previousPivot after each successfully applied BAL. If a later block in the +// the pivot after each successfully applied BAL. If a later block in the // gap fails to apply, the persisted state reflects the last successful block, // so a follow-up Sync can resume from there rather than reapplying everything. func TestCatchUpPersistsIncrementally(t *testing.T) { @@ -1776,7 +1831,7 @@ func testCatchUpPersistsIncrementally(t *testing.T, scheme string) { blocks[i] = balBlock{header: header, bal: buf.Bytes()} } - // First sync: complete sync to A so persisted state has previousPivot=A, + // First sync: complete sync to A so persisted state has pivot=A, // flat state covers all accounts. { var ( @@ -1826,22 +1881,22 @@ func testCatchUpPersistsIncrementally(t *testing.T, scheme string) { t.Fatal("expected Sync to fail when applyAccessList hits corrupt flat state") } - // Persisted previousPivot should now reflect the last successfully applied + // Persisted pivot should now reflect the last successfully applied // block (A+2). Without per-iteration saves, it would still be at A. loader := newSyncerV2(db, nodeScheme) loader.loadSyncStatus() - if loader.previousPivot == nil { - t.Fatal("expected persisted previousPivot to be set after partial catchUp") + if loader.pivot == nil { + t.Fatal("expected persisted pivot to be set after partial catchUp") } wantHash := blocks[1].header.Hash() - if loader.previousPivot.Hash() != wantHash { - t.Errorf("persisted previousPivot mismatch after partial catchUp: got %v, want %v (block A+2)", - loader.previousPivot.Hash(), wantHash) + if loader.pivot.Hash() != wantHash { + t.Errorf("persisted pivot mismatch after partial catchUp: got %v, want %v (block A+2)", + loader.pivot.Hash(), wantHash) } } // TestSyncStatusMarkedCompleteAfterCompletion verifies that after a full sync -// completes, the persisted sync status has Complete=true. This lets a +// completes, the persisted sync status reaches the complete phase. This lets a // subsequent Sync call distinguish "already done" from "fresh node" and skip. func TestSyncStatusMarkedCompleteAfterCompletion(t *testing.T) { t.Parallel() @@ -1870,13 +1925,13 @@ func testSyncStatusMarkedCompleteAfterCompletion(t *testing.T, scheme string) { } // After successful sync, persisted status should be present with - // Complete=true and the pivot we synced to. + // the complete phase and the pivot we synced to. loader := newSyncerV2(syncer.db, nodeScheme) loader.loadSyncStatus() - if !loader.complete { - t.Fatal("expected persisted status to have Complete=true after successful sync") + if loader.getPhase() != phaseComplete { + t.Fatal("expected persisted status to reach the complete phase after successful sync") } - if loader.previousPivot == nil || loader.previousPivot.Hash() != pivot.Hash() { + if loader.pivot == nil || loader.pivot.Hash() != pivot.Hash() { t.Fatalf("expected persisted pivot to match synced pivot") } } @@ -1906,7 +1961,7 @@ func TestSyncSkipsIfAlreadyComplete(t *testing.T) { t.Fatalf("first sync failed: %v", err) } - // Wipe the flat state. The persisted status (with Complete=true) stays. + // Wipe the flat state. The persisted status (in the complete phase) stays. if err := syncer.db.DeleteRange(rawdb.SnapshotAccountPrefix, []byte{rawdb.SnapshotAccountPrefix[0] + 1}); err != nil { t.Fatalf("failed to wipe account snapshot: %v", err) } @@ -1922,17 +1977,17 @@ func TestSyncSkipsIfAlreadyComplete(t *testing.T) { } } -// TestInterruptedRebuildRecovery verifies that if sync is interrupted after -// download completes but before trie rebuild finishes, the next Sync() call -// re-runs the download (which completes immediately) and rebuild. -func TestInterruptedRebuildRecovery(t *testing.T) { +// TestInterruptedGenerationRecovery verifies that if sync is interrupted after +// download completes but before trie generation finishes, the next Sync() call +// re-runs the download (which completes immediately) and generation. +func TestInterruptedGenerationRecovery(t *testing.T) { t.Parallel() nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, rawdb.HashScheme) root := sourceAccountTrie.Hash() // First run: complete download, save status, simulate interruption - // before rebuild by calling downloadState() directly + // before generation by calling downloadState() directly var ( once1 sync.Once cancel1 = make(chan struct{}) @@ -1946,9 +2001,8 @@ func TestInterruptedRebuildRecovery(t *testing.T) { syncer1.Register(src1) src1.remote = syncer1 pivot := mkPivot(0, root) - syncer1.pivot = pivot - syncer1.previousPivot = pivot // Sync sets this before downloadState syncer1.loadSyncStatus() + syncer1.pivot = pivot // Sync pins this before downloadState if err := syncer1.downloadState(cancel1); err != nil { t.Fatalf("download failed: %v", err) @@ -1960,11 +2014,11 @@ func TestInterruptedRebuildRecovery(t *testing.T) { syncer1.cleanAccountTasks() syncer1.saveSyncStatus() - // Status should exist (rebuild hasn't run yet) + // Status should exist (generation hasn't run yet) if rawdb.ReadSnapshotSyncStatus(db) == nil { t.Fatal("sync status should exist after download") } - // Second run: full Sync should detect tasks are done, run rebuild + // Second run: full Sync should detect tasks are done, run generation var ( once2 sync.Once cancel2 = make(chan struct{}) @@ -1980,11 +2034,16 @@ func TestInterruptedRebuildRecovery(t *testing.T) { if err := syncer2.Sync(mkPivot(0, root), cancel2); err != nil { t.Fatalf("resumed sync failed: %v", err) } - // After rebuild completes, status should be marked Complete=true. + // The resumed run re-arms the pivot freeze once its no-op download + // completes, the downloader relies on it until the pivot block commits. + if syncer2.FrozenPivot() == nil { + t.Fatal("pivot not frozen after resumed sync") + } + // After generation completes, status should reach the complete phase. loader := newSyncerV2(db, nodeScheme) loader.loadSyncStatus() - if !loader.complete { - t.Fatal("sync status should be marked Complete=true after rebuild completes") + if loader.getPhase() != phaseComplete { + t.Fatal("sync status should reach the complete phase after generation completes") } } @@ -2462,7 +2521,7 @@ func TestCatchUpRetriesOnBadBAL(t *testing.T) { // makeStorageTrieFromSlots builds a storage trie for owner from raw slot // key->value pairs, using the exact on-disk encoding the flat snapshot and the -// trie rebuild expect: each leaf is keyed by keccak256(slotKey) and its value is +// trie generation expect: each leaf is keyed by keccak256(slotKey) and its value is // rlp(TrimLeftZeroes(value)). Zero-valued slots are skipped (an unset slot has // no leaf). It returns the storage root, the dirty node set, and the sorted // snapshot leaves (which a test peer serves verbatim). @@ -2529,12 +2588,12 @@ func makeStateWithStorageContract(scheme string, plain []*kv, contractAddr commo // slot, an overwrite of an existing slot, a write of zero (deletion), and a // multi-tx write where the post-block value wins. // -// It fully syncs pivot A (flat-state download + trie rebuild), then moves the +// It fully syncs pivot A (flat-state download + trie generation), then moves the // pivot to A+1. The move triggers catchUp, which fetches the A+1 BAL, applies -// the storage diffs to the flat state, and rebuilds the trie. The rebuild +// the storage diffs to the flat state, and generates the trie. The generation // verifies the recomputed root against the pivot's expected post-catch-up root, // so a successful Sync proves the storage mutations were applied in the exact -// encoding the trie rebuild consumes. verifyTrie re-walks the result as an +// encoding the trie generation consumes. verifyTrie re-walks the result as an // independent confirmation. func TestCatchUpAppliesStorageBALs(t *testing.T) { t.Parallel() @@ -2620,7 +2679,7 @@ func testCatchUpAppliesStorageBALs(t *testing.T, scheme string) { // Sync, so the follow-up Sync's reorg check sees A as still-canonical and // runs catchUp instead of resetting. The A+1 header carries the BAL hash // (verified during catch-up) and the expected post-catch-up state root - // (verified by the trie rebuild). + // (verified by the trie generation). db := rawdb.NewMemoryDatabase() numA := uint64(128) emptyH := common.Hash{} @@ -2644,7 +2703,7 @@ func testCatchUpAppliesStorageBALs(t *testing.T, scheme string) { rawdb.WriteHeader(db, hdrB) rawdb.WriteCanonicalHash(db, hdrB.Hash(), numA+1) - // Sync 1: full flat-state download + trie rebuild against pivot A. + // Sync 1: full flat-state download + trie generation against pivot A. { var ( once sync.Once @@ -2665,7 +2724,7 @@ func testCatchUpAppliesStorageBALs(t *testing.T, scheme string) { } close(done) } - // Sanity: the rebuilt trie for pivot A is complete and matches rootA. This + // Sanity: the generated trie for pivot A is complete and matches rootA. This // also confirms the test fixture itself is internally consistent. verifyTrie(scheme, db, rootA, t) @@ -2690,6 +2749,12 @@ func testCatchUpAppliesStorageBALs(t *testing.T, scheme string) { if err := syncer.Sync(hdrB, cancel); err != nil { t.Fatalf("pivot A+1 catch-up sync failed: %v", err) } + // The freeze must re-arm on a pivot-moved cycle too, the downloader + // relies on it from download completion until commit, and it must + // point at the new pivot the catch-up rolled forward to. + if frozen := syncer.FrozenPivot(); frozen == nil || frozen.Hash() != hdrB.Hash() { + t.Fatal("pivot not frozen at the new header after catch-up sync") + } close(done) } diff --git a/triedb/generate.go b/triedb/generate.go index b6906f4c63..8d5a128aa1 100644 --- a/triedb/generate.go +++ b/triedb/generate.go @@ -337,7 +337,7 @@ func hashRanges(total int) [][2]common.Hash { return ranges } -// GenerateTrie rebuilds all tries (storage + account) from flat snapshot +// GenerateTrie builds all tries (storage + account) from flat snapshot // data in the database. The account hash space is partitioned into 16 // slices aligned with the first-nibble branching of the MPT root. Each // partition is processed by its own goroutine, which walks its slice, @@ -346,10 +346,8 @@ func hashRanges(total int) [][2]common.Hash { // trie. Once every partition has produced its subtree root, the top-level // branch is assembled and its hash verified against the expected root. // -// Resume: on entry, any partition that has a "done" marker from a -// previous run is skipped. Its subtree blob is read from the marker -// and handed to assembleRoot directly. On a mid-run crash, only the -// in-flight partition(s) are redone. +// Generation is all or nothing: an interrupted run leaves no resume +// state and the next run builds every partition from scratch. func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-chan struct{}) (GenerateStats, error) { var ( start = time.Now() @@ -366,9 +364,8 @@ func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-c go tickProgress(progressDone, start, &scanned, &updated, &progress) defer close(progressDone) - // For each partition, either skip (prior done marker found) or run - // it. Prior runs can leave the partition's raw root blob in the done - // marker. We recover it here so assembleRoot has everything it needs. + // Run every partition concurrently, each producing the subtree root + // blob that assembleRoot needs. var ( ranges = hashRanges(numPartitions) eg, ctx = errgroup.WithContext(context.Background()) @@ -376,11 +373,6 @@ func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-c for i, r := range ranges { partition := byte(i) rangeStart, rangeEnd := r[0], r[1] - if blob, ok := rawdb.ReadGenerateTriePartitionDone(db, partition); ok { - partitionBlobs[partition] = blob - progress[partition].Store(partitionFinished) - continue - } eg.Go(func() error { start := time.Now() blob, err := generatePartition(ctx, cancel, db, scheme, partition, rangeStart, rangeEnd, &scanned, &updated, &deleted, &progress[partition]) @@ -391,11 +383,6 @@ func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-c progress[partition].Store(partitionFinished) partitionBlobs[partition] = blob - - // Record completion only after the partition's batch has - // flushed inside generatePartition, so this marker appears - // on disk only when every write the partition did is durable. - rawdb.WriteGenerateTriePartitionDone(db, partition, blob) return nil }) } @@ -405,9 +392,8 @@ func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-c return GenerateStats{}, err } - // Assemble the top-level root from the partition blobs, verify it - // matches the expected root, and clear all partition markers on - // success. + // Assemble the top-level root from the partition blobs and verify it + // matches the expected root. got, err := assembleRoot(db, scheme, partitionBlobs) if err != nil { return GenerateStats{}, fmt.Errorf("assemble root: %w", err) @@ -415,15 +401,6 @@ func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-c if got != root { return GenerateStats{}, fmt.Errorf("state root mismatch: got %x, want %x", got, root) } - - // Clear the partition progress marker, ending the generation process. - batch := db.NewBatch() - for i := range numPartitions { - rawdb.DeleteGenerateTriePartitionDone(batch, byte(i)) - } - if err := batch.Write(); err != nil { - return GenerateStats{}, fmt.Errorf("clear partition markers: %w", err) - } log.Info("Generated state trie", "scanned", scanned.Load(), "updated", updated.Load(), "dangling-slots", deleted.Load(), "elapsed", common.PrettyDuration(time.Since(start))) return GenerateStats{ Scanned: scanned.Load(), diff --git a/triedb/generate_test.go b/triedb/generate_test.go index 2a7d98bfaf..bbbf6e14bc 100644 --- a/triedb/generate_test.go +++ b/triedb/generate_test.go @@ -370,97 +370,6 @@ func TestGenerateTrieOrphanStorage(t *testing.T) { } } -// TestGenerateTriePartialResume proves that the resume path actually -// fires when a partition's done marker is present. -func TestGenerateTriePartialResume(t *testing.T) { - // Build the account set. Empty storage keeps the test focused on the - // account-trie resume path. - const n = 200 - accounts := make([]testAccount, 0, n) - for i := 0; i < n; i++ { - addr := common.BytesToAddress([]byte{byte(i >> 8), byte(i)}) - hash := crypto.Keccak256Hash(addr[:]) - accounts = append(accounts, testAccount{ - hash: hash, - account: types.StateAccount{ - Nonce: uint64(i), - Balance: uint256.NewInt(uint64(i + 1)), - Root: types.EmptyRootHash, - CodeHash: types.EmptyCodeHash.Bytes(), - }, - }) - } - expectedRoot := buildExpectedRoot(t, accounts) - - for _, scheme := range []string{rawdb.HashScheme, rawdb.PathScheme} { - t.Run(scheme, func(t *testing.T) { - db := rawdb.NewMemoryDatabase() - - // Step 1: write the account snapshots for this run. - for _, a := range accounts { - rawdb.WriteAccountSnapshot(db, a.hash, types.SlimAccountRLP(a.account)) - } - - // Step 2: run every partition once to populate trie nodes on disk - // and capture each partition's raw root blob. - var ( - scanned atomic.Int64 - updated atomic.Int64 - deleted atomic.Int64 - ) - ranges := hashRanges(numPartitions) - blobs := make([][]byte, numPartitions) - for i, r := range ranges { - var pos atomic.Uint64 - blob, err := generatePartition(context.Background(), nil, db, scheme, byte(i), r[0], r[1], &scanned, &updated, &deleted, &pos) - if err != nil { - t.Fatalf("pre-run partition %d: %v", i, err) - } - blobs[i] = blob - } - - // Step 3: pre-seed done markers for even partitions only. - for i := 0; i < numPartitions; i++ { - if i%2 == 0 { - rawdb.WriteGenerateTriePartitionDone(db, byte(i), blobs[i]) - } - } - - // Step 4: delete flat-state account snapshots for every account that - // lives in an even partition. After this, rerunning generatePartition for - // an even partition would find no accounts and produce a nil blob, - // so a correct final root requires the resume path. - numDeleted := 0 - for _, a := range accounts { - if (a.hash[0]>>4)%2 == 0 { - rawdb.DeleteAccountSnapshot(db, a.hash) - numDeleted++ - } - } - if numDeleted == 0 { - t.Fatal("test setup failure: no accounts fell in even partitions") - } - - // Step 5: run GenerateTrie. Success implies resume actually consulted - // the markers. Without it, even partitions would yield nil blobs and - // the root check inside GenerateTrie would fail. - if _, err := GenerateTrie(db, scheme, expectedRoot, nil); err != nil { - t.Fatalf("partial-resume GenerateTrie failed: %v", err) - } - - // All markers cleared on success. - for i := 0; i < numPartitions; i++ { - if _, ok := rawdb.ReadGenerateTriePartitionDone(db, byte(i)); ok { - t.Errorf("partition %d marker not cleared after successful resume", i) - } - } - if scheme == rawdb.PathScheme { - assertCanonicalNodes(t, db, accounts) - } - }) - } -} - // TestHashRanges checks that hashRanges fully and contiguously covers the // 256-bit hash space, with the last range absorbing the rounding remainder. func TestHashRanges(t *testing.T) { From 6ed112aee06d73aca104985c22314846ecfe4955 Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Tue, 16 Jun 2026 09:11:15 +0800 Subject: [PATCH 07/24] eth/protocols/snap: fix catchup stall (#35158) This PR fixes an issue that when peers legitimately lack a requested BAL, empty (0x80) is delivered and this BAL entry will be refetched over and over again. A `refused` tracker is added and catchUp will fail if this BAL is unavailable against the entire peerset. --- eth/protocols/snap/syncv2.go | 127 ++++++++++++++++++++++++----------- 1 file changed, 87 insertions(+), 40 deletions(-) diff --git a/eth/protocols/snap/syncv2.go b/eth/protocols/snap/syncv2.go index ac84008697..bad52de184 100644 --- a/eth/protocols/snap/syncv2.go +++ b/eth/protocols/snap/syncv2.go @@ -69,6 +69,10 @@ const ( // are still hashes left to fetch. var errAccessListPeersExhausted = errors.New("all peers exhausted for BAL requests") +// errAccessListUnavailable is returned from the BAL catch-up when some gap +// block's access list cannot be retrieved against the current peerset. +var errAccessListUnavailable = errors.New("block access lists unavailable") + // accountRequestV2 tracks a pending account range request to ensure responses are // to actual requests and to validate any security constraints. // @@ -823,25 +827,22 @@ func (s *syncerV2) fetchAccessLists(hashes []common.Hash, headers map[common.Has } fetched := make(map[common.Hash]rlp.RawValue, len(hashes)) + // refused tracks the mapping between BAL and peerset which doesn't have + // it available. + refused := make(map[common.Hash]map[string]struct{}) + var ( accessListReqFails = make(chan *accessListRequest) accessListResps = make(chan *accessListResponse) lastStallLog = time.Now() ) for len(fetched) < len(hashes) { - // Assign BAL retrieval tasks to idle peers - s.assignAccessListTasks(pending, accessListResps, accessListReqFails, cancel) - - // If every peer is now stateless and nothing is in flight, no event - // short of cancel or a new peer joining can move us forward. Surface - // this so the caller can return and let a higher-level retry happen - // against a fresh peer set. - // - // TODO(rjl, jonny) add a time allowance before returning the error. - if s.accessListPeersExhausted() { - log.Warn("BAL peers exhausted, stopping catch-up early", "fetched", len(fetched), "remaining", len(pending)) - return nil, errAccessListPeersExhausted + if err := s.checkAccessListProgress(pending, refused); err != nil { + log.Warn("BAL fetch cannot progress", "err", err, "fetched", len(fetched), "remaining", len(pending)) + return nil, err } + // Assign BAL retrieval tasks to idle peers + s.assignAccessListTasks(pending, refused, accessListResps, accessListReqFails, cancel) // Periodic visibility while stalled with peers connected but idle. if len(pending) > 0 && time.Since(lastStallLog) > 30*time.Second { @@ -857,12 +858,18 @@ func (s *syncerV2) fetchAccessLists(hashes []common.Hash, headers map[common.Has // A new peer joined, try to assign it work case id := <-peerDrop: s.revertBALRequests(id, pending) + for h, set := range refused { + delete(set, id) + if len(set) == 0 { + delete(refused, h) + } + } case <-cancel: return nil, ErrCancelled case req := <-accessListReqFails: s.revertAccessListRequest(req, pending) case res := <-accessListResps: - s.processAccessListResponse(res, headers, pending, fetched) + s.processAccessListResponse(res, headers, pending, fetched, refused) } } // Assemble results in input order @@ -874,8 +881,9 @@ func (s *syncerV2) fetchAccessLists(hashes []common.Hash, headers map[common.Has } // assignAccessListTasks attempts to assign BAL fetch requests to idle -// peers for any hashes still in pending. -func (s *syncerV2) assignAccessListTasks(pending map[common.Hash]struct{}, success chan *accessListResponse, fail chan *accessListRequest, cancel chan struct{}) { +// peers for any hashes still in pending. Hashes a peer has already refused +// (recorded in refused) are not assigned back to that same peer. +func (s *syncerV2) assignAccessListTasks(pending map[common.Hash]struct{}, refused map[common.Hash]map[string]struct{}, success chan *accessListResponse, fail chan *accessListRequest, cancel chan struct{}) { s.lock.Lock() defer s.lock.Unlock() @@ -889,6 +897,33 @@ func (s *syncerV2) assignAccessListTasks(pending map[common.Hash]struct{}, succe ) idlers.ids, idlers.caps = idlers.ids[1:], idlers.caps[1:] + // Collect hashes to fetch, capped by peer capacity and the + // EIP-8189 2 MiB response soft limit (~72 KiB/BAL -> 28 blocks). + if cap > maxAccessListRequestCount { + cap = maxAccessListRequestCount + } + batch := make([]common.Hash, 0, cap) + for h := range pending { + // Skip hashes this peer has already refused; another peer + // must serve them. + if set := refused[h]; set != nil { + if _, ok := set[idle]; ok { + continue + } + } + delete(pending, h) + + batch = append(batch, h) + if len(batch) >= cap { + break + } + } + // The peer has already refused every pending hash; leave them in + // pending for another peer and move on without a wasted request. + if len(batch) == 0 { + continue + } + // Generate a unique request ID var reqid uint64 for { @@ -901,21 +936,6 @@ func (s *syncerV2) assignAccessListTasks(pending map[common.Hash]struct{}, succe } break } - - // Collect hashes to fetch, capped by peer capacity and the - // EIP-8189 2 MiB response soft limit (~72 KiB/BAL -> 28 blocks). - if cap > maxAccessListRequestCount { - cap = maxAccessListRequestCount - } - batch := make([]common.Hash, 0, cap) - for h := range pending { - delete(pending, h) - - batch = append(batch, h) - if len(batch) >= cap { - break - } - } req := &accessListRequest{ peer: idle, id: reqid, @@ -950,7 +970,7 @@ func (s *syncerV2) assignAccessListTasks(pending map[common.Hash]struct{}, succe // processAccessListResponse handles a successful BAL response. It // verifies each non-empty BAL against the corresponding block header and // stores the verified ones in fetched. -func (s *syncerV2) processAccessListResponse(res *accessListResponse, headers map[common.Hash]*types.Header, pending map[common.Hash]struct{}, fetched map[common.Hash]rlp.RawValue) { +func (s *syncerV2) processAccessListResponse(res *accessListResponse, headers map[common.Hash]*types.Header, pending map[common.Hash]struct{}, fetched map[common.Hash]rlp.RawValue, refused map[common.Hash]map[string]struct{}) { var ( stateless bool valid = make(map[common.Hash]rlp.RawValue) @@ -959,8 +979,14 @@ func (s *syncerV2) processAccessListResponse(res *accessListResponse, headers ma for i, raw := range res.accessLists { h := res.req.hashes[i] - // Peer doesn't have this BAL. Add it back to pending for retry. + // Peer doesn't have this BAL (a legitimate reply, e.g. the block is + // outside its retention window). Record the refusal and add the hash + // back to pending for a retry against other peers. if bytes.Equal(raw, rlp.EmptyString) { + if refused[h] == nil { + refused[h] = make(map[string]struct{}) + } + refused[h][res.req.peer] = struct{}{} continue } var b bal.BlockAccessList @@ -984,6 +1010,7 @@ func (s *syncerV2) processAccessListResponse(res *accessListResponse, headers ma // Re-add hashes that were not served back or invalid to pending for i := 0; i < len(res.req.hashes); i++ { if _, ok := valid[res.req.hashes[i]]; ok { + delete(refused, res.req.hashes[i]) continue } pending[res.req.hashes[i]] = struct{}{} @@ -2632,25 +2659,45 @@ func (s *syncerV2) reportSyncProgressV2(force bool) { "accounts", accounts, "slots", storage, "codes", bytecode, "eta", common.PrettyDuration(estTime-elapsed)) } -// accessListPeersExhausted reports whether forward progress on BAL fetches is -// impossible: at least one peer is connected, every connected peer is marked -// stateless, and no BAL requests are in flight. -func (s *syncerV2) accessListPeersExhausted() bool { +// checkAccessListProgress reports whether the BAL fetch can still make +// forward progress against the current peer set. +func (s *syncerV2) checkAccessListProgress(pending map[common.Hash]struct{}, refused map[common.Hash]map[string]struct{}) error { s.lock.RLock() defer s.lock.RUnlock() if len(s.peers) == 0 { - return false + return nil } if len(s.accessListReqs) > 0 { - return false + return nil } + serviceable := make(map[string]struct{}, len(s.peers)) for id := range s.peers { if _, ok := s.statelessPeers[id]; !ok { - return false + serviceable[id] = struct{}{} } } - return true + if len(serviceable) == 0 { + return errAccessListPeersExhausted + } + for h, set := range refused { + // Delivered by some other peer after all + if _, ok := pending[h]; !ok { + continue + } + unobtainable := true + for id := range serviceable { + if _, ok := set[id]; !ok { + unobtainable = false + break + } + } + if unobtainable { + log.Warn("Access list unavailable from all peers", "hash", h) + return errAccessListUnavailable + } + } + return nil } // sortIdlePeers builds a list of idle peers sorted by download capacity From 7d74166d3d921e33223130858abb0907f8b4adfa Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Tue, 16 Jun 2026 11:09:32 +0800 Subject: [PATCH 08/24] core: re-enable the legacy snapshot after sync (#35163) --- core/blockchain.go | 11 +++++---- core/blockchain_snapshot_test.go | 38 ++++++++++++++++++++++++++++++++ core/state/snapshot/snapshot.go | 33 ++++++++++++++++++++++----- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index acf2da1921..ecb33ce0b8 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -1194,10 +1194,13 @@ func (bc *BlockChain) SnapSyncComplete(hash common.Hash, isSnapV2 bool) error { return fmt.Errorf("non existent state [%x..]", root[:4]) } - // The legacy snapshot tree needs to be wiped and rebuilt from the trie - // after a snap/1 sync. - if !isSnapV2 && bc.snaps != nil { - bc.snaps.Rebuild(root) + // The legacy snapshot tree (hash scheme only) was persistently disabled + // before the sync, re-enables it explicitly. + // + // For snap/2 the downloaded flat state is already complete and root-verified, + // so the background generation is unnecessary. + if bc.snaps != nil { + bc.snaps.Rebuild(root, !isSnapV2) } // If all checks out, manually set the head block. diff --git a/core/blockchain_snapshot_test.go b/core/blockchain_snapshot_test.go index ae9398b97d..cd95f19697 100644 --- a/core/blockchain_snapshot_test.go +++ b/core/blockchain_snapshot_test.go @@ -29,6 +29,7 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/consensus" "github.com/ethereum/go-ethereum/consensus/ethash" "github.com/ethereum/go-ethereum/core/rawdb" @@ -721,3 +722,40 @@ func TestRecoverSnapshotFromWipingCrash(t *testing.T) { test.teardown() } } + +// TestSnapSyncCompleteRebuildsSnapshot verifies that completing a snap sync +// re-enables the legacy snapshot tree on the hash scheme for both syncer +// versions: SnapSyncStart persistently disables the tree, and only the +// rebuild on completion clears the marker again. +func TestSnapSyncCompleteRebuildsSnapshot(t *testing.T) { + for _, isSnapV2 := range []bool{false, true} { + _, _, chain, err := newCanonical(ethash.NewFaker(), 8, true, rawdb.HashScheme) + if err != nil { + t.Fatalf("failed to create chain: %v", err) + } + if err := chain.SnapSyncStart(); err != nil { + t.Fatalf("failed to start snap sync: %v", err) + } + if !rawdb.ReadSnapshotDisabled(chain.db) { + t.Fatal("snapshot should be disabled during snap sync") + } + head := chain.CurrentBlock() + if err := chain.SnapSyncComplete(head.Hash(), isSnapV2); err != nil { + t.Fatalf("failed to complete snap sync (v2=%v): %v", isSnapV2, err) + } + if rawdb.ReadSnapshotDisabled(chain.db) { + t.Fatalf("snapshot should be re-enabled after snap sync completion (v2=%v)", isSnapV2) + } + // snap/2 adopts the flat state without regeneration, so the snapshot + // must be immediately usable; snap/1 schedules a background rebuild + // instead (which may or may not have finished, no assertion there). + if isSnapV2 { + it, err := chain.snaps.AccountIterator(head.Root, common.Hash{}) + if err != nil { + t.Fatalf("adopted snapshot not immediately usable: %v", err) + } + it.Release() + } + chain.Stop() + } +} diff --git a/core/state/snapshot/snapshot.go b/core/state/snapshot/snapshot.go index cd0a55fee6..d3a5e9aa21 100644 --- a/core/state/snapshot/snapshot.go +++ b/core/state/snapshot/snapshot.go @@ -23,6 +23,7 @@ import ( "fmt" "sync" + "github.com/VictoriaMetrics/fastcache" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/types" @@ -213,7 +214,7 @@ func New(config Config, diskdb ethdb.KeyValueStore, triedb *triedb.Database, roo if err != nil { log.Warn("Failed to load snapshot", "err", err) if !config.NoBuild { - snap.Rebuild(root) + snap.Rebuild(root, true) return snap, nil } return nil, err // Bail out the error, don't rebuild automatically. @@ -683,10 +684,12 @@ func (t *Tree) Journal(root common.Hash) (common.Hash, error) { return base, nil } -// Rebuild wipes all available snapshot data from the persistent database and -// discard all caches and diff layers. Afterwards, it starts a new snapshot -// generator with the given root hash. -func (t *Tree) Rebuild(root common.Hash) { +// Rebuild discards all caches and diff layers and re-enables the snapshot +// feature. With generate set, it starts a new snapshot generator with the +// given root hash, wiping and regenerating the persistent flat state in the +// background. Without it, the on-disk flat state is adopted as the fully +// generated disk layer directly. +func (t *Tree) Rebuild(root common.Hash, generate bool) { t.lock.Lock() defer t.lock.Unlock() @@ -715,6 +718,26 @@ func (t *Tree) Rebuild(root common.Hash) { panic(fmt.Sprintf("unknown layer type: %T", layer)) } } + // Adopt the existing flat state as the generated disk layer if + // regeneration was not requested. + if !generate { + batch := t.diskdb.NewBatch() + rawdb.WriteSnapshotRoot(batch, root) + journalProgress(batch, nil, nil) + if err := batch.Write(); err != nil { + log.Crit("Failed to write snapshot completion marker", "err", err) + } + log.Info("Adopted state snapshot", "root", root) + t.layers = map[common.Hash]snapshot{ + root: &diskLayer{ + diskdb: t.diskdb, + triedb: t.triedb, + cache: fastcache.New(t.config.CacheSize * 1024 * 1024), + root: root, + }, + } + return + } // Start generating a new snapshot from scratch on a background thread. The // generator will run a wiper first if there's not one running right now. log.Info("Rebuilding state snapshot") From a326298f51e6ff39e410be00a94fe13c0515db71 Mon Sep 17 00:00:00 2001 From: Jonny Rhea <5555162+jrhea@users.noreply.github.com> Date: Tue, 16 Jun 2026 07:46:34 -0500 Subject: [PATCH 09/24] rpc: fix flaky TestTracingHTTPTimeout (#35172) `TestTracingHTTPTimeout` still flakes in CI after #35101, failing at the POST: --- FAIL: TestTracingHTTPTimeout (0.26s) tracing_test.go:633: request: Post "http://127.0.0.1:43497": EOF The test sets a short server `WriteTimeout` and posts a blocking call. `ContextRequestTimeout` leaves a fixed 100ms for the server to write its timeout response before the HTTP write deadline cuts the connection. I can't repro it locally, but my theory is that under load that write can miss the window, so the connection is dropped and the client POST returns `EOF`, failing the test before it inspects the span. This is the only test exposed to it because it is the only one that configures a `WriteTimeout`. The EOF is benign: the server sets the timeout error on the SERVER span before attempting the write, independent of whether the client receives the response. Since that span status is all the test asserts, `tryPostJSONRPC` tolerates the transport error instead of failing on it. --- rpc/tracing_test.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/rpc/tracing_test.go b/rpc/tracing_test.go index fa2ef09e76..dafa9fb8a7 100644 --- a/rpc/tracing_test.go +++ b/rpc/tracing_test.go @@ -346,17 +346,27 @@ func TestTracingSubscribeUnsubscribe(t *testing.T) { // like notifications (no "id" field). func postJSONRPC(t *testing.T, url, body string) { t.Helper() + if err := tryPostJSONRPC(url, body); err != nil { + t.Fatalf("request: %v", err) + } +} + +// tryPostJSONRPC is like postJSONRPC but returns the transport error instead of +// failing the test. The write-timeout test uses this because the HTTP +// WriteTimeout can drop the connection before the response is flushed. +func tryPostJSONRPC(url, body string) error { req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) if err != nil { - t.Fatalf("new request: %v", err) + return err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { - t.Fatalf("request: %v", err) + return err } _, _ = io.Copy(io.Discard, resp.Body) resp.Body.Close() + return nil } // TestTracingHTTPNotification verifies that a JSON-RPC notification emits the @@ -630,7 +640,12 @@ func TestTracingHTTPTimeout(t *testing.T) { // test_block waits on ctx.Done() and returns an error. The internal // timer cancels ctx, so test_block unblocks shortly after the timeout // response goes out. - postJSONRPC(t, httpsrv.URL, `{"jsonrpc":"2.0","id":1,"method":"test_block"}`) + // + // Ignore the client-side result. Under load the HTTP WriteTimeout can + // drop the connection before the timeout response is flushed, which the + // client sees as EOF. The server still records the timeout on its span, + // which is what we assert below. + _ = tryPostJSONRPC(httpsrv.URL, `{"jsonrpc":"2.0","id":1,"method":"test_block"}`) // Wait for the in-flight request to finish so the deferred spanEnd fires // before GetSpans is called. From 6e62cc5aa85249454fa80fba01a996864eee2483 Mon Sep 17 00:00:00 2001 From: Jonny Rhea <5555162+jrhea@users.noreply.github.com> Date: Tue, 16 Jun 2026 07:47:05 -0500 Subject: [PATCH 10/24] core/vm: compute stack operations in place (#35156) The stack primitives pop by value: pop() returns the 32-byte value itself, so every popped operand is copied out of the stack arena before it is used. The result side was already in place, peek returns a pointer and binary ops write into the new stack top. This PR fixes the operand side: pointer-returning primitives (popPtr, popPtrPeek, etc), with the handlers rewritten to read operands directly from their arena slots. Every popped operand paid the copy, whatever the op went on to do with it, so this optimization covers the arithmetic and comparison ops as much as JUMP, MSTORE, SSTORE and RETURN. The copy is visible in the assembly. On arm64, master's opLt spends four instructions moving the popped value through the frame, and the comparison then reads it back from there: LDP (R5), (R6, R7) ; load words 0 and 1 of the popped value from the arena LDP 16(R5), (R5, R8) ; load words 2 and 3 STP (R6, R7), vm.~r0-64(SP) ; store words 0 and 1 into a frame slot STP (R5, R8), vm.~r0-48(SP) ; store words 2 and 3 With popPtrPeek those four instructions are gone, the frame shrinks from locals=0x58 to locals=0x18, and the function from 336 to 288 bytes. The compiler cannot remove the copy itself: uint256.Int is a four-element array, and Go's SSA does not promote arrays longer than one element to registers, so a by-value pop pays this round trip no matter how far inlining gets, for LT exactly as for ADD. The CALL and CREATE families are deliberately not converted: a child frame reuses the same stack arena, so parent pointers into popped slots die when the child pushes. The rule is recorded on the primitives: pointers stay valid until the next push or any sub call. Converting the call family safely means materializing scalars before the child call, left for later work with a call-heavy benchmark to justify it. ### Benchmarks Measured with the benchmark suite from #35144 (the evm-bench contract workloads and the block import benchmark), which is not part of this PR's diff. Apple M4 Max, fixed iteration counts, n=10, all p=0.000. B/op and allocs/op are statistically identical on every benchmark: | benchmark | master | PR | vs master | |---|---|---|---| | Snailtracer | 60.0 ms | 54.1 ms | -9.8% | | TenThousandHashes | 13.2 ms | 12.2 ms | -7.8% | | ERC20Transfer | 11.7 ms | 11.0 ms | -5.5% | | ERC20Mint | 7.49 ms | 7.02 ms | -6.2% | | ERC20ApprovalTransfer | 8.92 ms | 8.44 ms | -5.4% | This PR is independent of #35144 but plays nicely with it: the generated dispatch there splices these handler bodies, so the in-place forms land in its fast path too, where they measure larger. ### Testing The rewritten handlers run on the interpreter's only execution path, so correctness rests on references outside the change: - **Consensus fixtures.** The full tests package passes: state tests, the execution-spec families, blockchain tests. - **Opcode testcases.** The JSON testcases compare individual opcode results against committed expected values. - **Tracer fixtures.** The tracetest reference files pin exact log and return data shapes, covering the rewritten LOG and RETURN paths. - **Cross-build differential.** A goevmlab campaign running this branch's evm against master's evm over generated state tests across four forks (Prague, Cancun, London, Osaka) with full trace comparison: 160,566 tests, zero divergences. --------- Co-authored-by: MariusVanDerWijden --- core/vm/eips.go | 16 ++--- core/vm/instructions.go | 136 ++++++++++++++++++---------------------- core/vm/stack.go | 56 +++++++++++++++++ 3 files changed, 120 insertions(+), 88 deletions(-) diff --git a/core/vm/eips.go b/core/vm/eips.go index ba7cbd7461..f8473e65e8 100644 --- a/core/vm/eips.go +++ b/core/vm/eips.go @@ -212,8 +212,7 @@ func opTstore(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if evm.readOnly { return nil, ErrWriteProtection } - loc := scope.Stack.pop() - val := scope.Stack.pop() + loc, val := scope.Stack.pop2() evm.StateDB.SetTransientState(scope.Contract.Address(), loc.Bytes32(), val.Bytes32()) return nil, nil } @@ -263,11 +262,7 @@ func enable5656(jt *JumpTable) { // opMcopy implements the MCOPY opcode (https://eips.ethereum.org/EIPS/eip-5656) func opMcopy(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - var ( - dst = scope.Stack.pop() - src = scope.Stack.pop() - length = scope.Stack.pop() - ) + dst, src, length := scope.Stack.pop3() // These values are checked for overflow during memory expansion calculation // (the memorySize function on the opcode). scope.Memory.Copy(dst.Uint64(), src.Uint64(), length.Uint64()) @@ -364,11 +359,8 @@ func enable8024(jt *JumpTable) { func opExtCodeCopyEIP4762(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { var ( - stack = scope.Stack - a = stack.pop() - memOffset = stack.pop() - codeOffset = stack.pop() - length = stack.pop() + stack = scope.Stack + a, memOffset, codeOffset, length = stack.pop4() ) uint64CodeOffset, overflow := codeOffset.Uint64WithOverflow() if overflow { diff --git a/core/vm/instructions.go b/core/vm/instructions.go index 92c363a356..209457f670 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -27,56 +27,56 @@ import ( ) func opAdd(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.Add(&x, y) + x, y := scope.Stack.pop1Peek1() + y.Add(x, y) return nil, nil } func opSub(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.Sub(&x, y) + x, y := scope.Stack.pop1Peek1() + y.Sub(x, y) return nil, nil } func opMul(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.Mul(&x, y) + x, y := scope.Stack.pop1Peek1() + y.Mul(x, y) return nil, nil } func opDiv(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.Div(&x, y) + x, y := scope.Stack.pop1Peek1() + y.Div(x, y) return nil, nil } func opSdiv(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.SDiv(&x, y) + x, y := scope.Stack.pop1Peek1() + y.SDiv(x, y) return nil, nil } func opMod(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.Mod(&x, y) + x, y := scope.Stack.pop1Peek1() + y.Mod(x, y) return nil, nil } func opSmod(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.SMod(&x, y) + x, y := scope.Stack.pop1Peek1() + y.SMod(x, y) return nil, nil } func opExp(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - base, exponent := scope.Stack.pop(), scope.Stack.peek() - exponent.Exp(&base, exponent) + base, exponent := scope.Stack.pop1Peek1() + exponent.Exp(base, exponent) return nil, nil } func opSignExtend(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - back, num := scope.Stack.pop(), scope.Stack.peek() - num.ExtendSign(num, &back) + back, num := scope.Stack.pop1Peek1() + num.ExtendSign(num, back) return nil, nil } @@ -87,7 +87,7 @@ func opNot(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opLt(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() + x, y := scope.Stack.pop1Peek1() if x.Lt(y) { y.SetOne() } else { @@ -97,7 +97,7 @@ func opLt(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opGt(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() + x, y := scope.Stack.pop1Peek1() if x.Gt(y) { y.SetOne() } else { @@ -107,7 +107,7 @@ func opGt(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opSlt(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() + x, y := scope.Stack.pop1Peek1() if x.Slt(y) { y.SetOne() } else { @@ -117,7 +117,7 @@ func opSlt(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opSgt(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() + x, y := scope.Stack.pop1Peek1() if x.Sgt(y) { y.SetOne() } else { @@ -127,7 +127,7 @@ func opSgt(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opEq(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() + x, y := scope.Stack.pop1Peek1() if x.Eq(y) { y.SetOne() } else { @@ -147,38 +147,38 @@ func opIszero(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opAnd(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.And(&x, y) + x, y := scope.Stack.pop1Peek1() + y.And(x, y) return nil, nil } func opOr(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.Or(&x, y) + x, y := scope.Stack.pop1Peek1() + y.Or(x, y) return nil, nil } func opXor(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y := scope.Stack.pop(), scope.Stack.peek() - y.Xor(&x, y) + x, y := scope.Stack.pop1Peek1() + y.Xor(x, y) return nil, nil } func opByte(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - th, val := scope.Stack.pop(), scope.Stack.peek() - val.Byte(&th) + th, val := scope.Stack.pop1Peek1() + val.Byte(th) return nil, nil } func opAddmod(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y, z := scope.Stack.pop(), scope.Stack.pop(), scope.Stack.peek() - z.AddMod(&x, &y, z) + x, y, z := scope.Stack.pop2Peek1() + z.AddMod(x, y, z) return nil, nil } func opMulmod(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - x, y, z := scope.Stack.pop(), scope.Stack.pop(), scope.Stack.peek() - z.MulMod(&x, &y, z) + x, y, z := scope.Stack.pop2Peek1() + z.MulMod(x, y, z) return nil, nil } @@ -187,7 +187,7 @@ func opMulmod(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // and pushes on the stack arg2 shifted to the left by arg1 number of bits. func opSHL(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // Note, second operand is left in the stack; accumulate result into it, and no need to push it afterwards - shift, value := scope.Stack.pop(), scope.Stack.peek() + shift, value := scope.Stack.pop1Peek1() if shift.LtUint64(256) { value.Lsh(value, uint(shift.Uint64())) } else { @@ -201,7 +201,7 @@ func opSHL(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // and pushes on the stack arg2 shifted to the right by arg1 number of bits with zero fill. func opSHR(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // Note, second operand is left in the stack; accumulate result into it, and no need to push it afterwards - shift, value := scope.Stack.pop(), scope.Stack.peek() + shift, value := scope.Stack.pop1Peek1() if shift.LtUint64(256) { value.Rsh(value, uint(shift.Uint64())) } else { @@ -214,7 +214,7 @@ func opSHR(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // The SAR instruction (arithmetic shift right) pops 2 values from the stack, first arg1 and then arg2, // and pushes on the stack arg2 shifted to the right by arg1 number of bits with sign extension. func opSAR(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - shift, value := scope.Stack.pop(), scope.Stack.peek() + shift, value := scope.Stack.pop1Peek1() if shift.GtUint64(256) { if value.Sign() >= 0 { value.Clear() @@ -230,7 +230,7 @@ func opSAR(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opKeccak256(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - offset, size := scope.Stack.pop(), scope.Stack.peek() + offset, size := scope.Stack.pop1Peek1() data := scope.Memory.GetPtr(offset.Uint64(), size.Uint64()) hash := crypto.Keccak256Hash(data) @@ -286,11 +286,7 @@ func opCallDataSize(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opCallDataCopy(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - var ( - memOffset = scope.Stack.pop() - dataOffset = scope.Stack.pop() - length = scope.Stack.pop() - ) + memOffset, dataOffset, length := scope.Stack.pop3() dataOffset64, overflow := dataOffset.Uint64WithOverflow() if overflow { dataOffset64 = math.MaxUint64 @@ -309,11 +305,7 @@ func opReturnDataSize(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) } func opReturnDataCopy(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - var ( - memOffset = scope.Stack.pop() - dataOffset = scope.Stack.pop() - length = scope.Stack.pop() - ) + memOffset, dataOffset, length := scope.Stack.pop3() offset64, overflow := dataOffset.Uint64WithOverflow() if overflow { @@ -321,7 +313,7 @@ func opReturnDataCopy(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) } // we can reuse dataOffset now (aliasing it for clarity) var end = dataOffset - end.Add(&dataOffset, &length) + end.Add(dataOffset, length) end64, overflow := end.Uint64WithOverflow() if overflow || uint64(len(evm.returnData)) < end64 { return nil, ErrReturnDataOutOfBounds @@ -342,11 +334,7 @@ func opCodeSize(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opCodeCopy(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - var ( - memOffset = scope.Stack.pop() - codeOffset = scope.Stack.pop() - length = scope.Stack.pop() - ) + memOffset, codeOffset, length := scope.Stack.pop3() uint64CodeOffset, overflow := codeOffset.Uint64WithOverflow() if overflow { uint64CodeOffset = math.MaxUint64 @@ -359,11 +347,8 @@ func opCodeCopy(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { func opExtCodeCopy(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { var ( - stack = scope.Stack - a = stack.pop() - memOffset = stack.pop() - codeOffset = stack.pop() - length = stack.pop() + stack = scope.Stack + a, memOffset, codeOffset, length = stack.pop4() ) uint64CodeOffset, overflow := codeOffset.Uint64WithOverflow() if overflow { @@ -480,7 +465,7 @@ func opGasLimit(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opPop(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - scope.Stack.pop() + scope.Stack.drop() return nil, nil } @@ -492,13 +477,13 @@ func opMload(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opMstore(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - mStart, val := scope.Stack.pop(), scope.Stack.pop() - scope.Memory.Set32(mStart.Uint64(), &val) + mStart, val := scope.Stack.pop2() + scope.Memory.Set32(mStart.Uint64(), val) return nil, nil } func opMstore8(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - off, val := scope.Stack.pop(), scope.Stack.pop() + off, val := scope.Stack.pop2() scope.Memory.store[off.Uint64()] = byte(val.Uint64()) return nil, nil } @@ -515,8 +500,7 @@ func opSstore(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if evm.readOnly { return nil, ErrWriteProtection } - loc := scope.Stack.pop() - val := scope.Stack.pop() + loc, val := scope.Stack.pop2() evm.StateDB.SetState(scope.Contract.Address(), loc.Bytes32(), val.Bytes32()) return nil, nil } @@ -525,8 +509,8 @@ func opJump(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if evm.abort.Load() { return nil, errStopToken } - pos := scope.Stack.pop() - if !scope.Contract.validJumpdest(&pos) { + pos := scope.Stack.pop1() + if !scope.Contract.validJumpdest(pos) { return nil, ErrInvalidJump } *pc = pos.Uint64() - 1 // pc will be increased by the interpreter loop @@ -537,9 +521,9 @@ func opJumpi(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if evm.abort.Load() { return nil, errStopToken } - pos, cond := scope.Stack.pop(), scope.Stack.pop() + pos, cond := scope.Stack.pop2() if !cond.IsZero() { - if !scope.Contract.validJumpdest(&pos) { + if !scope.Contract.validJumpdest(pos) { return nil, ErrInvalidJump } *pc = pos.Uint64() - 1 // pc will be increased by the interpreter loop @@ -864,14 +848,14 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opReturn(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - offset, size := scope.Stack.pop(), scope.Stack.pop() + offset, size := scope.Stack.pop2() ret := scope.Memory.GetCopy(offset.Uint64(), size.Uint64()) return ret, errStopToken } func opRevert(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - offset, size := scope.Stack.pop(), scope.Stack.pop() + offset, size := scope.Stack.pop2() ret := scope.Memory.GetCopy(offset.Uint64(), size.Uint64()) evm.returnData = ret @@ -893,7 +877,7 @@ func opSelfdestruct(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { var ( this = scope.Contract.Address() balance = evm.StateDB.GetBalance(this) - top = scope.Stack.pop() + top = scope.Stack.pop1() beneficiary = common.Address(top.Bytes20()) ) // The funds are burned immediately if the beneficiary is the caller itself, @@ -923,7 +907,7 @@ func opSelfdestruct6780(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, erro var ( this = scope.Contract.Address() balance = evm.StateDB.GetBalance(this) - top = scope.Stack.pop() + top = scope.Stack.pop1() beneficiary = common.Address(top.Bytes20()) newContract = evm.StateDB.IsNewContract(this) ) @@ -1092,9 +1076,9 @@ func makeLog(size int) executionFunc { } topics := make([]common.Hash, size) stack := scope.Stack - mStart, mSize := stack.pop(), stack.pop() + mStart, mSize := stack.pop2() for i := 0; i < size; i++ { - addr := stack.pop() + addr := stack.pop1() topics[i] = addr.Bytes32() } diff --git a/core/vm/stack.go b/core/vm/stack.go index d8000bc86d..564345ccd8 100644 --- a/core/vm/stack.go +++ b/core/vm/stack.go @@ -121,6 +121,62 @@ func (s *Stack) len() int { return s.size } +// drop removes the top element without reading it. +func (s *Stack) drop() { + s.inner.top-- + s.size-- +} + +// pop1 removes the top element and returns a pointer to it. The pointer +// stays valid only until the next push or sub call. +func (s *Stack) pop1() *uint256.Int { + s.inner.top-- + s.size-- + return &s.inner.data[s.inner.top] +} + +// pop2 removes the top two elements and returns pointers to them. The +// pointers stay valid only until the next push or sub call. +func (s *Stack) pop2() (top, second *uint256.Int) { + s.inner.top -= 2 + s.size -= 2 + return &s.inner.data[s.inner.top+1], &s.inner.data[s.inner.top] +} + +// pop3 removes the top three elements and returns pointers to them. The +// pointers stay valid only until the next push or sub call. +func (s *Stack) pop3() (top, second, third *uint256.Int) { + s.inner.top -= 3 + s.size -= 3 + return &s.inner.data[s.inner.top+2], &s.inner.data[s.inner.top+1], &s.inner.data[s.inner.top] +} + +// pop4 removes the top four elements and returns pointers to them. The +// pointers stay valid only until the next push or sub call. +func (s *Stack) pop4() (top, second, third, fourth *uint256.Int) { + s.inner.top -= 4 + s.size -= 4 + return &s.inner.data[s.inner.top+3], &s.inner.data[s.inner.top+2], &s.inner.data[s.inner.top+1], &s.inner.data[s.inner.top] +} + +// pop1Peek1 removes the top element and returns pointers to it and to the new +// top, the usual operand and write target of a binary operation. The first +// pointer stays valid only until the next push or sub call. +func (s *Stack) pop1Peek1() (top, rest *uint256.Int) { + s.inner.top-- + s.size-- + return &s.inner.data[s.inner.top], &s.inner.data[s.inner.top-1] +} + +// pop2Peek1 removes the top two elements and returns pointers to them and to +// the new top, for three operand operations. The first two pointers stay +// valid only until the next push or sub call. +func (s *Stack) pop2Peek1() (top, second, rest *uint256.Int) { + s.inner.top -= 2 + s.size -= 2 + return &s.inner.data[s.inner.top+1], &s.inner.data[s.inner.top], &s.inner.data[s.inner.top-1] +} + func (s *Stack) swap1() { s.inner.data[s.bottom+s.size-2], s.inner.data[s.bottom+s.size-1] = s.inner.data[s.bottom+s.size-1], s.inner.data[s.bottom+s.size-2] } From cb387c9bc32dddad0530be795f80f0744be8a927 Mon Sep 17 00:00:00 2001 From: cui Date: Tue, 16 Jun 2026 21:31:02 +0800 Subject: [PATCH 11/24] cmd/devp2p/internal/ethtest: validate received txs, not the sent ones (#35170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendInvalidTxs's *eth.TransactionsPacket case iterated `txs` — the locally-sent invalid transactions, every one of which is in `invalids` by construction — instead of the transactions actually carried by the received packet. As a result the loop returned "received bad tx" on the very first TransactionsPacket the peer sent, regardless of its contents, and never inspected what was really propagated. Iterate msg.Items() (the decoded contents of the received packet) so the "node must not propagate invalid txs" conformance check tests the real condition instead of producing a false negative. --------- Co-authored-by: Bosul Mun --- cmd/devp2p/internal/ethtest/transaction.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/devp2p/internal/ethtest/transaction.go b/cmd/devp2p/internal/ethtest/transaction.go index 8ce26f3e1a..855e799b4f 100644 --- a/cmd/devp2p/internal/ethtest/transaction.go +++ b/cmd/devp2p/internal/ethtest/transaction.go @@ -155,7 +155,11 @@ func (s *Suite) sendInvalidTxs(t *utesting.T, txs []*types.Transaction) error { switch msg := msg.(type) { case *eth.TransactionsPacket: - for _, tx := range txs { + received, err := msg.Items() + if err != nil { + return fmt.Errorf("failed to decode received transactions: %w", err) + } + for _, tx := range received { if _, ok := invalids[tx.Hash()]; ok { return fmt.Errorf("received bad tx: %s", tx.Hash()) } From ad68ce261b848726daac90efd93f26d673b167ad Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Wed, 17 Jun 2026 09:55:36 +0800 Subject: [PATCH 12/24] eth: reserve peer slot for usable snap peer (#35180) This PR improves the slot reservation logic in the context of snap/2. Geth has the mechanism to reserve roughly half the peer slots for peers supporting the snap protocol if snap syncing is needed by local node. With the context of snap/2, this mechanism should be changed that: we reserve the slot for the "usable snap peer", not blindly for peer with snap extension enabled (such as legacy snap/1, which can't serve the snap/2). --- eth/downloader/downloader.go | 12 ++++++++++++ eth/downloader/metrics.go | 4 ++++ eth/handler.go | 16 +++++++++++----- eth/peerset.go | 25 ++++++++++++++----------- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/eth/downloader/downloader.go b/eth/downloader/downloader.go index af4c4bb1f6..cfa1c02e9a 100644 --- a/eth/downloader/downloader.go +++ b/eth/downloader/downloader.go @@ -1132,11 +1132,23 @@ func (d *Downloader) DeliverSnapPacket(peer *snap.Peer, packet snap.Packet) erro // snap/2 syncer skips snap/1-only peers, which cannot answer its BAL requests. func (d *Downloader) RegisterSnapPeer(p *snap.Peer) error { if p.Version() < d.snapSyncer.Version() { + // The peer speaks an older snap version than the active syncer needs + // (e.g. snap/1 while we sync via snap/2). We still serve it, but it + // cannot answer our requests, so it is not registered for syncing. + // Surface it so an operator can tell a stalled sync from a quiet one. + snapPeerSkipMeter.Mark(1) + log.Debug("Skipping snap peer below syncer version", "peer", p.ID(), "version", p.Version(), "required", d.snapSyncer.Version()) return nil } return d.snapSyncer.Register(p) } +// SnapSyncVersion returns the snap protocol version of the active state syncer. +// Peers negotiating a lower version cannot serve its requests. +func (d *Downloader) SnapSyncVersion() uint { + return d.snapSyncer.Version() +} + // UnregisterSnapPeer removes a snap peer from the active state syncer. It mirrors // RegisterSnapPeer's version gate: a peer below the active syncer's version was // never registered, so there is nothing to remove. diff --git a/eth/downloader/metrics.go b/eth/downloader/metrics.go index bfe80ddbf1..caa8dfb9ee 100644 --- a/eth/downloader/metrics.go +++ b/eth/downloader/metrics.go @@ -38,4 +38,8 @@ var ( receiptTimeoutMeter = metrics.NewRegisteredMeter("eth/downloader/receipts/timeout", nil) throttleCounter = metrics.NewRegisteredCounter("eth/downloader/throttle", nil) + + // snapPeerSkipMeter tracks snap peers skipped by the state syncer because + // they negotiated a version below the one the syncer requires. + snapPeerSkipMeter = metrics.NewRegisteredMeter("eth/downloader/snap/peerskip", nil) ) diff --git a/eth/handler.go b/eth/handler.go index 3c5e122c80..2028517cec 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -245,11 +245,17 @@ func (h *handler) runEthPeer(peer *eth.Peer, handler eth.Handler) error { } reject := false // reserved peer slots if h.downloader.ConfigSyncMode() == ethconfig.SnapSync { - if snap == nil { - // If we are running snap-sync, we want to reserve roughly half the peer - // slots for peers supporting the snap protocol. - // The logic here is; we only allow up to 5 more non-snap peers than snap-peers. - if all, snp := h.peers.len(), h.peers.snapLen(); all-snp > snp+5 { + // A peer is useful to the active state syncer only if it offers the snap + // extension at (or above) the syncer's version. Non-snap peers AND peers + // stuck on an older snap version (e.g. snap/1 while we sync via snap/2) + // are both "non-usable": the node still serves them, but they must not be + // allowed to fill the slots reserved for peers that can serve the sync. + minVersion := h.downloader.SnapSyncVersion() + usable := snap != nil && snap.Version() >= minVersion + if !usable { + // Reserve roughly half the slots for usable peers: only allow up to 5 + // more non-usable peers (non-snap or below-version snap) than usable ones. + if all, snp := h.peers.len(), h.peers.snapLen(minVersion); all-snp > snp+5 { reject = true } } diff --git a/eth/peerset.go b/eth/peerset.go index e6f623f90c..fa946e8b17 100644 --- a/eth/peerset.go +++ b/eth/peerset.go @@ -49,8 +49,7 @@ var ( // peerSet represents the collection of active peers currently participating in // the `eth` protocol, with or without the `snap` extension. type peerSet struct { - peers map[string]*ethPeer // Peers connected on the `eth` protocol - snapPeers int // Number of `snap` compatible peers for connection prioritization + peers map[string]*ethPeer // Peers connected on the `eth` protocol snapWait map[string]chan *snap.Peer // Peers connected on `eth` waiting for their snap extension snapPend map[string]*snap.Peer // Peers connected on the `snap` protocol, but not yet on `eth` @@ -161,7 +160,6 @@ func (ps *peerSet) registerPeer(peer *eth.Peer, ext *snap.Peer) error { } if ext != nil { eth.snapExt = &snapPeer{ext} - ps.snapPeers++ } ps.peers[id] = eth return nil @@ -173,14 +171,10 @@ func (ps *peerSet) unregisterPeer(id string) error { ps.lock.Lock() defer ps.lock.Unlock() - peer, ok := ps.peers[id] - if !ok { + if _, ok := ps.peers[id]; !ok { return errPeerNotRegistered } delete(ps.peers, id) - if peer.snapExt != nil { - ps.snapPeers-- - } return nil } @@ -210,12 +204,21 @@ func (ps *peerSet) len() int { return len(ps.peers) } -// snapLen returns if the current number of `snap` peers in the set. -func (ps *peerSet) snapLen() int { +// snapLen returns the number of `snap` peers whose negotiated version is at +// least minVersion — i.e. peers usable by a state syncer running that version. +// Lower-version snap peers (which the node still serves but cannot sync from) +// are excluded, so they don't get counted toward the reserved snap-peer slots. +func (ps *peerSet) snapLen(minVersion uint) int { ps.lock.RLock() defer ps.lock.RUnlock() - return ps.snapPeers + var n int + for _, p := range ps.peers { + if p.snapExt != nil && p.snapExt.Version() >= minVersion { + n++ + } + } + return n } // close disconnects all peers. From 1be5da233085eb9c52256685a79f80c75107b167 Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Wed, 17 Jun 2026 09:57:08 +0800 Subject: [PATCH 13/24] eth/protocols/snap: redo the snap sync if the bal is unavailable (#35181) This PR introduces a new condition that if the local node falls behind too much and the required BAL for catching up is very likely to be unavailable, the entire snap sync will be restarting from scratch. As the defined BAL retention window is weak-subjective-period which is calculated dynamically. A more conservative threshold is used (90K blocks) for robustness. Apart from that, the BAL catchup will be divided into several spans and apply one by one. It's essential to prevent the potential out-of-memory panic of placing the entire BAL set in memory. --- eth/protocols/snap/syncv2.go | 153 +++++++++++++++++++----------- eth/protocols/snap/syncv2_test.go | 135 ++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 54 deletions(-) diff --git a/eth/protocols/snap/syncv2.go b/eth/protocols/snap/syncv2.go index bad52de184..161e5d2058 100644 --- a/eth/protocols/snap/syncv2.go +++ b/eth/protocols/snap/syncv2.go @@ -38,6 +38,7 @@ import ( "github.com/ethereum/go-ethereum/event" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/p2p/msgrate" + "github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie/trienode" @@ -55,6 +56,22 @@ const ( // the assumption that the gas limit is 60M. maxAccessListRequestCount = 28 + // maxCatchUpBlocks is the maximum gap (in blocks) that BAL catch-up is + // allowed to span. BALs are only retained by peers for a limited window + // (roughly two weeks, ~100k blocks at 12s block time). If the pivot has + // moved further than this conservative bound, the BALs needed to roll the + // flat state forward are likely no longer available, so we discard the + // stale progress and restart the sync from scratch rather than attempting + // a catch-up that is bound to fail partway through. + maxCatchUpBlocks = params.FullImmutabilityThreshold + + // catchUpWindow is the number of blocks BAL catch-up fetches and applies at + // a time. The whole gap can span up to maxCatchUpBlocks, so fetching it in + // one shot would buffer every block's BAL (~100 KiB each) in memory before + // applying any. Processing the gap in bounded windows caps peak memory to a + // single window's worth of BALs. + catchUpWindow = 512 + // syncProgressVersion is the version byte prepended to the JSON-encoded // syncProgressV2 when persisted. On load, a mismatching version byte causes // the persisted progress to be discarded and sync to start fresh. @@ -387,6 +404,8 @@ type syncerV2 struct { startTime time.Time // Time instance when snapshot sync started logTime time.Time // Time instance when status was last reported + catchUpWindow uint64 // Number of blocks fetched/applied per BAL catch-up window (overridable in tests) + pend sync.WaitGroup // Tracks network request goroutines for graceful shutdown lock sync.RWMutex // Protects fields that can change outside of sync (peers, reqs, pivot) } @@ -415,7 +434,8 @@ func newSyncerV2(db ethdb.Database, scheme string) *syncerV2 { bytecodeReqs: make(map[uint64]*bytecodeRequestV2), accessListReqs: make(map[uint64]*accessListRequest), - extProgress: new(syncProgressV2), + extProgress: new(syncProgressV2), + catchUpWindow: catchUpWindow, } if raw := rawdb.ReadSnapshotSyncStatus(db); len(raw) > 0 && raw[0] == syncProgressVersion { var progress syncProgressV2 @@ -559,10 +579,18 @@ func (s *syncerV2) Sync(target *types.Header, cancel chan struct{}) error { // progress is still usable. If yes, roll forward via BAL catch-up. If not, // wipe everything and restart fresh. if isPivotChanged { - if isPivotReorged(s.db, prevPivot, target) { + switch { + case isPivotReorged(s.db, prevPivot, target): log.Warn("Restarting snap sync from scratch", "oldnumber", prevPivot.Number, "oldHash", prevPivot.Hash()) s.resetSyncState() - } else { + case catchUpExceedsRetention(prevPivot, target): + // The pivot moved further than the BAL retention window. The access + // lists required for catch-up are almost certainly unavailable from + // peers, so discard the stale progress and resync from scratch + // instead of starting a catch-up doomed to stall. + log.Warn("Catch-up gap exceeds BAL retention, restarting snap sync from scratch", "oldnumber", prevPivot.Number, "newnumber", target.Number, "gap", new(big.Int).Sub(target.Number, prevPivot.Number), "limit", maxCatchUpBlocks) + s.resetSyncState() + default: // A canonical pivot move past a frozen pivot should be impossible: // the downloader both refuses moves (FrozenPivot) and resumes new // cycles against the frozen header itself. Reaching this branch @@ -728,6 +756,15 @@ func isPivotReorged(db ethdb.Database, prev, curr *types.Header) bool { return canonical != prev.Hash() } +// catchUpExceedsRetention reports whether rolling the flat state forward from +// prev to curr would span more blocks than peers are expected to retain BALs +// for. Beyond this bound the access lists needed for catch-up are likely gone, +// so the caller should wipe and resync from scratch instead. +func catchUpExceedsRetention(prev, curr *types.Header) bool { + gap := new(big.Int).Sub(curr.Number, prev.Number) + return gap.Cmp(big.NewInt(maxCatchUpBlocks)) > 0 +} + // catchUp runs the BAL catch-up. When the pivot has moved, it fetches BALs // for the gap blocks, verifies them against block headers, and applies the // diffs to roll flat state forward. @@ -738,69 +775,77 @@ func (s *syncerV2) catchUp(target *types.Header, cancel chan struct{}) error { s.lock.RUnlock() log.Info("Starting BAL catch-up", "from", from, "to", to, "blocks", to-from+1) - // Collect block hashes and headers for the gap range. - var ( - hashes = make([]common.Hash, 0, to-from+1) - headers = make(map[common.Hash]*types.Header, to-from+1) - ) - for num := from; num <= to; num++ { - hash := rawdb.ReadCanonicalHash(s.db, num) - if hash == (common.Hash{}) { - return fmt.Errorf("missing canonical hash for block %d during catch-up", num) - } - header := rawdb.ReadHeader(s.db, hash, num) - if header == nil { - return fmt.Errorf("missing header for block %d (hash %v) during catch-up", num, hash) - } - hashes = append(hashes, hash) - headers[hash] = header - } - - // Fetch BALs from peers - rawBALs, err := s.fetchAccessLists(hashes, headers, cancel) - if err != nil { - return err - } - - // Apply each BAL in block order. BALs are already verified by fetchAccessLists. - for i, raw := range rawBALs { + for start := from; start <= to; start += s.catchUpWindow { select { case <-cancel: return ErrCancelled default: } - num := from + uint64(i) - hash := hashes[i] - - // Decode the raw RLP into a BAL. + end := start + s.catchUpWindow - 1 + if end > to { + end = to + } + // Collect block hashes and headers for this window. var ( - b bal.BlockAccessList - batch = s.db.NewBatch() + hashes = make([]common.Hash, 0, end-start+1) + headers = make(map[common.Hash]*types.Header, end-start+1) ) - if err := rlp.DecodeBytes(raw, &b); err != nil { - return fmt.Errorf("failed to decode BAL for block %d: %v", num, err) + for num := start; num <= end; num++ { + hash := rawdb.ReadCanonicalHash(s.db, num) + if hash == (common.Hash{}) { + return fmt.Errorf("missing canonical hash for block %d during catch-up", num) + } + header := rawdb.ReadHeader(s.db, hash, num) + if header == nil { + return fmt.Errorf("missing header for block %d (hash %v) during catch-up", num, hash) + } + hashes = append(hashes, hash) + headers[hash] = header } - // applyAccessList failures are persistent. If a block's apply fails - // here, the next Sync will resume from this block and hit the same - // failure. Auto-recovery isn't implemented yet. - if err := s.applyAccessList(&b, batch); err != nil { - return fmt.Errorf("BAL application failed for block %d: %v", num, err) - } - - // Persist incremental progress so a crash mid-catchUp can resume - // from the next unapplied block. - s.lock.Lock() - s.pivot = headers[hash] - s.lock.Unlock() - s.saveSyncStatusWithDB(batch) - - // Commit the state transition alongside the sync progress atomically. - if err := batch.Write(); err != nil { + // Fetch this window's BALs from peers. + rawBALs, err := s.fetchAccessLists(hashes, headers, cancel) + if err != nil { return err } + + // Apply each BAL in block order. BALs are already verified by fetchAccessLists. + for i, raw := range rawBALs { + select { + case <-cancel: + return ErrCancelled + default: + } + num := start + uint64(i) + hash := hashes[i] + + // Decode the raw RLP into a BAL. + var ( + b bal.BlockAccessList + batch = s.db.NewBatch() + ) + if err := rlp.DecodeBytes(raw, &b); err != nil { + return fmt.Errorf("failed to decode BAL for block %d: %v", num, err) + } + if err := s.applyAccessList(&b, batch); err != nil { + return fmt.Errorf("BAL application failed for block %d: %v", num, err) + } + + // Persist incremental progress so a crash mid-catchUp can resume + // from the next unapplied block. + s.lock.Lock() + s.pivot = headers[hash] + s.lock.Unlock() + s.saveSyncStatusWithDB(batch) + + // Commit the state transition alongside the sync progress atomically. + if err := batch.Write(); err != nil { + return err + } + } + log.Info("BAL catch-up progress", "applied", end, "target", to, "remaining", to-end) } - log.Info("BAL catch-up complete", "blocks", len(rawBALs)) + log.Info("BAL catch-up complete", "from", from, "to", to) return nil } diff --git a/eth/protocols/snap/syncv2_test.go b/eth/protocols/snap/syncv2_test.go index aed7aac5f2..bad4123560 100644 --- a/eth/protocols/snap/syncv2_test.go +++ b/eth/protocols/snap/syncv2_test.go @@ -1895,6 +1895,141 @@ func testCatchUpPersistsIncrementally(t *testing.T, scheme string) { } } +// TestCatchUpWindowed verifies that catch-up correctly rolls the flat state +// forward when the gap spans several windows. With catchUpWindow shrunk to 2, +// a 5-block gap is processed as three windows ([A+1,A+2], [A+3,A+4], [A+5]), +// exercising the window boundaries. Every block's BAL must be fetched and +// applied, and the final pivot must reach the target. +func TestCatchUpWindowed(t *testing.T) { + t.Parallel() + testCatchUpWindowed(t, rawdb.HashScheme) + testCatchUpWindowed(t, rawdb.PathScheme) +} + +func testCatchUpWindowed(t *testing.T, scheme string) { + nodeScheme, sourceAccountTrie, elems, addrs := makeAccountTrieWithAddresses(100, scheme) + rootA := sourceAccountTrie.Hash() + numA := uint64(100) + + targetAddr := addrs[0] + targetHash := crypto.Keccak256Hash(targetAddr[:]) + + db := rawdb.NewMemoryDatabase() + emptyHash := common.Hash{} + zero := uint64(0) + + // Persist header + canonical hash for the base pivot A so reorg detection + // in Sync passes and catchUp (not reset) runs. + pivotA := &types.Header{ + Number: new(big.Int).SetUint64(numA), Root: rootA, Difficulty: common.Big0, + BaseFee: common.Big0, WithdrawalsHash: &emptyHash, + BlobGasUsed: &zero, ExcessBlobGas: &zero, + ParentBeaconRoot: &emptyHash, RequestsHash: &emptyHash, + } + rawdb.WriteHeader(db, pivotA) + rawdb.WriteCanonicalHash(db, pivotA.Hash(), numA) + + // Build a 5-block gap, each block bumping targetAddr's balance. The last + // block's balance is the expected final state. + const gap = 5 + var ( + lastHeader *types.Header + lastBalance *uint256.Int + balsByHash = make(map[common.Hash]rlp.RawValue, gap) + ) + for i := 0; i < gap; i++ { + blockNum := numA + uint64(i) + 1 + balance := uint256.NewInt(uint64(1000 * (i + 1))) + + cb := bal.NewConstructionBlockAccessList() + cb.BalanceChange(0, targetAddr, balance) + var buf bytes.Buffer + if err := cb.EncodeRLP(&buf); err != nil { + t.Fatal(err) + } + var b bal.BlockAccessList + if err := rlp.DecodeBytes(buf.Bytes(), &b); err != nil { + t.Fatal(err) + } + balHash := b.Hash() + header := &types.Header{ + Number: new(big.Int).SetUint64(blockNum), Difficulty: common.Big0, + BaseFee: common.Big0, WithdrawalsHash: &emptyHash, + BlobGasUsed: &zero, ExcessBlobGas: &zero, + ParentBeaconRoot: &emptyHash, RequestsHash: &emptyHash, + BlockAccessListHash: &balHash, + } + rawdb.WriteHeader(db, header) + rawdb.WriteCanonicalHash(db, header.Hash(), blockNum) + balsByHash[header.Hash()] = buf.Bytes() + lastHeader, lastBalance = header, balance + } + + // Seed sync to A: persisted state ends with pivot=A and full flat state. + { + var ( + once sync.Once + cancel = make(chan struct{}) + term = func() { once.Do(func() { close(cancel) }) } + ) + syncer := newSyncerV2(db, nodeScheme) + src := newTestPeerV2("seed", t, term) + src.accountTrie = sourceAccountTrie.Copy() + src.accountValues = elems + syncer.Register(src) + src.remote = syncer + if err := syncer.Sync(pivotA, cancel); err != nil { + t.Fatalf("seed sync failed: %v", err) + } + } + + // Run catch-up to A+5 directly with a window of 2, forcing three windows + // ([A+1,A+2], [A+3,A+4], [A+5]). Calling catchUp in isolation keeps the + // focus on the windowing logic without the surrounding download/trie-gen + // phases (which would need a real target state root). + var ( + once sync.Once + cancel = make(chan struct{}) + term = func() { once.Do(func() { close(cancel) }) } + ) + syncer := newSyncerV2(db, nodeScheme) + syncer.loadSyncStatus() // restores pivot=A from the seed sync + if syncer.pivot == nil || syncer.pivot.Number.Uint64() != numA { + t.Fatalf("expected restored pivot at block %d, got %v", numA, syncer.pivot) + } + syncer.catchUpWindow = 2 + src := newTestPeerV2("catchup", t, term) + src.accountTrie = sourceAccountTrie.Copy() + src.accountValues = elems + src.accessLists = balsByHash + syncer.Register(src) + src.remote = syncer + if err := syncer.catchUp(lastHeader, cancel); err != nil { + t.Fatalf("windowed catch-up failed: %v", err) + } + + // The account must reflect the final block's balance, proving every window + // (including the trailing partial one) was fetched and applied in order. + data := rawdb.ReadAccountSnapshot(db, targetHash) + if len(data) == 0 { + t.Fatal("target account missing after windowed catch-up") + } + account, err := types.FullAccount(data) + if err != nil { + t.Fatalf("failed to decode account: %v", err) + } + if account.Balance.Cmp(lastBalance) != 0 { + t.Errorf("balance after windowed catch-up: got %v, want %v", account.Balance, lastBalance) + } + + // The persisted pivot must have advanced all the way to the target. + loader := newSyncerV2(db, nodeScheme) + loader.loadSyncStatus() + if loader.pivot == nil || loader.pivot.Hash() != lastHeader.Hash() { + t.Errorf("persisted pivot did not reach target after windowed catch-up") + } +} + // TestSyncStatusMarkedCompleteAfterCompletion verifies that after a full sync // completes, the persisted sync status reaches the complete phase. This lets a // subsequent Sync call distinguish "already done" from "fresh node" and skip. From 0e810e4984e2e918d59991ae5310fb4b8091675d Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Wed, 17 Jun 2026 13:43:51 +0800 Subject: [PATCH 14/24] eth, triedb, internal: add snap/2 sync progress (#35178) This PR does two things: - Expose snap/2 specific sync progress fields - Seed the sync progress after `loadSyncStatus ` --- eth/downloader/downloader.go | 7 +++ eth/protocols/snap/syncer.go | 20 +++++--- eth/protocols/snap/syncv2.go | 61 +++++++++++++++++++----- ethclient/ethclient.go | 6 +++ interfaces.go | 9 +++- internal/ethapi/api.go | 3 ++ triedb/generate.go | 91 +++++++++++++++++++++++++++--------- triedb/generate_test.go | 18 ++++--- 8 files changed, 164 insertions(+), 51 deletions(-) diff --git a/eth/downloader/downloader.go b/eth/downloader/downloader.go index cfa1c02e9a..f23d9396ee 100644 --- a/eth/downloader/downloader.go +++ b/eth/downloader/downloader.go @@ -295,12 +295,19 @@ func (d *Downloader) Progress() ethereum.SyncProgress { SyncedBytecodeBytes: uint64(progress.BytecodeBytes), SyncedStorage: progress.StorageSynced, SyncedStorageBytes: uint64(progress.StorageBytes), + + // Snap/1 progress fields HealedTrienodes: progress.TrienodeHealSynced, HealedTrienodeBytes: uint64(progress.TrienodeHealBytes), HealedBytecodes: progress.BytecodeHealSynced, HealedBytecodeBytes: uint64(progress.BytecodeHealBytes), HealingTrienodes: progress.HealingTrienodes, HealingBytecode: progress.HealingBytecode, + + // Snap/2 progress fields + SyncedAccessLists: progress.AccessListSynced, + TotalAccessLists: progress.AccessListTotal, + TrieGenProgress: progress.TrieGenPercent, } } diff --git a/eth/protocols/snap/syncer.go b/eth/protocols/snap/syncer.go index 5d1aa67ea7..b5c1caedf6 100644 --- a/eth/protocols/snap/syncer.go +++ b/eth/protocols/snap/syncer.go @@ -41,6 +41,11 @@ type Progress struct { BytecodeHealBytes common.StorageSize HealingTrienodes uint64 HealingBytecode uint64 + + // snap/2-specific status. Reported by snap/2 only. + AccessListSynced uint64 // Block access lists fetched during catch-up + AccessListTotal uint64 // Total block access lists to fetch for catch-up + TrieGenPercent uint64 // Trie generation completion, in percent (0..100) } // Syncer is the uniform view over the snap/1 (*syncer) and snap/2 (*syncerV2) @@ -139,12 +144,15 @@ type syncerV2Adapter struct{ *syncerV2 } func (s syncerV2Adapter) Progress() Progress { progress := s.syncerV2.Progress() return Progress{ - AccountSynced: progress.AccountSynced, - AccountBytes: progress.AccountBytes, - BytecodeSynced: progress.BytecodeSynced, - BytecodeBytes: progress.BytecodeBytes, - StorageSynced: progress.StorageSynced, - StorageBytes: progress.StorageBytes, + AccountSynced: progress.AccountSynced, + AccountBytes: progress.AccountBytes, + BytecodeSynced: progress.BytecodeSynced, + BytecodeBytes: progress.BytecodeBytes, + StorageSynced: progress.StorageSynced, + StorageBytes: progress.StorageBytes, + AccessListSynced: progress.AccessListSynced, + AccessListTotal: progress.AccessListTotal, + TrieGenPercent: progress.TrieGenPercent, } } diff --git a/eth/protocols/snap/syncv2.go b/eth/protocols/snap/syncv2.go index 161e5d2058..fbbc10c60f 100644 --- a/eth/protocols/snap/syncv2.go +++ b/eth/protocols/snap/syncv2.go @@ -319,6 +319,10 @@ type syncProgressV2 struct { BytecodeBytes common.StorageSize // Number of bytecode bytes downloaded StorageSynced uint64 // Number of storage slots downloaded StorageBytes common.StorageSize // Number of storage trie bytes persisted to disk + + AccessListSynced uint64 `json:"-"` // Block access lists fetched during catch-up + AccessListTotal uint64 `json:"-"` // Total block access lists to fetch for catch-up + TrieGenPercent uint64 `json:"-"` // Trie generation completion, in percent (0..100) } // SyncPeerV2 abstracts out the methods required for a peer to be synced against @@ -399,6 +403,10 @@ type syncerV2 struct { storageSynced uint64 // Number of storage slots downloaded storageBytes common.StorageSize // Number of storage trie bytes persisted to disk + accessListSynced uint64 // Block access lists fetched so far during catch-up + accessListTotal uint64 // Block access lists to fetch for the current catch-up + genProgress atomic.Uint64 // The live trie-generation progress + extProgress *syncProgressV2 // progress that can be exposed to external caller. startTime time.Time // Time instance when snapshot sync started @@ -629,8 +637,9 @@ func (s *syncerV2) Sync(target *types.Header, cancel chan struct{}) error { if err := batch.Write(); err != nil { return err } - if _, err := triedb.GenerateTrie(s.db, s.scheme, root, cancel); err != nil { - return err + _, genErr := triedb.GenerateTrieWithProgress(s.db, s.scheme, root, cancel, &s.genProgress) + if genErr != nil { + return genErr } log.Info("Trie generation complete", "root", root) @@ -690,15 +699,9 @@ func (s *syncerV2) downloadState(cancel chan struct{}) error { // Update sync progress s.lock.Lock() - s.extProgress = &syncProgressV2{ - AccountSynced: s.accountSynced, - AccountBytes: s.accountBytes, - BytecodeSynced: s.bytecodeSynced, - BytecodeBytes: s.bytecodeBytes, - StorageSynced: s.storageSynced, - StorageBytes: s.storageBytes, - } + s.refreshProgressLocked() s.lock.Unlock() + // Wait for something to happen select { case <-s.update: @@ -775,6 +778,12 @@ func (s *syncerV2) catchUp(target *types.Header, cancel chan struct{}) error { s.lock.RUnlock() log.Info("Starting BAL catch-up", "from", from, "to", to, "blocks", to-from+1) + s.lock.Lock() + s.accessListTotal = to - from + 1 + s.accessListSynced = 0 + s.refreshProgressLocked() + s.lock.Unlock() + for start := from; start <= to; start += s.catchUpWindow { select { case <-cancel: @@ -916,6 +925,10 @@ func (s *syncerV2) fetchAccessLists(hashes []common.Hash, headers map[common.Has case res := <-accessListResps: s.processAccessListResponse(res, headers, pending, fetched, refused) } + s.lock.Lock() + s.accessListSynced += uint64(len(fetched)) + s.refreshProgressLocked() + s.lock.Unlock() } // Assemble results in input order results := make([]rlp.RawValue, len(hashes)) @@ -1099,6 +1112,11 @@ func (s *syncerV2) loadSyncStatus() { s.bytecodeBytes = progress.BytecodeBytes s.storageSynced = progress.StorageSynced s.storageBytes = progress.StorageBytes + + // Seed the externally-exposed snapshot from the restored counters so + // eth_syncing reports real stats during catch-up and trie generation + // after a resume, instead of the zero-valued initial snapshot. + s.refreshProgressLocked() return } } @@ -1174,6 +1192,9 @@ func (s *syncerV2) resetSyncState() { s.accountSynced, s.accountBytes = 0, 0 s.bytecodeSynced, s.bytecodeBytes = 0, 0 s.storageSynced, s.storageBytes = 0, 0 + s.accessListSynced, s.accessListTotal = 0, 0 + s.genProgress.Store(0) + s.refreshProgressLocked() var next common.Hash step := new(big.Int).Sub( @@ -1238,11 +1259,29 @@ func (s *syncerV2) saveSyncStatusWithDB(db ethdb.KeyValueWriter) { rawdb.WriteSnapshotSyncStatus(db, status) } +// refreshProgressLocked rebuilds the externally-exposed progress snapshot from +// the live counters. The caller must hold s.lock. +func (s *syncerV2) refreshProgressLocked() { + s.extProgress = &syncProgressV2{ + AccountSynced: s.accountSynced, + AccountBytes: s.accountBytes, + BytecodeSynced: s.bytecodeSynced, + BytecodeBytes: s.bytecodeBytes, + StorageSynced: s.storageSynced, + StorageBytes: s.storageBytes, + AccessListSynced: s.accessListSynced, + AccessListTotal: s.accessListTotal, + } +} + // Progress returns the snap sync status statistics. func (s *syncerV2) Progress() *syncProgressV2 { s.lock.Lock() defer s.lock.Unlock() - return s.extProgress + + p := *s.extProgress + p.TrieGenPercent = s.genProgress.Load() + return &p } // cleanAccountTasks removes account range retrieval tasks that have already been diff --git a/ethclient/ethclient.go b/ethclient/ethclient.go index 1d8573f982..e888acc222 100644 --- a/ethclient/ethclient.go +++ b/ethclient/ethclient.go @@ -839,6 +839,9 @@ type rpcProgress struct { HealedBytecodeBytes hexutil.Uint64 HealingTrienodes hexutil.Uint64 HealingBytecode hexutil.Uint64 + SyncedAccessLists hexutil.Uint64 + TotalAccessLists hexutil.Uint64 + TrieGenProgress hexutil.Uint64 TxIndexFinishedBlocks hexutil.Uint64 TxIndexRemainingBlocks hexutil.Uint64 StateIndexRemaining hexutil.Uint64 @@ -867,6 +870,9 @@ func (p *rpcProgress) toSyncProgress() *ethereum.SyncProgress { HealedBytecodeBytes: uint64(p.HealedBytecodeBytes), HealingTrienodes: uint64(p.HealingTrienodes), HealingBytecode: uint64(p.HealingBytecode), + SyncedAccessLists: uint64(p.SyncedAccessLists), + TotalAccessLists: uint64(p.TotalAccessLists), + TrieGenProgress: uint64(p.TrieGenProgress), TxIndexFinishedBlocks: uint64(p.TxIndexFinishedBlocks), TxIndexRemainingBlocks: uint64(p.TxIndexRemainingBlocks), StateIndexRemaining: uint64(p.StateIndexRemaining), diff --git a/interfaces.go b/interfaces.go index 8b3dbe3a42..2f4f53634f 100644 --- a/interfaces.go +++ b/interfaces.go @@ -127,13 +127,18 @@ type SyncProgress struct { SyncedStorage uint64 // Number of storage slots downloaded SyncedStorageBytes uint64 // Number of storage trie bytes persisted to disk + // Snap/1 specific fields HealedTrienodes uint64 // Number of state trie nodes downloaded HealedTrienodeBytes uint64 // Number of state trie bytes persisted to disk HealedBytecodes uint64 // Number of bytecodes downloaded HealedBytecodeBytes uint64 // Number of bytecodes persisted to disk + HealingTrienodes uint64 // Number of state trie nodes pending + HealingBytecode uint64 // Number of bytecodes pending - HealingTrienodes uint64 // Number of state trie nodes pending - HealingBytecode uint64 // Number of bytecodes pending + // Snap/2 specific fields + SyncedAccessLists uint64 // Number of block access lists fetched during catch-up + TotalAccessLists uint64 // Total number of block access lists to fetch for catch-up + TrieGenProgress uint64 // Trie generation completion, in percent (0..100) // "transaction indexing" fields TxIndexFinishedBlocks uint64 // Number of blocks whose transactions are already indexed diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 22a59aab58..2eb4dee3c0 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -182,6 +182,9 @@ func (api *EthereumAPI) Syncing(ctx context.Context) (interface{}, error) { "healedBytecodeBytes": hexutil.Uint64(progress.HealedBytecodeBytes), "healingTrienodes": hexutil.Uint64(progress.HealingTrienodes), "healingBytecode": hexutil.Uint64(progress.HealingBytecode), + "syncedAccessLists": hexutil.Uint64(progress.SyncedAccessLists), + "totalAccessLists": hexutil.Uint64(progress.TotalAccessLists), + "trieGenProgress": hexutil.Uint64(progress.TrieGenProgress), "txIndexFinishedBlocks": hexutil.Uint64(progress.TxIndexFinishedBlocks), "txIndexRemainingBlocks": hexutil.Uint64(progress.TxIndexRemainingBlocks), "stateIndexRemaining": hexutil.Uint64(progress.StateIndexRemaining), diff --git a/triedb/generate.go b/triedb/generate.go index 8d5a128aa1..1dbc0c2f69 100644 --- a/triedb/generate.go +++ b/triedb/generate.go @@ -65,6 +65,21 @@ const ( partitionFinished = ^uint64(0) ) +// genCounters bundles the progress counters threaded through a GenerateTrie run. +type genCounters struct { + accounts atomic.Int64 // accounts scanned + slots atomic.Int64 // storage slots scanned + accountUpdated atomic.Int64 // accounts whose stale storage Root was rewritten + storageDeleted atomic.Int64 // dangling storage slots removed + + accountTrieNodes atomic.Int64 // generated account trie nodes + accountTrieBytes atomic.Int64 // generated account trie bytes + storageTrieNodes atomic.Int64 // generated storage trie nodes + storageTrieBytes atomic.Int64 // generated storage trie bytes + + progress [numPartitions]atomic.Uint64 // per-partition keyspace position +} + // rangeIterators bundles the per-partition account and storage iterators. type rangeIterators struct { db ethdb.Database @@ -134,7 +149,7 @@ func reopenFlatIterator(db ethdb.Database, old *internal.HoldableIterator, prefi // both per-account storage subtries and the partition's slice of the // account trie. Returns the partition's stripped subtree root blob, or // nil if the partition had no accounts at all. -func generatePartition(ctx context.Context, cancel <-chan struct{}, db ethdb.Database, scheme string, partition byte, rangeStart, rangeEnd common.Hash, scanned, updated, deleted *atomic.Int64, pos *atomic.Uint64) ([]byte, error) { +func generatePartition(ctx context.Context, cancel <-chan struct{}, db ethdb.Database, scheme string, partition byte, rangeStart, rangeEnd common.Hash, c *genCounters) ([]byte, error) { iters := openRangeIterators(db, rangeStart) defer iters.release() @@ -154,6 +169,13 @@ func generatePartition(ctx context.Context, cancel <-chan struct{}, db ethdb.Dat if len(path) == 1 { root = common.CopyBytes(blob) } + c.accountTrieNodes.Add(1) + + if scheme == rawdb.PathScheme { + c.accountTrieBytes.Add(int64(len(path) + len(blob))) + } else { + c.accountTrieBytes.Add(int64(common.HashLength + len(blob))) + } rawdb.WriteTrieNode(batch, common.Hash{}, path, hash, blob, scheme) }) @@ -172,8 +194,8 @@ func generatePartition(ctx context.Context, cancel <-chan struct{}, db ethdb.Dat if bytes.Compare(accountHash[:], rangeEnd[:]) > 0 { break } - scanned.Add(1) - pos.Store(binary.BigEndian.Uint64(accountHash[:8])) + c.accounts.Add(1) + c.progress[partition].Store(binary.BigEndian.Uint64(accountHash[:8])) // Decode the account object account, err := types.FullAccount(iters.acct.Value()) @@ -184,6 +206,13 @@ func generatePartition(ctx context.Context, cancel <-chan struct{}, db ethdb.Dat // Build the account's storage trie from the flat storage snapshot. // StackTrie's onTrieNode callback persists nodes as they finalize. storageTrie := trie.NewStackTrie(func(path []byte, hash common.Hash, blob []byte) { + c.storageTrieNodes.Add(1) + + if scheme == rawdb.PathScheme { + c.storageTrieBytes.Add(int64(len(path) + common.HashLength + len(blob))) + } else { + c.storageTrieBytes.Add(int64(common.HashLength + len(blob))) + } rawdb.WriteTrieNode(batch, accountHash, path, hash, blob, scheme) }) @@ -213,7 +242,7 @@ func generatePartition(ctx context.Context, cancel <-chan struct{}, db ethdb.Dat copy(lastDanglingAccount, storageAccount) log.Error("Unexpected storage entries for dangling account", "expected", accountHash, "got", common.BytesToHash(storageAccount)) } - deleted.Add(1) + c.storageDeleted.Add(1) slotHash := sk[len(rawdb.SnapshotStoragePrefix)+common.HashLength:] rawdb.DeleteStorageSnapshot(batch, common.BytesToHash(storageAccount), common.BytesToHash(slotHash)) if err := iters.flushIfFull(batch, "dangling"); err != nil { @@ -237,6 +266,7 @@ func generatePartition(ctx context.Context, cancel <-chan struct{}, db ethdb.Dat if err := storageTrie.Update(slotHash, iters.stor.Value()); err != nil { return nil, fmt.Errorf("storage stack trie update for %x: %w", accountHash, err) } + c.slots.Add(1) if err := iters.flushIfFull(batch, "storage"); err != nil { return nil, err } @@ -252,7 +282,7 @@ func generatePartition(ctx context.Context, cancel <-chan struct{}, db ethdb.Dat if computed != account.Root { account.Root = computed rawdb.WriteAccountSnapshot(batch, accountHash, types.SlimAccountRLP(*account)) - updated.Add(1) + c.accountUpdated.Add(1) } fullAccount, err := rlp.EncodeToBytes(account) if err != nil { @@ -291,7 +321,7 @@ func generatePartition(ctx context.Context, cancel <-chan struct{}, db ethdb.Dat copy(lastDanglingTail, acct) log.Error("Unexpected storage entries for dangling account", "addrhash", common.BytesToHash(acct)) } - deleted.Add(1) + c.storageDeleted.Add(1) slotHash := sk[len(rawdb.SnapshotStoragePrefix)+common.HashLength:] rawdb.DeleteStorageSnapshot(batch, common.BytesToHash(acct), common.BytesToHash(slotHash)) if err := iters.flushIfFull(batch, "dangling tail"); err != nil { @@ -349,19 +379,21 @@ func hashRanges(total int) [][2]common.Hash { // Generation is all or nothing: an interrupted run leaves no resume // state and the next run builds every partition from scratch. func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-chan struct{}) (GenerateStats, error) { + return GenerateTrieWithProgress(db, scheme, root, cancel, nil) +} + +// GenerateTrieWithProgress is GenerateTrie with live progress reporting. +func GenerateTrieWithProgress(db ethdb.Database, scheme string, root common.Hash, cancel <-chan struct{}, prog *atomic.Uint64) (GenerateStats, error) { var ( start = time.Now() - scanned atomic.Int64 - updated atomic.Int64 - deleted atomic.Int64 - progress [numPartitions]atomic.Uint64 + c genCounters progressDone = make(chan struct{}) // partitionBlobs[i] holds the root node for partition i, or nil if // the partition is empty. partitionBlobs [numPartitions][]byte ) - go tickProgress(progressDone, start, &scanned, &updated, &progress) + go tickProgress(progressDone, start, &c, prog) defer close(progressDone) // Run every partition concurrently, each producing the subtree root @@ -375,13 +407,13 @@ func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-c rangeStart, rangeEnd := r[0], r[1] eg.Go(func() error { start := time.Now() - blob, err := generatePartition(ctx, cancel, db, scheme, partition, rangeStart, rangeEnd, &scanned, &updated, &deleted, &progress[partition]) + blob, err := generatePartition(ctx, cancel, db, scheme, partition, rangeStart, rangeEnd, &c) if err != nil { return err } log.Info("Partition done", "partition", partition, "elapsed", common.PrettyDuration(time.Since(start))) - progress[partition].Store(partitionFinished) + c.progress[partition].Store(partitionFinished) partitionBlobs[partition] = blob return nil }) @@ -391,7 +423,9 @@ func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-c if err := eg.Wait(); err != nil { return GenerateStats{}, err } - + if prog != nil { + prog.Store(100) + } // Assemble the top-level root from the partition blobs and verify it // matches the expected root. got, err := assembleRoot(db, scheme, partitionBlobs) @@ -401,11 +435,17 @@ func GenerateTrie(db ethdb.Database, scheme string, root common.Hash, cancel <-c if got != root { return GenerateStats{}, fmt.Errorf("state root mismatch: got %x, want %x", got, root) } - log.Info("Generated state trie", "scanned", scanned.Load(), "updated", updated.Load(), "dangling-slots", deleted.Load(), "elapsed", common.PrettyDuration(time.Since(start))) + log.Info("Generated state trie", + "accounts", c.accounts.Load(), "slots", c.slots.Load(), + "account-nodes", c.accountTrieNodes.Load(), "storage-nodes", c.storageTrieNodes.Load(), + "account-nodebytes", common.StorageSize(c.accountTrieBytes.Load()), "storage-nodebytes", common.StorageSize(c.storageTrieBytes.Load()), + "updated-accounts", c.accountUpdated.Load(), "dangling-slots", c.storageDeleted.Load(), + "elapsed", common.PrettyDuration(time.Since(start))) + return GenerateStats{ - Scanned: scanned.Load(), - Updated: updated.Load(), - Deleted: deleted.Load(), + Scanned: c.accounts.Load(), + Updated: c.accountUpdated.Load(), + Deleted: c.storageDeleted.Load(), }, nil } @@ -483,25 +523,32 @@ func assembleRoot(db ethdb.Database, scheme string, partitionBlobs [numPartition // tickProgress logs an aggregate progress line every 30 seconds until done // is closed. Cheap: a handful of atomic loads and one log line per tick. -func tickProgress(done <-chan struct{}, start time.Time, scanned, updated *atomic.Int64, progress *[numPartitions]atomic.Uint64) { +func tickProgress(done <-chan struct{}, start time.Time, c *genCounters, prog *atomic.Uint64) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() + for { select { case <-done: return case <-ticker.C: elapsed := time.Since(start) - fraction := progressFraction(progress) + fraction := progressFraction(&c.progress) + + // Notify the external subscriber about the generation progress + if prog != nil { + prog.Store(uint64(100 * fraction)) + } eta := "n/a" if fraction > 0.005 { eta = common.PrettyDuration(time.Duration(float64(elapsed) * (1.0/fraction - 1.0))).String() } log.Info("Generating trie", "progress", fmt.Sprintf("%.1f%%", fraction*100), "eta", eta, - "scanned", scanned.Load(), "updated", updated.Load(), + "accounts", c.accounts.Load(), "slots", c.slots.Load(), + "account-updated", c.accountUpdated.Load(), "dangling-slots", c.storageDeleted.Load(), "elapsed", common.PrettyDuration(elapsed), - "acct/s", uint64(float64(scanned.Load())/elapsed.Seconds())) + "acct/s", uint64(float64(c.accounts.Load())/elapsed.Seconds())) } } } diff --git a/triedb/generate_test.go b/triedb/generate_test.go index bbbf6e14bc..a7dbf1c81a 100644 --- a/triedb/generate_test.go +++ b/triedb/generate_test.go @@ -21,7 +21,6 @@ import ( "context" "math/big" "sort" - "sync/atomic" "testing" "github.com/ethereum/go-ethereum/common" @@ -674,22 +673,21 @@ func TestGenerateTrieBatchFlush(t *testing.T) { tc.build(db) peak := 0 - var scanned, updated, deleted atomic.Int64 - var pos atomic.Uint64 + var c genCounters ranges := hashRanges(numPartitions) if _, err := generatePartition(context.Background(), nil, peakBatchDB{Database: db, peak: &peak}, - rawdb.HashScheme, 0, ranges[0][0], ranges[0][1], &scanned, &updated, &deleted, &pos); err != nil { + rawdb.HashScheme, 0, ranges[0][0], ranges[0][1], &c); err != nil { t.Fatalf("generatePartition: %v", err) } - if scanned.Load() != tc.wantScanned { - t.Errorf("scanned = %d, want %d (an account was skipped?)", scanned.Load(), tc.wantScanned) + if c.accounts.Load() != tc.wantScanned { + t.Errorf("scanned = %d, want %d (an account was skipped?)", c.accounts.Load(), tc.wantScanned) } - if deleted.Load() != tc.wantDeleted { - t.Errorf("deleted = %d, want %d", deleted.Load(), tc.wantDeleted) + if c.storageDeleted.Load() != tc.wantDeleted { + t.Errorf("deleted = %d, want %d", c.storageDeleted.Load(), tc.wantDeleted) } - if updated.Load() != 0 { - t.Errorf("updated = %d, want 0 (a storage slot was dropped across a flush?)", updated.Load()) + if c.accountUpdated.Load() != 0 { + t.Errorf("updated = %d, want 0 (a storage slot was dropped across a flush?)", c.accountUpdated.Load()) } // The batch must have stayed bounded. Without this site's flush its // full write set (far larger than IdealBatchSize) buffers into one batch. From 7122ecc3ebba8f0416f4a14424a19c923b669ea7 Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Wed, 17 Jun 2026 13:44:12 +0800 Subject: [PATCH 15/24] eth/protocols/snap: remove uncovered states before resuming (#35159) This PR fixes an issue where flat states are continuously persisted during downloadState, while the sync journal is only persisted at the end of Sync. As a result, an unclean shutdown can leave the on-disk flat state ahead of the journal markers. Some persisted entries may be stale (storage slots that should have been deleted), and these dangling entries are not detected or fixed by subsequent state downloads. To address this, this PR introduces a cleanup step before state downloading begins. It removes all state entries that are not covered by the persisted journal markers. --- eth/protocols/snap/bal_apply_test.go | 15 +-- eth/protocols/snap/syncv2.go | 122 +++++++++++++++-- eth/protocols/snap/syncv2_test.go | 187 ++++++++++++++++++++++++++- 3 files changed, 300 insertions(+), 24 deletions(-) diff --git a/eth/protocols/snap/bal_apply_test.go b/eth/protocols/snap/bal_apply_test.go index e5c9188b1b..0db26331ad 100644 --- a/eth/protocols/snap/bal_apply_test.go +++ b/eth/protocols/snap/bal_apply_test.go @@ -325,10 +325,9 @@ func TestAccessListApplicationSkipsUnfetched(t *testing.T) { // is below Next so isFetched returns true; the unfetched hash equals Next // so isFetched returns false. syncer.tasks = []*accountTaskV2{{ - Next: unfetchedHash, - Last: common.MaxHash, - SubTasks: make(map[common.Hash][]*storageTaskV2), - stateCompleted: make(map[common.Hash]struct{}), + Next: unfetchedHash, + Last: common.MaxHash, + SubTasks: make(map[common.Hash][]*storageTaskV2), }} cb := bal.NewConstructionBlockAccessList() @@ -366,10 +365,9 @@ func TestAccessListApplicationSkipsUnfetchedStorage(t *testing.T) { } syncer.tasks = []*accountTaskV2{{ - Next: unfetchedHash, - Last: common.MaxHash, - SubTasks: make(map[common.Hash][]*storageTaskV2), - stateCompleted: make(map[common.Hash]struct{}), + Next: unfetchedHash, + Last: common.MaxHash, + SubTasks: make(map[common.Hash][]*storageTaskV2), }} // BAL touches an unfetched account with a storage write AND an empty @@ -425,7 +423,6 @@ func TestAccessListApplicationPartialStorage(t *testing.T) { Last: common.MaxHash, }}, }, - stateCompleted: make(map[common.Hash]struct{}), }} cb := bal.NewConstructionBlockAccessList() diff --git a/eth/protocols/snap/syncv2.go b/eth/protocols/snap/syncv2.go index fbbc10c60f..37da0b12b6 100644 --- a/eth/protocols/snap/syncv2.go +++ b/eth/protocols/snap/syncv2.go @@ -583,10 +583,19 @@ func (s *syncerV2) Sync(target *types.Header, cancel chan struct{}) error { log.Debug("Starting snapshot sync cycle", "root", root) - // If we resumed against a different pivot, decide whether the persisted - // progress is still usable. If yes, roll forward via BAL catch-up. If not, - // wipe everything and restart fresh. - if isPivotChanged { + if !isPivotChanged { + if prevPivot != nil { + // Resumed against the same pivot. An unclean shutdown may have left + // flushed snapshot data the journal doesn't cover. + if err := s.pruneStaleState(); err != nil { + log.Warn("Persisted progress unusable, restarting snap sync from scratch", "err", err) + s.resetSyncState() + } + } + } else { + // If we resumed against a different pivot, decide whether the persisted + // progress is still usable. If yes, roll forward via BAL catch-up. If not, + // wipe everything and restart fresh. switch { case isPivotReorged(s.db, prevPivot, target): log.Warn("Restarting snap sync from scratch", "oldnumber", prevPivot.Number, "oldHash", prevPivot.Hash()) @@ -599,6 +608,13 @@ func (s *syncerV2) Sync(target *types.Header, cancel chan struct{}) error { log.Warn("Catch-up gap exceeds BAL retention, restarting snap sync from scratch", "oldnumber", prevPivot.Number, "newnumber", target.Number, "gap", new(big.Int).Sub(target.Number, prevPivot.Number), "limit", maxCatchUpBlocks) s.resetSyncState() default: + // An unclean shutdown may have left flushed snapshot data the journal + // doesn't cover. + if err := s.pruneStaleState(); err != nil { + log.Warn("Persisted progress unusable, restarting snap sync from scratch", "err", err) + s.resetSyncState() + break + } // A canonical pivot move past a frozen pivot should be impossible: // the downloader both refuses moves (FrozenPivot) and resumes new // cycles against the frozen header itself. Reaching this branch @@ -683,6 +699,7 @@ func (s *syncerV2) downloadState(cancel chan struct{}) error { accountResps = make(chan *accountResponseV2) storageResps = make(chan *storageResponseV2) bytecodeResps = make(chan *bytecodeResponseV2) + lastJournal = time.Now() ) for { // Remove all completed tasks and terminate if everything's done @@ -691,7 +708,15 @@ func (s *syncerV2) downloadState(cancel chan struct{}) error { if len(s.tasks) == 0 { return nil } - + // Periodically persist the progress journal. The flat state batches + // are flushed continuously, while the journal otherwise only hits + // disk on graceful teardown; everything written beyond the journaled + // markers is sacrificed on an unclean shutdown, so the save interval + // bounds the loss. + if time.Since(lastJournal) > time.Minute { + lastJournal = time.Now() + s.saveSyncStatus() + } // Assign all the data retrieval tasks to any free peers s.assignAccountTasks(accountResps, accountReqFails, cancel) s.assignBytecodeTasks(bytecodeResps, bytecodeReqFails, cancel) @@ -1136,13 +1161,20 @@ func increaseKey(key []byte) []byte { return nil } -// DeleteHistoryByRange completely removes all database entries with the specific -// prefix. Note, this method assumes the space with the given prefix is exclusively -// occupied! +// deleteRange completely removes all database entries with the specific +// prefix. Note, this method assumes the space with the given prefix is +// exclusively occupied! func deleteRange(batch ethdb.Batch, prefix []byte) { - start := prefix - limit := increaseKey(bytes.Clone(prefix)) + deleteKeyRange(batch, prefix, increaseKey(bytes.Clone(prefix))) +} +// deleteKeyRange removes all database entries within the key range [start, +// limit). Note, this method assumes the range is exclusively occupied by +// sync data! +func deleteKeyRange(batch ethdb.Batch, start, limit []byte) { + if limit != nil && bytes.Compare(start, limit) >= 0 { + return + } // Try to remove the data in the range by a loop, as the leveldb // doesn't support the native range deletion. for { @@ -1154,7 +1186,9 @@ func deleteRange(batch ethdb.Batch, prefix []byte) { // therefore inconsistent. This is a tradeoff of the current LevelDB-based // approach. if errors.Is(err, ethdb.ErrTooManyKeys) { - batch.Write() + if err := batch.Write(); err != nil { + log.Crit("Failed to flush state deletions", "err", err) + } batch.Reset() continue } @@ -1172,6 +1206,72 @@ func (s *syncerV2) resetTrienodes(batch ethdb.Batch) { } } +// pruneStaleState removes any flat state the persisted journal does not cover. +func (s *syncerV2) pruneStaleState() error { + var ( + batch = s.db.NewBatch() + accountKey = func(h common.Hash) []byte { + return append(bytes.Clone(rawdb.SnapshotAccountPrefix), h.Bytes()...) + } + storageKey = func(hashes ...common.Hash) []byte { + key := bytes.Clone(rawdb.SnapshotStoragePrefix) + for _, h := range hashes { + key = append(key, h.Bytes()...) + } + return key + } + ) + keyRangeLimit := func(base []byte, last common.Hash) []byte { + if last != common.MaxHash { + return append(base, incHash(last).Bytes()...) + } + return increaseKey(base) + } + for _, task := range s.tasks { + deleteKeyRange(batch, accountKey(task.Next), keyRangeLimit(bytes.Clone(rawdb.SnapshotAccountPrefix), task.Last)) + + protected := make([]common.Hash, 0, len(task.stateCompleted)+len(task.SubTasks)) + for hash := range task.stateCompleted { + if bytes.Compare(hash[:], task.Next[:]) < 0 { + return errors.New("unexpected storage marker before the range") + } + if bytes.Compare(hash[:], task.Last[:]) > 0 { + return errors.New("unexpected storage marker after the range") + } + protected = append(protected, hash) + } + for hash := range task.SubTasks { + if _, ok := task.stateCompleted[hash]; ok { + return errors.New("unexpected duplicated storage marker") + } + if bytes.Compare(hash[:], task.Next[:]) < 0 { + return errors.New("unexpected storage marker before the range") + } + if bytes.Compare(hash[:], task.Last[:]) > 0 { + return errors.New("unexpected storage marker after the range") + } + protected = append(protected, hash) + } + sort.Slice(protected, func(i, j int) bool { + return bytes.Compare(protected[i][:], protected[j][:]) < 0 + }) + + start := storageKey(task.Next) + for _, hash := range protected { + deleteKeyRange(batch, start, storageKey(hash)) + start = increaseKey(storageKey(hash)) + } + deleteKeyRange(batch, start, keyRangeLimit(bytes.Clone(rawdb.SnapshotStoragePrefix), task.Last)) + + for account, subtasks := range task.SubTasks { + for _, sub := range subtasks { + deleteKeyRange(batch, storageKey(account, sub.Next), keyRangeLimit(storageKey(account), sub.Last)) + } + } + } + return batch.Write() +} + // resetSyncState wipes all persisted snap-sync data (sync status, account // and storage snapshots) and re-initializes in-memory state with a fresh // chunking of the account hash range. diff --git a/eth/protocols/snap/syncv2_test.go b/eth/protocols/snap/syncv2_test.go index bad4123560..9e366b108c 100644 --- a/eth/protocols/snap/syncv2_test.go +++ b/eth/protocols/snap/syncv2_test.go @@ -1411,10 +1411,9 @@ func TestSyncDetectsPivotReorged(t *testing.T) { seed.pivot = orphanPivot seed.accountSynced = 42 seed.tasks = []*accountTaskV2{{ - Next: common.HexToHash("0x80"), - Last: common.MaxHash, - SubTasks: make(map[common.Hash][]*storageTaskV2), - stateCompleted: make(map[common.Hash]struct{}), + Next: common.HexToHash("0x80"), + Last: common.MaxHash, + SubTasks: make(map[common.Hash][]*storageTaskV2), }} seed.saveSyncStatus() @@ -2182,6 +2181,186 @@ func TestInterruptedGenerationRecovery(t *testing.T) { } } +// TestPruneStaleState verifies that resuming a sync wipes exactly the flat +// state the journal does not cover: account and storage entries beyond the +// task cursors are removed, while everything below the cursors — and the +// journaled storage of completed-storage accounts and chunked large +// contracts inside the window — survives. +func TestPruneStaleState(t *testing.T) { + t.Parallel() + + var ( + db = rawdb.NewMemoryDatabase() + syncer = newSyncerV2(db, rawdb.HashScheme) + + fetched = common.Hash{0x20} // below the cursor, journal-covered + next = common.Hash{0x40} // task cursor + stale = common.Hash{0x50} // beyond the cursor, not covered + completed = common.Hash{0x60} // beyond the cursor, storage journaled complete + chunked = common.Hash{0x80} // beyond the cursor, large contract, partially journaled + staleToo = common.Hash{0xa0} // beyond the cursor, not covered + + subNext = common.Hash{0x50} // slot cursor of the chunked contract + slotLo = common.Hash{0x11} // below the slot cursor, journal-covered + slotHi = common.Hash{0x77} // beyond the slot cursor, not covered + + val = []byte{0xde, 0xad} + ) + syncer.tasks = []*accountTaskV2{{ + Next: next, + Last: common.MaxHash, + SubTasks: map[common.Hash][]*storageTaskV2{ + chunked: {{Next: subNext, Last: common.MaxHash}}, + }, + stateCompleted: map[common.Hash]struct{}{completed: {}}, + }} + + // Journal-covered state: account below the cursor with a storage slot, + // the completed account's storage, the chunked contract's low slots. + rawdb.WriteAccountSnapshot(db, fetched, val) + rawdb.WriteStorageSnapshot(db, fetched, slotLo, val) + rawdb.WriteStorageSnapshot(db, completed, slotLo, val) + rawdb.WriteStorageSnapshot(db, chunked, slotLo, val) + + // Uncovered state, flushed after the last journal save: accounts beyond + // the cursor (including the completed-storage one, whose account entry + // is only legitimate below the cursor), their storage, and the chunked + // contract's slots beyond its slot cursor. + rawdb.WriteAccountSnapshot(db, stale, val) + rawdb.WriteAccountSnapshot(db, completed, val) + rawdb.WriteAccountSnapshot(db, staleToo, val) + rawdb.WriteStorageSnapshot(db, stale, slotLo, val) + rawdb.WriteStorageSnapshot(db, staleToo, slotHi, val) + rawdb.WriteStorageSnapshot(db, chunked, slotHi, val) + + // Plant bare keys right at the start of the neighboring keyspaces. The + // task ranges end at the maximum hash, so a limit computed by bumping + // the full key would roll the carry over into these keyspaces and + // swallow such keys. + neighborOfAccounts := bytes.Clone(rawdb.SnapshotAccountPrefix) + neighborOfAccounts[len(neighborOfAccounts)-1]++ + db.Put(neighborOfAccounts, val) + + neighborOfStorage := bytes.Clone(rawdb.SnapshotStoragePrefix) + neighborOfStorage[len(neighborOfStorage)-1]++ + db.Put(neighborOfStorage, val) + + if err := syncer.pruneStaleState(); err != nil { + t.Fatalf("prune failed: %v", err) + } + for _, key := range [][]byte{neighborOfAccounts, neighborOfStorage} { + if has, _ := db.Has(key); !has { + t.Errorf("neighboring keyspace entry %x swallowed by the wipe", key) + } + } + + for _, tc := range []struct { + account common.Hash + slot *common.Hash + want bool + desc string + }{ + {fetched, nil, true, "account below cursor"}, + {fetched, &slotLo, true, "storage below cursor"}, + {completed, &slotLo, true, "storage of completed account"}, + {chunked, &slotLo, true, "chunked contract slot below slot cursor"}, + {stale, nil, false, "account beyond cursor"}, + {completed, nil, false, "account entry of completed account"}, + {staleToo, nil, false, "account beyond cursor (second)"}, + {stale, &slotLo, false, "storage of unprotected account"}, + {staleToo, &slotHi, false, "storage of unprotected account (second)"}, + {chunked, &slotHi, false, "chunked contract slot beyond slot cursor"}, + } { + var have bool + if tc.slot == nil { + have = len(rawdb.ReadAccountSnapshot(db, tc.account)) > 0 + } else { + have = len(rawdb.ReadStorageSnapshot(db, tc.account, *tc.slot)) > 0 + } + if have != tc.want { + t.Errorf("%s: present = %v, want %v", tc.desc, have, tc.want) + } + } +} + +// TestResumeWipesUncoveredState simulates an unclean shutdown mid-download: +// flat state flushed beyond the journaled cursor (which the journal therefore +// considers unfetched) must be wiped when the sync resumes — otherwise it +// would survive the re-download untouched and corrupt the generated trie. +func TestResumeWipesUncoveredState(t *testing.T) { + t.Parallel() + testResumeWipesUncoveredState(t, rawdb.HashScheme) + testResumeWipesUncoveredState(t, rawdb.PathScheme) +} + +func testResumeWipesUncoveredState(t *testing.T, scheme string) { + nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, scheme) + root := sourceAccountTrie.Hash() + db := rawdb.NewMemoryDatabase() + pivot := mkPivot(0, root) + + // First run: interrupt the download after a couple of responses, leaving + // a journal behind (saved on the graceful teardown). + var ( + once1 sync.Once + cancel1 = make(chan struct{}) + term1 = func() { once1.Do(func() { close(cancel1) }) } + responses atomic.Int32 + ) + syncer1 := newSyncerV2(db, nodeScheme) + src1 := newTestPeerV2("source1", t, term1) + src1.accountTrie = sourceAccountTrie.Copy() + src1.accountValues = elems + src1.accountRequestV2Handler = func(tp *testPeerV2, id uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { + if responses.Add(1) > 2 { + term1() + return nil + } + return defaultAccountRequestHandlerV2(tp, id, root, origin, limit, cap) + } + syncer1.Register(src1) + src1.remote = syncer1 + syncer1.Sync(pivot, cancel1) + + // Simulate the unclean shutdown: plant a bogus account that the journal + // does not cover (right beyond an unfinished task's cursor) — as if it + // was flushed after the journal was last saved. The account is not part + // of the source trie, so the re-download would never overwrite it and + // trie generation would fail the root check if it survived. + loader := newSyncerV2(db, nodeScheme) + loader.loadSyncStatus() + if len(loader.tasks) == 0 { + t.Fatal("expected unfinished tasks in the journal") + } + task := loader.tasks[len(loader.tasks)-1] + bogus := incHash(task.Next) + rawdb.WriteAccountSnapshot(db, bogus, types.SlimAccountRLP(types.StateAccount{ + Nonce: 1, Balance: uint256.NewInt(1), + Root: types.EmptyRootHash, CodeHash: types.EmptyCodeHash[:], + })) + + // Second run: the resume must prune the bogus entry and complete cleanly. + var ( + once2 sync.Once + cancel2 = make(chan struct{}) + term2 = func() { once2.Do(func() { close(cancel2) }) } + ) + syncer2 := newSyncerV2(db, nodeScheme) + src2 := newTestPeerV2("source2", t, term2) + src2.accountTrie = sourceAccountTrie.Copy() + src2.accountValues = elems + syncer2.Register(src2) + src2.remote = syncer2 + + if err := syncer2.Sync(pivot, cancel2); err != nil { + t.Fatalf("resumed sync failed: %v", err) + } + if len(rawdb.ReadAccountSnapshot(db, bogus)) != 0 { + t.Fatal("uncovered account entry should have been pruned on resume") + } + verifyTrie(scheme, db, root, t) +} + // TestFetchAccessListsMultiplePeers verifies that fetch distributes work // across multiple idle peers. func TestFetchAccessListsMultiplePeers(t *testing.T) { From 8c540cb082bf60a4260c2fef8ea30375c802e7f7 Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Wed, 17 Jun 2026 11:03:11 -0500 Subject: [PATCH 16/24] eth/catalyst: add testing_commitBlockV1 (#34995) Adds `testing_commitBlockV1`. It is the write companion of `testing_buildBlockV1`: it builds a block from the provided payload attributes and transactions on top of the current canonical head, inserts it, and sets it as the new head, returning the new head hash. --------- Co-authored-by: MariusVanDerWijden --- eth/catalyst/api_testing.go | 28 +++++++++- eth/catalyst/api_testing_test.go | 87 ++++++++++++++++++++++++++++++++ miner/payload_building.go | 6 +-- 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/eth/catalyst/api_testing.go b/eth/catalyst/api_testing.go index 2818d7f0bb..b017641a5b 100644 --- a/eth/catalyst/api_testing.go +++ b/eth/catalyst/api_testing.go @@ -17,6 +17,7 @@ package catalyst import ( + "context" "errors" "github.com/ethereum/go-ethereum/beacon/engine" @@ -47,6 +48,31 @@ func (api *testingAPI) BuildBlockV1(parentHash common.Hash, payloadAttributes en if api.eth.BlockChain().CurrentBlock().Hash() != parentHash { return nil, errors.New("parentHash is not current head") } + _, block, err := api.buildTestingBlock(payloadAttributes, transactions, extraData) + return block, err +} + +// CommitBlockV1 builds a block from the supplied attributes and transactions, inserts +// it into the chain, and sets it as the new canonical head. It is the equivalent of +// BuildBlockV1 followed by engine_newPayload + engine_forkchoiceUpdated, but skips the +// serialize/deserialize round-trip through ExecutableData. The block is built on top of +// the current head. +func (api *testingAPI) CommitBlockV1(ctx context.Context, payloadAttributes engine.PayloadAttributes, transactions *[]hexutil.Bytes, extraData *hexutil.Bytes) (common.Hash, error) { + block, _, err := api.buildTestingBlock(payloadAttributes, transactions, extraData) + if err != nil { + return common.Hash{}, err + } + if _, err := api.eth.BlockChain().InsertBlockWithoutSetHead(ctx, block, false); err != nil { + return common.Hash{}, err + } + if _, err := api.eth.BlockChain().SetCanonical(block); err != nil { + return common.Hash{}, err + } + return block.Hash(), nil +} + +func (api *testingAPI) buildTestingBlock(payloadAttributes engine.PayloadAttributes, transactions *[]hexutil.Bytes, extraData *hexutil.Bytes) (*types.Block, *engine.ExecutionPayloadEnvelope, error) { + parentHash := api.eth.BlockChain().CurrentBlock().Hash() // If transactions is empty but not nil, build an empty block // If the transactions is nil, build a block with the current transactions from the txpool // If the transactions is not nil and not empty, build a block with the transactions @@ -60,7 +86,7 @@ func (api *testingAPI) BuildBlockV1(parentHash common.Hash, payloadAttributes en var err error txs, err = engine.DecodeTransactions(dec) if err != nil { - return nil, err + return nil, nil, err } } extra := make([]byte, 0) diff --git a/eth/catalyst/api_testing_test.go b/eth/catalyst/api_testing_test.go index fd4d28d26a..8b20df13e4 100644 --- a/eth/catalyst/api_testing_test.go +++ b/eth/catalyst/api_testing_test.go @@ -119,3 +119,90 @@ func TestBuildBlockV1(t *testing.T) { } }) } + +func TestCommitBlockV1(t *testing.T) { + genesis, blocks := generateMergeChain(5, true) + n, ethservice := startEthService(t, genesis, blocks) + defer n.Close() + + api := &testingAPI{eth: ethservice} + ctx := context.Background() + + nextAttrs := func() engine.PayloadAttributes { + head := ethservice.BlockChain().CurrentBlock() + return engine.PayloadAttributes{ + Timestamp: head.Time + 1, + Random: crypto.Keccak256Hash([]byte("commit-test")), + SuggestedFeeRecipient: head.Coinbase, + } + } + + t.Run("commitEmptyBlock", func(t *testing.T) { + parent := ethservice.BlockChain().CurrentBlock() + emptyTxs := []hexutil.Bytes{} + hash, err := api.CommitBlockV1(ctx, nextAttrs(), &emptyTxs, nil) + if err != nil { + t.Fatalf("CommitBlockV1 failed: %v", err) + } + head := ethservice.BlockChain().CurrentBlock() + if head.Hash() != hash { + t.Errorf("head hash mismatch: got %x want %x", head.Hash(), hash) + } + if head.Number.Uint64() != parent.Number.Uint64()+1 { + t.Errorf("head number mismatch: got %d want %d", head.Number.Uint64(), parent.Number.Uint64()+1) + } + block := ethservice.BlockChain().GetBlockByHash(hash) + if block == nil { + t.Fatal("committed block not found in chain") + } + if len(block.Transactions()) != 0 { + t.Errorf("expected empty block, got %d transactions", len(block.Transactions())) + } + }) + + t.Run("commitBlockWithTransactions", func(t *testing.T) { + parent := ethservice.BlockChain().CurrentBlock() + nonce, _ := ethservice.APIBackend.GetPoolNonce(ctx, testAddr) + tx, _ := types.SignTx(types.NewTransaction(nonce, testAddr, big.NewInt(1), params.TxGas, big.NewInt(params.InitialBaseFee*2), nil), types.LatestSigner(ethservice.BlockChain().Config()), testKey) + enc, _ := tx.MarshalBinary() + txs := []hexutil.Bytes{enc} + + hash, err := api.CommitBlockV1(ctx, nextAttrs(), &txs, nil) + if err != nil { + t.Fatalf("CommitBlockV1 failed: %v", err) + } + head := ethservice.BlockChain().CurrentBlock() + if head.Hash() != hash { + t.Errorf("head hash mismatch: got %x want %x", head.Hash(), hash) + } + if head.Number.Uint64() != parent.Number.Uint64()+1 { + t.Errorf("head number mismatch: got %d want %d", head.Number.Uint64(), parent.Number.Uint64()+1) + } + block := ethservice.BlockChain().GetBlockByHash(hash) + if block == nil { + t.Fatal("committed block not found in chain") + } + if len(block.Transactions()) != 1 { + t.Fatalf("expected 1 transaction, got %d", len(block.Transactions())) + } + if block.Transactions()[0].Hash() != tx.Hash() { + t.Errorf("transaction hash mismatch: got %x want %x", block.Transactions()[0].Hash(), tx.Hash()) + } + }) + + t.Run("commitWithExtraData", func(t *testing.T) { + extra := hexutil.Bytes([]byte("hello")) + emptyTxs := []hexutil.Bytes{} + hash, err := api.CommitBlockV1(ctx, nextAttrs(), &emptyTxs, &extra) + if err != nil { + t.Fatalf("CommitBlockV1 failed: %v", err) + } + block := ethservice.BlockChain().GetBlockByHash(hash) + if block == nil { + t.Fatal("committed block not found in chain") + } + if string(block.Extra()) != "hello" { + t.Errorf("extraData mismatch: got %q want %q", block.Extra(), "hello") + } + }) +} diff --git a/miner/payload_building.go b/miner/payload_building.go index a2cc8df9d0..c4322e3317 100644 --- a/miner/payload_building.go +++ b/miner/payload_building.go @@ -341,7 +341,7 @@ func (payload *Payload) updateSpanForDelivery(bSpan trace.Span) { // BuildTestingPayload is for testing_buildBlockV*. It creates a block with the exact content given // by the parameters instead of using the locally available transactions. -func (miner *Miner) BuildTestingPayload(args *BuildPayloadArgs, transactions []*types.Transaction, empty bool, extraData []byte) (*engine.ExecutionPayloadEnvelope, error) { +func (miner *Miner) BuildTestingPayload(args *BuildPayloadArgs, transactions []*types.Transaction, empty bool, extraData []byte) (*types.Block, *engine.ExecutionPayloadEnvelope, error) { fullParams := &generateParams{ timestamp: args.Timestamp, forceTime: true, @@ -358,7 +358,7 @@ func (miner *Miner) BuildTestingPayload(args *BuildPayloadArgs, transactions []* } res := miner.generateWork(context.Background(), fullParams, false) if res.err != nil { - return nil, res.err + return nil, nil, res.err } - return engine.BlockToExecutableData(res.block, res.fees, res.sidecars, res.requests), nil + return res.block, engine.BlockToExecutableData(res.block, res.fees, res.sidecars, res.requests), nil } From 7c9032dff68aa469de45bec53e37c68d9d2a12d9 Mon Sep 17 00:00:00 2001 From: Marius van der Wijden Date: Thu, 18 Jun 2026 18:34:34 +0200 Subject: [PATCH 17/24] all: change reflect.Ptr to reflect.Pointer (#35176) Since go 1.18 reflect has `reflect.Pointer` which replaces `reflect.Ptr`. Newer versions of `govet` will alert. See also: https://pkg.go.dev/reflect#pkg-constants --- accounts/abi/argument.go | 2 +- accounts/abi/pack.go | 4 ++-- accounts/abi/reflect.go | 8 ++++---- log/format.go | 2 +- rlp/decode.go | 4 ++-- rlp/encode.go | 2 +- rlp/internal/rlpstruct/rlpstruct.go | 2 +- rlp/rlpgen/types.go | 2 +- rlp/typecache.go | 2 +- rpc/client.go | 2 +- rpc/json.go | 4 ++-- rpc/service.go | 2 +- 12 files changed, 18 insertions(+), 18 deletions(-) diff --git a/accounts/abi/argument.go b/accounts/abi/argument.go index c1bf7aec86..83353c39f7 100644 --- a/accounts/abi/argument.go +++ b/accounts/abi/argument.go @@ -112,7 +112,7 @@ func (arguments Arguments) UnpackIntoMap(v map[string]any, data []byte) error { // Copy performs the operation go format -> provided struct. func (arguments Arguments) Copy(v any, values []any) error { // make sure the passed value is arguments pointer - if reflect.Ptr != reflect.ValueOf(v).Kind() { + if reflect.Pointer != reflect.ValueOf(v).Kind() { return fmt.Errorf("abi: Unpack(non-pointer %T)", v) } if len(values) == 0 { diff --git a/accounts/abi/pack.go b/accounts/abi/pack.go index a4c73922d4..5dead0f8ea 100644 --- a/accounts/abi/pack.go +++ b/accounts/abi/pack.go @@ -39,7 +39,7 @@ func packElement(t Type, reflectValue reflect.Value) ([]byte, error) { switch t.T { case UintTy: // make sure to not pack a negative value into a uint type. - if reflectValue.Kind() == reflect.Ptr { + if reflectValue.Kind() == reflect.Pointer { val := new(big.Int).Set(reflectValue.Interface().(*big.Int)) if val.Sign() == -1 { return nil, errInvalidSign @@ -86,7 +86,7 @@ func packNum(value reflect.Value) []byte { return math.U256Bytes(new(big.Int).SetUint64(value.Uint())) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return math.U256Bytes(big.NewInt(value.Int())) - case reflect.Ptr: + case reflect.Pointer: return math.U256Bytes(new(big.Int).Set(value.Interface().(*big.Int))) default: panic("abi: fatal error") diff --git a/accounts/abi/reflect.go b/accounts/abi/reflect.go index f6696ea978..99c9cad767 100644 --- a/accounts/abi/reflect.go +++ b/accounts/abi/reflect.go @@ -53,7 +53,7 @@ func ConvertType(in interface{}, proto interface{}) interface{} { // indirect recursively dereferences the value until it either gets the value // or finds a big.Int func indirect(v reflect.Value) reflect.Value { - if v.Kind() == reflect.Ptr && v.Elem().Type() != reflect.TypeFor[big.Int]() { + if v.Kind() == reflect.Pointer && v.Elem().Type() != reflect.TypeFor[big.Int]() { return indirect(v.Elem()) } return v @@ -102,9 +102,9 @@ func mustArrayToByteSlice(value reflect.Value) reflect.Value { func set(dst, src reflect.Value) error { dstType, srcType := dst.Type(), src.Type() switch { - case dstType.Kind() == reflect.Interface && dst.Elem().IsValid() && (dst.Elem().Type().Kind() == reflect.Ptr || dst.Elem().CanSet()): + case dstType.Kind() == reflect.Interface && dst.Elem().IsValid() && (dst.Elem().Type().Kind() == reflect.Pointer || dst.Elem().CanSet()): return set(dst.Elem(), src) - case dstType.Kind() == reflect.Ptr && dstType.Elem() != reflect.TypeFor[big.Int](): + case dstType.Kind() == reflect.Pointer && dstType.Elem() != reflect.TypeFor[big.Int](): return set(dst.Elem(), src) case srcType.AssignableTo(dstType) && dst.CanSet(): dst.Set(src) @@ -138,7 +138,7 @@ func setSlice(dst, src reflect.Value) error { } func setArray(dst, src reflect.Value) error { - if src.Kind() == reflect.Ptr { + if src.Kind() == reflect.Pointer { return set(dst, indirect(src)) } array := reflect.New(dst.Type()).Elem() diff --git a/log/format.go b/log/format.go index e7dd8a4099..833474e829 100644 --- a/log/format.go +++ b/log/format.go @@ -123,7 +123,7 @@ func FormatSlogValue(v slog.Value, tmp []byte) (result []byte) { var value any defer func() { if err := recover(); err != nil { - if v := reflect.ValueOf(value); v.Kind() == reflect.Ptr && v.IsNil() { + if v := reflect.ValueOf(value); v.Kind() == reflect.Pointer && v.IsNil() { result = []byte("") } else { panic(err) diff --git a/rlp/decode.go b/rlp/decode.go index 19074072fb..881257cdfb 100644 --- a/rlp/decode.go +++ b/rlp/decode.go @@ -166,7 +166,7 @@ func makeDecoder(typ reflect.Type, tags rlpstruct.Tags) (dec decoder, err error) return decodeU256, nil case typ == u256Int: return decodeU256NoPtr, nil - case kind == reflect.Ptr: + case kind == reflect.Pointer: return makePtrDecoder(typ, tags) case reflect.PointerTo(typ).Implements(decoderInterface): return decodeDecoder, nil @@ -936,7 +936,7 @@ func (s *Stream) Decode(val interface{}) error { } rval := reflect.ValueOf(val) rtyp := rval.Type() - if rtyp.Kind() != reflect.Ptr { + if rtyp.Kind() != reflect.Pointer { return errNoPointer } if rval.IsNil() { diff --git a/rlp/encode.go b/rlp/encode.go index 9d04e6324a..4a89b5f730 100644 --- a/rlp/encode.go +++ b/rlp/encode.go @@ -173,7 +173,7 @@ func makeWriter(typ reflect.Type, ts rlpstruct.Tags) (writer, error) { return writeU256IntPtr, nil case typ == u256Int: return writeU256IntNoPtr, nil - case kind == reflect.Ptr: + case kind == reflect.Pointer: return makePtrWriter(typ, ts) case reflect.PointerTo(typ).Implements(encoderInterface): return makeEncoderWriter(typ), nil diff --git a/rlp/internal/rlpstruct/rlpstruct.go b/rlp/internal/rlpstruct/rlpstruct.go index 2e3eeb6881..5b448d37ed 100644 --- a/rlp/internal/rlpstruct/rlpstruct.go +++ b/rlp/internal/rlpstruct/rlpstruct.go @@ -156,7 +156,7 @@ func parseTag(field Field, lastPublic int) (Tags, error) { ts.Ignored = true case "nil", "nilString", "nilList": ts.NilOK = true - if field.Type.Kind != reflect.Ptr { + if field.Type.Kind != reflect.Pointer { return ts, TagError{Field: name, Tag: t, Err: "field is not a pointer"} } switch t { diff --git a/rlp/rlpgen/types.go b/rlp/rlpgen/types.go index ea7dc96d88..c73ea6d188 100644 --- a/rlp/rlpgen/types.go +++ b/rlp/rlpgen/types.go @@ -47,7 +47,7 @@ func typeReflectKind(typ types.Type) reflect.Kind { case *types.Map: return reflect.Map case *types.Pointer: - return reflect.Ptr + return reflect.Pointer case *types.Signature: return reflect.Func case *types.Slice: diff --git a/rlp/typecache.go b/rlp/typecache.go index eebf4cd611..1cc16952d1 100644 --- a/rlp/typecache.go +++ b/rlp/typecache.go @@ -203,7 +203,7 @@ func rtypeToStructType(typ reflect.Type, rec map[reflect.Type]*rlpstruct.Type) * IsDecoder: typ.Implements(decoderInterface), } rec[typ] = t - if k == reflect.Array || k == reflect.Slice || k == reflect.Ptr { + if k == reflect.Array || k == reflect.Slice || k == reflect.Pointer { t.Elem = rtypeToStructType(typ.Elem(), rec) } return t diff --git a/rpc/client.go b/rpc/client.go index 51a595e726..c73459e7e1 100644 --- a/rpc/client.go +++ b/rpc/client.go @@ -335,7 +335,7 @@ func (c *Client) Call(result interface{}, method string, args ...interface{}) er // The result must be a pointer so that package json can unmarshal into it. You // can also pass nil, in which case the result is ignored. func (c *Client) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { - if result != nil && reflect.TypeOf(result).Kind() != reflect.Ptr { + if result != nil && reflect.TypeOf(result).Kind() != reflect.Pointer { return fmt.Errorf("call result parameter must be pointer or nil interface: %v", result) } msg, err := c.newMessage(method, args...) diff --git a/rpc/json.go b/rpc/json.go index b2a961d109..f0e82afb5c 100644 --- a/rpc/json.go +++ b/rpc/json.go @@ -407,7 +407,7 @@ func parsePositionalArguments(rawArgs json.RawMessage, types []reflect.Type) ([] } // Set any missing args to nil. for i := len(args); i < len(types); i++ { - if types[i].Kind() != reflect.Ptr { + if types[i].Kind() != reflect.Pointer { return nil, fmt.Errorf("missing value for required argument %d", i) } args = append(args, reflect.Zero(types[i])) @@ -425,7 +425,7 @@ func parseArgumentArray(dec *json.Decoder, types []reflect.Type) ([]reflect.Valu if err := dec.Decode(argval.Interface()); err != nil { return args, fmt.Errorf("invalid argument %d: %v", i, err) } - if argval.IsNil() && types[i].Kind() != reflect.Ptr { + if argval.IsNil() && types[i].Kind() != reflect.Pointer { return args, fmt.Errorf("missing value for required argument %d", i) } args = append(args, argval.Elem()) diff --git a/rpc/service.go b/rpc/service.go index b64f43b82d..d75eb02383 100644 --- a/rpc/service.go +++ b/rpc/service.go @@ -225,7 +225,7 @@ func isErrorType(t reflect.Type) bool { // Is t Subscription or *Subscription? func isSubscriptionType(t reflect.Type) bool { - for t.Kind() == reflect.Ptr { + for t.Kind() == reflect.Pointer { t = t.Elem() } return t == subscriptionType From e5ff3596dbe792297edd5bf4e295ffbcd13307b3 Mon Sep 17 00:00:00 2001 From: cui Date: Sun, 21 Jun 2026 07:15:26 +0800 Subject: [PATCH 18/24] p2p/discover: fix waiting wrong duration (#35002) The timer should wait the remaining time, not the elapsed time. --- p2p/discover/lookup.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/p2p/discover/lookup.go b/p2p/discover/lookup.go index 416256fb36..c719333235 100644 --- a/p2p/discover/lookup.go +++ b/p2p/discover/lookup.go @@ -250,7 +250,7 @@ func (it *lookupIterator) slowdown() { if diff > minInterval { return } - wait := time.NewTimer(diff) + wait := time.NewTimer(minInterval - diff) defer wait.Stop() select { case <-wait.C: From 6b72f26ed351471a242b23f748f19e44c4511250 Mon Sep 17 00:00:00 2001 From: ozpool <151670776+ozpool@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:29:59 +0530 Subject: [PATCH 19/24] triedb/pathdb: log the expected version in obsolete-index cleanup (#35194) --- triedb/pathdb/history_indexer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/triedb/pathdb/history_indexer.go b/triedb/pathdb/history_indexer.go index 3789fae19b..955615bd82 100644 --- a/triedb/pathdb/history_indexer.go +++ b/triedb/pathdb/history_indexer.go @@ -766,7 +766,7 @@ func checkVersion(disk ethdb.KeyValueStore, typ historyType) { if err == nil { version = fmt.Sprintf("%d", m.Version) } - log.Info("Cleaned up obsolete history index", "type", typ, "version", version, "want", version) + log.Info("Cleaned up obsolete history index", "type", typ, "version", version, "want", fmt.Sprintf("%d", ver)) } // newHistoryIndexer constructs the history indexer and launches the background From 36a7dc72e96b3f42846be925cfeb2fad18489917 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Mon, 22 Jun 2026 09:46:12 +0200 Subject: [PATCH 20/24] version: release go-ethereum v1.17.4 --- version/version.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version/version.go b/version/version.go index 5d402f3009..5523503a7b 100644 --- a/version/version.go +++ b/version/version.go @@ -17,8 +17,8 @@ package version const ( - Major = 1 // Major version component of the current release - Minor = 17 // Minor version component of the current release - Patch = 4 // Patch version component of the current release - Meta = "unstable" // Version metadata to append to the version string + Major = 1 // Major version component of the current release + Minor = 17 // Minor version component of the current release + Patch = 4 // Patch version component of the current release + Meta = "stable" // Version metadata to append to the version string ) From af7bf37b53d3f83779ddfca04dd602597ca2353a Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Mon, 22 Jun 2026 09:47:26 +0200 Subject: [PATCH 21/24] version: begin v1.17.5 release cycle --- version/version.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version/version.go b/version/version.go index 5523503a7b..349220d364 100644 --- a/version/version.go +++ b/version/version.go @@ -17,8 +17,8 @@ package version const ( - Major = 1 // Major version component of the current release - Minor = 17 // Minor version component of the current release - Patch = 4 // Patch version component of the current release - Meta = "stable" // Version metadata to append to the version string + Major = 1 // Major version component of the current release + Minor = 17 // Minor version component of the current release + Patch = 5 // Patch version component of the current release + Meta = "unstable" // Version metadata to append to the version string ) From eea629b9b38817dffd425e0726ac6ca75c4db97d Mon Sep 17 00:00:00 2001 From: Bosul Mun Date: Mon, 22 Jun 2026 10:31:51 +0200 Subject: [PATCH 22/24] core/txpool: drop support for v0 blob sidecar (#35191) This PR drops support for v0 blob sidecar in blobpool. Since the osaka fork activation time has passed, these code paths are now unused. It is assumed that only v1 transactions exist in the blobpool. --- cmd/devp2p/internal/ethtest/suite.go | 6 +- core/txpool/blobpool/blobpool.go | 13 +-- core/txpool/blobpool/blobpool_test.go | 123 ++++++++++---------------- core/txpool/blobpool/cache.go | 17 ++-- core/txpool/blobpool/cache_test.go | 6 +- core/txpool/blobpool/limbo.go | 2 +- core/txpool/blobpool/slotter.go | 21 ----- core/txpool/blobpool/slotter_test.go | 41 --------- core/txpool/validation.go | 35 ++------ eth/catalyst/api_test.go | 105 ---------------------- eth/protocols/eth/handler_test.go | 4 +- 11 files changed, 69 insertions(+), 304 deletions(-) diff --git a/cmd/devp2p/internal/ethtest/suite.go b/cmd/devp2p/internal/ethtest/suite.go index eff573ecb1..6dc77941c1 100644 --- a/cmd/devp2p/internal/ethtest/suite.go +++ b/cmd/devp2p/internal/ethtest/suite.go @@ -1013,11 +1013,11 @@ func makeSidecar(data ...byte) *types.BlobTxSidecar { for i := range blobs { blobs[i][0] = data[i] c, _ := kzg4844.BlobToCommitment(&blobs[i]) - p, _ := kzg4844.ComputeBlobProof(&blobs[i], c) + cellProofs, _ := kzg4844.ComputeCellProofs(&blobs[i]) commitments = append(commitments, c) - proofs = append(proofs, p) + proofs = append(proofs, cellProofs...) } - return types.NewBlobTxSidecar(types.BlobSidecarVersion0, blobs, commitments, proofs) + return types.NewBlobTxSidecar(types.BlobSidecarVersion1, blobs, commitments, proofs) } func (s *Suite) makeBlobTxs(count, blobs int, discriminator byte) (txs types.Transactions) { diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index fcd1f7566a..ee73c8e897 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -228,22 +228,17 @@ func encodeForNetwork(storedRLP []byte) ([]byte, error) { // 2. Find the version of sidecar. version, _, err := rlp.SplitUint64(elems[1]) - if err != nil || version > 255 { + if err != nil || version > 255 || version == 0 { return nil, fmt.Errorf("invalid version: %w", err) } - versionByte := byte(version) // 3. Extract sidecar elements. commitmentsRLP := elems[2] proofsRLP := elems[3] blobsRLP := elems[4] // 4. Reconstruct into the network format. - var outer [][]byte - if versionByte == types.BlobSidecarVersion0 { - outer = [][]byte{txRLP, blobsRLP, commitmentsRLP, proofsRLP} - } else { - outer = [][]byte{txRLP, elems[1], blobsRLP, commitmentsRLP, proofsRLP} - } + outer := [][]byte{txRLP, elems[1], blobsRLP, commitmentsRLP, proofsRLP} + body, err := rlp.MergeListValues(outer) if err != nil { return nil, err @@ -555,7 +550,7 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser p.state = state // Create new slotter for pre-Osaka blob configuration. - slotter := newSlotter(params.BlobTxMaxBlobs) + slotter := newSlotterEIP7594(params.BlobTxMaxBlobs) // See if we need to migrate the queue blob store after fusaka slotter, err = tryMigrate(p.chain.Config(), slotter, queuedir) diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go index 6b61591227..0e182947f0 100644 --- a/core/txpool/blobpool/blobpool_test.go +++ b/core/txpool/blobpool/blobpool_test.go @@ -53,7 +53,6 @@ import ( var ( testBlobs []*kzg4844.Blob testBlobCommits []kzg4844.Commitment - testBlobProofs []kzg4844.Proof testBlobCellProofs [][]kzg4844.Proof testBlobVHashes [][32]byte testBlobIndices = make(map[[32]byte]int) @@ -69,9 +68,6 @@ func init() { testBlobCommit, _ := kzg4844.BlobToCommitment(testBlob) testBlobCommits = append(testBlobCommits, testBlobCommit) - testBlobProof, _ := kzg4844.ComputeBlobProof(testBlob, testBlobCommit) - testBlobProofs = append(testBlobProofs, testBlobProof) - testBlobCellProof, _ := kzg4844.ComputeCellProofs(testBlob) testBlobCellProofs = append(testBlobCellProofs, testBlobCellProof) @@ -243,7 +239,7 @@ func encodeForPool(tx *types.Transaction) []byte { // makeMultiBlobTx is a utility method to construct a random blob tx with // certain number of blobs in its sidecar. -func makeMultiBlobTx(nonce uint64, gasTipCap uint64, gasFeeCap uint64, blobFeeCap uint64, blobCount int, blobOffset int, key *ecdsa.PrivateKey, version byte) *types.Transaction { +func makeMultiBlobTx(nonce uint64, gasTipCap uint64, gasFeeCap uint64, blobFeeCap uint64, blobCount int, blobOffset int, key *ecdsa.PrivateKey) *types.Transaction { var ( blobs []kzg4844.Blob blobHashes []common.Hash @@ -253,12 +249,8 @@ func makeMultiBlobTx(nonce uint64, gasTipCap uint64, gasFeeCap uint64, blobFeeCa for i := 0; i < blobCount; i++ { blobs = append(blobs, *testBlobs[blobOffset+i]) commitments = append(commitments, testBlobCommits[blobOffset+i]) - if version == types.BlobSidecarVersion0 { - proofs = append(proofs, testBlobProofs[blobOffset+i]) - } else { - cellProofs, _ := kzg4844.ComputeCellProofs(testBlobs[blobOffset+i]) - proofs = append(proofs, cellProofs...) - } + cellProofs, _ := kzg4844.ComputeCellProofs(testBlobs[blobOffset+i]) + proofs = append(proofs, cellProofs...) blobHashes = append(blobHashes, testBlobVHashes[blobOffset+i]) } blobtx := &types.BlobTx{ @@ -270,7 +262,7 @@ func makeMultiBlobTx(nonce uint64, gasTipCap uint64, gasFeeCap uint64, blobFeeCa BlobFeeCap: uint256.NewInt(blobFeeCap), BlobHashes: blobHashes, Value: uint256.NewInt(100), - Sidecar: types.NewBlobTxSidecar(version, blobs, commitments, proofs), + Sidecar: types.NewBlobTxSidecar(types.BlobSidecarVersion1, blobs, commitments, proofs), } return types.MustSignNewTx(key, types.LatestSigner(params.MainnetChainConfig), blobtx) } @@ -293,7 +285,7 @@ func makeUnsignedTxWithTestBlob(nonce uint64, gasTipCap uint64, gasFeeCap uint64 BlobFeeCap: uint256.NewInt(blobFeeCap), BlobHashes: []common.Hash{testBlobVHashes[blobIdx]}, Value: uint256.NewInt(100), - Sidecar: types.NewBlobTxSidecar(types.BlobSidecarVersion0, []kzg4844.Blob{*testBlobs[blobIdx]}, []kzg4844.Commitment{testBlobCommits[blobIdx]}, []kzg4844.Proof{testBlobProofs[blobIdx]}), + Sidecar: types.NewBlobTxSidecar(types.BlobSidecarVersion1, []kzg4844.Blob{*testBlobs[blobIdx]}, []kzg4844.Commitment{testBlobCommits[blobIdx]}, testBlobCellProofs[blobIdx]), } } @@ -440,36 +432,18 @@ func verifyBlobRetrievals(t *testing.T, pool *BlobPool) { hashes = append(hashes, tx.vhashes...) } } - blobs1, _, proofs1, err := pool.getBlobs(hashes, types.BlobSidecarVersion0) + blobs, _, proofs, err := pool.getBlobs(hashes, types.BlobSidecarVersion1) if err != nil { t.Fatal(err) } - blobs2, _, proofs2, err := pool.getBlobs(hashes, types.BlobSidecarVersion1) - if err != nil { - t.Fatal(err) - } - // Cross validate what we received vs what we wanted - if len(blobs1) != len(hashes) || len(proofs1) != len(hashes) { - t.Errorf("retrieved blobs/proofs size mismatch: have %d/%d, want %d", len(blobs1), len(proofs1), len(hashes)) - return - } - if len(blobs2) != len(hashes) || len(proofs2) != len(hashes) { - t.Errorf("retrieved blobs/proofs size mismatch: have %d/%d, want blobs %d, want proofs: %d", len(blobs2), len(proofs2), len(hashes), len(hashes)) + if len(blobs) != len(hashes) || len(proofs) != len(hashes) { + t.Errorf("retrieved blobs/proofs size mismatch: have %d/%d, want blobs %d, want proofs: %d", len(blobs), len(proofs), len(hashes), len(hashes)) return } for i, hash := range hashes { - // If an item is missing from both, but shouldn't, error - if (blobs1[i] == nil || proofs1[i] == nil) && (blobs2[i] == nil || proofs2[i] == nil) { - t.Errorf("tracked blob retrieval failed: item %d, hash %x", i, hash) - continue - } // Item retrieved, make sure it matches the expectation index := testBlobIndices[hash] - if blobs1[i] != nil && (*blobs1[i] != *testBlobs[index] || proofs1[i][0] != testBlobProofs[index]) { - t.Errorf("retrieved blob or proof mismatch: item %d, hash %x", i, hash) - continue - } - if blobs2[i] != nil && (*blobs2[i] != *testBlobs[index] || !slices.Equal(proofs2[i], testBlobCellProofs[index])) { + if blobs[i] != nil && (*blobs[i] != *testBlobs[index] || !slices.Equal(proofs[i], testBlobCellProofs[index])) { t.Errorf("retrieved blob or proof mismatch: item %d, hash %x", i, hash) continue } @@ -503,7 +477,7 @@ func TestOpenDrops(t *testing.T) { storage := t.TempDir() os.MkdirAll(filepath.Join(storage, pendingTransactionStore), 0700) - store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotter(testMaxBlobsPerBlock), nil) + store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotterEIP7594(testMaxBlobsPerBlock), nil) // Insert a malformed transaction to verify that decoding errors (or format // changes) are handled gracefully (case 1) @@ -825,7 +799,7 @@ func TestOpenIndex(t *testing.T) { storage := t.TempDir() os.MkdirAll(filepath.Join(storage, pendingTransactionStore), 0700) - store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotter(testMaxBlobsPerBlock), nil) + store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotterEIP7594(testMaxBlobsPerBlock), nil) // Insert a sequence of transactions with varying price points to check that // the cumulative minimum will be maintained. @@ -913,7 +887,7 @@ func TestOpenHeap(t *testing.T) { storage := t.TempDir() os.MkdirAll(filepath.Join(storage, pendingTransactionStore), 0700) - store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotter(testMaxBlobsPerBlock), nil) + store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotterEIP7594(testMaxBlobsPerBlock), nil) // Insert a few transactions from a few accounts. To remove randomness from // the heap initialization, use a deterministic account/tx/priority ordering. @@ -1088,7 +1062,7 @@ func TestChangingSlotterSize(t *testing.T) { storage := t.TempDir() os.MkdirAll(filepath.Join(storage, pendingTransactionStore), 0700) - store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotter(6), nil) + store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotterEIP7594(6), nil) // Create transactions from a few accounts. var ( @@ -1100,9 +1074,9 @@ func TestChangingSlotterSize(t *testing.T) { addr2 = crypto.PubkeyToAddress(key2.PublicKey) addr3 = crypto.PubkeyToAddress(key3.PublicKey) - tx1 = makeMultiBlobTx(0, 1, 1000, 100, 6, 0, key1, types.BlobSidecarVersion0) - tx2 = makeMultiBlobTx(0, 1, 800, 70, 6, 0, key2, types.BlobSidecarVersion0) - tx3 = makeMultiBlobTx(0, 1, 800, 110, 24, 0, key3, types.BlobSidecarVersion0) + tx1 = makeMultiBlobTx(0, 1, 1000, 100, 6, 0, key1) + tx2 = makeMultiBlobTx(0, 1, 800, 70, 6, 0, key2) + tx3 = makeMultiBlobTx(0, 1, 800, 110, 24, 0, key3) blob1 = encodeForPool(tx1) blob2 = encodeForPool(tx2) @@ -1203,9 +1177,9 @@ func TestBillyMigration(t *testing.T) { addr2 = crypto.PubkeyToAddress(key2.PublicKey) addr3 = crypto.PubkeyToAddress(key3.PublicKey) - tx1 = makeMultiBlobTx(0, 1, 1000, 100, 6, 0, key1, types.BlobSidecarVersion0) - tx2 = makeMultiBlobTx(0, 1, 800, 70, 6, 0, key2, types.BlobSidecarVersion0) - tx3 = makeMultiBlobTx(0, 1, 800, 110, 24, 0, key3, types.BlobSidecarVersion0) + tx1 = makeMultiBlobTx(0, 1, 1000, 100, 6, 0, key1) + tx2 = makeMultiBlobTx(0, 1, 800, 70, 6, 0, key2) + tx3 = makeMultiBlobTx(0, 1, 800, 110, 24, 0, key3) blob1 = encodeForPool(tx1) blob2 = encodeForPool(tx2) @@ -1299,7 +1273,7 @@ func TestLegacyTxConversion(t *testing.T) { // Initialize the pending store with two blob transactions encoded in the // legacy format. queuedir := filepath.Join(storage, pendingTransactionStore) - store, err := billy.Open(billy.Options{Path: queuedir}, newSlotter(testMaxBlobsPerBlock), nil) + store, err := billy.Open(billy.Options{Path: queuedir}, newSlotterEIP7594(testMaxBlobsPerBlock), nil) if err != nil { t.Fatalf("failed to open billy: %v", err) } @@ -1309,8 +1283,8 @@ func TestLegacyTxConversion(t *testing.T) { addr1 := crypto.PubkeyToAddress(key1.PublicKey) addr2 := crypto.PubkeyToAddress(key2.PublicKey) - tx1 := makeMultiBlobTx(0, 1, 1000, 100, 2, 0, key1, types.BlobSidecarVersion0) - tx2 := makeMultiBlobTx(0, 1, 1000, 100, 2, 2, key2, types.BlobSidecarVersion0) + tx1 := makeMultiBlobTx(0, 1, 1000, 100, 2, 0, key1) + tx2 := makeMultiBlobTx(0, 1, 1000, 100, 2, 2, key2) for _, tx := range []*types.Transaction{tx1, tx2} { legacy, err := rlp.EncodeToBytes(tx) @@ -1408,8 +1382,8 @@ func TestBlobCountLimit(t *testing.T) { // Attempt to add transactions. var ( - tx1 = makeMultiBlobTx(0, 1, 1000, 100, 6, 0, key1, types.BlobSidecarVersion0) - tx2 = makeMultiBlobTx(0, 1, 800, 70, 7, 0, key2, types.BlobSidecarVersion0) + tx1 = makeMultiBlobTx(0, 1, 1000, 100, 6, 0, key1) + tx2 = makeMultiBlobTx(0, 1, 800, 70, 7, 0, key2) ) errs := pool.Add([]*types.Transaction{tx1, tx2}, true) @@ -1811,7 +1785,7 @@ func TestAdd(t *testing.T) { storage := filepath.Join(t.TempDir(), fmt.Sprintf("test-%d", i)) os.MkdirAll(filepath.Join(storage, pendingTransactionStore), 0700) - store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotter(testMaxBlobsPerBlock), nil) + store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotterEIP7594(testMaxBlobsPerBlock), nil) // Insert the seed transactions for the pool startup var ( @@ -1922,7 +1896,7 @@ func TestGetBlobs(t *testing.T) { storage := t.TempDir() os.MkdirAll(filepath.Join(storage, pendingTransactionStore), 0700) - store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotter(params.BlobTxMaxBlobs), nil) + store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotterEIP7594(params.BlobTxMaxBlobs), nil) // Create transactions from a few accounts. var ( @@ -1934,9 +1908,9 @@ func TestGetBlobs(t *testing.T) { addr2 = crypto.PubkeyToAddress(key2.PublicKey) addr3 = crypto.PubkeyToAddress(key3.PublicKey) - tx1 = makeMultiBlobTx(0, 1, 1000, 100, 6, 0, key1, types.BlobSidecarVersion0) // [0, 6) - tx2 = makeMultiBlobTx(0, 1, 800, 70, 6, 6, key2, types.BlobSidecarVersion1) // [6, 12) - tx3 = makeMultiBlobTx(0, 1, 800, 110, 6, 12, key3, types.BlobSidecarVersion0) // [12, 18) + tx1 = makeMultiBlobTx(0, 1, 1000, 100, 6, 0, key1) // [0, 6) + tx2 = makeMultiBlobTx(0, 1, 800, 70, 6, 6, key2) // [6, 12) + tx3 = makeMultiBlobTx(0, 1, 800, 110, 6, 12, key3) // [12, 18) blob1 = encodeForPool(tx1) blob2 = encodeForPool(tx2) @@ -2004,7 +1978,7 @@ func TestGetBlobs(t *testing.T) { }{ { start: 0, limit: 6, - version: types.BlobSidecarVersion0, + version: types.BlobSidecarVersion1, }, { start: 0, limit: 6, @@ -2012,7 +1986,7 @@ func TestGetBlobs(t *testing.T) { }, { start: 0, limit: 6, fillRandom: true, - version: types.BlobSidecarVersion0, + version: types.BlobSidecarVersion1, }, { start: 0, limit: 6, fillRandom: true, @@ -2020,7 +1994,7 @@ func TestGetBlobs(t *testing.T) { }, { start: 3, limit: 9, - version: types.BlobSidecarVersion0, + version: types.BlobSidecarVersion1, }, { start: 3, limit: 9, @@ -2028,7 +2002,7 @@ func TestGetBlobs(t *testing.T) { }, { start: 3, limit: 9, fillRandom: true, - version: types.BlobSidecarVersion0, + version: types.BlobSidecarVersion1, }, { start: 3, limit: 9, fillRandom: true, @@ -2036,7 +2010,7 @@ func TestGetBlobs(t *testing.T) { }, { start: 3, limit: 15, - version: types.BlobSidecarVersion0, + version: types.BlobSidecarVersion1, }, { start: 3, limit: 15, @@ -2044,7 +2018,7 @@ func TestGetBlobs(t *testing.T) { }, { start: 3, limit: 15, fillRandom: true, - version: types.BlobSidecarVersion0, + version: types.BlobSidecarVersion1, }, { start: 3, limit: 15, fillRandom: true, @@ -2052,7 +2026,7 @@ func TestGetBlobs(t *testing.T) { }, { start: 0, limit: 18, - version: types.BlobSidecarVersion0, + version: types.BlobSidecarVersion1, }, { start: 0, limit: 18, @@ -2060,7 +2034,7 @@ func TestGetBlobs(t *testing.T) { }, { start: 0, limit: 18, fillRandom: true, - version: types.BlobSidecarVersion0, + version: types.BlobSidecarVersion1, }, { start: 0, limit: 18, fillRandom: true, @@ -2114,7 +2088,7 @@ func TestGetBlobs(t *testing.T) { if blobs[j] == nil || proofs[j] == nil { // This is only an error if there was no version mismatch if (c.version == types.BlobSidecarVersion1 && 6 <= testBlobIndex && testBlobIndex < 12) || - (c.version == types.BlobSidecarVersion0 && (testBlobIndex < 6 || 12 <= testBlobIndex)) { + (c.version == types.BlobSidecarVersion1 && (testBlobIndex < 6 || 12 <= testBlobIndex)) { t.Errorf("tracked blob retrieval failed: item %d, hash %x", j, vhashes[j]) } continue @@ -2125,15 +2099,9 @@ func TestGetBlobs(t *testing.T) { continue } // Item retrieved, make sure the proof matches the expectation - if c.version == types.BlobSidecarVersion0 { - if proofs[j][0] != testBlobProofs[testBlobIndex] { - t.Errorf("retrieved proof mismatch: item %d, hash %x", j, vhashes[j]) - } - } else { - want, _ := kzg4844.ComputeCellProofs(blobs[j]) - if !reflect.DeepEqual(want, proofs[j]) { - t.Errorf("retrieved proof mismatch: item %d, hash %x", j, vhashes[j]) - } + want, _ := kzg4844.ComputeCellProofs(blobs[j]) + if !reflect.DeepEqual(want, proofs[j]) { + t.Errorf("retrieved proof mismatch: item %d, hash %x", j, vhashes[j]) } } } @@ -2143,13 +2111,12 @@ func TestGetBlobs(t *testing.T) { // TestEncodeForNetwork verifies that encodeForNetwork produces output identical // to rlp.EncodeToBytes on the original transaction, for both V0 and V1 sidecars. func TestEncodeForNetwork(t *testing.T) { - t.Run("v0", func(t *testing.T) { testEncodeForNetwork(t, types.BlobSidecarVersion0) }) - t.Run("v1", func(t *testing.T) { testEncodeForNetwork(t, types.BlobSidecarVersion1) }) + t.Run("v1", func(t *testing.T) { testEncodeForNetwork(t) }) } -func testEncodeForNetwork(t *testing.T, version byte) { +func testEncodeForNetwork(t *testing.T) { key, _ := crypto.GenerateKey() - tx := makeMultiBlobTx(0, 1, 1, 1, 1, 0, key, version) + tx := makeMultiBlobTx(0, 1, 1, 1, 1, 0, key) wantRLP, err := rlp.EncodeToBytes(tx) if err != nil { @@ -2162,7 +2129,7 @@ func testEncodeForNetwork(t *testing.T, version byte) { t.Fatalf("encodeForNetwork failed: %v", err) } if !bytes.Equal(gotRLP, wantRLP) { - t.Fatalf("network encoding mismatch (version %d): got %d bytes, want %d bytes", version, len(gotRLP), len(wantRLP)) + t.Fatalf("network encoding mismatch: got %d bytes, want %d bytes", len(gotRLP), len(wantRLP)) } } diff --git a/core/txpool/blobpool/cache.go b/core/txpool/blobpool/cache.go index de4001a1b1..12f9802775 100644 --- a/core/txpool/blobpool/cache.go +++ b/core/txpool/blobpool/cache.go @@ -343,22 +343,15 @@ func (c *Cache) update(want []common.Hash) { if _, exists := c.entries[v]; exists { continue // recompute only new entries } - var pf []kzg4844.Proof - switch sidecar.Version { - case types.BlobSidecarVersion0: - pf = []kzg4844.Proof{sidecar.Proofs[i]} - case types.BlobSidecarVersion1: - cellProofs, err := sidecar.CellProofsAt(i) - if err != nil { - log.Error("Failed to get cell proofs", "txhash", ptx.Tx.Hash(), "err", err) - continue - } - pf = cellProofs + cellProofs, err := sidecar.CellProofsAt(i) + if err != nil { + log.Error("Failed to get cell proofs", "txhash", ptx.Tx.Hash(), "err", err) + continue } c.entries[v] = &cachedBlob{ blob: &sidecar.Blobs[i], commitment: sidecar.Commitments[i], - proofs: pf, + proofs: cellProofs, version: sidecar.Version, } cacheBlobsGauge.Inc(1) diff --git a/core/txpool/blobpool/cache_test.go b/core/txpool/blobpool/cache_test.go index f7ece4c1b8..da13cda4b2 100644 --- a/core/txpool/blobpool/cache_test.go +++ b/core/txpool/blobpool/cache_test.go @@ -58,7 +58,7 @@ func newTestCache(t *testing.T, txConfig []txSpec) *testCache { if err := os.MkdirAll(filepath.Join(storage, pendingTransactionStore), 0700); err != nil { t.Fatalf("mkdir: %v", err) } - store, err := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotter(params.BlobTxMaxBlobs), nil) + store, err := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotterEIP7594(params.BlobTxMaxBlobs), nil) if err != nil { t.Fatalf("billy open: %v", err) } @@ -70,7 +70,7 @@ func newTestCache(t *testing.T, txConfig []txSpec) *testCache { ) for _, s := range txConfig { key, _ := crypto.GenerateKey() - tx := makeMultiBlobTx(0, s.tip, 1_000_000, 1_000_000, s.blobs, offset, key, types.BlobSidecarVersion1) + tx := makeMultiBlobTx(0, s.tip, 1_000_000, 1_000_000, s.blobs, offset, key) if _, err := store.Put(encodeForPool(tx)); err != nil { t.Fatalf("store put: %v", err) } @@ -143,7 +143,7 @@ func newTestCache(t *testing.T, txConfig []txSpec) *testCache { func (tc *testCache) inject(t *testing.T, spec txSpec) []common.Hash { t.Helper() key, _ := crypto.GenerateKey() - tx := makeMultiBlobTx(0, spec.tip, 1_000_000, 1_000_000, spec.blobs, tc.offset, key, types.BlobSidecarVersion1) + tx := makeMultiBlobTx(0, spec.tip, 1_000_000, 1_000_000, spec.blobs, tc.offset, key) tc.offset += spec.blobs ptx := newBlobTxForPool(tx) diff --git a/core/txpool/blobpool/limbo.go b/core/txpool/blobpool/limbo.go index b8bee2f22a..e696a6fcc0 100644 --- a/core/txpool/blobpool/limbo.go +++ b/core/txpool/blobpool/limbo.go @@ -56,7 +56,7 @@ func newLimbo(config *params.ChainConfig, datadir string) (*limbo, error) { } // Create new slotter for pre-Osaka blob configuration. - slotter := newSlotter(params.BlobTxMaxBlobs) + slotter := newSlotterEIP7594(params.BlobTxMaxBlobs) // See if we need to migrate the limbo after fusaka. slotter, err := tryMigrate(config, slotter, datadir) diff --git a/core/txpool/blobpool/slotter.go b/core/txpool/blobpool/slotter.go index 3399361e55..49a46d4f42 100644 --- a/core/txpool/blobpool/slotter.go +++ b/core/txpool/blobpool/slotter.go @@ -58,27 +58,6 @@ func tryMigrate(config *params.ChainConfig, slotter billy.SlotSizeFn, datadir st return slotter, nil } -// newSlotter creates a helper method for the Billy datastore that returns the -// individual shelf sizes used to store transactions in. -// -// The slotter will create shelves for each possible blob count + some tx metadata -// wiggle room, up to the max permitted limits. -// -// The slotter also creates a shelf for 0-blob transactions. Whilst those are not -// allowed in the current protocol, having an empty shelf is not a relevant use -// of resources, but it makes stress testing with junk transactions simpler. -func newSlotter(maxBlobsPerTransaction int) billy.SlotSizeFn { - slotsize := uint32(txAvgSize) - slotsize -= uint32(blobSize) // underflows, it's ok, will overflow back in the first return - - return func() (size uint32, done bool) { - slotsize += blobSize - finished := slotsize > uint32(maxBlobsPerTransaction)*blobSize+txMaxSize - - return slotsize, finished - } -} - // newSlotterEIP7594 creates a different slotter for EIP-7594 transactions. // EIP-7594 (PeerDAS) changes the average transaction size which means the current // static 4KB average size is not enough anymore. diff --git a/core/txpool/blobpool/slotter_test.go b/core/txpool/blobpool/slotter_test.go index e4cf232f4e..24ad2b5330 100644 --- a/core/txpool/blobpool/slotter_test.go +++ b/core/txpool/blobpool/slotter_test.go @@ -20,47 +20,6 @@ import ( "testing" ) -// Tests that the slotter creates the expected database shelves. -func TestNewSlotter(t *testing.T) { - // Generate the database shelve sizes - slotter := newSlotter(6) - - var shelves []uint32 - for { - shelf, done := slotter() - shelves = append(shelves, shelf) - if done { - break - } - } - // Compare the database shelves to the expected ones - want := []uint32{ - 0*blobSize + txAvgSize, // 0 blob + some expected tx infos - 1*blobSize + txAvgSize, // 1 blob + some expected tx infos - 2*blobSize + txAvgSize, // 2 blob + some expected tx infos (could be fewer blobs and more tx data) - 3*blobSize + txAvgSize, // 3 blob + some expected tx infos (could be fewer blobs and more tx data) - 4*blobSize + txAvgSize, // 4 blob + some expected tx infos (could be fewer blobs and more tx data) - 5*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos < 4 blobs + max tx metadata size - 6*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos < 4 blobs + max tx metadata size - 7*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos < 4 blobs + max tx metadata size - 8*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos < 4 blobs + max tx metadata size - 9*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos < 4 blobs + max tx metadata size - 10*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos < 4 blobs + max tx metadata size - 11*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos < 4 blobs + max tx metadata size - 12*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos < 4 blobs + max tx metadata size - 13*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos < 4 blobs + max tx metadata size - 14*blobSize + txAvgSize, // 1-6 blobs + unexpectedly large tx infos >= 4 blobs + max tx metadata size - } - if len(shelves) != len(want) { - t.Errorf("shelves count mismatch: have %d, want %d", len(shelves), len(want)) - } - for i := 0; i < len(shelves) && i < len(want); i++ { - if shelves[i] != want[i] { - t.Errorf("shelf %d mismatch: have %d, want %d", i, shelves[i], want[i]) - } - } -} - // Tests that the slotter creates the expected database shelves. func TestNewSlotterEIP7594(t *testing.T) { // Generate the database shelve sizes diff --git a/core/txpool/validation.go b/core/txpool/validation.go index 3b30dd30ef..4651c06b3e 100644 --- a/core/txpool/validation.go +++ b/core/txpool/validation.go @@ -154,7 +154,7 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types return fmt.Errorf("%w: gas tip cap %v, minimum needed %v", ErrTxGasPriceTooLow, tx.GasTipCap(), opts.MinTip) } if tx.Type() == types.BlobTxType { - return validateBlobTx(tx, head, opts) + return validateBlobTx(tx) } if tx.Type() == types.SetCodeTxType { if len(tx.SetCodeAuthorizations()) == 0 { @@ -165,19 +165,14 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types } // validateBlobTx implements the blob-transaction specific validations. -func validateBlobTx(tx *types.Transaction, head *types.Header, opts *ValidationOptions) error { +func validateBlobTx(tx *types.Transaction) error { sidecar := tx.BlobTxSidecar() if sidecar == nil { return errors.New("missing sidecar in blob transaction") } - // Ensure the sidecar is constructed with the correct version, consistent - // with the current fork. - version := types.BlobSidecarVersion0 - if opts.Config.IsOsaka(head.Number, head.Time) { - version = types.BlobSidecarVersion1 - } - if sidecar.Version != version { - return fmt.Errorf("unexpected sidecar version, want: %d, got: %d", version, sidecar.Version) + // Ensure the sidecar is constructed with the correct version + if sidecar.Version != types.BlobSidecarVersion1 { + return fmt.Errorf("unexpected sidecar version, want: %d, got: %d", types.BlobSidecarVersion1, sidecar.Version) } // Ensure the blob fee cap satisfies the minimum blob gas price if tx.BlobGasFeeCapIntCmp(blobTxMinBlobGasPrice) < 0 { @@ -198,26 +193,8 @@ func validateBlobTx(tx *types.Transaction, head *types.Header, opts *ValidationO if err := sidecar.ValidateBlobCommitmentHashes(hashes); err != nil { return err } - // Fork-specific sidecar checks, including proof verification. - if sidecar.Version == types.BlobSidecarVersion1 { - return validateBlobSidecarOsaka(sidecar, hashes) - } else { - return validateBlobSidecarLegacy(sidecar, hashes) - } + return validateBlobSidecarOsaka(sidecar, hashes) } - -func validateBlobSidecarLegacy(sidecar *types.BlobTxSidecar, hashes []common.Hash) error { - if len(sidecar.Proofs) != len(hashes) { - return fmt.Errorf("invalid number of %d blob proofs expected %d", len(sidecar.Proofs), len(hashes)) - } - for i := range sidecar.Blobs { - if err := kzg4844.VerifyBlobProof(&sidecar.Blobs[i], sidecar.Commitments[i], sidecar.Proofs[i]); err != nil { - return fmt.Errorf("%w: invalid blob proof: %v", ErrKZGVerificationError, err) - } - } - return nil -} - func validateBlobSidecarOsaka(sidecar *types.BlobTxSidecar, hashes []common.Hash) error { if len(sidecar.Proofs) != len(hashes)*kzg4844.CellProofsPerBlob { return fmt.Errorf("invalid number of %d blob proofs expected %d", len(sidecar.Proofs), len(hashes)*kzg4844.CellProofsPerBlob) diff --git a/eth/catalyst/api_test.go b/eth/catalyst/api_test.go index 0cf2c5c8e6..849c123979 100644 --- a/eth/catalyst/api_test.go +++ b/eth/catalyst/api_test.go @@ -1917,111 +1917,6 @@ func newGetBlobEnv(t testing.TB, version byte) (*node.Node, *ConsensusAPI) { api := newConsensusAPIWithoutHeartbeat(ethServ) return n, api } - -func TestGetBlobsV1(t *testing.T) { - n, api := newGetBlobEnv(t, 0) - defer n.Close() - - suites := []struct { - start int - limit int - fillRandom bool - }{ - { - start: 0, limit: 1, - }, - { - start: 0, limit: 1, fillRandom: true, - }, - { - start: 0, limit: 2, - }, - { - start: 0, limit: 2, fillRandom: true, - }, - { - start: 1, limit: 3, - }, - { - start: 1, limit: 3, fillRandom: true, - }, - { - start: 0, limit: 6, - }, - { - start: 0, limit: 6, fillRandom: true, - }, - { - start: 1, limit: 5, - }, - { - start: 1, limit: 5, fillRandom: true, - }, - } - for i, suite := range suites { - // Fill the request for retrieving blobs - var ( - vhashes []common.Hash - expect engine.BlobAndProofListV1 - ) - // fill missing blob at the beginning - if suite.fillRandom { - vhashes = append(vhashes, testrand.Hash()) - expect = append(expect, nil) - } - for j := suite.start; j < suite.limit; j++ { - vhashes = append(vhashes, testBlobVHashes[j]) - expect = append(expect, &engine.BlobAndProofV1{ - Blob: testBlobs[j][:], - Proof: testBlobProofs[j][:], - }) - - // fill missing blobs in the middle - if suite.fillRandom && rand.Intn(2) == 0 { - vhashes = append(vhashes, testrand.Hash()) - expect = append(expect, nil) - } - } - // fill missing blobs at the end - if suite.fillRandom { - vhashes = append(vhashes, testrand.Hash()) - expect = append(expect, nil) - } - result, err := api.GetBlobsV1(context.Background(), vhashes) - if err != nil { - t.Errorf("Unexpected error for case %d, %v", i, err) - } - if !reflect.DeepEqual(result, expect) { - t.Fatalf("Unexpected result for case %d", i) - } - } -} - -func TestGetBlobsV1AfterOsakaFork(t *testing.T) { - genesis := &core.Genesis{ - Config: params.MergedTestChainConfig, - Alloc: types.GenesisAlloc{testAddr: {Balance: testBalance}}, - Difficulty: common.Big0, - Timestamp: 1, // Timestamp > 0 to ensure Osaka fork is active - } - n, ethServ := startEthService(t, genesis, nil) - defer n.Close() - - var engineErr *engine.EngineAPIError - api := newConsensusAPIWithoutHeartbeat(ethServ) - _, err := api.GetBlobsV1(context.Background(), []common.Hash{testrand.Hash()}) - if !errors.As(err, &engineErr) { - t.Fatalf("Unexpected error: %T", err) - } else { - if engineErr.ErrorCode() != -38005 { - t.Fatalf("Expected error code -38005, got %d", engineErr.ErrorCode()) - } - if engineErr.Error() != "Unsupported fork" { - t.Fatalf("Expected error message 'Unsupported fork', got '%s'", engineErr.Error()) - } - } -} - func TestGetBlobsV2And3(t *testing.T) { n, api := newGetBlobEnv(t, 1) defer n.Close() diff --git a/eth/protocols/eth/handler_test.go b/eth/protocols/eth/handler_test.go index 3f40fdb3b3..874b4ad5a5 100644 --- a/eth/protocols/eth/handler_test.go +++ b/eth/protocols/eth/handler_test.go @@ -858,7 +858,7 @@ func testGetPooledTransaction(t *testing.T, blobTx bool) { emptyBlob = kzg4844.Blob{} emptyBlobs = []kzg4844.Blob{emptyBlob} emptyBlobCommit, _ = kzg4844.BlobToCommitment(&emptyBlob) - emptyBlobProof, _ = kzg4844.ComputeBlobProof(&emptyBlob, emptyBlobCommit) + emptyCellProof, _ = kzg4844.ComputeCellProofs(&emptyBlob) emptyBlobHash = kzg4844.CalcBlobHashV1(sha256.New(), &emptyBlobCommit) ) backend := newTestBackendWithGenerator(0, true, true, nil) @@ -882,7 +882,7 @@ func testGetPooledTransaction(t *testing.T, blobTx bool) { To: testAddr, BlobHashes: []common.Hash{emptyBlobHash}, BlobFeeCap: uint256.MustFromBig(common.Big1), - Sidecar: types.NewBlobTxSidecar(types.BlobSidecarVersion0, emptyBlobs, []kzg4844.Commitment{emptyBlobCommit}, []kzg4844.Proof{emptyBlobProof}), + Sidecar: types.NewBlobTxSidecar(types.BlobSidecarVersion1, emptyBlobs, []kzg4844.Commitment{emptyBlobCommit}, emptyCellProof), }) if err != nil { t.Fatal(err) From b72371887ad1028f2ddb238866b97af664b717f3 Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Mon, 22 Jun 2026 16:33:56 +0800 Subject: [PATCH 23/24] core/vm: inline the gas deduction (#35203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR inlines the gas deduction by getting rid of the tracer and use `chargeRegularOnly` for the non-state opcode. It fixes a performance regression introduced by EIP-8037 PR. ``` throughput MGas/s | 184.4 (±0.3%) | 193.1 (±1.0%) | +4.7% ▲ -- | -- | -- | -- mean newPayload | 164.2 ms (±0.3%) | 156.9 ms (±1.0%) | -4.5% ▲ p50 newPayload | 154.6 ms (±0.1%) | 147.6 ms (±0.7%) | -4.5% ▲ p95 newPayload | 273.3 ms (±2.3%) | 261.6 ms (±2.4%) | -4.3% ≈ noise p99 newPayload | 403.6 ms (±4.4%) | 380.9 ms (±4.0%) | -5.6% ≈ noise ``` --- core/vm/gascosts.go | 63 +++++++++++++++++++++++------------------- core/vm/interpreter.go | 13 ++++----- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index b1756ab5fe..881575e0a8 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -85,43 +85,48 @@ func (g GasBudget) String() string { return fmt.Sprintf("<%v,%v,used=<%v,%v>>", g.RegularGas, g.StateGas, g.UsedRegularGas, g.UsedStateGas) } -// CanAfford reports whether the running balance can cover the given cost. -// State-gas charges that exceed the reservoir spill into regular gas. -func (g GasBudget) CanAfford(cost GasCosts) bool { - if g.RegularGas < cost.RegularGas { - return false - } - if cost.StateGas > g.StateGas { - spillover := cost.StateGas - g.StateGas - if spillover > g.RegularGas-cost.RegularGas { - return false - } - } - return true -} - // Charge deducts a combined regular+state cost from the running balance and // updates the usage accumulators. State-gas in excess of the reservoir spills // into regular_gas. func (g *GasBudget) Charge(cost GasCosts) (GasBudget, bool) { prior := *g - if !g.CanAfford(cost) { - return prior, false - } - // Charge regular gas - g.RegularGas -= cost.RegularGas - g.UsedRegularGas += cost.RegularGas + ok := g.charge(cost) + return prior, ok +} - // Charge state gas - if cost.StateGas > g.StateGas { - spillover := cost.StateGas - g.StateGas - g.StateGas = 0 - g.RegularGas -= spillover - } else { - g.StateGas -= cost.StateGas +// chargeRegularOnly deducts a regular-only cost. +func (g *GasBudget) chargeRegularOnly(r uint64) bool { + if g.RegularGas < r { + return false } + g.RegularGas -= r + g.UsedRegularGas += r + return true +} + +// charge deducts both the state and regular cost. +func (g *GasBudget) charge(cost GasCosts) bool { + if g.RegularGas < cost.RegularGas { + return false + } + regular := g.RegularGas - cost.RegularGas + state := g.StateGas + + if cost.StateGas > state { + spillover := cost.StateGas - state + if spillover > regular { + return false + } + regular -= spillover + state = 0 + } else { + state -= cost.StateGas + } + g.RegularGas = regular + g.StateGas = state + g.UsedRegularGas += cost.RegularGas g.UsedStateGas += int64(cost.StateGas) - return prior, true + return true } // AsTracing converts the GasBudget into the tracing-facing Gas vector. diff --git a/core/vm/interpreter.go b/core/vm/interpreter.go index ca33670163..df69c3d1fd 100644 --- a/core/vm/interpreter.go +++ b/core/vm/interpreter.go @@ -192,7 +192,7 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte return nil, &ErrStackOverflow{stackLen: sLen, limit: operation.maxStack} } // for tracing: this gas consumption event is emitted below in the debug section. - if !contract.chargeRegular(cost, nil, tracing.GasChangeIgnored) { + if !contract.Gas.chargeRegularOnly(cost) { return nil, ErrOutOfGas } @@ -222,12 +222,11 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte if err != nil { return nil, fmt.Errorf("%w: %v", ErrOutOfGas, err) } - // EIP-8037: charge regular gas before state gas. The state charge - // is a no-op when dynamicCost.StateGas == 0 (e.g., pre-Amsterdam). - if !contract.chargeRegular(dynamicCost.RegularGas, nil, tracing.GasChangeIgnored) { - return nil, ErrOutOfGas - } - if !contract.chargeState(dynamicCost.StateGas, nil, tracing.GasChangeIgnored) { + if dynamicCost.StateGas == 0 { + if !contract.Gas.chargeRegularOnly(dynamicCost.RegularGas) { + return nil, ErrOutOfGas + } + } else if !contract.Gas.charge(dynamicCost) { return nil, ErrOutOfGas } } From c80681ca7318ff06810cabb856af37921ab703e5 Mon Sep 17 00:00:00 2001 From: rayoo Date: Mon, 22 Jun 2026 01:48:03 -0700 Subject: [PATCH 24/24] p2p/discover: return errNoUDPEndpoint in RequestENR (#34933) Mirror the guard applied to (*UDPv4).Dial in #34916: when the target node has no usable UDP endpoint, return errNoUDPEndpoint instead of silently sending the ENRRequest to an invalid AddrPort and waiting for a timeout. The other UDPEndpoint-using request paths in this file already do this: ping v4_udp.go:215 errNoUDPEndpoint Ping v4_udp.go:228 errNoUDPEndpoint newLookup v4_udp.go:309 errNoUDPEndpoint RequestENR v4_udp.go:358 addr, _ := n.UDPEndpoint() <-- outlier RequestENR is reachable from external callers like cmd/devp2p/crawl.go, which feeds in arbitrary nodes that may not have a UDP port set. Before this change, such nodes burn one full RPC timeout; after it, the caller gets a clean error immediately. The added test fails on master with "RPC timeout" and the trace logs "PING/v4 addr=invalid AddrPort", confirming packets are being written to an unspecified address; with the fix it returns errNoUDPEndpoint without doing any I/O. --- p2p/discover/v4_udp.go | 5 ++++- p2p/discover/v4_udp_test.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/p2p/discover/v4_udp.go b/p2p/discover/v4_udp.go index ae8cbec3e2..28702f0fc6 100644 --- a/p2p/discover/v4_udp.go +++ b/p2p/discover/v4_udp.go @@ -355,7 +355,10 @@ func (t *UDPv4) findnode(toid enode.ID, toAddrPort netip.AddrPort, target v4wire // RequestENR sends ENRRequest to the given node and waits for a response. func (t *UDPv4) RequestENR(n *enode.Node) (*enode.Node, error) { - addr, _ := n.UDPEndpoint() + addr, ok := n.UDPEndpoint() + if !ok { + return nil, errNoUDPEndpoint + } t.ensureBond(n.ID(), addr) req := &v4wire.ENRRequest{ diff --git a/p2p/discover/v4_udp_test.go b/p2p/discover/v4_udp_test.go index 287f0c34fa..da86bb0e06 100644 --- a/p2p/discover/v4_udp_test.go +++ b/p2p/discover/v4_udp_test.go @@ -158,6 +158,23 @@ func TestUDPv4_pingTimeout(t *testing.T) { } } +// TestUDPv4_RequestENRNoUDPEndpoint verifies that RequestENR returns a clean +// errNoUDPEndpoint error for a node without a usable UDP endpoint, instead of +// silently sending a packet to the zero address and waiting for a timeout. +// This mirrors the existing guards in ping/Ping/findnode. +func TestUDPv4_RequestENRNoUDPEndpoint(t *testing.T) { + t.Parallel() + test := newUDPTest(t) + defer test.close() + + key := newkey() + // UDP port 0 makes UDPEndpoint return ok=false. + node := enode.NewV4(&key.PublicKey, net.ParseIP("1.2.3.4"), 2222, 0) + if _, err := test.udp.RequestENR(node); !errors.Is(err, errNoUDPEndpoint) { + t.Errorf("expected errNoUDPEndpoint, got %v", err) + } +} + type testPacket byte func (req testPacket) Kind() byte { return byte(req) }