From 7f3afa1b71e150b51d1f7b3d16bfecbb5de046fa Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Mon, 22 Jun 2026 18:50:49 +0800 Subject: [PATCH 1/9] ethdb/pebble, cmd, node: add pebble v2 support (#34009) This PR adds the support of Pebble v2, details as below: - Pebble V2 will be used if database is empty - Pebble V1 will be used if database is not empty and the format is old - Upgrade command (geth db pebble-upgrade) is provided to upgrade the format to v2 offline --- cmd/geth/dbcmd.go | 28 ++ cmd/keeper/go.sum | 28 +- core/rawdb/database.go | 37 +- ethdb/pebble/pebble.go | 68 ++-- ethdb/pebble/pebble_test.go | 59 ++- ethdb/pebble/pebble_v1.go | 753 +++++++++++++++++++++++++++++++++++ ethdb/pebble/version.go | 146 +++++++ ethdb/pebble/version_test.go | 135 +++++++ go.mod | 14 +- go.sum | 38 +- node/database.go | 12 + 11 files changed, 1260 insertions(+), 58 deletions(-) create mode 100644 ethdb/pebble/pebble_v1.go create mode 100644 ethdb/pebble/version.go create mode 100644 ethdb/pebble/version_test.go diff --git a/cmd/geth/dbcmd.go b/cmd/geth/dbcmd.go index 173fc97def..408d0e3777 100644 --- a/cmd/geth/dbcmd.go +++ b/cmd/geth/dbcmd.go @@ -38,6 +38,7 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/ethdb/pebble" "github.com/ethereum/go-ethereum/internal/tablewriter" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/rlp" @@ -102,6 +103,7 @@ Remove blockchain and state databases`, dbMetadataCmd, dbCheckStateContentCmd, dbInspectHistoryCmd, + dbPebbleUpgradeCmd, }, } dbInspectCmd = &cli.Command{ @@ -242,6 +244,17 @@ WARNING: This is a low-level operation which may cause database corruption!`, }, utils.NetworkFlags, utils.DatabaseFlags), Description: "This command queries the history of the account or storage slot within the specified block range", } + dbPebbleUpgradeCmd = &cli.Command{ + Action: dbPebbleUpgrade, + Name: "pebble-upgrade", + Usage: "Upgrade a legacy pebble v1 database to pebble v2 format", + Flags: slices.Concat(utils.NetworkFlags, utils.DatabaseFlags), + Description: `This command upgrades a legacy Pebble v1 database so +that it becomes compatible with Pebble v2. The upgrade process converts the +database format to the oldest format supported by Pebble v2. It's not the +one-way operation, instead, the database can still be opened by older versions +of geth that use the pebble v1 library.`, + } ) func removeDB(ctx *cli.Context) error { @@ -543,6 +556,21 @@ func dbCompact(ctx *cli.Context) error { return nil } +func dbPebbleUpgrade(ctx *cli.Context) error { + stack, _ := makeConfigNode(ctx) + defer stack.Close() + + path := stack.ResolvePath("chaindata") + dbType := rawdb.PreexistingDatabase(path) + if dbType == "" { + return fmt.Errorf("no database found at %s", path) + } + if dbType != rawdb.DBPebble { + return fmt.Errorf("database at %s is %s, not pebble", path, dbType) + } + return pebble.Upgrade(path) +} + // dbGet shows the value of a given database key func dbGet(ctx *cli.Context) error { if ctx.NArg() != 1 { diff --git a/cmd/keeper/go.sum b/cmd/keeper/go.sum index 51a6a3fad2..1906759f12 100644 --- a/cmd/keeper/go.sum +++ b/cmd/keeper/go.sum @@ -1,7 +1,11 @@ -github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= -github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/RaduBerinde/axisds v0.1.0 h1:YItk/RmU5nvlsv/awo2Fjx97Mfpt4JfgtEVAGPrLdz8= +github.com/RaduBerinde/axisds v0.1.0/go.mod h1:UHGJonU9z4YYGKJxSaC6/TNcLOBptpmM5m2Cksbnw0Y= +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 h1:bsU8Tzxr/PNz75ayvCnxKZWEYdLMPDkUgticP4a4Bvk= +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54/go.mod h1:0tr7FllbE9gJkHq7CVeeDDFAFKQVy5RnCSSNBOvdqbc= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= @@ -14,6 +18,8 @@ github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3M github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b h1:SHlYZ/bMx7frnmeqCu+xm0TCxXLzX3jQIVuFbnFGtFU= +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b/go.mod h1:Gq51ZeKaFCXk6QwuGM0w1dnaOqc/F5zKT2zA9D6Xeac= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= @@ -22,8 +28,12 @@ github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZe github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/pebble/v2 v2.1.4 h1:j9wPgMDbkErFdAKYFGhsoCcvzcjR+6zrJ4jhKtJ6bOk= +github.com/cockroachdb/pebble/v2 v2.1.4/go.mod h1:Reo1RTniv1UjVTAu/Fv74y5i3kJ5gmVrPhO9UtFiKn8= github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b h1:VXvSNzmr8hMj8XTuY0PT9Ane9qZGul/p67vGYwl9BFI= +github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= @@ -72,8 +82,8 @@ github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZ github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= -github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= -github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -87,6 +97,8 @@ github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzW github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 h1:0lgqHvJWHLGW5TuObJrfyEi6+ASTKDBWikGvPqy9Yiw= +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= @@ -95,14 +107,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= -github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= -github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= -github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/core/rawdb/database.go b/core/rawdb/database.go index 8063bc6419..f4cb72fcec 100644 --- a/core/rawdb/database.go +++ b/core/rawdb/database.go @@ -352,17 +352,40 @@ const ( // PreexistingDatabase checks the given data directory whether a database is already // instantiated at that location, and if so, returns the type of database (or the // empty string). +// +// The database flavors are told apart by their on-disk file layout: +// +// CURRENT marker.manifest.* OPTIONS* +// leveldb x +// pebble v1 x x +// pebble v2 x x func PreexistingDatabase(path string) string { - if _, err := os.Stat(filepath.Join(path, "CURRENT")); err != nil { + var ( + hasCurrent = fileExists(filepath.Join(path, "CURRENT")) + hasMarker = anyFileMatches(filepath.Join(path, "marker.manifest.*")) + hasOptions = anyFileMatches(filepath.Join(path, "OPTIONS*")) + ) + switch { + case hasMarker, hasCurrent && hasOptions: + return DBPebble + case hasCurrent: + return DBLeveldb + default: return "" // No pre-existing db } - if matches, err := filepath.Glob(filepath.Join(path, "OPTIONS*")); len(matches) > 0 || err != nil { - if err != nil { - panic(err) // only possible if the pattern is malformed - } - return DBPebble +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func anyFileMatches(pattern string) bool { + matches, err := filepath.Glob(pattern) + if err != nil { + panic(err) // only possible if the pattern is malformed } - return DBLeveldb + return len(matches) > 0 } type counter uint64 diff --git a/ethdb/pebble/pebble.go b/ethdb/pebble/pebble.go index 7654d582c4..41ac260c38 100644 --- a/ethdb/pebble/pebble.go +++ b/ethdb/pebble/pebble.go @@ -18,6 +18,7 @@ package pebble import ( + "context" "errors" "fmt" "runtime" @@ -26,8 +27,8 @@ import ( "sync/atomic" "time" - "github.com/cockroachdb/pebble" - "github.com/cockroachdb/pebble/bloom" + "github.com/cockroachdb/pebble/v2" + "github.com/cockroachdb/pebble/v2/bloom" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/log" @@ -52,7 +53,7 @@ const ( degradationWarnInterval = time.Minute ) -// Database is a persistent key-value store based on the pebble storage engine. +// Database is a persistent key-value store based on the pebble v2 storage engine. // Apart from basic data storage functionality it also supports batch writes and // iterating over the keyspace in binary-alphabetical order. type Database struct { @@ -77,8 +78,8 @@ type Database struct { zombieMemTablesGauge *metrics.Gauge // Gauge for tracking the number of zombie memory tables blockCacheHitGauge *metrics.Gauge // Gauge for tracking the number of total hit in the block cache blockCacheMissGauge *metrics.Gauge // Gauge for tracking the number of total miss in the block cache - tableCacheHitGauge *metrics.Gauge // Gauge for tracking the number of total hit in the table cache - tableCacheMissGauge *metrics.Gauge // Gauge for tracking the number of total miss in the table cache + tableCacheHitGauge *metrics.Gauge // Gauge for tracking the number of total hit in the file cache + tableCacheMissGauge *metrics.Gauge // Gauge for tracking the number of total miss in the file cache filterHitGauge *metrics.Gauge // Gauge for tracking the number of total hit in bloom filter filterMissGauge *metrics.Gauge // Gauge for tracking the number of total miss in bloom filter estimatedCompDebtGauge *metrics.Gauge // Gauge for tracking the number of bytes that need to be compacted @@ -186,7 +187,7 @@ func New(file string, cache int, handles int, namespace string, readonly bool) ( handles = minHandles } logger := log.New("database", file) - logger.Info("Allocated cache and file handles", "cache", common.StorageSize(cache*1024*1024), "handles", handles) + logger.Info("Allocated cache and file handles", "cache", common.StorageSize(cache*1024*1024), "handles", handles, "version", "v2") // The max memtable size is limited by the uint32 offsets stored in // internal/arenaskl.node, DeferredBatchOp, and flushableBatchEntry. @@ -232,6 +233,7 @@ func New(file string, cache int, handles int, namespace string, readonly bool) ( // of course). Geth is expected to handle recovery from an unclean shutdown. writeOptions: pebble.NoSync, } + numCPU := runtime.NumCPU() opt := &pebble.Options{ // Pebble has a single combined cache area and the write // buffers are taken from this too. Assign all available @@ -256,20 +258,30 @@ func New(file string, cache int, handles int, namespace string, readonly bool) ( // The default compaction concurrency(1 thread), // Here use all available CPUs for faster compaction. - MaxConcurrentCompactions: runtime.NumCPU, + CompactionConcurrencyRange: func() (int, int) { return 1, numCPU }, // Per-level options. Options for at least one level must be specified. The // options for the last level are used for all subsequent levels. - Levels: []pebble.LevelOptions{ - {TargetFileSize: 2 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, - {TargetFileSize: 4 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, - {TargetFileSize: 8 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, - {TargetFileSize: 16 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, - {TargetFileSize: 32 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, - {TargetFileSize: 64 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, + Levels: [7]pebble.LevelOptions{ + {FilterPolicy: bloom.FilterPolicy(10)}, + {FilterPolicy: bloom.FilterPolicy(10)}, + {FilterPolicy: bloom.FilterPolicy(10)}, + {FilterPolicy: bloom.FilterPolicy(10)}, + {FilterPolicy: bloom.FilterPolicy(10)}, + {FilterPolicy: bloom.FilterPolicy(10)}, // Pebble doesn't use the Bloom filter at level6 for read efficiency. - {TargetFileSize: 128 * 1024 * 1024}, + {}, + }, + // Per-level target file sizes (replaces LevelOptions.TargetFileSize in v2). + TargetFileSizes: [7]int64{ + 2 * 1024 * 1024, + 4 * 1024 * 1024, + 8 * 1024 * 1024, + 16 * 1024 * 1024, + 32 * 1024 * 1024, + 64 * 1024 * 1024, + 128 * 1024 * 1024, }, ReadOnly: readonly, EventListener: &pebble.EventListener{ @@ -299,6 +311,14 @@ func New(file string, cache int, handles int, namespace string, readonly bool) ( // the compaction debt as around 10GB. By reducing it to 2, the compaction // debt will be less than 1GB, but with more frequent compactions scheduled. L0CompactionThreshold: 2, + + // FormatFlushableIngest is the minimum FormatMajorVersion supported by + // pebble v2. The more advanced version can be enabled later. + // + // This version is supported by both v1 and v2. It serves as the natural + // bridge point: a v1 database can be ratcheted up to FormatFlushableIngest + // using pebble v1, and then pebble v2 can open it since that's its minimum. + FormatMajorVersion: formatMinV2, } // Disable seek compaction explicitly. Check https://github.com/ethereum/go-ethereum/pull/20130 // for more details. @@ -309,7 +329,7 @@ func New(file string, cache int, handles int, namespace string, readonly bool) ( // - there is one more overlapping sub-level0; // - there is an additional 256 MB of compaction debt; // - // The maximum concurrency is still capped by MaxConcurrentCompactions, but with + // The maximum concurrency is still capped by CompactionConcurrencyRange, but with // these settings compactions can scale up more readily. opt.Experimental.L0CompactionConcurrency = 1 opt.Experimental.CompactionDebtConcurrency = 1 << 28 // 256MB @@ -506,7 +526,7 @@ func (d *Database) Compact(start []byte, limit []byte) error { if limit == nil { limit = ethdb.MaximumKey } - return d.db.Compact(start, limit, true) // Parallelization is preferred + return d.db.Compact(context.Background(), start, limit, true) // Parallelization is preferred } // Path returns the path to the database directory. @@ -565,10 +585,10 @@ func (d *Database) meter(refresh time.Duration, namespace string) { compTimes[i%2] = compTime for _, levelMetrics := range stats.Levels { - nWrite += int64(levelMetrics.BytesCompacted) - nWrite += int64(levelMetrics.BytesFlushed) - compWrite += int64(levelMetrics.BytesCompacted) - compRead += int64(levelMetrics.BytesRead) + nWrite += int64(levelMetrics.TableBytesCompacted) + nWrite += int64(levelMetrics.TableBytesFlushed) + compWrite += int64(levelMetrics.TableBytesCompacted) + compRead += int64(levelMetrics.TableBytesRead) } nWrite += int64(stats.WAL.BytesWritten) @@ -607,8 +627,8 @@ func (d *Database) meter(refresh time.Duration, namespace string) { d.liveMemTablesGauge.Update(stats.MemTable.Count) d.zombieMemTablesGauge.Update(stats.MemTable.ZombieCount) d.estimatedCompDebtGauge.Update(int64(stats.Compact.EstimatedDebt)) - d.tableCacheHitGauge.Update(stats.TableCache.Hits) - d.tableCacheMissGauge.Update(stats.TableCache.Misses) + d.tableCacheHitGauge.Update(stats.FileCache.Hits) + d.tableCacheMissGauge.Update(stats.FileCache.Misses) d.blockCacheHitGauge.Update(stats.BlockCache.Hits) d.blockCacheMissGauge.Update(stats.BlockCache.Misses) d.filterHitGauge.Update(stats.Filter.Hits) @@ -619,7 +639,7 @@ func (d *Database) meter(refresh time.Duration, namespace string) { if i >= len(d.levelsGauge) { d.levelsGauge = append(d.levelsGauge, metrics.GetOrRegisterGauge(namespace+fmt.Sprintf("tables/level%v", i), nil)) } - d.levelsGauge[i].Update(level.NumFiles) + d.levelsGauge[i].Update(level.TablesCount) } // Sleep a bit, then repeat the stats collection diff --git a/ethdb/pebble/pebble_test.go b/ethdb/pebble/pebble_test.go index e703a8d0ce..7d96a6626c 100644 --- a/ethdb/pebble/pebble_test.go +++ b/ethdb/pebble/pebble_test.go @@ -21,7 +21,11 @@ import ( "testing" "github.com/cockroachdb/pebble" + pebblev1 "github.com/cockroachdb/pebble" + pebblev2 "github.com/cockroachdb/pebble/v2" + vfsv2 "github.com/cockroachdb/pebble/v2/vfs" "github.com/cockroachdb/pebble/vfs" + vfsv1 "github.com/cockroachdb/pebble/vfs" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/ethdb/dbtest" ) @@ -29,8 +33,8 @@ import ( func TestPebbleDB(t *testing.T) { t.Run("DatabaseSuite", func(t *testing.T) { dbtest.TestDatabaseSuite(t, func() ethdb.KeyValueStore { - db, err := pebble.Open("", &pebble.Options{ - FS: vfs.NewMem(), + db, err := pebblev2.Open("", &pebblev2.Options{ + FS: vfsv2.NewMem(), }) if err != nil { t.Fatal(err) @@ -39,13 +43,24 @@ func TestPebbleDB(t *testing.T) { db: db, } }) + dbtest.TestDatabaseSuite(t, func() ethdb.KeyValueStore { + db, err := pebblev1.Open("", &pebblev1.Options{ + FS: vfsv1.NewMem(), + }) + if err != nil { + t.Fatal(err) + } + return &V1Database{ + db: db, + } + }) }) } func BenchmarkPebbleDB(b *testing.B) { dbtest.BenchDatabaseSuite(b, func() ethdb.KeyValueStore { - db, err := pebble.Open("", &pebble.Options{ - FS: vfs.NewMem(), + db, err := pebblev2.Open("", &pebblev2.Options{ + FS: vfsv2.NewMem(), }) if err != nil { b.Fatal(err) @@ -54,9 +69,20 @@ func BenchmarkPebbleDB(b *testing.B) { db: db, } }) + dbtest.BenchDatabaseSuite(b, func() ethdb.KeyValueStore { + db, err := pebblev1.Open("", &pebblev1.Options{ + FS: vfsv1.NewMem(), + }) + if err != nil { + b.Fatal(err) + } + return &V1Database{ + db: db, + } + }) } -func TestPebbleLogData(t *testing.T) { +func TestPebbleLogDataV1(t *testing.T) { db, err := pebble.Open("", &pebble.Options{ FS: vfs.NewMem(), }) @@ -78,3 +104,26 @@ func TestPebbleLogData(t *testing.T) { t.Fatal("Unknown database entry") } } + +func TestPebbleLogDataV2(t *testing.T) { + db, err := pebblev2.Open("", &pebblev2.Options{ + FS: vfsv2.NewMem(), + }) + if err != nil { + t.Fatal(err) + } + + _, _, err = db.Get(nil) + if !errors.Is(err, pebblev2.ErrNotFound) { + t.Fatal("Unknown database entry") + } + + b := db.NewBatch() + b.LogData(nil, nil) + db.Apply(b, pebblev2.Sync) + + _, _, err = db.Get(nil) + if !errors.Is(err, pebblev2.ErrNotFound) { + t.Fatal("Unknown database entry") + } +} diff --git a/ethdb/pebble/pebble_v1.go b/ethdb/pebble/pebble_v1.go new file mode 100644 index 0000000000..4d0ed3f2de --- /dev/null +++ b/ethdb/pebble/pebble_v1.go @@ -0,0 +1,753 @@ +// Copyright 2023 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 . + +// Legacy pebble v1 wrapper. This file mirrors pebble.go but with V1-prefixed +// types so that it can coexist alongside a future v2 variant in the same package. + +package pebble + +import ( + "errors" + "fmt" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/cockroachdb/pebble" + "github.com/cockroachdb/pebble/bloom" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/metrics" +) + +// V1Database is a persistent key-value store based on the pebble v1 storage engine. +// Apart from basic data storage functionality it also supports batch writes and +// iterating over the keyspace in binary-alphabetical order. +type V1Database struct { + fn string // filename for reporting + db *pebble.DB // Underlying pebble storage engine + namespace string // Namespace for metrics + + compTimeMeter *metrics.Meter // Meter for measuring the total time spent in database compaction + compReadMeter *metrics.Meter // Meter for measuring the data read during compaction + compWriteMeter *metrics.Meter // Meter for measuring the data written during compaction + writeDelayNMeter *metrics.Meter // Meter for measuring the write delay number due to database compaction + writeDelayMeter *metrics.Meter // Meter for measuring the write delay duration due to database compaction + diskSizeGauge *metrics.Gauge // Gauge for tracking the size of all the levels in the database + diskReadMeter *metrics.Meter // Meter for measuring the effective amount of data read + diskWriteMeter *metrics.Meter // Meter for measuring the effective amount of data written + memCompGauge *metrics.Gauge // Gauge for tracking the number of memory compaction + level0CompGauge *metrics.Gauge // Gauge for tracking the number of table compaction in level0 + nonlevel0CompGauge *metrics.Gauge // Gauge for tracking the number of table compaction in non0 level + seekCompGauge *metrics.Gauge // Gauge for tracking the number of table compaction caused by read opt + manualMemAllocGauge *metrics.Gauge // Gauge for tracking amount of non-managed memory currently allocated + liveMemTablesGauge *metrics.Gauge // Gauge for tracking the number of live memory tables + zombieMemTablesGauge *metrics.Gauge // Gauge for tracking the number of zombie memory tables + blockCacheHitGauge *metrics.Gauge // Gauge for tracking the number of total hit in the block cache + blockCacheMissGauge *metrics.Gauge // Gauge for tracking the number of total miss in the block cache + tableCacheHitGauge *metrics.Gauge // Gauge for tracking the number of total hit in the table cache + tableCacheMissGauge *metrics.Gauge // Gauge for tracking the number of total miss in the table cache + filterHitGauge *metrics.Gauge // Gauge for tracking the number of total hit in bloom filter + filterMissGauge *metrics.Gauge // Gauge for tracking the number of total miss in bloom filter + estimatedCompDebtGauge *metrics.Gauge // Gauge for tracking the number of bytes that need to be compacted + liveCompGauge *metrics.Gauge // Gauge for tracking the number of in-progress compactions + liveCompSizeGauge *metrics.Gauge // Gauge for tracking the size of in-progress compactions + liveIterGauge *metrics.Gauge // Gauge for tracking the number of live database iterators + levelsGauge []*metrics.Gauge // Gauge for tracking the number of tables in levels + + quitLock sync.RWMutex // Mutex protecting the quit channel and the closed flag + quitChan chan chan error // Quit channel to stop the metrics collection before closing the database + closed bool // keep track of whether we're Closed + + log log.Logger // Contextual logger tracking the database path + + activeComp int // Current number of active compactions + compStartTime time.Time // The start time of the earliest currently-active compaction + compTime atomic.Int64 // Total time spent in compaction in ns + level0Comp atomic.Uint32 // Total number of level-zero compactions + nonLevel0Comp atomic.Uint32 // Total number of non level-zero compactions + + writeStalled atomic.Bool // Flag whether the write is stalled + writeDelayStartTime time.Time // The start time of the latest write stall + writeDelayReason string // The reason of the latest write stall + writeDelayCount atomic.Int64 // Total number of write stall counts + writeDelayTime atomic.Int64 // Total time spent in write stalls + + writeOptions *pebble.WriteOptions +} + +func (d *V1Database) onCompactionBegin(info pebble.CompactionInfo) { + if d.activeComp == 0 { + d.compStartTime = time.Now() + } + l0 := info.Input[0] + if l0.Level == 0 { + d.level0Comp.Add(1) + } else { + d.nonLevel0Comp.Add(1) + } + d.activeComp++ +} + +func (d *V1Database) onCompactionEnd(info pebble.CompactionInfo) { + if d.activeComp == 1 { + d.compTime.Add(int64(time.Since(d.compStartTime))) + } else if d.activeComp == 0 { + panic("should not happen") + } + d.activeComp-- +} + +func (d *V1Database) onWriteStallBegin(b pebble.WriteStallBeginInfo) { + d.writeDelayStartTime = time.Now() + d.writeDelayCount.Add(1) + d.writeStalled.Store(true) + + // Take just the first word of the reason. These are two potential + // reasons for the write stall: + // - memtable count limit reached + // - L0 file count limit exceeded + reason := b.Reason + if i := strings.IndexByte(reason, ' '); i != -1 { + reason = reason[:i] + } + if reason == "L0" || reason == "memtable" { + d.writeDelayReason = reason + metrics.GetOrRegisterGauge(d.namespace+"stall/count/"+reason, nil).Inc(1) + } +} + +func (d *V1Database) onWriteStallEnd() { + d.writeDelayTime.Add(int64(time.Since(d.writeDelayStartTime))) + d.writeStalled.Store(false) + + if d.writeDelayReason != "" { + metrics.GetOrRegisterResettingTimer(d.namespace+"stall/time/"+d.writeDelayReason, nil).UpdateSince(d.writeDelayStartTime) + d.writeDelayReason = "" + } + d.writeDelayStartTime = time.Time{} +} + +// NewV1 returns a wrapped pebble v1 DB object. The namespace is the prefix that the +// metrics reporting should use for surfacing internal stats. +func NewV1(file string, cache int, handles int, namespace string, readonly bool) (*V1Database, error) { + // Ensure we have some minimal caching and file guarantees + if cache < minCache { + cache = minCache + } + if handles < minHandles { + handles = minHandles + } + logger := log.New("database", file) + logger.Info("Allocated cache and file handles", "cache", common.StorageSize(cache*1024*1024), "handles", handles, "version", "v1") + + // The max memtable size is limited by the uint32 offsets stored in + // internal/arenaskl.node, DeferredBatchOp, and flushableBatchEntry. + // + // - MaxUint32 on 64-bit platforms; + // - MaxInt on 32-bit platforms. + // + // It is used when slices are limited to Uint32 on 64-bit platforms (the + // length limit for slices is naturally MaxInt on 32-bit platforms). + // + // Taken from https://github.com/cockroachdb/pebble/blob/master/internal/constants/constants.go + maxMemTableSize := (1<<31)<<(^uint(0)>>63) - 1 + + // Four memory tables are configured, each with a default size of 256 MB. + // Having multiple smaller memory tables while keeping the total memory + // limit unchanged allows writes to be flushed more smoothly. This helps + // avoid compaction spikes and mitigates write stalls caused by heavy + // compaction workloads. + memTableNumber := 4 + memTableSize := cache * 1024 * 1024 / 2 / memTableNumber + + // The memory table size is currently capped at maxMemTableSize-1 due to a + // known bug in the pebble where maxMemTableSize is not recognized as a + // valid size. + // + // TODO use the maxMemTableSize as the maximum table size once the issue + // in pebble is fixed. + if memTableSize >= maxMemTableSize { + memTableSize = maxMemTableSize - 1 + } + db := &V1Database{ + fn: file, + log: logger, + quitChan: make(chan chan error), + namespace: namespace, + + // Use asynchronous write mode by default. Otherwise, the overhead of frequent fsync + // operations can be significant, especially on platforms with slow fsync performance + // (e.g., macOS) or less capable SSDs. + // + // Note that enabling async writes means recent data may be lost in the event of an + // application-level panic (writes will also be lost on a machine-level failure, + // of course). Geth is expected to handle recovery from an unclean shutdown. + writeOptions: pebble.NoSync, + } + opt := &pebble.Options{ + // Pebble has a single combined cache area and the write + // buffers are taken from this too. Assign all available + // memory allowance for cache. + Cache: pebble.NewCache(int64(cache * 1024 * 1024)), + MaxOpenFiles: handles, + + // The size of memory table(as well as the write buffer). + // Note, there may have more than two memory tables in the system. + MemTableSize: uint64(memTableSize), + + // MemTableStopWritesThreshold places a hard limit on the number + // of the existent MemTables(including the frozen one). + // + // Note, this must be the number of tables not the size of all memtables + // according to https://github.com/cockroachdb/pebble/blob/master/options.go#L738-L742 + // and to https://github.com/cockroachdb/pebble/blob/master/db.go#L1892-L1903. + // + // MemTableStopWritesThreshold is set to twice the maximum number of + // allowed memtables to accommodate temporary spikes. + MemTableStopWritesThreshold: memTableNumber * 2, + + // The default compaction concurrency(1 thread), + // Here use all available CPUs for faster compaction. + MaxConcurrentCompactions: runtime.NumCPU, + + // Per-level options. Options for at least one level must be specified. The + // options for the last level are used for all subsequent levels. + Levels: []pebble.LevelOptions{ + {TargetFileSize: 2 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, + {TargetFileSize: 4 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, + {TargetFileSize: 8 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, + {TargetFileSize: 16 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, + {TargetFileSize: 32 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, + {TargetFileSize: 64 * 1024 * 1024, FilterPolicy: bloom.FilterPolicy(10)}, + + // Pebble doesn't use the Bloom filter at level6 for read efficiency. + {TargetFileSize: 128 * 1024 * 1024}, + }, + ReadOnly: readonly, + EventListener: &pebble.EventListener{ + CompactionBegin: db.onCompactionBegin, + CompactionEnd: db.onCompactionEnd, + WriteStallBegin: db.onWriteStallBegin, + WriteStallEnd: db.onWriteStallEnd, + }, + Logger: panicLogger{}, // TODO(karalabe): Delete when this is upstreamed in Pebble + + // Pebble is configured to use asynchronous write mode, meaning write operations + // return as soon as the data is cached in memory, without waiting for the WAL + // to be written. This mode offers better write performance but risks losing + // recent writes if the application crashes or a power failure/system crash occurs. + // + // By setting the WALBytesPerSync, the cached WAL writes will be periodically + // flushed at the background if the accumulated size exceeds this threshold. + WALBytesPerSync: 5 * ethdb.IdealBatchSize, + + // L0CompactionThreshold specifies the number of L0 read-amplification + // necessary to trigger an L0 compaction. It essentially refers to the + // number of sub-levels at the L0. For each sub-level, it contains several + // L0 files which are non-overlapping with each other, typically produced + // by a single memory-table flush. + // + // The default value in Pebble is 4, which is a bit too large to have + // the compaction debt as around 10GB. By reducing it to 2, the compaction + // debt will be less than 1GB, but with more frequent compactions scheduled. + L0CompactionThreshold: 2, + } + // Disable seek compaction explicitly. Check https://github.com/ethereum/go-ethereum/pull/20130 + // for more details. + opt.Experimental.ReadSamplingMultiplier = -1 + + // These two settings define the conditions under which compaction concurrency + // is increased. Specifically, one additional compaction job will be enabled when: + // - there is one more overlapping sub-level0; + // - there is an additional 256 MB of compaction debt; + // + // The maximum concurrency is still capped by MaxConcurrentCompactions, but with + // these settings compactions can scale up more readily. + opt.Experimental.L0CompactionConcurrency = 1 + opt.Experimental.CompactionDebtConcurrency = 1 << 28 // 256MB + + // Open the db and recover any potential corruptions + innerDB, err := pebble.Open(file, opt) + if err != nil { + return nil, err + } + db.db = innerDB + + db.compTimeMeter = metrics.GetOrRegisterMeter(namespace+"compact/time", nil) + db.compReadMeter = metrics.GetOrRegisterMeter(namespace+"compact/input", nil) + db.compWriteMeter = metrics.GetOrRegisterMeter(namespace+"compact/output", nil) + db.diskSizeGauge = metrics.GetOrRegisterGauge(namespace+"disk/size", nil) + db.diskReadMeter = metrics.GetOrRegisterMeter(namespace+"disk/read", nil) + db.diskWriteMeter = metrics.GetOrRegisterMeter(namespace+"disk/write", nil) + db.writeDelayMeter = metrics.GetOrRegisterMeter(namespace+"compact/writedelay/duration", nil) + db.writeDelayNMeter = metrics.GetOrRegisterMeter(namespace+"compact/writedelay/counter", nil) + db.memCompGauge = metrics.GetOrRegisterGauge(namespace+"compact/memory", nil) + db.level0CompGauge = metrics.GetOrRegisterGauge(namespace+"compact/level0", nil) + db.nonlevel0CompGauge = metrics.GetOrRegisterGauge(namespace+"compact/nonlevel0", nil) + db.seekCompGauge = metrics.GetOrRegisterGauge(namespace+"compact/seek", nil) + db.manualMemAllocGauge = metrics.GetOrRegisterGauge(namespace+"memory/manualalloc", nil) + db.liveMemTablesGauge = metrics.GetOrRegisterGauge(namespace+"table/live", nil) + db.zombieMemTablesGauge = metrics.GetOrRegisterGauge(namespace+"table/zombie", nil) + db.blockCacheHitGauge = metrics.GetOrRegisterGauge(namespace+"cache/block/hit", nil) + db.blockCacheMissGauge = metrics.GetOrRegisterGauge(namespace+"cache/block/miss", nil) + db.tableCacheHitGauge = metrics.GetOrRegisterGauge(namespace+"cache/table/hit", nil) + db.tableCacheMissGauge = metrics.GetOrRegisterGauge(namespace+"cache/table/miss", nil) + db.filterHitGauge = metrics.GetOrRegisterGauge(namespace+"filter/hit", nil) + db.filterMissGauge = metrics.GetOrRegisterGauge(namespace+"filter/miss", nil) + db.estimatedCompDebtGauge = metrics.GetOrRegisterGauge(namespace+"compact/estimateDebt", nil) + db.liveCompGauge = metrics.GetOrRegisterGauge(namespace+"compact/live/count", nil) + db.liveCompSizeGauge = metrics.GetOrRegisterGauge(namespace+"compact/live/size", nil) + db.liveIterGauge = metrics.GetOrRegisterGauge(namespace+"iter/count", nil) + + // Start up the metrics gathering and return + go db.meter(metricsGatheringInterval, namespace) + return db, nil +} + +// Close stops the metrics collection, flushes any pending data to disk and closes +// all io accesses to the underlying key-value store. +func (d *V1Database) Close() error { + d.quitLock.Lock() + defer d.quitLock.Unlock() + // Allow double closing, simplifies things + if d.closed { + return nil + } + d.closed = true + if d.quitChan != nil { + errc := make(chan error) + d.quitChan <- errc + if err := <-errc; err != nil { + d.log.Error("Metrics collection failed", "err", err) + } + d.quitChan = nil + } + return d.db.Close() +} + +// Has retrieves if a key is present in the key-value store. +func (d *V1Database) Has(key []byte) (bool, error) { + d.quitLock.RLock() + defer d.quitLock.RUnlock() + if d.closed { + return false, pebble.ErrClosed + } + _, closer, err := d.db.Get(key) + if err == pebble.ErrNotFound { + return false, nil + } else if err != nil { + return false, err + } + if err = closer.Close(); err != nil { + return false, err + } + return true, nil +} + +// Get retrieves the given key if it's present in the key-value store. +func (d *V1Database) Get(key []byte) ([]byte, error) { + d.quitLock.RLock() + defer d.quitLock.RUnlock() + if d.closed { + return nil, pebble.ErrClosed + } + dat, closer, err := d.db.Get(key) + if err != nil { + return nil, err + } + ret := make([]byte, len(dat)) + copy(ret, dat) + if err = closer.Close(); err != nil { + return nil, err + } + return ret, nil +} + +// Put inserts the given value into the key-value store. +func (d *V1Database) Put(key []byte, value []byte) error { + d.quitLock.RLock() + defer d.quitLock.RUnlock() + if d.closed { + return pebble.ErrClosed + } + return d.db.Set(key, value, d.writeOptions) +} + +// Delete removes the key from the key-value store. +func (d *V1Database) Delete(key []byte) error { + d.quitLock.RLock() + defer d.quitLock.RUnlock() + if d.closed { + return pebble.ErrClosed + } + return d.db.Delete(key, d.writeOptions) +} + +// DeleteRange deletes all of the keys (and values) in the range [start,end) +// (inclusive on start, exclusive on end). +func (d *V1Database) DeleteRange(start, end []byte) error { + d.quitLock.RLock() + defer d.quitLock.RUnlock() + + if d.closed { + return pebble.ErrClosed + } + // There is no special flag to represent the end of key range + // in pebble(nil in leveldb). Use an ugly hack to construct a + // large key to represent it. + if end == nil { + end = ethdb.MaximumKey + } + return d.db.DeleteRange(start, end, d.writeOptions) +} + +// NewBatch creates a write-only key-value store that buffers changes to its host +// database until a final write is called. +func (d *V1Database) NewBatch() ethdb.Batch { + return &v1batch{ + b: d.db.NewBatch(), + db: d, + } +} + +// NewBatchWithSize creates a write-only database batch with pre-allocated buffer. +func (d *V1Database) NewBatchWithSize(size int) ethdb.Batch { + return &v1batch{ + b: d.db.NewBatchWithSize(size), + db: d, + } +} + +// Stat returns the internal metrics of Pebble in a text format. It's a developer +// method to read everything there is to read, independent of Pebble version. +func (d *V1Database) Stat() (string, error) { + return d.db.Metrics().String(), nil +} + +// Compact flattens the underlying data store for the given key range. In essence, +// deleted and overwritten versions are discarded, and the data is rearranged to +// reduce the cost of operations needed to access them. +// +// A nil start is treated as a key before all keys in the data store; a nil limit +// is treated as a key after all keys in the data store. If both is nil then it +// will compact entire data store. +func (d *V1Database) Compact(start []byte, limit []byte) error { + // There is no special flag to represent the end of key range + // in pebble(nil in leveldb). Use an ugly hack to construct a + // large key to represent it. + // Note any prefixed database entry will be smaller than this + // flag, as for trie nodes we need the 32 byte 0xff because + // there might be a shared prefix starting with a number of + // 0xff-s, so 32 ensures than only a hash collision could touch it. + // https://github.com/cockroachdb/pebble/issues/2359#issuecomment-1443995833 + if limit == nil { + limit = ethdb.MaximumKey + } + return d.db.Compact(start, limit, true) // Parallelization is preferred +} + +// Path returns the path to the database directory. +func (d *V1Database) Path() string { + return d.fn +} + +// SyncKeyValue flushes all pending writes in the write-ahead-log to disk, +// ensuring data durability up to that point. +func (d *V1Database) SyncKeyValue() error { + // The entry (value=nil) is not written to the database; it is only + // added to the WAL. Writing this special log entry in sync mode + // automatically flushes all previous writes, ensuring database + // durability up to this point. + b := d.db.NewBatch() + b.LogData(nil, nil) + return d.db.Apply(b, pebble.Sync) +} + +// NewIterator creates a binary-alphabetical iterator over a subset +// of database content with a particular key prefix, starting at a particular +// initial key (or after, if it does not exist). +func (d *V1Database) NewIterator(prefix []byte, start []byte) ethdb.Iterator { + iter, _ := d.db.NewIter(&pebble.IterOptions{ + LowerBound: append(prefix, start...), + UpperBound: upperBound(prefix), + }) + iter.First() + return &v1pebbleIterator{iter: iter, moved: true, released: false} +} + +// meter periodically retrieves internal pebble counters and reports them to +// the metrics subsystem. +func (d *V1Database) meter(refresh time.Duration, namespace string) { + var errc chan error + timer := time.NewTimer(refresh) + defer timer.Stop() + + // Create storage and warning log tracer for write delay. + var ( + compTimes [2]int64 + compWrites [2]int64 + compReads [2]int64 + + nWrites [2]int64 + + writeDelayTimes [2]int64 + writeDelayCounts [2]int64 + lastWriteStallReport time.Time + ) + + // Iterate ad infinitum and collect the stats + for i := 1; errc == nil; i++ { + var ( + compWrite int64 + compRead int64 + nWrite int64 + + stats = d.db.Metrics() + compTime = d.compTime.Load() + writeDelayCount = d.writeDelayCount.Load() + writeDelayTime = d.writeDelayTime.Load() + nonLevel0CompCount = int64(d.nonLevel0Comp.Load()) + level0CompCount = int64(d.level0Comp.Load()) + ) + writeDelayTimes[i%2] = writeDelayTime + writeDelayCounts[i%2] = writeDelayCount + compTimes[i%2] = compTime + + for _, levelMetrics := range stats.Levels { + nWrite += int64(levelMetrics.BytesCompacted) + nWrite += int64(levelMetrics.BytesFlushed) + compWrite += int64(levelMetrics.BytesCompacted) + compRead += int64(levelMetrics.BytesRead) + } + + nWrite += int64(stats.WAL.BytesWritten) + + compWrites[i%2] = compWrite + compReads[i%2] = compRead + nWrites[i%2] = nWrite + + d.writeDelayNMeter.Mark(writeDelayCounts[i%2] - writeDelayCounts[(i-1)%2]) + d.writeDelayMeter.Mark(writeDelayTimes[i%2] - writeDelayTimes[(i-1)%2]) + // Print a warning log if writing has been stalled for a while. The log will + // be printed per minute to avoid overwhelming users. + if d.writeStalled.Load() && writeDelayCounts[i%2] == writeDelayCounts[(i-1)%2] && + time.Now().After(lastWriteStallReport.Add(degradationWarnInterval)) { + d.log.Warn("Database compacting, degraded performance") + lastWriteStallReport = time.Now() + } + d.compTimeMeter.Mark(compTimes[i%2] - compTimes[(i-1)%2]) + d.compReadMeter.Mark(compReads[i%2] - compReads[(i-1)%2]) + d.compWriteMeter.Mark(compWrites[i%2] - compWrites[(i-1)%2]) + d.diskSizeGauge.Update(int64(stats.DiskSpaceUsage())) + d.diskReadMeter.Mark(0) // pebble doesn't track non-compaction reads + d.diskWriteMeter.Mark(nWrites[i%2] - nWrites[(i-1)%2]) + + // See https://github.com/cockroachdb/pebble/pull/1628#pullrequestreview-1026664054 + manuallyAllocated := stats.BlockCache.Size + int64(stats.MemTable.Size) + int64(stats.MemTable.ZombieSize) + d.manualMemAllocGauge.Update(manuallyAllocated) + d.memCompGauge.Update(stats.Flush.Count) + d.nonlevel0CompGauge.Update(nonLevel0CompCount) + d.level0CompGauge.Update(level0CompCount) + d.seekCompGauge.Update(stats.Compact.ReadCount) + d.liveCompGauge.Update(stats.Compact.NumInProgress) + d.liveCompSizeGauge.Update(stats.Compact.InProgressBytes) + d.liveIterGauge.Update(stats.TableIters) + + d.liveMemTablesGauge.Update(stats.MemTable.Count) + d.zombieMemTablesGauge.Update(stats.MemTable.ZombieCount) + d.estimatedCompDebtGauge.Update(int64(stats.Compact.EstimatedDebt)) + d.tableCacheHitGauge.Update(stats.TableCache.Hits) + d.tableCacheMissGauge.Update(stats.TableCache.Misses) + d.blockCacheHitGauge.Update(stats.BlockCache.Hits) + d.blockCacheMissGauge.Update(stats.BlockCache.Misses) + d.filterHitGauge.Update(stats.Filter.Hits) + d.filterMissGauge.Update(stats.Filter.Misses) + + for i, level := range stats.Levels { + // Append metrics for additional layers + if i >= len(d.levelsGauge) { + d.levelsGauge = append(d.levelsGauge, metrics.GetOrRegisterGauge(namespace+fmt.Sprintf("tables/level%v", i), nil)) + } + d.levelsGauge[i].Update(level.NumFiles) + } + + // Sleep a bit, then repeat the stats collection + select { + case errc = <-d.quitChan: + // Quit requesting, stop hammering the database + case <-timer.C: + timer.Reset(refresh) + // Timeout, gather a new set of stats + } + } + errc <- nil +} + +// v1batch is a write-only batch that commits changes to its host database +// when Write is called. A v1batch cannot be used concurrently. +type v1batch struct { + b *pebble.Batch + db *V1Database + size int +} + +// Put inserts the given value into the batch for later committing. +func (b *v1batch) Put(key, value []byte) error { + if err := b.b.Set(key, value, nil); err != nil { + return err + } + b.size += len(key) + len(value) + return nil +} + +// Delete inserts the key removal into the batch for later committing. +func (b *v1batch) Delete(key []byte) error { + if err := b.b.Delete(key, nil); err != nil { + return err + } + b.size += len(key) + return nil +} + +// DeleteRange removes all keys in the range [start, end) from the batch for +// later committing, inclusive on start, exclusive on end. +func (b *v1batch) DeleteRange(start, end []byte) error { + // There is no special flag to represent the end of key range + // in pebble(nil in leveldb). Use an ugly hack to construct a + // large key to represent it. + if end == nil { + end = ethdb.MaximumKey + } + if err := b.b.DeleteRange(start, end, nil); err != nil { + return err + } + // Approximate size impact - just the keys + b.size += len(start) + len(end) + return nil +} + +// ValueSize retrieves the amount of data queued up for writing. +func (b *v1batch) ValueSize() int { + return b.size +} + +// Write flushes any accumulated data to disk. +func (b *v1batch) Write() error { + b.db.quitLock.RLock() + defer b.db.quitLock.RUnlock() + if b.db.closed { + return pebble.ErrClosed + } + return b.b.Commit(b.db.writeOptions) +} + +// Reset resets the batch for reuse. +func (b *v1batch) Reset() { + b.b.Reset() + b.size = 0 +} + +// Replay replays the batch contents. +func (b *v1batch) Replay(w ethdb.KeyValueWriter) error { + reader := b.b.Reader() + for { + kind, k, v, ok, err := reader.Next() + if !ok || err != nil { + return err + } + // The (k,v) slices might be overwritten if the batch is reset/reused, + // and the receiver should copy them if they are to be retained long-term. + if kind == pebble.InternalKeyKindSet { + if err = w.Put(k, v); err != nil { + return err + } + } else if kind == pebble.InternalKeyKindDelete { + if err = w.Delete(k); err != nil { + return err + } + } else if kind == pebble.InternalKeyKindRangeDelete { + // For range deletion, k is the start key and v is the end key + if rangeDeleter, ok := w.(ethdb.KeyValueRangeDeleter); ok { + if err = rangeDeleter.DeleteRange(k, v); err != nil { + return err + } + } else { + return errors.New("ethdb.KeyValueWriter does not implement DeleteRange") + } + } else { + return fmt.Errorf("unhandled operation, keytype: %v", kind) + } + } +} + +// Close closes the batch and releases all associated resources. After it is +// closed, any subsequent operations on this batch are undefined. +func (b *v1batch) Close() { + b.b.Close() +} + +// v1pebbleIterator is a wrapper of underlying iterator in storage engine. +// The purpose of this structure is to implement the missing APIs. +// +// The v1pebbleIterator is not thread-safe. +type v1pebbleIterator struct { + iter *pebble.Iterator + moved bool + released bool +} + +// Next moves the iterator to the next key/value pair. It returns whether the +// iterator is exhausted. +func (iter *v1pebbleIterator) Next() bool { + if iter.moved { + iter.moved = false + return iter.iter.Valid() + } + return iter.iter.Next() +} + +// Error returns any accumulated error. Exhausting all the key/value pairs +// is not considered to be an error. +func (iter *v1pebbleIterator) Error() error { + return iter.iter.Error() +} + +// Key returns the key of the current key/value pair, or nil if done. The caller +// should not modify the contents of the returned slice, and its contents may +// change on the next call to Next. +func (iter *v1pebbleIterator) Key() []byte { + return iter.iter.Key() +} + +// Value returns the value of the current key/value pair, or nil if done. The +// caller should not modify the contents of the returned slice, and its contents +// may change on the next call to Next. +func (iter *v1pebbleIterator) Value() []byte { + return iter.iter.Value() +} + +// Release releases associated resources. Release should always succeed and can +// be called multiple times without causing error. +func (iter *v1pebbleIterator) Release() { + if !iter.released { + iter.iter.Close() + iter.released = true + } +} diff --git a/ethdb/pebble/version.go b/ethdb/pebble/version.go new file mode 100644 index 0000000000..64a8d3e153 --- /dev/null +++ b/ethdb/pebble/version.go @@ -0,0 +1,146 @@ +// Copyright 2025 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 pebble + +import ( + "fmt" + "runtime" + + pebblev1 "github.com/cockroachdb/pebble" + v1bloom "github.com/cockroachdb/pebble/bloom" + pebblev2 "github.com/cockroachdb/pebble/v2" + v2vfs "github.com/cockroachdb/pebble/v2/vfs" + v1vfs "github.com/cockroachdb/pebble/vfs" + "github.com/ethereum/go-ethereum/log" +) + +// formatMinV2 is the minimum FormatMajorVersion supported by pebble v2. +// Databases with a lower format version must be opened with pebble v1. +const formatMinV2 = pebblev2.FormatFlushableIngest + +// PeekFormatVersion reads the format version of an existing pebble database +// without opening it. +func PeekFormatVersion(file string) (bool, uint64, error) { + desc, err := pebblev2.Peek(file, v2vfs.Default) + if err == nil && desc.Exists { + return true, uint64(desc.FormatMajorVersion), nil + } + // Pebble v2 dropped support for the legacy FormatMostCompatible layout, + // which relies on the CURRENT file rather than a manifest marker. + // + // Databases created by older Geth (which never set FormatMajorVersion + // and therefore default to FormatMostCompatible) are not recognized by + // v2's Peek: it reports Exists=false with a nil error instead of failing. + // It may also fail outright on some old databases. In both cases fall + // back to v1's Peek, which still understands the CURRENT-file layout. + desc1, err1 := pebblev1.Peek(file, v1vfs.Default) + if err1 != nil { + // Surface the v2 error if there was one, otherwise the v1 error. + // Such as the folder is not existent, fs.ErrNotExist. + if err != nil { + return false, 0, err + } + return false, 0, err1 + } + if !desc1.Exists { + // Neither version found a database; treat as a new/empty directory. + return false, 0, nil + } + return true, uint64(desc1.FormatMajorVersion), nil +} + +// NeedsV1 returns true if the database at the given path requires pebble v1 +// to open (format version too old for pebble v2). +func NeedsV1(file string) bool { + exists, ver, err := PeekFormatVersion(file) + if err != nil || !exists { + return false // New database or error; use v2 + } + return pebblev2.FormatMajorVersion(ver) < formatMinV2 +} + +// Upgrade upgrades an existing pebble v1 database to be compatible with pebble v2. +// It opens the database with pebble v1 at its current format version, then uses +// RatchetFormatMajorVersion to migrate to FormatFlushableIngest (the minimum format +// version that pebble v2 supports). +// +// Notably, it's not an irreversible upgrade, the database can still be opened with +// legacy Geth binary. +func Upgrade(file string) error { + exists, ver, err := PeekFormatVersion(file) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("pebble database not found at %s", file) + } + if pebblev2.FormatMajorVersion(ver) >= formatMinV2 { + log.Info("Database format already compatible with pebble v2", "version", ver) + return nil + } + // FormatFlushableIngest exists in both pebble v1 and pebble v2 and it serves + // as the natural bridge point: a v1 database can be ratcheted up to + // FormatFlushableIngest using pebble v1, and then pebble v2 can open it since + // that's its minimum supported format. + v1Target := pebblev1.FormatFlushableIngest + log.Info("Upgrading pebble database format via v1", "from", ver, "to", v1Target) + + numCPU := runtime.NumCPU() + opt := &pebblev1.Options{ + // Open at the current on-disk format version; do not request a + // higher version here so that the upgrade happens explicitly via + // RatchetFormatMajorVersion below. + MaxConcurrentCompactions: func() int { return numCPU }, + Levels: []pebblev1.LevelOptions{ + {TargetFileSize: 2 * 1024 * 1024, FilterPolicy: v1bloom.FilterPolicy(10)}, + {TargetFileSize: 4 * 1024 * 1024, FilterPolicy: v1bloom.FilterPolicy(10)}, + {TargetFileSize: 8 * 1024 * 1024, FilterPolicy: v1bloom.FilterPolicy(10)}, + {TargetFileSize: 16 * 1024 * 1024, FilterPolicy: v1bloom.FilterPolicy(10)}, + {TargetFileSize: 32 * 1024 * 1024, FilterPolicy: v1bloom.FilterPolicy(10)}, + {TargetFileSize: 64 * 1024 * 1024, FilterPolicy: v1bloom.FilterPolicy(10)}, + {TargetFileSize: 128 * 1024 * 1024}, + }, + Logger: panicLogger{}, + } + db, err := pebblev1.Open(file, opt) + if err != nil { + return fmt.Errorf("failed to open database with pebble v1 for upgrade: %w", err) + } + if err := db.RatchetFormatMajorVersion(v1Target); err != nil { + db.Close() + return fmt.Errorf("failed to ratchet format version to %d: %w", v1Target, err) + } + if err := db.Close(); err != nil { + return fmt.Errorf("failed to close database after v1 upgrade: %w", err) + } + log.Info("Pebble v1 format upgrade complete, verifying v2 compatibility") + + // Verify that pebble v2 can open the upgraded database. + opt2 := &pebblev2.Options{ + Logger: panicLogger{}, + FormatMajorVersion: formatMinV2, + } + db2, err := pebblev2.Open(file, opt2) + if err != nil { + return fmt.Errorf("failed to open database with pebble v2 after upgrade: %w", err) + } + if err := db2.Close(); err != nil { + return fmt.Errorf("failed to close database after v2 verification: %w", err) + } + log.Info("Pebble database format upgrade complete") + return nil +} diff --git a/ethdb/pebble/version_test.go b/ethdb/pebble/version_test.go new file mode 100644 index 0000000000..58d74cafaf --- /dev/null +++ b/ethdb/pebble/version_test.go @@ -0,0 +1,135 @@ +// Copyright 2025 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 pebble + +import ( + "testing" + + pebblev1 "github.com/cockroachdb/pebble" + pebblev2 "github.com/cockroachdb/pebble/v2" + v2vfs "github.com/cockroachdb/pebble/v2/vfs" + v1vfs "github.com/cockroachdb/pebble/vfs" +) + +// TestPeekFormatVersionLegacyV1 verifies that PeekFormatVersion correctly +// detects a legacy pebble v1 database written in the FormatMostCompatible +// layout. Older Geth never set FormatMajorVersion, so pebble v1 defaulted to +// FormatMostCompatible, which uses the CURRENT file rather than a manifest +// marker. Pebble v2's Peek does not understand this layout and reports +// Exists=false with a nil error, so PeekFormatVersion must fall back to v1. +func TestPeekFormatVersionLegacyV1(t *testing.T) { + dir := t.TempDir() + + // Create a v1 database with default options (no FormatMajorVersion set), + // which yields FormatMostCompatible, exactly as legacy Geth would. + db, err := pebblev1.Open(dir, &pebblev1.Options{}) + if err != nil { + t.Fatalf("failed to create v1 database: %v", err) + } + if got := db.FormatMajorVersion(); got != pebblev1.FormatMostCompatible { + db.Close() + t.Fatalf("unexpected on-disk format version: have %d, want %d", got, pebblev1.FormatMostCompatible) + } + if err := db.Set([]byte("foo"), []byte("bar"), pebblev1.Sync); err != nil { + db.Close() + t.Fatalf("failed to write to v1 database: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("failed to close v1 database: %v", err) + } + + // Document the underlying pebble v2 behavior that motivates the v1 + // fallback: v2's Peek silently fails to recognize this database. + if desc, err := pebblev2.Peek(dir, v2vfs.Default); err == nil && desc.Exists { + t.Fatal("expected pebble v2 Peek to not recognize a FormatMostCompatible database") + } + + exists, ver, err := PeekFormatVersion(dir) + if err != nil { + t.Fatalf("PeekFormatVersion returned error: %v", err) + } + if !exists { + t.Fatal("expected legacy v1 database to be detected, got exists=false") + } + if ver != uint64(pebblev1.FormatMostCompatible) { + t.Fatalf("unexpected format version: have %d, want %d", ver, pebblev1.FormatMostCompatible) + } + // The database is too old for pebble v2, so it must be routed through v1. + if !NeedsV1(dir) { + t.Fatal("expected NeedsV1 to be true for a FormatMostCompatible database") + } +} + +// TestPeekFormatVersionV2 verifies that PeekFormatVersion detects a database +// written at a pebble v2 compatible format version directly via v2's Peek. +func TestPeekFormatVersionV2(t *testing.T) { + dir := t.TempDir() + + db, err := pebblev2.Open(dir, &pebblev2.Options{ + FormatMajorVersion: formatMinV2, + }) + if err != nil { + t.Fatalf("failed to create v2 database: %v", err) + } + if err := db.Set([]byte("foo"), []byte("bar"), pebblev2.Sync); err != nil { + db.Close() + t.Fatalf("failed to write to v2 database: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("failed to close v2 database: %v", err) + } + + exists, ver, err := PeekFormatVersion(dir) + if err != nil { + t.Fatalf("PeekFormatVersion returned error: %v", err) + } + if !exists { + t.Fatal("expected v2 database to be detected, got exists=false") + } + if ver != uint64(formatMinV2) { + t.Fatalf("unexpected format version: have %d, want %d", ver, formatMinV2) + } + if NeedsV1(dir) { + t.Fatal("expected NeedsV1 to be false for a v2 database") + } +} + +// TestPeekFormatVersionEmpty verifies that an empty directory (a new database +// location) is reported as non-existent by both v2 and v1 Peek, rather than +// being misreported. +func TestPeekFormatVersionEmpty(t *testing.T) { + dir := t.TempDir() + + // Sanity check that v1's Peek also reports a non-existent database here, + // so the test exercises the "neither version found a database" branch. + if desc, err := pebblev1.Peek(dir, v1vfs.Default); err != nil { + t.Fatalf("v1 Peek on empty directory returned error: %v", err) + } else if desc.Exists { + t.Fatal("expected v1 Peek to report no database in an empty directory") + } + + exists, _, err := PeekFormatVersion(dir) + if err != nil { + t.Fatalf("PeekFormatVersion returned error: %v", err) + } + if exists { + t.Fatal("expected no database in an empty directory, got exists=true") + } + if NeedsV1(dir) { + t.Fatal("expected NeedsV1 to be false for an empty directory") + } +} diff --git a/go.mod b/go.mod index cc3f7e7eb1..a81ad08926 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/cespare/cp v0.1.0 github.com/cloudflare/cloudflare-go v0.114.0 github.com/cockroachdb/pebble v1.1.5 + github.com/cockroachdb/pebble/v2 v2.1.4 github.com/consensys/gnark-crypto v0.18.1 github.com/crate-crypto/go-eth-kzg v1.5.0 github.com/davecgh/go-spew v1.1.1 @@ -45,7 +46,7 @@ require ( github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c github.com/jackpal/go-nat-pmp v1.0.2 github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 - github.com/klauspost/compress v1.17.8 + github.com/klauspost/compress v1.17.11 github.com/kylelemons/godebug v1.1.0 github.com/mattn/go-colorable v0.1.13 github.com/mattn/go-isatty v0.0.20 @@ -83,10 +84,15 @@ require ( ) require ( + github.com/RaduBerinde/axisds v0.1.0 // indirect + github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b // indirect + github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 // indirect github.com/pion/dtls/v3 v3.1.2 // indirect github.com/pion/transport/v4 v4.0.1 // indirect github.com/wlynxg/anet v0.0.5 // indirect @@ -101,7 +107,7 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect - github.com/DataDog/zstd v1.4.5 // indirect + github.com/DataDog/zstd v1.5.7 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 github.com/StackExchange/wmi v1.2.1 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.13 // indirect @@ -153,10 +159,10 @@ require ( github.com/pion/logging v0.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.15.0 // indirect + github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.42.0 // indirect - github.com/prometheus/procfs v0.9.0 // indirect + github.com/prometheus/procfs v0.10.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index 1bc679a9f6..2150365e17 100644 --- a/go.sum +++ b/go.sum @@ -10,16 +10,22 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0 h1:gggzg0SUMs6SQbEw+ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0/go.mod h1:+6KLcKIVgxoBDMqMO/Nvy7bZ9a0nbU3I1DtFQK3YvB4= github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 h1:OBhqkivkhkMqLPymWEppkm7vgPQY2XsHoEkaMQ0AdZY= github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= -github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= -github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/RaduBerinde/axisds v0.1.0 h1:YItk/RmU5nvlsv/awo2Fjx97Mfpt4JfgtEVAGPrLdz8= +github.com/RaduBerinde/axisds v0.1.0/go.mod h1:UHGJonU9z4YYGKJxSaC6/TNcLOBptpmM5m2Cksbnw0Y= +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 h1:bsU8Tzxr/PNz75ayvCnxKZWEYdLMPDkUgticP4a4Bvk= +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54/go.mod h1:0tr7FllbE9gJkHq7CVeeDDFAFKQVy5RnCSSNBOvdqbc= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/aclements/go-perfevent v0.0.0-20240301234650-f7843625020f h1:JjxwchlOepwsUWcQwD2mLUAGE9aCp0/ehy6yCHFBOvo= +github.com/aclements/go-perfevent v0.0.0-20240301234650-f7843625020f/go.mod h1:tMDTce/yLLN/SK8gMOxQfnyeMeCg8KGzp0D1cbECEeo= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/aws/aws-sdk-go-v2 v1.21.2 h1:+LXZ0sgo8quN9UOKXXzAWRT3FWd4NxeXWOZom9pE7GA= @@ -63,18 +69,26 @@ github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86c github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudflare/cloudflare-go v0.114.0 h1:ucoti4/7Exo0XQ+rzpn1H+IfVVe++zgiM+tyKtf0HUA= github.com/cloudflare/cloudflare-go v0.114.0/go.mod h1:O7fYfFfA6wKqKFn2QIR9lhj7FDw6VQCGOY6hd2TBtd0= -github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= -github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b h1:SHlYZ/bMx7frnmeqCu+xm0TCxXLzX3jQIVuFbnFGtFU= +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b/go.mod h1:Gq51ZeKaFCXk6QwuGM0w1dnaOqc/F5zKT2zA9D6Xeac= +github.com/cockroachdb/datadriven v1.0.3-0.20250407164829-2945557346d5 h1:UycK/E0TkisVrQbSoxvU827FwgBBcZ95nRRmpj/12QI= +github.com/cockroachdb/datadriven v1.0.3-0.20250407164829-2945557346d5/go.mod h1:jsaKMvD3RBCATk1/jbUZM8C9idWBJME9+VRZ5+Liq1g= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/metamorphic v0.0.0-20231108215700-4ba948b56895 h1:XANOgPYtvELQ/h4IrmPAohXqe2pWA8Bwhejr3VQoZsA= +github.com/cockroachdb/metamorphic v0.0.0-20231108215700-4ba948b56895/go.mod h1:aPd7gM9ov9M8v32Yy5NJrDyOcD8z642dqs+F0CeNXfA= github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/pebble/v2 v2.1.4 h1:j9wPgMDbkErFdAKYFGhsoCcvzcjR+6zrJ4jhKtJ6bOk= +github.com/cockroachdb/pebble/v2 v2.1.4/go.mod h1:Reo1RTniv1UjVTAu/Fv74y5i3kJ5gmVrPhO9UtFiKn8= github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b h1:VXvSNzmr8hMj8XTuY0PT9Ane9qZGul/p67vGYwl9BFI= +github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= @@ -138,6 +152,8 @@ github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x github.com/getkin/kin-openapi v0.53.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9 h1:r5GgOLGbza2wVHRzK7aAj6lWZjfbAwiu/RDCVOKjRyM= +github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -233,8 +249,8 @@ github.com/kilic/bls12-381 v0.1.0 h1:encrdjqKMEvabVQ7qYOKu1OvhqpK4s47wDYtNiPtlp4 github.com/kilic/bls12-381 v0.1.0/go.mod h1:vDTTHJONJ6G+P2R74EhnyotQDTliQDnFEwhdmfzw1ig= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= -github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -272,6 +288,8 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 h1:0lgqHvJWHLGW5TuObJrfyEi6+ASTKDBWikGvPqy9Yiw= +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= @@ -315,14 +333,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= -github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= -github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= -github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/protolambda/bls12-381-util v0.1.0 h1:05DU2wJN7DTU7z28+Q+zejXkIsA/MF8JZQGhtBZZiWk= github.com/protolambda/bls12-381-util v0.1.0/go.mod h1:cdkysJTRpeFeuUVx/TXGDQNMTiRAalk1vQw3TYTHcE4= github.com/protolambda/zrnt v0.34.1 h1:qW55rnhZJDnOb3TwFiFRJZi3yTXFrJdGOFQM7vCwYGg= diff --git a/node/database.go b/node/database.go index 274ccbfa7e..9fbd7140f0 100644 --- a/node/database.go +++ b/node/database.go @@ -114,7 +114,19 @@ func newLevelDBDatabase(file string, cache int, handles int, namespace string, r // newPebbleDBDatabase creates a persistent key-value database without a freezer // moving immutable chain segments into cold storage. +// +// If the database already exists with a legacy pebble v1 format, it is opened +// using pebble v1 for backward compatibility and a warning is logged directing +// the user to upgrade offline. New databases use pebble v2. func newPebbleDBDatabase(file string, cache int, handles int, namespace string, readonly bool) (ethdb.KeyValueStore, error) { + if pebble.NeedsV1(file) { + log.Warn("Pebble database uses legacy v1 format; upgrade offline with 'geth db pebble-upgrade'") + db, err := pebble.NewV1(file, cache, handles, namespace, readonly) + if err != nil { + return nil, err + } + return db, nil + } db, err := pebble.New(file, cache, handles, namespace, readonly) if err != nil { return nil, err From 023a57373255d356c9ad3aeae0a3c07b822931b2 Mon Sep 17 00:00:00 2001 From: jvn Date: Mon, 22 Jun 2026 20:30:43 +0530 Subject: [PATCH 2/9] core/rawdb/eradb: support ere files in era store (#34978) When ancient history is pruned, geth serves old block bodies and receipts back from era files on disk. Until now that fallback only worked for .era1 files (pre-merge), so requests for post-merge blocks backed by .ere files failed even though the data was present. This PR generalizes the era store to open both formats. --------- Co-authored-by: Sina Mahmoodi --- core/rawdb/eradb/eradb.go | 135 ++++++++++++------ core/rawdb/eradb/eradb_test.go | 100 +++++++++++++ .../sepolia-00000-deadbeef-noreceipts.ere | Bin 0 -> 222 bytes go.mod | 2 +- internal/era/execdb/iterator.go | 2 +- internal/era/execdb/reader.go | 8 +- 6 files changed, 198 insertions(+), 49 deletions(-) create mode 100644 core/rawdb/eradb/testdata/noreceipts/sepolia-00000-deadbeef-noreceipts.ere diff --git a/core/rawdb/eradb/eradb.go b/core/rawdb/eradb/eradb.go index d715c824ed..390715d46f 100644 --- a/core/rawdb/eradb/eradb.go +++ b/core/rawdb/eradb/eradb.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Lesser General Public License // along with the go-ethereum library. If not, see . -// Package eradb implements a history backend using era1 files. +// Package eradb implements a history backend using era1 or ere files. package eradb import ( @@ -27,6 +27,7 @@ import ( "github.com/ethereum/go-ethereum/common/lru" "github.com/ethereum/go-ethereum/internal/era" + "github.com/ethereum/go-ethereum/internal/era/execdb" "github.com/ethereum/go-ethereum/internal/era/onedb" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/rlp" @@ -36,7 +37,7 @@ const openFileLimit = 64 var errClosed = errors.New("era store is closed") -// Store manages read access to a directory of era1 files. +// Store manages read access to a directory of era1 or ere files. // The getter methods are thread-safe. type Store struct { datadir string @@ -52,7 +53,8 @@ type Store struct { type fileCacheEntry struct { refcount int // reference count. This is protected by Store.mu! opened chan struct{} // signals opening of file has completed - file *onedb.Era // the file + file era.Era // the file + slim bool // true if receipts are stored in the ere slim encoding err error // error from opening the file } @@ -77,7 +79,7 @@ func New(datadir string) (*Store, error) { return db, nil } -// Close closes all open era1 files in the cache. +// Close closes all open era files in the cache. func (db *Store) Close() { db.mu.Lock() defer db.mu.Unlock() @@ -132,12 +134,14 @@ func (db *Store) GetRawReceipts(number uint64) ([]byte, error) { if err != nil { return nil, err } - return convertReceipts(data) + return convertReceipts(data, entry.slim) } -// convertReceipts transforms an encoded block receipts list from the format -// used by era1 into the 'storage' format used by the go-ethereum ancients database. -func convertReceipts(input []byte) ([]byte, error) { +// convertReceipts transforms an encoded block receipts list into the 'storage' +// format used by the go-ethereum ancients database, i.e. a list of +// [status, gas-used, logs]. The input uses the era1 network encoding, or the +// ere slim encoding when slim is true. +func convertReceipts(input []byte, slim bool) ([]byte, error) { var ( out bytes.Buffer enc = rlp.NewEncoderBuffer(&out) @@ -148,32 +152,41 @@ func convertReceipts(input []byte) ([]byte, error) { } outerList := enc.List() for i := 0; blockListIter.Next(); i++ { - kind, content, _, err := rlp.Split(blockListIter.Value()) - if err != nil { - return nil, fmt.Errorf("receipt %d invalid: %v", i, err) - } - var receiptData []byte - switch kind { - case rlp.Byte: - return nil, fmt.Errorf("receipt %d is single byte", i) - case rlp.String: - // Typed receipt - skip type. - receiptData = content[1:] - case rlp.List: - // Legacy receipt + var ( + receiptData []byte + skip int + ) + if slim { + // Slim receipt is [tx-type, status, gas-used, logs]: skip the tx-type. receiptData = blockListIter.Value() + skip = 0 + } else { + // Era1 receipt is [status, gas-used, bloom, logs], prefixed by the + // tx type if non-legacy: skip the bloom. + kind, content, _, err := rlp.Split(blockListIter.Value()) + if err != nil { + return nil, fmt.Errorf("receipt %d invalid: %v", i, err) + } + switch kind { + case rlp.Byte: + return nil, fmt.Errorf("receipt %d is single byte", i) + case rlp.String: + // Typed receipt - skip type. + receiptData = content[1:] + case rlp.List: + // Legacy receipt + receiptData = blockListIter.Value() + } + skip = 2 } - // Convert data list. - // Input is [status, gas-used, bloom, logs] - // Output is [status, gas-used, logs], i.e. we need to skip the bloom. dataIter, err := rlp.NewListIterator(receiptData) if err != nil { return nil, fmt.Errorf("receipt %d has invalid data: %v", i, err) } innerList := enc.List() for field := 0; dataIter.Next(); field++ { - if field == 2 { - continue // skip bloom + if field == skip { + continue } enc.Write(dataIter.Value()) } @@ -202,11 +215,11 @@ func (db *Store) getEraByEpoch(epoch uint64) *fileCacheEntry { case fileIsNew: // Open the file and put it into the cache. - e, err := db.openEraFile(epoch) + e, slim, err := db.openEraFile(epoch) if err != nil { db.fileFailedToOpen(epoch, entry, err) } else { - db.fileOpened(epoch, entry, e) + db.fileOpened(epoch, entry, e, slim) } close(entry.opened) @@ -250,7 +263,7 @@ func (db *Store) getCacheEntry(epoch uint64) (stat fileCacheStatus, entry *fileC } // fileOpened is called after an era file has been successfully opened. -func (db *Store) fileOpened(epoch uint64, entry *fileCacheEntry, file *onedb.Era) { +func (db *Store) fileOpened(epoch uint64, entry *fileCacheEntry, file era.Era, slim bool) { db.mu.Lock() defer db.mu.Unlock() @@ -267,6 +280,7 @@ func (db *Store) fileOpened(epoch uint64, entry *fileCacheEntry, file *onedb.Era // Add it to the LRU. This may evict an existing item, which we have to close. entry.file = file + entry.slim = slim evictedEpoch, evictedEntry, _ := db.lru.Add3(epoch, entry) if evictedEntry != nil { evictedEntry.derefAndClose(evictedEpoch) @@ -283,32 +297,61 @@ func (db *Store) fileFailedToOpen(epoch uint64, entry *fileCacheEntry, err error entry.err = err } -func (db *Store) openEraFile(epoch uint64) (*onedb.Era, error) { - // File name scheme is --. - glob := fmt.Sprintf("*-%05d-*.era1", epoch) - matches, err := filepath.Glob(filepath.Join(db.datadir, glob)) - if err != nil { - return nil, err +// openEraFile opens the era file of the given epoch. The second return value +// signals whether the receipts in the file use the ere slim encoding. +func (db *Store) openEraFile(epoch uint64) (era.Era, bool, error) { + // File name scheme is -- for era1 files and + // --(-)* for ere files. + var matches []string + for _, glob := range []string{ + fmt.Sprintf("*-%05d-*.era1", epoch), + fmt.Sprintf("*-%05d-*.ere", epoch), + } { + m, err := filepath.Glob(filepath.Join(db.datadir, glob)) + if err != nil { + return nil, false, err + } + matches = append(matches, m...) } if len(matches) > 1 { - return nil, fmt.Errorf("multiple era1 files found for epoch %d", epoch) + return nil, false, fmt.Errorf("multiple era files found for epoch %d: %v", epoch, matches) } if len(matches) == 0 { - return nil, fs.ErrNotExist + return nil, false, fs.ErrNotExist } filename := matches[0] - e, err := onedb.Open(filename) - if err != nil { - return nil, err + var ( + e era.Era + err error + slim = filepath.Ext(filename) == ".ere" + ) + if slim { + var f *execdb.Era + f, err = execdb.Open(filename) + if err != nil { + return nil, false, err + } + // Files written with the "noreceipts" profile cannot serve as a + // history backend, since the receipts cannot be retrieved. + if !f.HasReceipts() { + f.Close() + return nil, false, fmt.Errorf("ere file %s contains no receipts", filepath.Base(filename)) + } + e = f + } else { + e, err = onedb.Open(filename) + if err != nil { + return nil, false, err + } } // Sanity-check start block. - if e.Start()%uint64(era.MaxSize) != 0 { + if e.Start() != epoch*uint64(era.MaxSize) { e.Close() - return nil, fmt.Errorf("pre-merge era1 file has invalid boundary. %d %% %d != 0", e.Start(), era.MaxSize) + return nil, false, fmt.Errorf("era file %s has wrong start block %d for epoch %d", filepath.Base(filename), e.Start(), epoch) } - log.Debug("Opened era1 file", "epoch", epoch) - return e.(*onedb.Era), nil + log.Debug("Opened era file", "epoch", epoch, "name", filepath.Base(filename)) + return e, slim, nil } // doneWithFile signals that the caller has finished using a file. @@ -339,9 +382,9 @@ func (entry *fileCacheEntry) derefAndClose(epoch uint64) (closed bool) { closeErr := entry.file.Close() if closeErr == nil { - log.Debug("Closed era1 file", "epoch", epoch) + log.Debug("Closed era file", "epoch", epoch) } else { - log.Warn("Error closing era1 file", "epoch", epoch, "err", closeErr) + log.Warn("Error closing era file", "epoch", epoch, "err", closeErr) } return true } diff --git a/core/rawdb/eradb/eradb_test.go b/core/rawdb/eradb/eradb_test.go index 41047dbbe9..5c3c97204f 100644 --- a/core/rawdb/eradb/eradb_test.go +++ b/core/rawdb/eradb/eradb_test.go @@ -17,10 +17,15 @@ package eradb import ( + "math/big" + "os" + "path/filepath" "sync" "testing" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/internal/era/execdb" + "github.com/ethereum/go-ethereum/internal/era/onedb" "github.com/ethereum/go-ethereum/rlp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -48,6 +53,101 @@ func TestEraDatabase(t *testing.T) { assert.Equal(t, 3, len(receipts), "receipts length mismatch") } +// TestEreDatabase checks that the store can serve bodies and receipts from a +// directory of ere files, and that the receipts returned are byte-identical to +// the ones derived from the equivalent era1 files. +func TestEreDatabase(t *testing.T) { + dir := t.TempDir() + convertEra1ToEre(t, "testdata/sepolia-00000-643a00f7.era1", dir, "sepolia", 0) + convertEra1ToEre(t, "testdata/sepolia-00021-b8814b14.era1", dir, "sepolia", 21) + + db, err := New(dir) + require.NoError(t, err) + defer db.Close() + + r, err := db.GetRawBody(175881) + require.NoError(t, err) + var body *types.Body + err = rlp.DecodeBytes(r, &body) + require.NoError(t, err) + require.NotNil(t, body, "block body not found") + assert.Equal(t, 3, len(body.Transactions)) + + r, err = db.GetRawReceipts(175881) + require.NoError(t, err) + var receipts []*types.ReceiptForStorage + err = rlp.DecodeBytes(r, &receipts) + require.NoError(t, err) + require.NotNil(t, receipts, "receipts not found") + assert.Equal(t, 3, len(receipts), "receipts length mismatch") + + // Cross-check against the era1 store: both backends must return the same + // storage encoding. + eraDB, err := New("testdata") + require.NoError(t, err) + defer eraDB.Close() + for _, num := range []uint64{0, 1024, 172032, 175881, 180223} { + want, err := eraDB.GetRawReceipts(num) + require.NoError(t, err) + got, err := db.GetRawReceipts(num) + require.NoError(t, err) + assert.Equal(t, want, got, "receipts mismatch at block %d", num) + + wantBody, err := eraDB.GetRawBody(num) + require.NoError(t, err) + gotBody, err := db.GetRawBody(num) + require.NoError(t, err) + assert.Equal(t, wantBody, gotBody, "body mismatch at block %d", num) + } +} + +// TestEreDatabaseNoReceipts checks that ere files written with the +// "noreceipts" profile are rejected by the store. The testdata fixture is a +// minimal single-block ere file whose index has no receipts component. +func TestEreDatabaseNoReceipts(t *testing.T) { + db, err := New("testdata/noreceipts") + require.NoError(t, err) + defer db.Close() + + _, err = db.GetRawBody(0) + require.ErrorContains(t, err, "no receipts") + _, err = db.GetRawReceipts(0) + require.ErrorContains(t, err, "no receipts") +} + +// convertEra1ToEre reads an era1 file and writes its contents as an ere file +// into dir, using the canonical ere file name. +func convertEra1ToEre(t *testing.T, era1Path, dir, network string, epoch int) { + t.Helper() + + e, err := onedb.Open(era1Path) + require.NoError(t, err) + defer e.Close() + + f, err := os.CreateTemp(dir, "ere-convert-*") + require.NoError(t, err) + defer f.Close() + + builder := execdb.NewBuilder(f) + td, err := e.InitialTD() + require.NoError(t, err) + + it, err := e.Iterator() + require.NoError(t, err) + for it.Next() { + block, receipts, err := it.BlockAndReceipts() + require.NoError(t, err) + td.Add(td, block.Difficulty()) + require.NoError(t, builder.Add(block, receipts, new(big.Int).Set(td))) + } + require.NoError(t, it.Error()) + + lastHash, err := builder.Finalize() + require.NoError(t, err) + require.NoError(t, f.Close()) + require.NoError(t, os.Rename(f.Name(), filepath.Join(dir, execdb.Filename(network, epoch, lastHash)))) +} + func TestEraDatabaseConcurrentOpen(t *testing.T) { db, err := New("testdata") require.NoError(t, err) diff --git a/core/rawdb/eradb/testdata/noreceipts/sepolia-00000-deadbeef-noreceipts.ere b/core/rawdb/eradb/testdata/noreceipts/sepolia-00000-deadbeef-noreceipts.ere new file mode 100644 index 0000000000000000000000000000000000000000..ab791d1e00858578a63c1f61d3fcc9a27e858cfa GIT binary patch literal 222 zcmYc_VgLhXhCnd=pN)Z`*e@}lAdA0rfPVq{=pY+z(;@@7=%fEdfbz``JkW-uc=1H%s^ojZpP9AINm f0828$RO6u2jSNs^lKw-$ZYX^NN;5-M!OQ~y*qbyH literal 0 HcmV?d00001 diff --git a/go.mod b/go.mod index a81ad08926..50b5519762 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,6 @@ require ( github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c github.com/jackpal/go-nat-pmp v1.0.2 github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 - github.com/klauspost/compress v1.17.11 github.com/kylelemons/godebug v1.1.0 github.com/mattn/go-colorable v0.1.13 github.com/mattn/go-isatty v0.0.20 @@ -92,6 +91,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 // indirect github.com/pion/dtls/v3 v3.1.2 // indirect github.com/pion/transport/v4 v4.0.1 // indirect diff --git a/internal/era/execdb/iterator.go b/internal/era/execdb/iterator.go index 6a972aef11..348bbae2b9 100644 --- a/internal/era/execdb/iterator.go +++ b/internal/era/execdb/iterator.go @@ -26,7 +26,7 @@ import ( "github.com/ethereum/go-ethereum/internal/era" "github.com/ethereum/go-ethereum/internal/era/e2store" "github.com/ethereum/go-ethereum/rlp" - "github.com/klauspost/compress/snappy" + "github.com/golang/snappy" ) type Iterator struct { diff --git a/internal/era/execdb/reader.go b/internal/era/execdb/reader.go index e9831f9655..e835918e1d 100644 --- a/internal/era/execdb/reader.go +++ b/internal/era/execdb/reader.go @@ -30,7 +30,7 @@ import ( "github.com/ethereum/go-ethereum/internal/era" "github.com/ethereum/go-ethereum/internal/era/e2store" "github.com/ethereum/go-ethereum/rlp" - "github.com/klauspost/compress/snappy" + "github.com/golang/snappy" ) // Era object represents an era file that contains blocks and their components. @@ -204,6 +204,12 @@ func (e *Era) HasComponent(c componentType) bool { return ok } +// HasReceipts reports whether the file contains a receipts component. Files +// written with the "noreceipts" profile omit it. +func (e *Era) HasReceipts() bool { + return e.HasComponent(receipts) +} + // InitialTD returns initial total difficulty before the difficulty of the // first block of the Era is applied. Returns an error if TD is not available // (e.g., post-merge epoch). From 73eeee65bf6dd1f3d81726e54c1ce62bcf256db1 Mon Sep 17 00:00:00 2001 From: Ng Wei Han <47109095+weiihann@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:26:17 +0700 Subject: [PATCH 3/9] trie/bintrie: use bitarray for path encoding + fix serialization issues (#34772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 1-byte-per-bit path encoding with bit-packed `BitArray`, reducing DB key size by 8x Benchmark (sparse single-leaf write, M3 Pro): ``` │ Before (1B/bit) │ After (BitArray) │ │ sec/op │ sec/op vs base │ CollectNodesSparseWrite-11 10.50µ ± 1% 9.78µ ± 1% -6.86% │ B/op │ B/op vs base │ CollectNodesSparseWrite-11 5.50Ki ± 0% 5.09Ki ± 0% -7.38% │ allocs/op │ allocs vs base │ CollectNodesSparseWrite-11 67 ± 0% 58 ± 0% -13.43% ``` --------- Co-authored-by: Guillaume Ballet <3272758+gballet@users.noreply.github.com> --- trie/bintrie/binary_node_test.go | 29 +- trie/bintrie/bitarray.go | 160 +++++++++++ trie/bintrie/bitarray_test.go | 205 +++++++++++++ trie/bintrie/format_test.go | 445 +++++++++++++++++++++++++++++ trie/bintrie/internal_node.go | 8 +- trie/bintrie/internal_node_test.go | 11 +- trie/bintrie/iterator.go | 8 +- trie/bintrie/node_store.go | 11 +- trie/bintrie/stem_node_test.go | 16 +- trie/bintrie/store_commit.go | 306 ++++++++++++-------- trie/bintrie/store_ops.go | 8 + trie/bintrie/trie.go | 33 ++- 12 files changed, 1080 insertions(+), 160 deletions(-) create mode 100644 trie/bintrie/bitarray.go create mode 100644 trie/bintrie/bitarray_test.go create mode 100644 trie/bintrie/format_test.go diff --git a/trie/bintrie/binary_node_test.go b/trie/bintrie/binary_node_test.go index 857060a0c0..d91a85a765 100644 --- a/trie/bintrie/binary_node_test.go +++ b/trie/bintrie/binary_node_test.go @@ -40,7 +40,10 @@ func TestSerializeDeserializeInternalNode(t *testing.T) { s.root = rootRef // Serialize the node — grouped format at groupDepth=1: - // [type(1)][groupDepth(1)][bitmap(1)][leftHash(32)][rightHash(32)] = 67 bytes + // [type(1)][groupDepth(1)][bitmap(1)][depths(1)][leftHash(32)][rightHash(32)] = 68 bytes. + // Both children are at depthOffset=groupDepth=1 (the bottom of the 1-level + // group). Each depth is stored as (offset-1)=0 in 3 bits, so the two entries + // pack into a single byte 0x00. serialized := s.serializeNode(rootRef, 1) if serialized[0] != nodeTypeInternal { @@ -50,7 +53,7 @@ func TestSerializeDeserializeInternalNode(t *testing.T) { t.Errorf("Expected groupDepth byte to be 1, got %d", serialized[1]) } - expectedLen := NodeTypeBytes + 1 + 1 + 2*HashSize // type + groupDepth + bitmap + 2 hashes = 67 + expectedLen := NodeTypeBytes + 1 + 1 + 1 + 2*HashSize // type + groupDepth + bitmap + packed depths + 2 hashes = 68 if len(serialized) != expectedLen { t.Errorf("Expected serialized length to be %d, got %d", expectedLen, len(serialized)) } @@ -60,7 +63,13 @@ func TestSerializeDeserializeInternalNode(t *testing.T) { t.Errorf("Expected bitmap byte 0xc0, got 0x%02x", serialized[2]) } - hashesStart := NodeTypeBytes + 1 + 1 + depthsStart := NodeTypeBytes + 1 + 1 + // Two depth offsets of 1 → stored as (1-1)=0 each → packed byte 0x00. + if serialized[depthsStart] != 0x00 { + t.Errorf("Expected packed depth byte 0x00, got 0x%02x", serialized[depthsStart]) + } + + hashesStart := depthsStart + 1 if !bytes.Equal(serialized[hashesStart:hashesStart+HashSize], leftHash[:]) { t.Error("Left hash not found at expected position") } @@ -244,29 +253,29 @@ func TestKeyToPath(t *testing.T) { { name: "depth 0", depth: 0, - key: []byte{0x80}, // 10000000 in binary - expected: []byte{1}, + key: []byte{0x80}, // 10000000 in binary + expected: []byte{0x80, 1}, // 1 bit "1", left-aligned, + length byte 1 wantErr: false, }, { name: "depth 7", depth: 7, - key: []byte{0xFF}, // 11111111 in binary - expected: []byte{1, 1, 1, 1, 1, 1, 1, 1}, + key: []byte{0xFF}, // 11111111 in binary + expected: []byte{0xFF, 8}, // 8-bit value 0xFF + length byte 8 wantErr: false, }, { name: "depth crossing byte boundary", depth: 10, - key: []byte{0xFF, 0x00}, // 11111111 00000000 in binary - expected: []byte{1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0}, + key: []byte{0xFF, 0x00}, // 11111111 00000000 in binary + expected: []byte{0xFF, 0x00, 11}, // top 11 bits "11111111 000", left-aligned, + length byte 11 wantErr: false, }, { name: "max valid depth", depth: StemSize*8 - 1, key: make([]byte, HashSize), - expected: make([]byte, StemSize*8), + expected: append(make([]byte, StemSize), StemSize*8), // 248 bits of zeros + length byte 248 wantErr: false, }, { diff --git a/trie/bintrie/bitarray.go b/trie/bintrie/bitarray.go new file mode 100644 index 0000000000..4da3e0c5b5 --- /dev/null +++ b/trie/bintrie/bitarray.go @@ -0,0 +1,160 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of go-ethereum. +// +// go-ethereum is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// go-ethereum 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with go-ethereum. If not, see . +package bintrie + +// BitArray represents a trie path: the most significant `len` bits of a key, +// packed big-endian and MSB-first. Bit i (0 = most significant) lives at +// bytes[i/8] in mask 1<<(7-i%8). All bits at positions >= len are kept zero so +// that two paths are byte-equal iff they are logically equal. +// +// This mirrors the on-disk key layout, so path manipulation is plain slicing +// and copying: no shifting or endianness conversion is required. The maximum +// length is 248 bits (a 31-byte trie stem), and is a uint8 so the spare bits in +// the final byte are always available. +type BitArray struct { + len uint8 + bytes [32]byte +} + +// NewBitArray creates a bit array of the given length whose bits are the `length` +// least-significant bits of val, read most-significant-first. Used by tests to +// build expected paths; the value is interpreted as a number, not raw bytes. +func NewBitArray(length uint8, val uint64) BitArray { + var b BitArray + b.len = length + for p := uint8(0); p < length; p++ { + if (val>>(length-1-p))&1 == 1 { + b.bytes[p/8] |= 1 << (7 - p%8) + } + } + return b +} + +// Len returns the number of used bits. +func (b *BitArray) Len() uint8 { + return b.len +} + +// Bytes returns the packed big-endian, MSB-first representation. Bits beyond +// len are zero. +func (b *BitArray) Bytes() [32]byte { + return b.bytes +} + +// AppendBit sets the bit array to x with a single bit appended, and returns the +// receiver. Safe when b and x alias the same value. +func (b *BitArray) AppendBit(x *BitArray, bit uint8) *BitArray { + *b = *x + if bit&1 == 1 { + // Position b.len is guaranteed zero by the all-bits-beyond-len-are-zero + // invariant, so a 1 only needs setting; a 0 is already in place. + b.bytes[b.len/8] |= 1 << (7 - b.len%8) + } + b.len++ + return b +} + +// MSBs sets the bit array to the most significant n bits of x and returns the +// receiver. If n >= x.len it is an exact copy of x. Think of it as x[:n]. +func (b *BitArray) MSBs(x *BitArray, n uint8) *BitArray { + *b = *x + if n < b.len { + b.len = n + b.maskTail() + } + return b +} + +// Equal reports whether two bit arrays hold the same path. +func (b *BitArray) Equal(x *BitArray) bool { + return b.len == x.len && b.bytes == x.bytes +} + +// SetBytes sets the bit array to the most significant `length` bits of data, +// interpreted as big-endian bytes, and returns the receiver. At most 32 bytes +// of data are read; bits beyond length are zeroed. +func (b *BitArray) SetBytes(length uint8, data []byte) *BitArray { + b.bytes = [32]byte{} + copy(b.bytes[:], data) + b.len = length + b.maskTail() + return b +} + +// SetBit sets the bit array to a single bit and returns the receiver. +func (b *BitArray) SetBit(bit uint8) *BitArray { + b.bytes = [32]byte{} + b.len = 1 + if bit&1 == 1 { + b.bytes[0] = 0x80 + } + return b +} + +// Copy returns a value copy of the bit array. +func (b *BitArray) Copy() BitArray { + return *b +} + +// Set sets the bit array to the same value as x and returns the receiver. +func (b *BitArray) Set(x *BitArray) *BitArray { + *b = *x + return b +} + +// KeyBytes returns the path-to-DB-key encoding: the active bytes (the +// left-aligned MSB-first prefix) followed by a single trailing byte holding the +// bit-length. The trailing length disambiguates paths whose active bytes +// coincide (e.g. 1-bit "1" packs to [0x80, 0x01] and 8-bit "10000000" to +// [0x80, 0x08]). The empty path encodes as no bytes. +func (b *BitArray) KeyBytes() []byte { + if b.len == 0 { + return nil + } + bc := (int(b.len) + 7) / 8 + res := make([]byte, bc+1) + copy(res[:bc], b.bytes[:bc]) + res[bc] = b.len + return res +} + +// PutKeyBytes writes the key encoding (active bytes followed by length byte) +// into dst and returns the populated sub-slice. The empty path returns dst[:0] +// without touching dst. For non-empty paths dst must have len >= 33 (32 packed +// bytes for 248 bits + 1 length byte). +func (b *BitArray) PutKeyBytes(dst []byte) []byte { + if b.len == 0 { + return dst[:0] + } + bc := (int(b.len) + 7) / 8 + _ = dst[bc] // bounds check hint + copy(dst[:bc], b.bytes[:bc]) + dst[bc] = b.len + return dst[:bc+1] +} + +// maskTail zeroes every bit at a position >= len, preserving the invariant that +// equal paths are byte-equal. +func (b *BitArray) maskTail() { + full := int(b.len / 8) + if rem := b.len % 8; rem != 0 { + b.bytes[full] &= byte(0xFF) << (8 - rem) + full++ + } + for i := full; i < len(b.bytes); i++ { + b.bytes[i] = 0 + } +} diff --git a/trie/bintrie/bitarray_test.go b/trie/bintrie/bitarray_test.go new file mode 100644 index 0000000000..10cfc4dc20 --- /dev/null +++ b/trie/bintrie/bitarray_test.go @@ -0,0 +1,205 @@ +package bintrie + +import ( + "bytes" + "testing" +) + +// ba builds a BitArray with the given length and leading bytes, for use as an +// expected value. Remaining bytes are zero. +func ba(length uint8, lead ...byte) BitArray { + var b BitArray + b.len = length + copy(b.bytes[:], lead) + return b +} + +func TestNewBitArray(t *testing.T) { + tests := []struct { + name string + length uint8 + val uint64 + want BitArray + }{ + {"empty", 0, 0, ba(0)}, + {"single 1", 1, 1, ba(1, 0x80)}, + {"single 0", 1, 0, ba(1, 0x00)}, + {"101", 3, 0b101, ba(3, 0xA0)}, + {"full byte", 8, 0xFF, ba(8, 0xFF)}, + {"ten bits", 10, 0x3FF, ba(10, 0xFF, 0xC0)}, + {"high bits ignored beyond length", 3, 0b11101, ba(3, 0xA0)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewBitArray(tt.length, tt.val) + if !got.Equal(&tt.want) { + t.Errorf("NewBitArray(%d, %#x) = %x (len %d), want %x (len %d)", + tt.length, tt.val, got.bytes, got.len, tt.want.bytes, tt.want.len) + } + }) + } +} + +func TestSetBytes(t *testing.T) { + tests := []struct { + name string + length uint8 + data []byte + want BitArray + }{ + {"empty", 0, []byte{0xFF}, ba(0)}, + {"full byte", 8, []byte{0xAB}, ba(8, 0xAB)}, + {"top 4 bits", 4, []byte{0xFF}, ba(4, 0xF0)}, + {"11 bits masks tail", 11, []byte{0xFF, 0xFF}, ba(11, 0xFF, 0xE0)}, + {"data longer than length", 4, []byte{0xFF, 0xFF}, ba(4, 0xF0)}, + {"data shorter than length", 16, []byte{0xAB}, ba(16, 0xAB, 0x00)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := new(BitArray).SetBytes(tt.length, tt.data) + if !got.Equal(&tt.want) { + t.Errorf("SetBytes(%d, %x) = %x (len %d), want %x (len %d)", + tt.length, tt.data, got.bytes, got.len, tt.want.bytes, tt.want.len) + } + }) + } +} + +func TestSetBytesFull(t *testing.T) { + data := bytes.Repeat([]byte{0xFF}, 32) + got := new(BitArray).SetBytes(248, data) + want := ba(248) + for i := 0; i < 31; i++ { + want.bytes[i] = 0xFF + } + if !got.Equal(&want) { + t.Errorf("SetBytes(248, 0xFF*32): byte 31 must be zeroed; got %x", got.bytes) + } +} + +func TestMSBs(t *testing.T) { + x := new(BitArray).SetBytes(16, []byte{0xAB, 0xCD}) + tests := []struct { + name string + n uint8 + want BitArray + }{ + {"prefix byte", 8, ba(8, 0xAB)}, + {"prefix nibble", 4, ba(4, 0xA0)}, + {"zero", 0, ba(0)}, + {"n equals len", 16, ba(16, 0xAB, 0xCD)}, + {"n exceeds len copies x", 20, ba(16, 0xAB, 0xCD)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := new(BitArray).MSBs(x, tt.n) + if !got.Equal(&tt.want) { + t.Errorf("MSBs(x, %d) = %x (len %d), want %x (len %d)", + tt.n, got.bytes, got.len, tt.want.bytes, tt.want.len) + } + }) + } +} + +func TestAppendBit(t *testing.T) { + // Build "101" one bit at a time from empty. + var p BitArray + for _, bit := range []uint8{1, 0, 1} { + p.AppendBit(&p, bit) // receiver aliases argument + } + if want := ba(3, 0xA0); !p.Equal(&want) { + t.Fatalf("append 1,0,1 = %x (len %d), want %x (len 3)", p.bytes, p.len, want.bytes) + } + + // Append across a byte boundary: 8 ones then a 1 → 9 bits. + var q BitArray + for i := 0; i < 9; i++ { + q.AppendBit(&q, 1) + } + if want := ba(9, 0xFF, 0x80); !q.Equal(&want) { + t.Fatalf("append nine 1s = %x (len %d), want %x (len 9)", q.bytes, q.len, want.bytes) + } + + // Appending to a copy must not mutate the source. + src := new(BitArray).SetBytes(4, []byte{0xF0}) + child := *src + child.AppendBit(&child, 0) + if want := ba(4, 0xF0); !src.Equal(&want) { + t.Errorf("source mutated by append on copy: %x", src.bytes) + } + if want := ba(5, 0xF0); !child.Equal(&want) { + t.Errorf("append 0 = %x (len %d), want %x (len 5)", child.bytes, child.len, want.bytes) + } +} + +func TestSetBit(t *testing.T) { + if got, want := new(BitArray).SetBit(1), ba(1, 0x80); !got.Equal(&want) { + t.Errorf("SetBit(1) = %x (len %d), want %x", got.bytes, got.len, want.bytes) + } + if got, want := new(BitArray).SetBit(0), ba(1, 0x00); !got.Equal(&want) { + t.Errorf("SetBit(0) = %x (len %d), want %x", got.bytes, got.len, want.bytes) + } +} + +func TestEqual(t *testing.T) { + a := NewBitArray(3, 0b101) + b := NewBitArray(3, 0b101) + if !a.Equal(&b) { + t.Error("equal arrays reported unequal") + } + // Same active bytes, different length must be unequal. + c := NewBitArray(2, 0b10) // "10" -> byte 0x80, len 2 + d := ba(3, c.bytes[0]) // same byte, len 3 + if c.Equal(&d) { + t.Error("arrays with different length reported equal") + } +} + +func TestKeyBytesRoundTrip(t *testing.T) { + tests := []struct { + name string + length uint8 + data []byte + want []byte // expected KeyBytes output + }{ + {"empty", 0, nil, nil}, + {"one bit", 1, []byte{0x80}, []byte{0x80, 1}}, + {"full byte", 8, []byte{0x80}, []byte{0x80, 8}}, + {"eleven bits", 11, []byte{0xFF, 0xFF}, []byte{0xFF, 0xE0, 11}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := new(BitArray).SetBytes(tt.length, tt.data) + key := src.KeyBytes() + if !bytes.Equal(key, tt.want) { + t.Fatalf("KeyBytes() = %x, want %x", key, tt.want) + } + + // PutKeyBytes must agree with KeyBytes. + var buf [33]byte + if put := src.PutKeyBytes(buf[:]); !bytes.Equal(put, tt.want) { + t.Fatalf("PutKeyBytes() = %x, want %x", put, tt.want) + } + + // Re-parse the active bytes and confirm the path round-trips. + if tt.length == 0 { + return + } + lengthByte := key[len(key)-1] + reparsed := new(BitArray).SetBytes(lengthByte, key[:len(key)-1]) + if !reparsed.Equal(src) { + t.Fatalf("round-trip mismatch: %x (len %d) != %x (len %d)", + reparsed.bytes, reparsed.len, src.bytes, src.len) + } + }) + } +} + +func TestCopyIsIndependent(t *testing.T) { + src := new(BitArray).SetBytes(8, []byte{0xAB}) + cp := src.Copy() + cp.AppendBit(&cp, 1) + if want := ba(8, 0xAB); !src.Equal(&want) { + t.Errorf("Copy not independent: source became %x (len %d)", src.bytes, src.len) + } +} diff --git a/trie/bintrie/format_test.go b/trie/bintrie/format_test.go new file mode 100644 index 0000000000..0942ce2dc2 --- /dev/null +++ b/trie/bintrie/format_test.go @@ -0,0 +1,445 @@ +// Copyright 2026 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 bintrie + +import ( + "bytes" + "encoding/binary" + "fmt" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/trie" + "github.com/ethereum/go-ethereum/trie/trienode" +) + +// TestRootHashMatchesReadBackHash pins the round-trip invariant: the root +// hash a Commit advertises must be exactly the value a fresh reader computes +// from the on-disk blob. Before Option B the writer produced a natural-depth +// hash while DeserializeAndHash produced an extended-depth hash, so the two +// disagreed for any non-trivial subtree — this test failed. With the +// per-entry depth byte, the reader rebuilds the natural-shape tree and the +// hashes match for every groupDepth and every divergence bit. +func TestRootHashMatchesReadBackHash(t *testing.T) { + for groupDepth := 1; groupDepth <= MaxGroupDepth; groupDepth++ { + // divergeBit ∈ [0, groupDepth-1] places the two stems at natural + // depth (divergeBit+1) within the root group; we want to exercise + // every depth-offset value the new format must handle. + for divergeBit := 0; divergeBit < groupDepth; divergeBit++ { + t.Run(fmt.Sprintf("gd=%d/diverge=%d", groupDepth, divergeBit), func(t *testing.T) { + tr := &BinaryTrie{ + store: newNodeStore(), + tracer: trie.NewPrevalueTracer(), + groupDepth: groupDepth, + } + stemL, stemR := stemsDivergingAt(divergeBit) + if err := tr.store.Insert(stemL, oneKey[:], nil); err != nil { + t.Fatalf("Insert stemL: %v", err) + } + if err := tr.store.Insert(stemR, twoKey[:], nil); err != nil { + t.Fatalf("Insert stemR: %v", err) + } + + natural := tr.Hash() + _, ns := tr.Commit(false) + rootNode, ok := ns.Nodes[""] + if !ok { + t.Fatalf("Commit produced no root blob (path \"\")") + } + + readBack, err := DeserializeAndHash(rootNode.Blob, 0) + if err != nil { + t.Fatalf("DeserializeAndHash: %v", err) + } + if natural != readBack { + t.Fatalf("round-trip hash mismatch:\n"+ + " tr.Hash() = %x\n"+ + " DeserializeAndHash(rootBlob) = %x\n"+ + "the parent's stored root hash cannot be reproduced from its own blob", + natural, readBack) + } + }) + } + } +} + +// TestMultiStemMixedDepths inserts four stems that diverge at different +// depths within a single groupDepth=5 group, then round-trips the trie +// through Commit + fresh-read. Verifies that every stem is retrievable by +// key after reload — exercises the new format with several depth-offset +// values in the same blob (1, 2, 3, 4) and confirms attachInGroup builds +// the natural-shape tree correctly. +func TestMultiStemMixedDepths(t *testing.T) { + const groupDepth = 5 + + // Each stem diverges from `0x00…00` at a different bit, so naturally: + // - stem at bit-0 divergence → depth 1 + // - stem at bit-1 divergence → depth 2 + // - stem at bit-2 divergence → depth 3 + // - stem at bit-3 divergence → depth 4 + stems := [][]byte{ + zeroKey[:], + bitFlipStem(0), // diverge at bit 0 + bitFlipStem(1), // diverge at bit 1 (prefix "0" matches stem 0) + bitFlipStem(2), // diverge at bit 2 (prefix "00") + bitFlipStem(3), // diverge at bit 3 (prefix "000") + } + values := []common.Hash{oneKey, twoKey, threeKey, fourKey, ffKey} + + tr := &BinaryTrie{ + store: newNodeStore(), + tracer: trie.NewPrevalueTracer(), + groupDepth: groupDepth, + } + for i, stem := range stems { + if err := tr.store.Insert(stem, values[i][:], nil); err != nil { + t.Fatalf("Insert stem %d: %v", i, err) + } + } + + before := tr.Hash() + _, ns := tr.Commit(false) + rootBlob, ok := ns.Nodes[""] + if !ok { + t.Fatalf("no root blob in NodeSet") + } + readBack, err := DeserializeAndHash(rootBlob.Blob, 0) + if err != nil { + t.Fatalf("DeserializeAndHash: %v", err) + } + if before != readBack { + t.Fatalf("hash mismatch: tr.Hash()=%x DeserializeAndHash(rootBlob)=%x", before, readBack) + } + + // Reload the root blob into a fresh store and confirm structure. + fresh := newNodeStore() + ref, err := fresh.deserializeNodeWithHash(rootBlob.Blob, 0, before) + if err != nil { + t.Fatalf("deserializeNodeWithHash: %v", err) + } + if ref.Kind() != kindInternal { + t.Fatalf("expected root to be Internal, got kind %d", ref.Kind()) + } + // Spot-check: the reload-tree's root hash equals the commit-time hash. + if got := fresh.computeHash(ref); got != before { + t.Fatalf("reload root hash mismatch: got %x, want %x", got, before) + } +} + +// TestDecodeRejectsNonCanonicalPosition hand-crafts a blob where the bitmap +// position has nonzero trailing bits given its depth offset. Two +// implementations must produce byte-identical blobs for the same logical +// content, so a non-canonical position is unambiguously an invalid blob. +func TestDecodeRejectsNonCanonicalPosition(t *testing.T) { + // groupDepth=5, bitmap size = 4 bytes. Set bit at position 5 (binary + // 00101) and declare depthOffset=2. Top 2 bits of 00101 are 00 (path + // "00"), the trailing 3 bits should be zero — they're 101 here, so the + // reader must reject. + blob := []byte{nodeTypeInternal, 5} + // bitmap[0] = bit at position 5 → 1 << (7-5) = 0x04 + blob = append(blob, 0x04, 0x00, 0x00, 0x00) + // depths[0] = 2, packed as (2-1)=1 in 3 bits MSB-first → 0b0010_0000 = 0x20 + blob = append(blob, 0x20) + // hashes[0] = 32 zero bytes + blob = append(blob, make([]byte, HashSize)...) + + s := newNodeStore() + _, err := s.deserializeNode(blob, 0) + if err == nil { + t.Fatal("expected non-canonical position error, got nil") + } + if err.Error() != "non-canonical bitmap position" { + t.Errorf("expected 'non-canonical bitmap position', got %q", err.Error()) + } +} + +// TestDecodeRejectsInvalidDepthOffset covers depthOffset>groupDepth (the entry +// would live below the group's bottom layer, impossible by construction). The +// old depthOffset=0 and depthOffset>MaxGroupDepth cases are gone: the 3-bit +// field stores (offset-1) ∈ [0,7], so offset 0 and offset 9 are unrepresentable +// and can no longer be hand-crafted into a blob. Only offset>groupDepth with +// groupDepth= 31*8 { return nil, errors.New("node too deep") } - path := make([]byte, 0, depth+1) - for i := range depth + 1 { - bit := key[i/8] >> (7 - (i % 8)) & 1 - path = append(path, bit) - } - return path, nil + path := new(BitArray).SetBytes(uint8(depth+1), key) + return path.KeyBytes(), nil } // Invariant: dirty=false implies mustRecompute=false. Every mutation that diff --git a/trie/bintrie/internal_node_test.go b/trie/bintrie/internal_node_test.go index 4d8da8af37..2f7ade14b0 100644 --- a/trie/bintrie/internal_node_test.go +++ b/trie/bintrie/internal_node_test.go @@ -283,14 +283,13 @@ func TestInternalNodeCollectNodes(t *testing.T) { t.Fatal(err) } - var collectedPaths [][]byte - flushFn := func(path []byte, hash common.Hash, serialized []byte) { - pathCopy := make([]byte, len(path)) - copy(pathCopy, path) - collectedPaths = append(collectedPaths, pathCopy) + var collectedPaths []BitArray + flushFn := func(path BitArray, hash common.Hash, serialized []byte) { + collectedPaths = append(collectedPaths, path) } - s.collectNodes(s.root, []byte{1}, flushFn, 8) + initialPath := NewBitArray(1, 1) + s.collectNodes(s.root, initialPath, flushFn, 8) // Should have collected 3 nodes: left stem, right stem, and the internal node itself if len(collectedPaths) != 3 { diff --git a/trie/bintrie/iterator.go b/trie/bintrie/iterator.go index a920f91378..e678ee310d 100644 --- a/trie/bintrie/iterator.go +++ b/trie/bintrie/iterator.go @@ -188,20 +188,20 @@ func (it *binaryNodeIterator) Parent() common.Hash { return it.store.computeHash(it.stack[len(it.stack)-2].Node) } -// Path returns the bit-path to the current node. +// Path returns the bit-packed path to the current node. // Callers must not retain references to the returned slice after calling Next. func (it *binaryNodeIterator) Path() []byte { if it.Leaf() { return it.LeafKey() } - var path []byte + var path BitArray for i, state := range it.stack { if i >= len(it.stack)-1 { break } - path = append(path, byte(state.Index)) + path.AppendBit(&path, uint8(state.Index)) } - return path + return path.KeyBytes() } func (it *binaryNodeIterator) NodeBlob() []byte { diff --git a/trie/bintrie/node_store.go b/trie/bintrie/node_store.go index 8a35f06ee1..06e49d1d72 100644 --- a/trie/bintrie/node_store.go +++ b/trie/bintrie/node_store.go @@ -41,10 +41,15 @@ type nodeStore struct { // stem-split keeps the old stem at a deeper position), so they don't // have free lists. freeHashed []uint32 + + // orphans holds on-disk paths whose committed blob has been abandoned by a + // stem depth-promotion since the last commit. Commit emits a deletion for + // each (unless a freshly flushed node reoccupies the path), then clears it. + orphans map[string]struct{} } func newNodeStore() *nodeStore { - return &nodeStore{root: emptyRef} + return &nodeStore{root: emptyRef, orphans: make(map[string]struct{})} } func (s *nodeStore) allocInternal() uint32 { @@ -179,6 +184,10 @@ func (s *nodeStore) Copy() *nodeStore { ns.freeHashed = make([]uint32, len(s.freeHashed)) copy(ns.freeHashed, s.freeHashed) } + ns.orphans = make(map[string]struct{}, len(s.orphans)) + for path := range s.orphans { + ns.orphans[path] = struct{}{} + } return ns } diff --git a/trie/bintrie/stem_node_test.go b/trie/bintrie/stem_node_test.go index ae6b57ab34..8c563fce9d 100644 --- a/trie/bintrie/stem_node_test.go +++ b/trie/bintrie/stem_node_test.go @@ -313,14 +313,13 @@ func TestStemNodeCollectNodes(t *testing.T) { t.Fatal(err) } - var collectedPaths [][]byte - flushFn := func(path []byte, hash common.Hash, serialized []byte) { - pathCopy := make([]byte, len(path)) - copy(pathCopy, path) - collectedPaths = append(collectedPaths, pathCopy) + var collectedPaths []BitArray + flushFn := func(path BitArray, hash common.Hash, serialized []byte) { + collectedPaths = append(collectedPaths, path) } - s.collectNodes(s.root, []byte{0, 1, 0}, flushFn, 8) + initialPath := NewBitArray(3, 0b010) + s.collectNodes(s.root, initialPath, flushFn, 8) // Should have collected one node (itself) if len(collectedPaths) != 1 { @@ -328,7 +327,8 @@ func TestStemNodeCollectNodes(t *testing.T) { } // Check the path - if !bytes.Equal(collectedPaths[0], []byte{0, 1, 0}) { - t.Errorf("Path mismatch: expected [0, 1, 0], got %v", collectedPaths[0]) + expectedPath := NewBitArray(3, 0b010) + if !collectedPaths[0].Equal(&expectedPath) { + t.Errorf("Path mismatch: expected %v, got %v", expectedPath, collectedPaths[0]) } } diff --git a/trie/bintrie/store_commit.go b/trie/bintrie/store_commit.go index b14bffbc6c..03710326b7 100644 --- a/trie/bintrie/store_commit.go +++ b/trie/bintrie/store_commit.go @@ -27,7 +27,7 @@ import ( "github.com/ethereum/go-ethereum/common" ) -type nodeFlushFn func(path []byte, hash common.Hash, serialized []byte) +type nodeFlushFn func(path BitArray, hash common.Hash, serialized []byte) func (s *nodeStore) Hash() common.Hash { return s.computeHash(s.root) @@ -111,7 +111,7 @@ func (s *nodeStore) hashInternal(idx uint32) common.Hash { // It traverses up to `remainingDepth` levels, storing hashes of bottom-layer children. // position tracks the current index (0 to 2^groupDepth - 1) for bitmap placement. // hashes collects the hashes of present children, bitmap tracks which positions are present. -func (s *nodeStore) serializeSubtree(ref nodeRef, remainingDepth int, position int, absoluteDepth int, bitmap []byte, hashes *[]common.Hash) { +func (s *nodeStore) serializeSubtree(ref nodeRef, remainingDepth int, position int, groupDepth int, bitmap []byte, hashes *[]common.Hash, depths *[]uint8) { if remainingDepth == 0 { // Bottom layer: store hash if not empty switch ref.Kind() { @@ -122,6 +122,7 @@ func (s *nodeStore) serializeSubtree(ref nodeRef, remainingDepth int, position i // StemNode, HashedNode, or InternalNode at boundary: store hash bitmap[position/8] |= 1 << (7 - (position % 8)) *hashes = append(*hashes, s.computeHash(ref)) + *depths = append(*depths, uint8(groupDepth)) } return } @@ -130,57 +131,81 @@ func (s *nodeStore) serializeSubtree(ref nodeRef, remainingDepth int, position i case kindInternal: leftPos := position * 2 rightPos := position*2 + 1 - s.serializeSubtree(s.getInternal(ref.Index()).left, remainingDepth-1, leftPos, absoluteDepth+1, bitmap, hashes) - s.serializeSubtree(s.getInternal(ref.Index()).right, remainingDepth-1, rightPos, absoluteDepth+1, bitmap, hashes) + s.serializeSubtree(s.getInternal(ref.Index()).left, remainingDepth-1, leftPos, groupDepth, bitmap, hashes, depths) + s.serializeSubtree(s.getInternal(ref.Index()).right, remainingDepth-1, rightPos, groupDepth, bitmap, hashes, depths) case kindEmpty: return default: // StemNode or HashedNode encountered before reaching the group's bottom // layer. Compute the leaf bitmap position where this node's hash will // be stored. - leafPos := position - switch ref.Kind() { - case kindStem: - sn := s.getStem(ref.Index()) - // Extend position using the stem's key bits so that - // GetValuesAtStem traversal (which follows key bits) finds the hash. - for d := 0; d < remainingDepth; d++ { - bit := sn.Stem[(absoluteDepth+d)/8] >> (7 - ((absoluteDepth + d) % 8)) & 1 - leafPos = leafPos*2 + int(bit) - } - default: - // HashedNode or unknown: extend all-left (no key bits available). - // This matches the all-zero path that resolveNode would follow. - leafPos = position << remainingDepth - } - bitmap[leafPos/8] |= 1 << (7 - (leafPos % 8)) + bitmapPos := position << remainingDepth + bitmap[bitmapPos/8] |= 1 << (7 - (bitmapPos % 8)) *hashes = append(*hashes, s.computeHash(ref)) + *depths = append(*depths, uint8(groupDepth-remainingDepth)) } } +// depthBits is the number of bits used to encode one depth offset. +const depthBits = 3 + +// packedDepthsLen returns the byte length of k packed depth entries +func packedDepthsLen(k int) int { + return (k*depthBits + 7) / 8 +} + +// writeDepth writes a depth entry at idx into the buf, MSB-first. +func writeDepth(buf []byte, idx int, v uint8) { + pos := idx * depthBits + for i := range depthBits { + bit := (v >> (depthBits - 1 - i)) & 1 + p := pos + i + buf[p>>3] |= bit << (7 - (p & 7)) + } +} + +// readDepth reads a depth for entry idx from buf. +func readDepth(buf []byte, idx int) uint8 { + pos := idx * depthBits + var v uint8 + for i := range depthBits { + p := pos + i + bit := (buf[p>>3] >> (7 - (p & 7))) & 1 + v = v<<1 | bit + } + return v +} + // SerializeNode serializes a node into the flat on-disk format. func (s *nodeStore) serializeNode(ref nodeRef, groupDepth int) []byte { switch ref.Kind() { case kindInternal: - // InternalNode group: 1 byte type + 1 byte group depth + variable bitmap + N×32 byte hashes + // InternalNode group format: + // [type(1)] [groupDepth(1)] [bitmap (2^groupDepth bits)] [depths(3 bits × K, padded)] [hashes(32B × K)] bitmapSize := bitmapSizeForDepth(groupDepth) bitmap := make([]byte, bitmapSize) var hashes []common.Hash + var depths []uint8 - node := s.getInternal(ref.Index()) - s.serializeSubtree(ref, groupDepth, 0, int(node.depth), bitmap, &hashes) + s.serializeSubtree(ref, groupDepth, 0, groupDepth, bitmap, &hashes, &depths) // Build serialized output - serializedLen := NodeTypeBytes + 1 + bitmapSize + len(hashes)*HashSize + k := len(hashes) + depthsLen := packedDepthsLen(k) + serializedLen := NodeTypeBytes + 1 + bitmapSize + depthsLen + k*HashSize serialized := make([]byte, serializedLen) serialized[0] = nodeTypeInternal - serialized[1] = byte(groupDepth) // group depth => bitmap size for a sparse group + serialized[1] = byte(groupDepth) copy(serialized[2:2+bitmapSize], bitmap) - offset := NodeTypeBytes + 1 + bitmapSize - for _, h := range hashes { - copy(serialized[offset:offset+HashSize], h.Bytes()) - offset += HashSize + depthsOff := NodeTypeBytes + 1 + bitmapSize + for i, d := range depths { + writeDepth(serialized[depthsOff:depthsOff+depthsLen], i, d-1) + } + + hashesOff := depthsOff + depthsLen + for i, h := range hashes { + copy(serialized[hashesOff+i*HashSize:hashesOff+(i+1)*HashSize], h.Bytes()) } return serialized @@ -229,56 +254,90 @@ func (s *nodeStore) deserializeNodeWithHash(serialized []byte, depth int, hn com } // deserializeSubtree reconstructs an InternalNode subtree from grouped serialization. -// remainingDepth is how many more levels to build, position is current index in the bitmap, -// nodeDepth is the actual trie depth for the node being created. -// hashIdx tracks the current position in the hash data (incremented as hashes are consumed). -func (s *nodeStore) deserializeSubtree(hn common.Hash, remainingDepth int, position int, nodeDepth int, bitmap []byte, hashData []byte, hashIdx *int, mustRecompute bool, dirty bool) (nodeRef, error) { - if remainingDepth == 0 { - // Bottom layer: check bitmap and return HashedNode or Empty - if bitmap[position/8]>>(7-(position%8))&1 == 1 { - if len(hashData) < (*hashIdx+1)*HashSize { - return emptyRef, errInvalidSerializedLength - } - hash := common.BytesToHash(hashData[*hashIdx*HashSize : (*hashIdx+1)*HashSize]) - *hashIdx++ - return s.newHashedRef(hash), nil - } +func (s *nodeStore) deserializeSubtree(hn common.Hash, groupDepth int, nodeDepth int, bitmap []byte, depths []byte, hashData []byte, mustRecompute bool, dirty bool) (nodeRef, error) { + if len(hashData)%HashSize != 0 { + return emptyRef, errInvalidSerializedLength + } + k := len(hashData) / HashSize + if len(depths) != packedDepthsLen(k) { + return emptyRef, errInvalidSerializedLength + } + if k == 0 { return emptyRef, nil } - // Check if this entire subtree is empty by examining all relevant bitmap bits - leftPos := position * 2 - rightPos := position*2 + 1 - - // note that the parent might not need root computations, but the children - // do, because their hash isn't saved. Hence `mustRecompute` is set to `true`. - left, err := s.deserializeSubtree(common.Hash{}, remainingDepth-1, leftPos, nodeDepth+1, bitmap, hashData, hashIdx, true, dirty) - if err != nil { - return emptyRef, err - } - right, err := s.deserializeSubtree(common.Hash{}, remainingDepth-1, rightPos, nodeDepth+1, bitmap, hashData, hashIdx, true, dirty) - if err != nil { - return emptyRef, err - } - - // If both children are empty, return Empty - if left.IsEmpty() && right.IsEmpty() { - return emptyRef, nil - } - - ref := s.newInternalRef(nodeDepth) - node := s.getInternal(ref.Index()) - node.left = left - node.right = right - node.mustRecompute = mustRecompute + rootRef := s.newInternalRef(nodeDepth) + rootNode := s.getInternal(rootRef.Index()) + rootNode.mustRecompute = mustRecompute if !mustRecompute { - // mustRecompute will only be false for the root of the subtree, - // for which we already know the hash. - node.hash = hn - node.mustRecompute = false + rootNode.hash = hn } - node.dirty = dirty - return ref, nil + rootNode.dirty = dirty + + bitmapBits := 1 << groupDepth + entryIdx := 0 + for bit := 0; bit < bitmapBits; bit++ { + if bitmap[bit/8]>>(7-(bit%8))&1 == 0 { + continue + } + depthOffset := int(readDepth(depths, entryIdx)) + 1 + if depthOffset > groupDepth { + return emptyRef, errors.New("invalid depth offset") + } + // Canonical-encoding check: trailing position bits must be zero. + mask := (1 << (groupDepth - depthOffset)) - 1 + if bit&mask != 0 { + return emptyRef, errors.New("non-canonical bitmap position") + } + var hash common.Hash + copy(hash[:], hashData[entryIdx*HashSize:(entryIdx+1)*HashSize]) + if err := s.attachInGroup(rootRef, nodeDepth, groupDepth, depthOffset, bit, hash, dirty); err != nil { + return emptyRef, err + } + entryIdx++ + } + return rootRef, nil +} + +func (s *nodeStore) attachInGroup(rootRef nodeRef, rootDepth, groupDepth, depthOffset, bitmapPos int, hash common.Hash, dirty bool) error { + cur := rootRef + for level := 0; level < depthOffset-1; level++ { + bit := (bitmapPos >> (groupDepth - 1 - level)) & 1 + node := s.getInternal(cur.Index()) + childRef := node.left + if bit == 1 { + childRef = node.right + } + if childRef.IsEmpty() { + newRef := s.newInternalRef(rootDepth + level + 1) + s.getInternal(newRef.Index()).dirty = dirty + if bit == 0 { + node.left = newRef + } else { + node.right = newRef + } + cur = newRef + continue + } + if childRef.Kind() != kindInternal { + return errors.New("overlapping entries in group blob") + } + cur = childRef + } + leafBit := (bitmapPos >> (groupDepth - depthOffset)) & 1 + node := s.getInternal(cur.Index()) + if leafBit == 0 { + if !node.left.IsEmpty() { + return errors.New("overlapping entries in group blob") + } + node.left = s.newHashedRef(hash) + } else { + if !node.right.IsEmpty() { + return errors.New("overlapping entries in group blob") + } + node.right = s.newHashedRef(hash) + } + return nil } func (s *nodeStore) decodeNode(serialized []byte, depth int, hn common.Hash, mustRecompute, dirty bool) (nodeRef, error) { @@ -288,7 +347,9 @@ func (s *nodeStore) decodeNode(serialized []byte, depth int, hn common.Hash, mus switch serialized[0] { case nodeTypeInternal: - // Grouped format: 1 byte type + 1 byte group depth + variable bitmap + N×32 byte hashes + // Grouped format: + // [type(1)] [groupDepth(1)] [bitmap (2^groupDepth bits, padded to bitmapSize bytes)] + // [depthOffsets (3 bits × K, padded to bytes)] [hashes (32B × K)] if len(serialized) < NodeTypeBytes+1 { return emptyRef, errInvalidSerializedLength } @@ -301,10 +362,38 @@ func (s *nodeStore) decodeNode(serialized []byte, depth int, hn common.Hash, mus return 0, errInvalidSerializedLength } bitmap := serialized[2 : 2+bitmapSize] - hashData := serialized[2+bitmapSize:] - hashIdx := 0 - return s.deserializeSubtree(hn, groupDepth, 0, depth, bitmap, hashData, &hashIdx, mustRecompute, dirty) + bitmapBits := 1 << groupDepth + if bitmapBits < 8 { + padMask := byte(0xFF) >> bitmapBits + if bitmap[0]&padMask != 0 { + return emptyRef, errors.New("non-canonical bitmap padding") + } + } + + k := 0 + for _, b := range bitmap { + k += bits.OnesCount8(b) + } + depthsLen := packedDepthsLen(k) + expectedLen := NodeTypeBytes + 1 + bitmapSize + depthsLen + k*HashSize + if len(serialized) != expectedLen { + return emptyRef, errInvalidSerializedLength + } + depthsOff := NodeTypeBytes + 1 + bitmapSize + depths := serialized[depthsOff : depthsOff+depthsLen] + hashData := serialized[depthsOff+depthsLen : depthsOff+depthsLen+k*HashSize] + + // Canonical-encoding check: the unused low bits of the last packed + // depth byte must be zero. + if usedBits := k * depthBits; usedBits%8 != 0 { + padMask := byte(0xFF) >> (usedBits % 8) + if depths[depthsLen-1]&padMask != 0 { + return emptyRef, errors.New("non-canonical depth padding") + } + } + + return s.deserializeSubtree(hn, groupDepth, depth, bitmap, depths, hashData, mustRecompute, dirty) case nodeTypeStem: if len(serialized) < NodeTypeBytes+StemSize+StemBitmapSize { @@ -340,7 +429,10 @@ func (s *nodeStore) decodeNode(serialized []byte, depth int, hn common.Hash, mus // CollectNodes flushes every node that needs flushing via flushfn in post-order. // Invariant: any ancestor of a node that needs flushing is itself marked, so a // clean root means the whole subtree is clean. -func (s *nodeStore) collectNodes(ref nodeRef, path []byte, flushfn nodeFlushFn, groupDepth int) { +// +// BitArray is passed by value (33 bytes) to keep child paths on the stack. +// Passing by pointer causes escape to heap per recursive call. +func (s *nodeStore) collectNodes(ref nodeRef, path BitArray, flushfn nodeFlushFn, groupDepth int) { switch ref.Kind() { case kindInternal: node := s.getInternal(ref.Index()) @@ -375,77 +467,51 @@ func (s *nodeStore) collectNodes(ref nodeRef, path []byte, flushfn nodeFlushFn, // collectChildGroups traverses within a group to find and collect nodes in the next group. // remainingLevels is how many more levels below the current node until we reach the group boundary. // When remainingLevels=0, the current node's children are at the next group boundary. -func (s *nodeStore) collectChildGroups(node *InternalNode, path []byte, flushfn nodeFlushFn, groupDepth int, remainingLevels int) error { +func (s *nodeStore) collectChildGroups(node *InternalNode, path BitArray, flushfn nodeFlushFn, groupDepth int, remainingLevels int) error { if remainingLevels == 0 { // Current node is at depth (groupBoundary - 1), its children are at the next group boundary if !node.left.IsEmpty() { - s.collectNodes(node.left, appendBit(path, 0), flushfn, groupDepth) + leftPath := path + leftPath.AppendBit(&leftPath, 0) + s.collectNodes(node.left, leftPath, flushfn, groupDepth) } if !node.right.IsEmpty() { - s.collectNodes(node.right, appendBit(path, 1), flushfn, groupDepth) + rightPath := path + rightPath.AppendBit(&rightPath, 1) + s.collectNodes(node.right, rightPath, flushfn, groupDepth) } return nil } if !node.left.IsEmpty() { + leftPath := path + leftPath.AppendBit(&leftPath, 0) switch node.left.Kind() { case kindInternal: n := s.getInternal(node.left.Index()) - if err := s.collectChildGroups(n, appendBit(path, 0), flushfn, groupDepth, remainingLevels-1); err != nil { + if err := s.collectChildGroups(n, leftPath, flushfn, groupDepth, remainingLevels-1); err != nil { return err } default: - extPath := s.extendPathToGroupLeaf(appendBit(path, 0), node.left, remainingLevels) - s.collectNodes(node.left, extPath, flushfn, groupDepth) + s.collectNodes(node.left, leftPath, flushfn, groupDepth) } } if !node.right.IsEmpty() { + rightPath := path + rightPath.AppendBit(&rightPath, 1) switch node.right.Kind() { case kindInternal: n := s.getInternal(node.right.Index()) - if err := s.collectChildGroups(n, appendBit(path, 1), flushfn, groupDepth, remainingLevels-1); err != nil { + if err := s.collectChildGroups(n, rightPath, flushfn, groupDepth, remainingLevels-1); err != nil { return err } default: - extPath := s.extendPathToGroupLeaf(appendBit(path, 1), node.right, remainingLevels) - s.collectNodes(node.right, extPath, flushfn, groupDepth) + s.collectNodes(node.right, rightPath, flushfn, groupDepth) } } return nil } -// extendPathToGroupLeaf extends a storage path to the group's leaf boundary, -// matching the projection done by serializeSubtree. For StemNodes, the path -// is extended using the stem's key bits (same as serializeSubtree). For other -// node types, the path is extended with all-zero (left) bits. -func (s *nodeStore) extendPathToGroupLeaf(path []byte, node nodeRef, remainingLevels int) []byte { - if remainingLevels <= 0 { - return path - } - if node.Kind() == kindStem { - sn := s.getStem(node.Index()) - for _ = range remainingLevels { - bit := sn.Stem[len(path)/8] >> (7 - (len(path) % 8)) & 1 - path = appendBit(path, bit) - } - } else { - // HashedNode or other: all-left extension (matches serializeSubtree's - // position << remainingDepth behavior). - for _ = range remainingLevels { - path = appendBit(path, 0) - } - } - return path -} - -// appendBit appends a bit to a path, returning a new slice -func appendBit(path []byte, bit byte) []byte { - var p [256]byte - copy(p[:], path) - result := p[:len(path)] - return append(result, bit) -} - func (s *nodeStore) toDot(ref nodeRef, parent, path string) string { switch ref.Kind() { case kindInternal: diff --git a/trie/bintrie/store_ops.go b/trie/bintrie/store_ops.go index 9a73c8bd64..d3765bd651 100644 --- a/trie/bintrie/store_ops.go +++ b/trie/bintrie/store_ops.go @@ -262,7 +262,15 @@ func (s *nodeStore) splitStemValuesInsert(existingRef nodeRef, newStem []byte, v bitStem := existing.Stem[existing.depth/8] >> (7 - (existing.depth % 8)) & 1 nRef := s.newInternalRef(int(existing.depth)) nNode := s.getInternal(nRef.Index()) + if !existing.dirty { + var buf [33]byte + oldPath := new(BitArray).SetBytes(existing.depth, existing.Stem[:]).PutKeyBytes(buf[:]) + s.orphans[string(oldPath)] = struct{}{} + } existing.depth++ + // The existing stem's on-disk path lengthens by one bit, which means + // the stem must be re-flushed at the longer new path. + existing.dirty = true bitKey := newStem[nNode.depth/8] >> (7 - (nNode.depth % 8)) & 1 if bitKey == bitStem { diff --git a/trie/bintrie/trie.go b/trie/bintrie/trie.go index 0d0c0e0e70..7b05e68df9 100644 --- a/trie/bintrie/trie.go +++ b/trie/bintrie/trie.go @@ -347,12 +347,35 @@ func (t *BinaryTrie) Hash() common.Hash { func (t *BinaryTrie) Commit(_ bool) (common.Hash, *trienode.NodeSet) { nodeset := trienode.NewNodeSet(common.Hash{}) - // Pre-size the path buffer: collectNodes reuses it in-place via - // append/truncate; 32 covers typical binary-trie depth without regrowth. - pathBuf := make([]byte, 0, 32) - t.store.collectNodes(t.store.root, pathBuf, func(path []byte, hash common.Hash, serialized []byte) { - nodeset.AddNode(path, trienode.NewNodeWithPrev(hash, serialized, t.tracer.Get(path))) + // Stem depth-promotion abandons a committed blob and is the only source of + // orphans. When none are pending (the common case) we skip tracking flushed + // paths entirely, keeping Commit allocation-free beyond the node set. + var added map[string]struct{} + if len(t.store.orphans) > 0 { + added = make(map[string]struct{}) + } + var rootPath BitArray + t.store.collectNodes(t.store.root, rootPath, func(path BitArray, hash common.Hash, serialized []byte) { + var buf [33]byte + pathBytes := path.PutKeyBytes(buf[:]) + if added != nil { + added[string(pathBytes)] = struct{}{} + } + nodeset.AddNode(pathBytes, trienode.NewNodeWithPrev(hash, serialized, t.tracer.Get(pathBytes))) }, t.groupDepth) + + // Delete blobs abandoned by stem depth-promotion, unless a freshly flushed + // node already reoccupies the path (the group-boundary case). + if len(t.store.orphans) > 0 { + for path := range t.store.orphans { + if _, ok := added[path]; ok { + continue + } + nodeset.AddNode([]byte(path), trienode.NewDeletedWithPrev(t.tracer.Get([]byte(path)))) + } + t.store.orphans = make(map[string]struct{}) + } + return t.Hash(), nodeset } From aec597d7b9cceb7b89c043406a1f91ec6fc9db6c Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Tue, 23 Jun 2026 14:31:24 +0800 Subject: [PATCH 4/9] core/vm: implements EIP-8037 spec change (#35173) Implements spec change https://github.com/ethereum/EIPs/pull/11807 This PR resolves the conflict between the EIP-7928 and EIP-8037. Specifically in contract deployment, EIP-7928 requires to not resolve the deployed account until it's accessed, while in EIP-8037, the early access is required to determine if the account-creation should be charged or not. This PR addresses this conflict by changing the EIP-8037 a bit, unconditionally charge the account creation in CREATE Family (CreateTx, Create/Create2 opcode) and refunds the associated gas cost if the account creation doesn't happen ultimately. Checkout https://hackmd.io/@bFEBbZiVSAO0IURh9qzEFg/BJmFYqCeGl for more details What's more, now the LIFO mechanism is used for refilling the state cost in frame revert, frame halt, state opcode refunds. --- core/eip8037_test.go | 614 +++++++++++++++ core/state_transition.go | 18 +- .../tracing/gen_gas_change_reason_stringer.go | 6 +- core/tracing/hooks.go | 6 +- core/vm/contract.go | 16 +- core/vm/eip8037_test.go | 739 ++++++++++++++++++ core/vm/eips.go | 3 + core/vm/evm.go | 64 +- core/vm/gas_table.go | 111 +++ core/vm/gascosts.go | 118 +-- core/vm/instructions.go | 29 +- core/vm/jump_table.go | 3 + core/vm/operations_acl.go | 95 +++ core/vm/runtime/runtime.go | 2 +- 14 files changed, 1703 insertions(+), 121 deletions(-) create mode 100644 core/eip8037_test.go create mode 100644 core/vm/eip8037_test.go diff --git a/core/eip8037_test.go b/core/eip8037_test.go new file mode 100644 index 0000000000..8626c0b090 --- /dev/null +++ b/core/eip8037_test.go @@ -0,0 +1,614 @@ +// 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 . + +// Transaction- and block-level tests for EIP-8037 (multidimensional state-gas +// metering). They apply whole transactions and inspect the 2D block gas pool +// (cumulativeRegular / cumulativeState) and the receipt/peak figures. + +package core + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/beacon" + "github.com/ethereum/go-ethereum/consensus/ethash" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" +) + +var ( + cfg8037 = balChainConfig() + signer8037 = types.LatestSigner(cfg8037) + rules8037 = cfg8037.Rules(big.NewInt(0), true, 0) + senderKey, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + senderAddr = crypto.PubkeyToAddress(senderKey.PublicKey) + + // state-gas charges in units (CPSB applied). + newAccountState = uint64(params.AccountCreationSize * params.CostPerStateByte) // 183,600 + newSlotState = uint64(params.StorageCreationSize * params.CostPerStateByte) // 97,920 + authBaseState = uint64(params.AuthorizationCreationSize * params.CostPerStateByte) // 35,190 + authWorstState = newAccountState + authBaseState // 218,790 +) + +// mkState builds an in-memory StateDB from a genesis allocation. +func mkState(alloc types.GenesisAlloc) *state.StateDB { + sdb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + for addr, acc := range alloc { + sdb.CreateAccount(addr) + if acc.Balance != nil { + sdb.AddBalance(addr, uint256.MustFromBig(acc.Balance), tracing.BalanceChangeUnspecified) + } + if acc.Nonce != 0 { + sdb.SetNonce(addr, acc.Nonce, tracing.NonceChangeGenesis) + } + if len(acc.Code) != 0 { + sdb.SetCode(addr, acc.Code, tracing.CodeChangeUnspecified) + } + for k, v := range acc.Storage { + sdb.SetState(addr, k, v) + } + } + sdb.Finalise(true) + return sdb +} + +// amsterdamCoreEVM builds an Amsterdam EVM over statedb with fees disabled. +func amsterdamCoreEVM(sdb *state.StateDB) *vm.EVM { + ctx := vm.BlockContext{ + CanTransfer: CanTransfer, + Transfer: Transfer, + GetHash: func(uint64) common.Hash { return common.Hash{} }, + BlockNumber: big.NewInt(0), + Random: &common.Hash{}, + Difficulty: big.NewInt(0), + BaseFee: big.NewInt(0), + BlobBaseFee: big.NewInt(0), + GasLimit: 60_000_000, + CostPerStateByte: params.CostPerStateByte, + } + return vm.NewEVM(ctx, sdb, cfg8037, vm.Config{NoBaseFee: true}) +} + +// applyMsg applies one transaction with a fresh block gas pool and returns the +// execution result, the gas pool (for the 2D split) and any consensus error. +func applyMsg(t *testing.T, sdb *state.StateDB, tx *types.Transaction) (*ExecutionResult, *GasPool, error) { + t.Helper() + evm := amsterdamCoreEVM(sdb) + msg, err := TransactionToMessage(tx, signer8037, evm.Context.BaseFee) + if err != nil { + t.Fatalf("to message: %v", err) + } + gp := NewGasPool(evm.Context.GasLimit) + // Drive the stateTransition directly (as ApplyMessage does) so the test can + // inspect the final tx-level GasBudget vector via st.gasRemaining. + evm.SetTxContext(NewEVMTxContext(msg)) + st := newStateTransition(evm, msg, gp) + res, err := st.execute() + if err == nil && res != nil { + assertPoolSane(t, res, gp) + limit := min(msg.GasLimit, params.MaxTxGas) + assertBudgetSane(t, vm.NewGasBudget(limit, msg.GasLimit-limit), st.gasRemaining) + } + return res, gp, err +} + +// assertBudgetSane validates the final tx-level GasBudget vector: +// +// regular: RegularGas + UsedRegularGas + Spilled == initial.RegularGas +// state: StateGas + UsedStateGas == initial.StateGas + Spilled +// scalar: Used(initial) == UsedRegularGas + UsedStateGas +func assertBudgetSane(t *testing.T, initial, got vm.GasBudget) { + t.Helper() + if got.RegularGas+got.UsedRegularGas+got.Spilled != initial.RegularGas { + t.Fatalf("regular not conserved: R=%d usedR=%d spilled=%d, want sum %d", + got.RegularGas, got.UsedRegularGas, got.Spilled, initial.RegularGas) + } + if int64(got.StateGas)+got.UsedStateGas != int64(initial.StateGas)+int64(got.Spilled) { + t.Fatalf("state not conserved: S=%d usedS=%d spilled=%d, want %d+spilled", + got.StateGas, got.UsedStateGas, got.Spilled, initial.StateGas) + } + if int64(got.Used(initial)) != int64(got.UsedRegularGas)+got.UsedStateGas { + t.Fatalf("scalar mismatch: used=%d, usedR=%d usedS=%d", + got.Used(initial), got.UsedRegularGas, got.UsedStateGas) + } +} + +// assertPoolSane validates the whole 2D block-gas-pool vector after a single tx. +// +// receipt: cumulativeUsed == res.UsedGas <= res.MaxUsedGas +// pre-refund: cumulativeRegular + cumulativeState <= res.MaxUsedGas (peak) +// bottleneck: Used() == max(cumulativeRegular, cumulativeState) <= initial +func assertPoolSane(t *testing.T, res *ExecutionResult, gp *GasPool) { + t.Helper() + if gp.cumulativeUsed != res.UsedGas { + t.Fatalf("receipt scalar = %d, want UsedGas %d", gp.cumulativeUsed, res.UsedGas) + } + if res.UsedGas > res.MaxUsedGas { + t.Fatalf("post-refund gas %d exceeds peak %d", res.UsedGas, res.MaxUsedGas) + } + if sum := gp.cumulativeRegular + gp.cumulativeState; sum > res.MaxUsedGas { + t.Fatalf("regular+state %d exceeds peak %d", sum, res.MaxUsedGas) + } + if gp.Used() != max(gp.cumulativeRegular, gp.cumulativeState) { + t.Fatalf("block used %d != max(%d,%d)", gp.Used(), gp.cumulativeRegular, gp.cumulativeState) + } + if gp.Used() > gp.initial { + t.Fatalf("block used %d exceeds limit %d", gp.Used(), gp.initial) + } +} + +// senderAlloc funds the sender with the given extra accounts merged in. +func senderAlloc(extra types.GenesisAlloc) types.GenesisAlloc { + alloc := types.GenesisAlloc{senderAddr: {Balance: big.NewInt(1e18)}} + for a, acc := range extra { + alloc[a] = acc + } + return alloc +} + +// callTx builds a signed dynamic-fee call to `to` with zero fees. +func callTx(nonce uint64, to common.Address, value int64, gas uint64, data []byte) *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.DynamicFeeTx{ + ChainID: cfg8037.ChainID, Nonce: nonce, To: &to, Value: big.NewInt(value), + Gas: gas, GasFeeCap: big.NewInt(0), GasTipCap: big.NewInt(0), Data: data, + }) +} + +// createTx builds a signed contract-creation transaction. +func createTx(nonce, gas uint64, initCode []byte) *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.DynamicFeeTx{ + ChainID: cfg8037.ChainID, Nonce: nonce, To: nil, Value: big.NewInt(0), + Gas: gas, GasFeeCap: big.NewInt(0), GasTipCap: big.NewInt(0), Data: initCode, + }) +} + +var ( + deploy3 = []byte{0x60, 0x03, 0x60, 0x00, 0xf3} // init: return 3 bytes of code + revertI = []byte{0x60, 0x00, 0x60, 0x00, 0xfd} // init: REVERT +) + +// ===================== Top-level create transaction ====================== + +// A creation tx's intrinsic gas pre-charges one account creation as state gas. +func TestCreateTxIntrinsicChargesAccountUnconditionally(t *testing.T) { + cost, err := IntrinsicGas(nil, nil, nil, true, rules8037, params.CostPerStateByte) + if err != nil { + t.Fatal(err) + } + if cost.StateGas != newAccountState { + t.Fatalf("intrinsic state gas = %d, want %d", cost.StateGas, newAccountState) + } +} + +// Creating onto a pre-existing (balance-only) address refills the account +// portion; only the code deposit is charged as state gas. +func TestCreateTxPreexistingDestRefill(t *testing.T) { + derived := crypto.CreateAddress(senderAddr, 0) + sdb := mkState(senderAlloc(types.GenesisAlloc{derived: {Balance: big.NewInt(1)}})) + _, gp, err := applyMsg(t, sdb, createTx(0, 1_000_000, deploy3)) + if err != nil { + t.Fatal(err) + } + if want := uint64(3 * params.CostPerStateByte); gp.cumulativeState != want { + t.Fatalf("state gas = %d, want %d", gp.cumulativeState, want) + } +} + +// A creation tx that reverts refills the account-creation charge. +func TestCreateTxRevertRefill(t *testing.T) { + sdb := mkState(senderAlloc(nil)) + res, gp, err := applyMsg(t, sdb, createTx(0, 1_000_000, revertI)) + if err != nil { + t.Fatal(err) + } + if !res.Failed() { + t.Fatal("expected failed creation") + } + if gp.cumulativeState != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", gp.cumulativeState) + } +} + +// An address collision burns gas_left while refilling the account charge. +func TestCreateTxCollisionConsumesGasLeft(t *testing.T) { + const gas = 1_000_000 + derived := crypto.CreateAddress(senderAddr, 0) + sdb := mkState(senderAlloc(types.GenesisAlloc{derived: {Nonce: 1}})) + res, gp, err := applyMsg(t, sdb, createTx(0, gas, deploy3)) + if err != nil { + t.Fatal(err) + } + if !res.Failed() { + t.Fatal("expected collision failure") + } + if gp.cumulativeState != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", gp.cumulativeState) + } + // All forwarded gas_left is burned; only the refilled account charge (which + // had spilled into regular) returns to gas_left. So regular gas consumed is + // exactly tx.gas - newAccountState, with no other refund. + if want := uint64(gas) - newAccountState; gp.cumulativeRegular != want { + t.Fatalf("regular gas = %d, want %d", gp.cumulativeRegular, want) + } +} + +// ======================== Transaction validation ========================= + +// The regular dimension must have room for min(tx.gas, MaxTxGas). +func TestValidationRegularGasAvailable(t *testing.T) { + gp := NewGasPool(30_000_000) + gp.cumulativeRegular = 29_000_000 + if gp.CheckGasAmsterdam(2_000_000, 0) == nil { + t.Fatal("expected regular dimension full") + } + if err := gp.CheckGasAmsterdam(1_000_000, 0); err != nil { + t.Fatalf("regular fits but rejected: %v", err) + } +} + +// The state dimension must have room for the whole tx.gas. +func TestValidationStateGasAvailable(t *testing.T) { + gp := NewGasPool(30_000_000) + gp.cumulativeState = 29_000_000 + if gp.CheckGasAmsterdam(0, 2_000_000) == nil { + t.Fatal("expected state dimension full") + } + if err := gp.CheckGasAmsterdam(0, 1_000_000); err != nil { + t.Fatalf("state fits but rejected: %v", err) + } +} + +// tx.gas may exceed MaxTxGas: regular is capped at MaxTxGas while the state +// dimension reserves the full tx.gas (the excess lands in the reservoir). +func TestValidationStateGasOverflowAllowed(t *testing.T) { + gas := params.MaxTxGas + 5_000_000 + gp := NewGasPool(40_000_000) + if err := gp.CheckGasAmsterdam(min(gas, params.MaxTxGas), gas); err != nil { + t.Fatalf("overflow tx rejected at pool: %v", err) + } + // A real transfer with gas above MaxTxGas is accepted under Amsterdam. + sdb := mkState(senderAlloc(nil)) + to := common.HexToAddress("0xc0ffee") + if _, _, err := applyMsg(t, sdb, callTx(0, to, 1, gas, nil)); err != nil { + t.Fatalf("tx with gas > MaxTxGas rejected: %v", err) + } +} + +// Intrinsic regular gas above MaxTxGas (EIP-7825 cap) is rejected. +func TestValidationIntrinsicRegularCap(t *testing.T) { + al := make(types.AccessList, 8000) // ~19.2M regular, over the 16.77M cap + for i := range al { + al[i].Address = common.BigToAddress(big.NewInt(int64(i + 1))) + } + tx := types.MustSignNewTx(senderKey, signer8037, &types.DynamicFeeTx{ + ChainID: cfg8037.ChainID, Nonce: 0, To: &senderAddr, Value: big.NewInt(0), + Gas: 25_000_000, GasFeeCap: big.NewInt(0), GasTipCap: big.NewInt(0), AccessList: al, + }) + if _, _, err := applyMsg(t, mkState(senderAlloc(nil)), tx); err == nil { + t.Fatal("expected rejection for intrinsic regular over MaxTxGas") + } +} + +// ========================= Refund and gas used =========================== + +// clearSlots deploys a contract that zeroes slots 1..n, each preset to 1. +func clearSlots(addr common.Address, n int) (types.GenesisAlloc, []byte) { + var code []byte + storage := make(map[common.Hash]common.Hash, n) + for s := 1; s <= n; s++ { + code = append(code, 0x60, 0x00, 0x60, byte(s), 0x55) // PUSH1 0; PUSH1 s; SSTORE + storage[common.BytesToHash([]byte{byte(s)})] = common.BytesToHash([]byte{1}) + } + return types.GenesisAlloc{addr: {Code: append(code, 0x00), Storage: storage}}, nil +} + +// tx_gas_used_before_refund (peak) exceeds the post-refund gas used. +func TestGasUsedBeforeRefund(t *testing.T) { + c := common.HexToAddress("0xc1ea0") + alloc, _ := clearSlots(c, 1) + res, _, err := applyMsg(t, mkState(senderAlloc(alloc)), callTx(0, c, 0, 1_000_000, nil)) + if err != nil { + t.Fatal(err) + } + if res.MaxUsedGas <= res.UsedGas { + t.Fatalf("peak %d must exceed post-refund %d", res.MaxUsedGas, res.UsedGas) + } +} + +// The refund is capped at 20% of gas used before refund. +func TestRefundCappedAt20Percent(t *testing.T) { + c := common.HexToAddress("0xc1ea3") + alloc, _ := clearSlots(c, 3) // refund (3x4800) exceeds the 20% cap + res, _, err := applyMsg(t, mkState(senderAlloc(alloc)), callTx(0, c, 0, 1_000_000, nil)) + if err != nil { + t.Fatal(err) + } + if want := res.MaxUsedGas - res.MaxUsedGas/5; res.UsedGas != want { + t.Fatalf("gas used = %d, want capped %d", res.UsedGas, want) + } +} + +// The EIP-7623 calldata floor is applied after the refund. +func TestRefundCalldataFloorAfterRefund(t *testing.T) { + data := make([]byte, 1000) // all-zero calldata: floor dominates a bare call + floor, _ := FloorDataGas(rules8037, data, nil) + to := common.HexToAddress("0xeeee") + res, _, err := applyMsg(t, mkState(senderAlloc(nil)), callTx(0, to, 0, 1_000_000, data)) + if err != nil { + t.Fatal(err) + } + if res.UsedGas != floor { + t.Fatalf("gas used = %d, want floor %d", res.UsedGas, floor) + } +} + +// When the floor exceeds the post-refund gas, it negates part of the refund. +func TestRefundFloorNegatesRefund(t *testing.T) { + c := common.HexToAddress("0xc1ea1") + alloc, _ := clearSlots(c, 1) + data := make([]byte, 1000) + floor, _ := FloorDataGas(rules8037, data, nil) + res, _, err := applyMsg(t, mkState(senderAlloc(alloc)), callTx(0, c, 0, 1_000_000, data)) + if err != nil { + t.Fatal(err) + } + if res.UsedGas != floor { + t.Fatalf("gas used = %d, want floor %d (refund negated)", res.UsedGas, floor) + } +} + +// ========================= Block-level accounting ======================== + +// The pool tracks regular and state cumulatively in separate counters. +func TestBlockTracksTwoCounters(t *testing.T) { + gp := NewGasPool(60_000_000) + if err := gp.ChargeGasAmsterdam(100, 200, 300); err != nil { + t.Fatal(err) + } + if gp.cumulativeRegular != 100 || gp.cumulativeState != 200 { + t.Fatalf("counters = (%d,%d), want (100,200)", gp.cumulativeRegular, gp.cumulativeState) + } +} + +// Block gas used is the max of the two dimensions. +func TestBlockGasUsedIsMax(t *testing.T) { + gp := NewGasPool(60_000_000) + gp.ChargeGasAmsterdam(100, 200, 300) + if gp.Used() != 200 { + t.Fatalf("block used = %d, want 200", gp.Used()) + } +} + +// Block validity is checked against the max dimension, not the sum. +func TestBlockValidityAgainstMax(t *testing.T) { + gp := NewGasPool(150) + // regular 100 + state 120: sum 220 > 150 but max 120 <= 150 is valid. + if err := gp.ChargeGasAmsterdam(100, 120, 0); err != nil { + t.Fatalf("max within limit but rejected: %v", err) + } + // state 200 alone exceeds the limit. + if err := gp.ChargeGasAmsterdam(0, 200, 0); err == nil { + t.Fatal("expected block overflow on state dimension") + } +} + +// The block header gas_used reflects the bottleneck dimension (here, state), +// which the base-fee update then equilibrates against. +func TestBlockBaseFeeUsesMax(t *testing.T) { + c := common.HexToAddress("0x5707e5") + var code []byte + for s := 1; s <= 5; s++ { + code = append(code, 0x60, byte(s), 0x60, byte(s), 0x55) // SSTORE new slot s + } + env := newBALTestEnv(types.GenesisAlloc{c: {Code: append(code, 0x00)}}) + engine := beacon.New(ethash.NewFaker()) + _, blocks, _ := GenerateChainWithGenesis(env.gspec, engine, 1, func(_ int, b *BlockGen) { + b.AddTx(env.tx(0, &c, big.NewInt(0), 1_000_000, 0, nil)) + }) + if want := 5 * newSlotState; blocks[0].GasUsed() != want { + t.Fatalf("block gas used = %d, want %d (state bottleneck)", blocks[0].GasUsed(), want) + } +} + +// Receipt cumulative_gas_used is the running sum of per-tx gas (post-refund, +// post-floor), so consecutive receipts differ by exactly that tx's gas. +func TestReceiptCumulativeGasUsed(t *testing.T) { + env := newBALTestEnv(nil) + a, b := common.HexToAddress("0xaaaa"), common.HexToAddress("0xbbbb") + engine := beacon.New(ethash.NewFaker()) + _, _, receipts := GenerateChainWithGenesis(env.gspec, engine, 1, func(_ int, g *BlockGen) { + g.AddTx(env.tx(0, &a, big.NewInt(1), txGasNewAccount, 0, nil)) + g.AddTx(env.tx(1, &b, big.NewInt(1), txGasNewAccount, 0, nil)) + }) + r := receipts[0] + if got := r[1].CumulativeGasUsed - r[0].CumulativeGasUsed; got != r[1].GasUsed { + t.Fatalf("cumulative delta = %d, want tx gas %d", got, r[1].GasUsed) + } +} + +// ======================= EIP-7702 authorizations ========================= + +// signAuth signs an authorization from authKey for the given delegate and nonce. +func signAuth(t *testing.T, authKey string, delegate common.Address, nonce uint64) (types.SetCodeAuthorization, common.Address) { + t.Helper() + k, _ := crypto.HexToECDSA(authKey) + auth, err := types.SignSetCode(k, types.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(cfg8037.ChainID), Address: delegate, Nonce: nonce, + }) + if err != nil { + t.Fatalf("sign auth: %v", err) + } + return auth, crypto.PubkeyToAddress(k.PublicKey) +} + +func setCodeTx(nonce uint64, to common.Address, auths []types.SetCodeAuthorization) *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.SetCodeTx{ + ChainID: uint256.MustFromBig(cfg8037.ChainID), Nonce: nonce, To: to, Value: new(uint256.Int), + Gas: 1_000_000, GasFeeCap: new(uint256.Int), GasTipCap: new(uint256.Int), AuthList: auths, + }) +} + +const authKeyA = "0202020202020202020202020202020202020202020202020202002020202020" + +var delegate8037 = common.HexToAddress("0xde1e8a7e") + +// Intrinsic gas pre-charges the worst-case (account + indicator) per auth. +func TestAuthIntrinsicWorstCase(t *testing.T) { + cost, err := IntrinsicGas(nil, nil, []types.SetCodeAuthorization{{}}, false, rules8037, params.CostPerStateByte) + if err != nil { + t.Fatal(err) + } + if cost.StateGas != authWorstState { + t.Fatalf("intrinsic state gas = %d, want %d", cost.StateGas, authWorstState) + } +} + +// An invalid authorization refills its entire intrinsic state-gas charge. +func TestAuthInvalidRefillFull(t *testing.T) { + k, _ := crypto.HexToECDSA(authKeyA) + bad, _ := types.SignSetCode(k, types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(999), Address: delegate8037, Nonce: 0, // wrong chain id + }) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{bad})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != 0 { + t.Fatalf("state gas = %d, want 0 (fully refilled)", gp.cumulativeState) + } +} + +// A pre-existing authority refills the account portion (indicator stands). +func TestAuthAccountExistsRefill(t *testing.T) { + auth, authority := signAuth(t, authKeyA, delegate8037, 0) + sdb := mkState(senderAlloc(types.GenesisAlloc{authority: {Balance: big.NewInt(1)}})) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{auth})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != authBaseState { + t.Fatalf("state gas = %d, want %d (account refilled)", gp.cumulativeState, authBaseState) + } +} + +// Setting a delegation on an already-delegated authority refills the indicator +// portion (and the account portion, since the authority already exists). +func TestAuthSetOnDelegatedRefillBase(t *testing.T) { + auth, authority := signAuth(t, authKeyA, delegate8037, 0) + pre := types.AddressToDelegation(common.HexToAddress("0xabcd")) + sdb := mkState(senderAlloc(types.GenesisAlloc{authority: {Code: pre}})) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{auth})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != 0 { + t.Fatalf("state gas = %d, want 0 (account+indicator refilled)", gp.cumulativeState) + } +} + +// A net-new delegation on a fresh authority keeps the full worst-case charge. +func TestAuthSetNetNewNoRefill(t *testing.T) { + auth, _ := signAuth(t, authKeyA, delegate8037, 0) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{auth})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != authWorstState { + t.Fatalf("state gas = %d, want %d (no refill)", gp.cumulativeState, authWorstState) + } +} + +// Clearing a delegation writes no indicator, so the indicator portion refills. +func TestAuthClearRefillBase(t *testing.T) { + auth, _ := signAuth(t, authKeyA, common.Address{}, 0) // clear (address ZERO) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{auth})) + if err != nil { + t.Fatal(err) + } + if want := newAccountState; gp.cumulativeState != want { + t.Fatalf("state gas = %d, want %d (indicator refilled)", gp.cumulativeState, want) + } +} + +// 0->a->0 in one tx: the indicator created by an earlier auth and cleared by a +// later one writes zero net bytes, so both indicator charges refill. +func TestAuthClearSameTxDoubleRefill(t *testing.T) { + set, authority := signAuth(t, authKeyA, delegate8037, 0) + clr, _ := signAuth(t, authKeyA, common.Address{}, 1) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{set, clr})) + if err != nil { + t.Fatal(err) + } + _ = authority + if want := newAccountState; gp.cumulativeState != want { + t.Fatalf("state gas = %d, want %d (net-zero delegation)", gp.cumulativeState, want) + } +} + +// The same authority across two auths is charged for its account only once. +func TestAuthDuplicateAuthorityOnce(t *testing.T) { + a0, _ := signAuth(t, authKeyA, delegate8037, 0) + a1, _ := signAuth(t, authKeyA, delegate8037, 1) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{a0, a1})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != authWorstState { + t.Fatalf("state gas = %d, want %d (leaf+indicator once)", gp.cumulativeState, authWorstState) + } +} + +// ===================== System contracts / system calls =================== + +// System call gas limit keeps 30M regular plus a state reservoir for new slots. +func TestSystemCallGasLimit(t *testing.T) { + limit, budget := systemCallGasBudget(amsterdamCoreEVM(mkState(nil))) + if limit != 30_000_000 || budget.RegularGas != 30_000_000 { + t.Fatalf("limit/regular = %d/%d, want 30M/30M", limit, budget.RegularGas) + } +} + +// The extra system budget is placed in the state reservoir (16 new slots). +func TestSystemCallExtraInReservoir(t *testing.T) { + _, budget := systemCallGasBudget(amsterdamCoreEVM(mkState(nil))) + want := uint64(params.SystemMaxSStoresPerCall * params.CostPerStateByte * params.StorageCreationSize) + if budget.StateGas != want { + t.Fatalf("reservoir = %d, want %d", budget.StateGas, want) + } +} + +// System calls do not contribute to either block dimension: an empty block +// (whose system calls still write state) reports zero gas used. +func TestSystemCallNotCountedInBlock(t *testing.T) { + env := newBALTestEnv(nil) + engine := beacon.New(ethash.NewFaker()) + _, blocks, _ := GenerateChainWithGenesis(env.gspec, engine, 1, func(_ int, b *BlockGen) {}) + if blocks[0].GasUsed() != 0 { + t.Fatalf("block gas used = %d, want 0 (system calls excluded)", blocks[0].GasUsed()) + } +} diff --git a/core/state_transition.go b/core/state_transition.go index dac8123530..bbeb163b16 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -674,11 +674,6 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { 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 { // Check whether the init code size has been exceeded. @@ -686,12 +681,13 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { 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) + var creation bool + ret, _, result, creation, vmerr = st.evm.Create(msg.From, msg.Data, st.gasRemaining.ForwardAll(), value) + st.gasRemaining.Absorb(result) - // If the contract creation failed, refund the account-creation state - // gas pre-charged in IntrinsicGas. - if rules.IsAmsterdam && vmerr != nil { + // If the contract creation failed, or the destination was pre-existing, + // refund the account-creation state gas pre-charged in IntrinsicGas. + if rules.IsAmsterdam && !creation { st.gasRemaining.RefundState(params.AccountCreationSize * st.evm.Context.CostPerStateByte) } } else { @@ -711,7 +707,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { } // Execute the transaction's call. ret, result, vmerr = st.evm.Call(msg.From, st.to(), msg.Data, st.gasRemaining.ForwardAll(), value) - st.gasRemaining.Absorb(result, forwarded) + st.gasRemaining.Absorb(result) } // Settle down the gas usage and refund the ETH back if any remaining diff --git a/core/tracing/gen_gas_change_reason_stringer.go b/core/tracing/gen_gas_change_reason_stringer.go index 3d3aa96fad..e40f490051 100644 --- a/core/tracing/gen_gas_change_reason_stringer.go +++ b/core/tracing/gen_gas_change_reason_stringer.go @@ -28,17 +28,17 @@ func _() { _ = x[GasChangeWitnessCodeChunk-17] _ = x[GasChangeWitnessContractCollisionCheck-18] _ = x[GasChangeTxDataFloor-19] - _ = x[GasChangeAccountCreation-20] + _ = x[GasChangeRefundAccountCreation-20] _ = x[GasChangeIgnored-255] } const ( - _GasChangeReason_name_0 = "UnspecifiedTxInitialBalanceTxIntrinsicGasTxRefundsTxLeftOverReturnedCallInitialBalanceCallLeftOverReturnedCallLeftOverRefundedCallContractCreationCallContractCreation2CallCodeStorageCallOpCodeCallPrecompiledContractCallStorageColdAccessCallFailedExecutionWitnessContractInitWitnessContractCreationWitnessCodeChunkWitnessContractCollisionCheckTxDataFloorAccountCreation" + _GasChangeReason_name_0 = "UnspecifiedTxInitialBalanceTxIntrinsicGasTxRefundsTxLeftOverReturnedCallInitialBalanceCallLeftOverReturnedCallLeftOverRefundedCallContractCreationCallContractCreation2CallCodeStorageCallOpCodeCallPrecompiledContractCallStorageColdAccessCallFailedExecutionWitnessContractInitWitnessContractCreationWitnessCodeChunkWitnessContractCollisionCheckTxDataFloorRefundAccountCreation" _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, 368} + _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, 374} ) func (i GasChangeReason) String() string { diff --git a/core/tracing/hooks.go b/core/tracing/hooks.go index 78b85fafea..667c6341b4 100644 --- a/core/tracing/hooks.go +++ b/core/tracing/hooks.go @@ -472,9 +472,9 @@ 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 + // GasChangeRefundAccountCreation represents the cancellation of a + // pre-charged account-creation cost when no account is created. + GasChangeRefundAccountCreation 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. diff --git a/core/vm/contract.go b/core/vm/contract.go index 9155e9f84a..45dad42be0 100644 --- a/core/vm/contract.go +++ b/core/vm/contract.go @@ -152,10 +152,20 @@ func (c *Contract) chargeState(s uint64, logger *tracing.Hooks, reason tracing.G 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) { +// refundState refunds the pre-charged state gas back to state reservoir. +func (c *Contract) refundState(s uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) { prior := c.Gas - c.Gas.Absorb(child, forwarded) + c.Gas.RefundState(s) + + if s != 0 && logger.HasGasHook() && reason != tracing.GasChangeIgnored { + logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) + } +} + +// refundGas absorbs a sub-call's leftover GasBudget into this contract's gas state. +func (c *Contract) refundGas(child GasBudget, logger *tracing.Hooks, reason tracing.GasChangeReason) { + prior := c.Gas + c.Gas.Absorb(child) if logger.HasGasHook() && reason != tracing.GasChangeIgnored { logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) } diff --git a/core/vm/eip8037_test.go b/core/vm/eip8037_test.go new file mode 100644 index 0000000000..658096d228 --- /dev/null +++ b/core/vm/eip8037_test.go @@ -0,0 +1,739 @@ +// 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 . + +// Opcode-level tests for EIP-8037 (multidimensional state-gas metering). +// They drive a single frame via evm.Call and assert the state-gas accounting +// exposed by the returned GasBudget (UsedStateGas / StateGas / Spilled). + +package vm + +import ( + "math" + "math/big" + "math/rand" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" +) + +// state-gas charges in units (CPSB applied). +var ( + stateGasNewAccount = int64(params.AccountCreationSize * params.CostPerStateByte) // 183,600 + stateGasNewSlot = int64(params.StorageCreationSize * params.CostPerStateByte) // 97,920 +) + +// amsterdam8037Config clones MergedTestChainConfig with Amsterdam (EIP-8037) live. +func amsterdam8037Config() *params.ChainConfig { + cfg := *params.MergedTestChainConfig + cfg.AmsterdamTime = new(uint64) + blob := *cfg.BlobScheduleConfig + blob.Amsterdam = blob.Osaka + cfg.BlobScheduleConfig = &blob + return &cfg +} + +// amsterdam8037EVM builds an EVM with real value transfers and CPSB wired in. +func amsterdam8037EVM(statedb StateDB) *EVM { + ctx := BlockContext{ + CanTransfer: func(db StateDB, addr common.Address, amount *uint256.Int) bool { + return db.GetBalance(addr).Cmp(amount) >= 0 + }, + Transfer: func(db StateDB, sender, recipient common.Address, amount *uint256.Int, _ *params.Rules) { + db.SubBalance(sender, amount, tracing.BalanceChangeTransfer) + db.AddBalance(recipient, amount, tracing.BalanceChangeTransfer) + }, + BlockNumber: big.NewInt(0), + Random: &common.Hash{}, + CostPerStateByte: params.CostPerStateByte, + } + return NewEVM(ctx, statedb, amsterdam8037Config(), Config{}) +} + +// run8037 executes code at a contract address and returns the call's return +// data and the resulting budget. setup mutates the pre-state (before Finalise) +// and may fund the contract. +func run8037(t *testing.T, code []byte, gas GasBudget, value *uint256.Int, setup func(db *state.StateDB, self common.Address)) ([]byte, GasBudget, error) { + t.Helper() + self := common.BytesToAddress([]byte("self")) + statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + statedb.CreateAccount(self) + statedb.SetCode(self, code, tracing.CodeChangeUnspecified) + if setup != nil { + setup(statedb, self) + } + statedb.Finalise(true) + ret, result, err := amsterdam8037EVM(statedb).Call(common.Address{}, self, nil, gas, value) + assertBudgetSane(t, gas, result) + return ret, result, err +} + +// assertBudgetSane verifies the GasBudget conservation identities that must hold +// for any frame exit (success, revert or halt), validating the whole vector. +// +// regular: RegularGas + UsedRegularGas + Spilled == initial.RegularGas +// state: StateGas + UsedStateGas == initial.StateGas + Spilled +// scalar: Used(initial) == UsedRegularGas + UsedStateGas +func assertBudgetSane(t *testing.T, initial, got GasBudget) { + t.Helper() + if got.RegularGas+got.UsedRegularGas+got.Spilled != initial.RegularGas { + t.Fatalf("regular not conserved: R=%d usedR=%d spilled=%d, want sum %d", + got.RegularGas, got.UsedRegularGas, got.Spilled, initial.RegularGas) + } + if int64(got.StateGas)+got.UsedStateGas != int64(initial.StateGas)+int64(got.Spilled) { + t.Fatalf("state not conserved: S=%d usedS=%d spilled=%d, want %d+spilled", + got.StateGas, got.UsedStateGas, got.Spilled, initial.StateGas) + } + if int64(got.Used(initial)) != int64(got.UsedRegularGas)+got.UsedStateGas { + t.Fatalf("scalar mismatch: used=%d, usedR=%d usedS=%d", + got.Used(initial), got.UsedRegularGas, got.UsedStateGas) + } +} + +// hugeBudget is a budget that never runs out, with a separate state reservoir. +func hugeBudget() GasBudget { return NewGasBudget(math.MaxUint64/2, math.MaxUint64/2) } + +// sstore returns "PUSH val; PUSH slot; SSTORE" bytecode. +func sstore(slot, val byte) []byte { return []byte{0x60, val, 0x60, slot, 0x55} } + +// setSlot commits an original (pre-tx) value into a storage slot. +func setSlot(slot, val byte) func(*state.StateDB, common.Address) { + return func(db *state.StateDB, self common.Address) { + db.SetState(self, common.BytesToHash([]byte{slot}), common.BytesToHash([]byte{val})) + } +} + +// ============================ SSTORE state-gas ============================= + +// 0 -> 0 -> x: brand-new slot is charged one storage-creation. +func TestSStoreNewSlot(t *testing.T) { + _, res, err := run8037(t, sstore(0, 1), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateGasNewSlot { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, stateGasNewSlot) + } +} + +// 0 -> x -> 0: slot created then cleared in-tx, net charge refilled to zero. +func TestSStoreClearZeroAtStart(t *testing.T) { + code := append(sstore(0, 1), sstore(0, 0)...) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// x -> x -> 0: clearing a slot non-zero at tx start makes no state adjustment. +func TestSStoreClearOriginalNonzero(t *testing.T) { + _, res, err := run8037(t, sstore(0, 0), hugeBudget(), new(uint256.Int), setSlot(0, 1)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// x -> 0 -> x: clearing then restoring the original value makes no adjustment. +func TestSStoreRestoreOriginal(t *testing.T) { + code := append(sstore(0, 0), sstore(0, 1)...) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), setSlot(0, 1)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// x -> y: overwriting an existing slot with another value makes no adjustment. +func TestSStoreOtherWrite(t *testing.T) { + _, res, err := run8037(t, sstore(0, 2), hugeBudget(), new(uint256.Int), setSlot(0, 1)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// New-slot charge is metered at the opcode: with a reservoir smaller than the +// charge it spills into regular gas exactly at the SSTORE. +func TestSStoreChargedAtOpcodeEnd(t *testing.T) { + _, res, err := run8037(t, sstore(0, 1), NewGasBudget(1_000_000, 100), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if want := uint64(stateGasNewSlot) - 100; res.Spilled != want { + t.Fatalf("spilled = %d, want %d", res.Spilled, want) + } +} + +// The SSTORE reentrancy sentry checks gas_left only; the reservoir is excluded. +// Uses a noop write (1->1->1) so the sentry is the sole gate. +func TestSStoreStipendExcludesReservoir(t *testing.T) { + // regular at the sentry, huge reservoir: must still fail. + if _, _, err := run8037(t, sstore(0, 1), NewGasBudget(2306, math.MaxUint64/2), new(uint256.Int), setSlot(0, 1)); err == nil { + t.Fatal("expected sentry failure with regular gas at the limit") + } + // one more regular gas clears the sentry. + if _, _, err := run8037(t, sstore(0, 1), NewGasBudget(2307, math.MaxUint64/2), new(uint256.Int), setSlot(0, 1)); err != nil { + t.Fatalf("unexpected failure above sentry: %v", err) + } +} + +// ---- CALL / CREATE bytecode helpers ---- + +var ( + freshAddr = common.BytesToAddress([]byte("fresh-target")) + existAddr = common.BytesToAddress([]byte("exist-target")) + balanceAddr = common.BytesToAddress([]byte("balance-only")) + childAddr = common.BytesToAddress([]byte("child-frame")) + revertInit = []byte{0x60, 0x00, 0x60, 0x00, 0xfd} // PUSH1 0; PUSH1 0; REVERT + invalidInit = []byte{0xfe} // INVALID + deploy3Init = []byte{0x60, 0x03, 0x60, 0x00, 0xf3} // return 3 bytes of code + deploy0Init = []byte{0x60, 0x00, 0x60, 0x00, 0xf3} // return 0 bytes of code + stop = []byte{0x00} + revertTail = []byte{0x60, 0x00, 0x60, 0x00, 0xfd} + invalidTail = []byte{0xfe} + stateDeposit = int64(3 * params.CostPerStateByte) // 3-byte code deposit (4,590) +) + +// callCode builds bytecode that CALLs `to` forwarding `value` wei and all gas, +// followed by `tail`. +func callCode(to common.Address, value byte, tail []byte) []byte { + b := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, value, 0x73} + b = append(b, to.Bytes()...) + b = append(b, 0x5a, 0xf1) // GAS; CALL + return append(b, tail...) +} + +// deployCode builds bytecode that MSTOREs init and runs CREATE/CREATE2 with value. +func deployCode(init []byte, create2 bool, value byte) []byte { + word := make([]byte, 32) + copy(word[32-len(init):], init) + off, sz := byte(32-len(init)), byte(len(init)) + b := append([]byte{0x7f}, word...) // PUSH32 init-word + b = append(b, 0x60, 0x00, 0x52) // PUSH1 0; MSTORE + if create2 { + b = append(b, 0x60, 0x00, 0x60, sz, 0x60, off, 0x60, value, 0xf5) // salt,size,off,value; CREATE2 + } else { + b = append(b, 0x60, sz, 0x60, off, 0x60, value, 0xf0) // size,off,value; CREATE + } + return append(b, 0x00) // STOP +} + +func fund(addr common.Address, wei int64) func(*state.StateDB, common.Address) { + return func(db *state.StateDB, _ common.Address) { + db.AddBalance(addr, uint256.NewInt(uint64(wei)), tracing.BalanceChangeUnspecified) + } +} + +// ====================== CALL* new-account state-gas ======================= + +// CALL with value to a non-existent account charges one account creation. +func TestCallValueToNewAccount(t *testing.T) { + _, res, err := run8037(t, callCode(freshAddr, 1, stop), hugeBudget(), new(uint256.Int), fund(common.BytesToAddress([]byte("self")), 10)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateGasNewAccount { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, stateGasNewAccount) + } +} + +// CALL with value to an existing (code-bearing) account is not charged. +func TestCallValueToExistingAccount(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + db.CreateAccount(existAddr) + db.SetCode(existAddr, stop, tracing.CodeChangeUnspecified) + db.AddBalance(self, uint256.NewInt(10), tracing.BalanceChangeUnspecified) + } + _, res, err := run8037(t, callCode(existAddr, 1, stop), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// CALL with zero value creates no account, so nothing is charged. +func TestCallZeroValueToNewAccount(t *testing.T) { + _, res, err := run8037(t, callCode(freshAddr, 0, stop), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// CALL that fails before the child frame (insufficient balance) refills the charge. +func TestCallInsufficientBalanceRefill(t *testing.T) { + // self has no balance, so the value transfer fails the CanTransfer check. + _, res, err := run8037(t, callCode(freshAddr, 1, stop), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// A new-account charge is refilled when its frame reverts. +func TestCallChildRevertRefill(t *testing.T) { + code := callCode(freshAddr, 1, revertTail) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), fund(common.BytesToAddress([]byte("self")), 10)) + if err != ErrExecutionReverted { + t.Fatalf("err = %v, want revert", err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// A new-account charge is refilled when its frame halts exceptionally. +func TestCallChildExceptionalHaltRefill(t *testing.T) { + code := callCode(freshAddr, 1, invalidTail) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), fund(common.BytesToAddress([]byte("self")), 10)) + if err == nil || err == ErrExecutionReverted { + t.Fatalf("err = %v, want exceptional halt", err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// An account with balance but no code/nonce is existent: no account charge. +func TestCallBalanceOnlyAccountIsExistent(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + db.AddBalance(balanceAddr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + db.AddBalance(self, uint256.NewInt(10), tracing.BalanceChangeUnspecified) + } + _, res, err := run8037(t, callCode(balanceAddr, 1, stop), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// ===================== CREATE / CREATE2 state-gas ========================= + +// CREATE to a fresh address charges account creation plus code deposit. +func TestCreateNewAccount(t *testing.T) { + _, res, err := run8037(t, deployCode(deploy3Init, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if want := stateGasNewAccount + stateDeposit; res.UsedStateGas != want { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, want) + } +} + +// CREATE onto a pre-existing (balance-only) leaf refills the account portion; +// only the code deposit is charged. +func TestCreatePreexistingTarget(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + derived := crypto.CreateAddress(self, db.GetNonce(self)) + db.AddBalance(derived, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + } + _, res, err := run8037(t, deployCode(deploy3Init, false, 0), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateDeposit { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, stateDeposit) + } +} + +// CREATE whose init code reverts refills the account charge and deposits nothing. +func TestCreateInitRevertRefill(t *testing.T) { + _, res, err := run8037(t, deployCode(revertInit, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// CREATE whose init code halts exceptionally refills the account charge. +func TestCreateInitOOGRefill(t *testing.T) { + _, res, err := run8037(t, deployCode(invalidInit, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// CREATE onto an address collision (existing nonce) refills the account charge. +func TestCreateAddressCollisionRefill(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + derived := crypto.CreateAddress(self, db.GetNonce(self)) + db.SetNonce(derived, 1, tracing.NonceChangeUnspecified) + } + _, res, err := run8037(t, deployCode(deploy3Init, false, 0), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// CREATE with value exceeding balance fails before the frame and is refilled. +func TestCreateInsufficientBalanceRefill(t *testing.T) { + // self has no balance; CREATE forwards value 1. + _, res, err := run8037(t, deployCode(deploy3Init, false, 1), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// CREATE2 charges account creation plus code deposit identically to CREATE. +func TestCreate2SameSemantics(t *testing.T) { + _, res, err := run8037(t, deployCode(deploy3Init, true, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if want := stateGasNewAccount + stateDeposit; res.UsedStateGas != want { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, want) + } +} + +// The code-deposit portion is charged per byte independently of the account +// charge: the delta between a 3-byte and 0-byte deploy is exactly 3 x CPSB. +func TestCreateCodeDepositChargedSeparately(t *testing.T) { + _, big3, err := run8037(t, deployCode(deploy3Init, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + _, big0, err := run8037(t, deployCode(deploy0Init, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if got := big3.UsedStateGas - big0.UsedStateGas; got != stateDeposit { + t.Fatalf("deposit delta = %d, want %d", got, stateDeposit) + } +} + +// ========================= SELFDESTRUCT state-gas ========================= + +// selfdestruct sending balance to a non-existent beneficiary creates it. +func TestSelfdestructCreatesNewAccount(t *testing.T) { + code := append([]byte{0x73}, freshAddr.Bytes()...) // PUSH20 beneficiary + code = append(code, 0xff) // SELFDESTRUCT + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), fund(common.BytesToAddress([]byte("self")), 10)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateGasNewAccount { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, stateGasNewAccount) + } +} + +// selfdestruct to an existing beneficiary creates no account. +func TestSelfdestructToExistingAccount(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + db.AddBalance(existAddr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + db.AddBalance(self, uint256.NewInt(10), tracing.BalanceChangeUnspecified) + } + code := append([]byte{0x73}, existAddr.Bytes()...) + code = append(code, 0xff) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// A contract created and self-destructed in the same tx gets no refill: the +// account-creation charge stands. +func TestSelfdestructSameTxAccountNoRefill(t *testing.T) { + // init code selfdestructs to self (existing), so only the create charges. + self := common.BytesToAddress([]byte("self")) + init := append([]byte{0x73}, self.Bytes()...) + init = append(init, 0xff) + _, res, err := run8037(t, deployCode(init, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateGasNewAccount { + t.Fatalf("state gas = %d, want %d (no refill)", res.UsedStateGas, stateGasNewAccount) + } +} + +// selfdestruct of a pre-existing account refills nothing (EIP-6780: not removed). +func TestSelfdestructPreexistingNoRefill(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + db.AddBalance(existAddr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + } + code := append([]byte{0x73}, existAddr.Bytes()...) + code = append(code, 0xff) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// ===================== Reservoir / gas_left mechanics ===================== + +// State-gas is drawn from the reservoir first: a charge within reservoir size +// does not spill into regular gas. +func TestReservoirDrawnFirst(t *testing.T) { + _, res, err := run8037(t, sstore(0, 1), NewGasBudget(1_000_000, 200_000), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.Spilled != 0 { + t.Fatalf("spilled = %d, want 0", res.Spilled) + } + if want := uint64(200_000 - stateGasNewSlot); res.StateGas != want { + t.Fatalf("reservoir left = %d, want %d", res.StateGas, want) + } +} + +// The GAS opcode returns gas_left only, excluding the reservoir. +func TestGasOpcodeExcludesReservoir(t *testing.T) { + code := []byte{0x5a, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3} // GAS; MSTORE; RETURN(32) + ret, _, err := run8037(t, code, NewGasBudget(1_000_000, 500_000), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if got := new(uint256.Int).SetBytes(ret).Uint64(); got != 1_000_000-GasQuickStep { + t.Fatalf("GAS = %d, want %d (reservoir excluded)", got, 1_000_000-GasQuickStep) + } +} + +// Refills are LIFO: borrowed regular gas is repaid before the reservoir. With a +// zero reservoir, a 0->x->0 SSTORE repays the spill and leaves the reservoir at 0. +func TestLIFORefillOrder(t *testing.T) { + code := append(sstore(0, 1), sstore(0, 0)...) + _, res, err := run8037(t, code, NewGasBudget(1_000_000, 0), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.Spilled != 0 || res.StateGas != 0 || res.UsedStateGas != 0 { + t.Fatalf("after LIFO refill: spilled=%d reservoir=%d used=%d, want 0/0/0", res.Spilled, res.StateGas, res.UsedStateGas) + } +} + +// State-gas charged inside a child frame is refilled at the frame boundary when +// the child reverts or halts. +func TestStateGasMeteredAtFrameBoundary(t *testing.T) { + for _, tt := range []struct { + name string + tail []byte + }{ + {"revert", revertTail}, + {"halt", invalidTail}, + } { + t.Run(tt.name, func(t *testing.T) { + childCode := append(sstore(0, 1), tt.tail...) + setup := func(db *state.StateDB, self common.Address) { + db.CreateAccount(childAddr) + db.SetCode(childAddr, childCode, tracing.CodeChangeUnspecified) + } + _, res, err := run8037(t, callCode(childAddr, 0, stop), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled at boundary)", res.UsedStateGas) + } + }) + } +} + +// ===================== LIFO refill vector invariant ========================= + +// Charge A then B (both spilling into regular because the reservoir is too +// small), then refill only A. The refill must repay the borrowed regular gas +// first (Spilled -> 0) before crediting the reservoir, leaving B outstanding. +func TestLIFORefillRepaysRegularBeforeReservoir(t *testing.T) { + initial := NewGasBudget(1000, 100) // reservoir covers only 100 of state gas + b := initial + + b.ChargeState(150) // A: 100 from reservoir, 50 spills into regular + b.ChargeState(30) // B: reservoir empty, all 30 spills + if b.Spilled != 80 || b.StateGas != 0 { + t.Fatalf("after A+B: spilled=%d reservoir=%d, want 80/0", b.Spilled, b.StateGas) + } + + b.RefundState(150) // refill A: repay 80 to regular first, 70 tops reservoir + if b.Spilled != 0 { + t.Fatalf("spilled=%d, want 0 (regular repaid before reservoir)", b.Spilled) + } + if b.StateGas != 70 { + t.Fatalf("reservoir=%d, want 70 (remainder after repaying regular)", b.StateGas) + } + assertBudgetSane(t, initial, b) +} + +// Fuzz arbitrary sequences of state/regular charges and LIFO refills around the +// reservoir/spill boundary: the GasBudget vector must stay self-consistent after +// every op and across all three frame-exit forms, and refilling every charge +// must restore the state side exactly (reservoir to initial, nothing borrowed). +func TestLIFOVectorInvariantUnderRandomOps(t *testing.T) { + rng := rand.New(rand.NewSource(8037)) + for trial := 0; trial < 2000; trial++ { + initial := NewGasBudget(1_000_000, uint64(rng.Intn(1000))) + b := initial + outstanding := int64(0) // state-gas charged but not yet refilled + for step := 0; step < 40; step++ { + switch rng.Intn(3) { + case 0: // state charge (may spill into regular) + if s := uint64(rng.Intn(400)); b.CanAfford(GasCosts{StateGas: s}) { + b.ChargeState(s) + outstanding += int64(s) + } + case 1: // regular charge + if r := uint64(rng.Intn(400)); b.CanAfford(GasCosts{RegularGas: r}) { + b.ChargeRegular(r) + } + case 2: // LIFO refill of part of the outstanding state gas + if outstanding > 0 { + s := uint64(rng.Int63n(outstanding) + 1) + b.RefundState(s) + outstanding -= int64(s) + } + } + assertBudgetSane(t, initial, b) + assertBudgetSane(t, initial, b.ExitSuccess()) + assertBudgetSane(t, initial, b.ExitRevert()) + assertBudgetSane(t, initial, b.ExitHalt()) + } + if outstanding > 0 { + b.RefundState(uint64(outstanding)) + } + if b.Spilled != 0 || b.StateGas != initial.StateGas || b.UsedStateGas != 0 { + t.Fatalf("trial %d: after full refill spilled=%d reservoir=%d used=%d, want 0/%d/0", + trial, b.Spilled, b.StateGas, b.UsedStateGas, initial.StateGas) + } + } +} + +// ================== Halting frame terminal state (nested) =================== + +func concat(parts ...[]byte) []byte { + var b []byte + for _, p := range parts { + b = append(b, p...) + } + return b +} + +// assertHalted checks the predictable terminal budget of an exceptionally +// halted frame: regular gas fully consumed, state restored to the frame's +// initial reservoir, and no net state-gas used. +func assertHalted(t *testing.T, initial, got GasBudget) { + t.Helper() + if got.RegularGas != 0 { + t.Fatalf("RegularGas = %d, want 0 (gas_left consumed on halt)", got.RegularGas) + } + if got.StateGas != initial.StateGas { + t.Fatalf("StateGas = %d, want %d (reservoir restored)", got.StateGas, initial.StateGas) + } + if got.UsedStateGas != 0 { + t.Fatalf("UsedStateGas = %d, want 0 (all refilled)", got.UsedStateGas) + } +} + +var ( + haltGrandchild = common.BytesToAddress([]byte("grandchild")) + haltOKChild = common.BytesToAddress([]byte("child-ok")) // succeeds, calls grandchild + haltBadChild = common.BytesToAddress([]byte("child-halt")) // SSTOREs then INVALID +) + +// haltFrameChildren is a run8037 setup that funds self and deploys a 3-level +// child set: a success child that itself calls a grandchild, and a halting child. +func haltFrameChildren(db *state.StateDB, self common.Address) { + db.AddBalance(self, uint256.NewInt(1000), tracing.BalanceChangeUnspecified) + db.CreateAccount(haltGrandchild) + db.SetCode(haltGrandchild, concat(sstore(5, 5), []byte{0x00}), tracing.CodeChangeUnspecified) // new slot; STOP + db.CreateAccount(haltOKChild) + db.SetCode(haltOKChild, concat(sstore(1, 1), callCode(haltGrandchild, 0, nil), []byte{0x00}), tracing.CodeChangeUnspecified) + db.CreateAccount(haltBadChild) + db.SetCode(haltBadChild, concat(sstore(3, 3), []byte{0xfe}), tracing.CodeChangeUnspecified) // new slot; INVALID +} + +// A frame that charges state, drives a successful child (with a grandchild), a +// halting child and a new-account call, then halts, returns the predictable +// terminal budget regardless of all the descendant activity. +func TestHaltFrameTerminalState(t *testing.T) { + top := concat( + sstore(0, 1), // self: new slot + callCode(haltOKChild, 0, nil), // child + grandchild succeed + callCode(haltBadChild, 0, nil), // descendant halts (contained) + callCode(freshAddr, 1, nil), // new-account charge + []byte{0xfe}, // this frame halts + ) + initial := NewGasBudget(2_000_000, 300_000) + _, res, err := run8037(t, top, initial, new(uint256.Int), haltFrameChildren) + if err == nil || err == ErrExecutionReverted { + t.Fatalf("err = %v, want exceptional halt", err) + } + assertHalted(t, initial, res) +} + +// Fuzz: arbitrary sequences of state writes, child calls (success / halting) and +// new-account calls, always terminated by INVALID. However the descendants +// behave, a halted frame's terminal budget is always (0, initial reservoir, 0). +func TestHaltFrameTerminalStateFuzz(t *testing.T) { + rng := rand.New(rand.NewSource(80371)) + for trial := 0; trial < 400; trial++ { + steps := [][]byte{ + sstore(byte(1+rng.Intn(20)), 1), + callCode(haltOKChild, 0, nil), + callCode(haltBadChild, 0, nil), + callCode(freshAddr, 1, nil), + } + var code []byte + for n := 1 + rng.Intn(8); n > 0; n-- { + code = append(code, steps[rng.Intn(len(steps))]...) + } + code = append(code, 0xfe) // halt + initial := NewGasBudget(3_000_000, uint64(rng.Intn(400_000))) + _, res, err := run8037(t, code, initial, new(uint256.Int), haltFrameChildren) + if err == nil || err == ErrExecutionReverted { + t.Fatalf("trial %d: err = %v, want halt", trial, err) + } + assertHalted(t, initial, res) + } +} diff --git a/core/vm/eips.go b/core/vm/eips.go index f8473e65e8..814e4be788 100644 --- a/core/vm/eips.go +++ b/core/vm/eips.go @@ -587,7 +587,10 @@ func enable7843(jt *JumpTable) { // enable8037 enables the multidimensional-metering as specified in EIP-8037. func enable8037(jt *JumpTable) { jt[CREATE].constantGas = params.CreateGasAmsterdam + jt[CREATE].dynamicGas = gasCreateEip8037 jt[CREATE2].constantGas = params.CreateGasAmsterdam + jt[CREATE2].dynamicGas = gasCreate2Eip8037 + jt[CALL].dynamicGas = gasCallEIP8037 jt[SELFDESTRUCT].dynamicGas = gasSelfdestruct8037 jt[SSTORE].dynamicGas = gasSStore8037 } diff --git a/core/vm/evm.go b/core/vm/evm.go index 50d9e8ab0c..15609a0205 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -265,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, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() p, isPrecompile := evm.precompile(addr) if !evm.StateDB.Exist(addr) { if !isPrecompile && evm.chainRules.IsEIP4762 && !isSystemCall(caller) { @@ -279,7 +279,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g wgas := evm.AccessEvents.CodeHashGas(addr, true, gas.RegularGas, false) if _, ok := gas.ChargeRegular(wgas); !ok { evm.StateDB.RevertToSnapshot(snapshot) - return nil, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, gas.ExitHalt(), ErrOutOfGas } } @@ -289,16 +289,6 @@ 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. @@ -324,7 +314,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -360,7 +350,7 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt if !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance } - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { @@ -375,7 +365,7 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -406,7 +396,7 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, if evm.depth > int(params.CallCreateDepth) { return nil, gas, ErrDepth } - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { @@ -419,7 +409,7 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -453,7 +443,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 - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() // 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, @@ -471,7 +461,7 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) if err != ErrExecutionReverted { @@ -484,7 +474,7 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []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, address common.Address, typ OpCode) (ret []byte, createAddress common.Address, result GasBudget, err error) { +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, creation bool, err error) { // Depth check execution. Fail if we're trying to execute above the // limit. var nonce uint64 @@ -505,18 +495,17 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value }(gas) } if err != nil { - return nil, common.Address{}, gas, err + return nil, common.Address{}, gas, false, 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 { - return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(), false, ErrOutOfGas } if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(prior.AsTracing(), gas.AsTracing(), tracing.GasChangeWitnessContractCollisionCheck) @@ -537,13 +526,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) + halt := gas.ExitHalt() if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(gas.AsTracing(), halt.AsTracing(), tracing.GasChangeCallFailedExecution) } // EIP-8037 collision rule: the state reservoir is fully preserved on // address collision while regular gas is burnt. - return nil, common.Address{}, halt, ErrContractAddressCollision + return nil, common.Address{}, halt, false, 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 @@ -551,18 +540,7 @@ 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) - } - } + creation = true } // CreateContract means that regardless of whether the account previously existed // in the state trie or not, it _now_ becomes created as a _contract_ account. @@ -577,7 +555,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 { - return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(), false, ErrOutOfGas } prior, _ := gas.Charge(GasCosts{RegularGas: consumed}) if evm.Config.Tracer.HasGasHook() { @@ -602,17 +580,17 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) { evm.StateDB.RevertToSnapshot(snapshot) - exit := contract.Gas.Exit(err, reservoir) + exit := contract.Gas.Exit(err) if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(contract.Gas.AsTracing(), exit.AsTracing(), tracing.GasChangeCallFailedExecution) } } - return ret, address, exit, err + return ret, address, exit, false, err } // Either success, or pre-Homestead ErrCodeStoreOutOfGas (gas preserved). // Both packaged as a success-form GasBudget. - return ret, address, contract.Gas.ExitSuccess(), err + return ret, address, contract.Gas.ExitSuccess(), creation, err } // initNewContract runs a new contract's creation code, performs checks on the @@ -668,7 +646,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, result GasBudget, err error) { +func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int) (ret []byte, contractAddr common.Address, result GasBudget, creation bool, err error) { contractAddr = crypto.CreateAddress(caller, evm.StateDB.GetNonce(caller)) return evm.create(caller, code, gas, value, contractAddr, CREATE) } @@ -677,7 +655,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, result 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, creation bool, err error) { inithash := crypto.Keccak256Hash(code) contractAddr = crypto.CreateAddress2(caller, salt.Bytes32(), inithash[:]) return evm.create(caller, code, gas, endowment, contractAddr, CREATE2) diff --git a/core/vm/gas_table.go b/core/vm/gas_table.go index 550375c9c0..4bd971e711 100644 --- a/core/vm/gas_table.go +++ b/core/vm/gas_table.go @@ -519,6 +519,117 @@ func gasSelfdestruct(evm *EVM, contract *Contract, stack *Stack, mem *Memory, me return GasCosts{RegularGas: gas}, nil } +func gasCreateEip8037(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 + } + size, overflow := stack.back(2).Uint64WithOverflow() + if overflow { + return GasCosts{}, ErrGasUintOverflow + } + if err := CheckMaxInitCodeSize(&evm.chainRules, size); err != nil { + return GasCosts{}, err + } + // Since size <= MaxInitCodeSizeAmsterdam, these multiplications cannot overflow + words := (size + 31) / 32 + wordGas := params.InitCodeWordGas * words + + // Unconditionally pre-charge the account creation and refunds if the creation + // doesn't happen after the create-frame. + return GasCosts{ + RegularGas: gas + wordGas, + StateGas: params.AccountCreationSize * evm.Context.CostPerStateByte, + }, nil +} + +func gasCreate2Eip8037(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 + } + size, overflow := stack.back(2).Uint64WithOverflow() + if overflow { + return GasCosts{}, ErrGasUintOverflow + } + if err := CheckMaxInitCodeSize(&evm.chainRules, size); err != nil { + return GasCosts{}, err + } + // Since size <= MaxInitCodeSizeAmsterdam, these multiplications cannot overflow + words := (size + 31) / 32 + + // CREATE2 charges both InitCodeWordGas (EIP-3860) and Keccak256WordGas + // (for address hashing). + wordGas := (params.InitCodeWordGas + params.Keccak256WordGas) * words + + // Unconditionally pre-charge the account creation and refunds if the creation + // doesn't happen after the create-frame. + return GasCosts{ + RegularGas: gas + wordGas, + StateGas: params.AccountCreationSize * evm.Context.CostPerStateByte, + }, nil +} + +// regularGasCall8037 is the intrinsic gas calculator for CALL in Amsterdam. +// It computes memory expansion + value transfer gas but excludes new account +// creation, which is handled as state gas by the wrapper. +func regularGasCall8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { + var ( + gas uint64 + transfersValue = !stack.back(2).IsZero() + ) + if evm.readOnly && transfersValue { + return 0, ErrWriteProtection + } + memoryGas, err := memoryGasCost(mem, memorySize) + if err != nil { + return 0, err + } + var transferGas uint64 + if transfersValue && !evm.chainRules.IsEIP4762 { + transferGas = params.CallValueTransferGas + } + var overflow bool + if gas, overflow = math.SafeAdd(memoryGas, transferGas); overflow { + return 0, ErrGasUintOverflow + } + return gas, nil +} + +// stateGasCall8037 is the stateful gas calculator for CALL in Amsterdam (EIP-8037). +// It only returns the state-dependent gas (account creation as state gas). +// Memory gas, transfer gas, and callGas are handled by gasCallStateless and +// makeCallVariantGasCall. +func stateGasCall8037(evm *EVM, contract *Contract, stack *Stack) (uint64, error) { + var ( + gas uint64 + transfersValue = !stack.back(2).IsZero() + address = common.Address(stack.back(1).Bytes20()) + ) + // TODO(rjl, marius), can EIP8037 implicitly means the EIP158 is also activated? + // It's technically possible to skip the EIP158 but very unlikely in practice. + if evm.chainRules.IsEIP158 { + // 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 transfersValue && evm.StateDB.Empty(address) { + gas += params.AccountCreationSize * evm.Context.CostPerStateByte + } + } else if !evm.StateDB.Exist(address) { + gas += params.AccountCreationSize * evm.Context.CostPerStateByte + } + return gas, nil +} + func gasSelfdestruct8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { if evm.readOnly { return GasCosts{}, ErrWriteProtection diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index 881575e0a8..10b8eeb033 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -65,6 +65,11 @@ type GasBudget struct { 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 + + // Spilled tracks how much of this frame's regular gas (gas_left) + // has been borrowed to cover state-gas charges that exceeded the + // reservoir. + Spilled uint64 } // NewGasBudget initializes a fresh GasBudget for execution / forwarding, @@ -82,7 +87,7 @@ func (g GasBudget) Used(initial GasBudget) uint64 { // String returns a visual representation of the budget. func (g GasBudget) String() string { - return fmt.Sprintf("<%v,%v,used=<%v,%v>>", g.RegularGas, g.StateGas, g.UsedRegularGas, g.UsedStateGas) + return fmt.Sprintf("<%v,%v,used=<%v,%v>,borrowed=%v>", g.RegularGas, g.StateGas, g.UsedRegularGas, g.UsedStateGas, g.Spilled) } // Charge deducts a combined regular+state cost from the running balance and @@ -104,6 +109,21 @@ func (g *GasBudget) chargeRegularOnly(r uint64) bool { return true } +// CanAfford reports whether the running budget can cover the given cost vector +// without going out of gas. The regular cost must fit in the regular balance, +// and any state gas in excess of the reservoir must be coverable by the +// remaining regular gas (the spillover), mirroring charge without mutating. +func (g GasBudget) CanAfford(cost GasCosts) bool { + if g.RegularGas < cost.RegularGas { + return false + } + regular := g.RegularGas - cost.RegularGas + if cost.StateGas > g.StateGas { + return cost.StateGas-g.StateGas <= regular + } + return true +} + // charge deducts both the state and regular cost. func (g *GasBudget) charge(cost GasCosts) bool { if g.RegularGas < cost.RegularGas { @@ -111,6 +131,7 @@ func (g *GasBudget) charge(cost GasCosts) bool { } regular := g.RegularGas - cost.RegularGas state := g.StateGas + spilled := g.Spilled if cost.StateGas > state { spillover := cost.StateGas - state @@ -119,6 +140,7 @@ func (g *GasBudget) charge(cost GasCosts) bool { } regular -= spillover state = 0 + spilled += spillover } else { state -= cost.StateGas } @@ -126,6 +148,7 @@ func (g *GasBudget) charge(cost GasCosts) bool { g.StateGas = state g.UsedRegularGas += cost.RegularGas g.UsedStateGas += int64(cost.StateGas) + g.Spilled = spilled return true } @@ -151,14 +174,25 @@ func (g *GasBudget) IsZero() bool { } // 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 +// Per EIP-8037, the refund repays the regular gas previously borrowed for +// state-gas spillover (tracked by Spilled) before crediting the +// reservoir: it is returned to RegularGas up to the outstanding borrowed +// amount, and only the remainder tops up StateGas. // -// which the revert path relies on for the correct gross refund. +// The signed usage counter is decremented by the full refund regardless of the +// split, preserving the per-frame invariant: +// +// StateGas + UsedStateGas == initialStateGas + Spilled +// +// which the revert and halt paths rely on for the correct gross refund. func (g *GasBudget) RefundState(s uint64) { - g.StateGas += s + repay := min(s, g.Spilled) + g.RegularGas += repay + g.Spilled -= repay + + // Whatever is left tops up the reservoir. + g.StateGas += s - repay g.UsedStateGas -= int64(s) } @@ -206,54 +240,49 @@ 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. +// ExitRevert produces the leftover for a REVERT exit. The frame's state +// changes are discarded, so all state gas it charged is refilled to its origin +// (EIP-8037): up to Spilled is returned to RegularGas (the regular +// gas it borrowed), and the remainder restores the reservoir. Because the +// borrowed regular gas is repaid first, the reservoir is made whole back to its +// start-of-frame value. func (g GasBudget) ExitRevert() GasBudget { - reservoir := int64(g.StateGas) + g.UsedStateGas + reservoir := int64(g.StateGas) + g.UsedStateGas - int64(g.Spilled) if reservoir < 0 { // Reservoir should never be negative. By construction it equals - // the initial state-gas allocation plus any spillover to regular - // gas. + // the initial state-gas allocation. reservoir = 0 - log.Warn("Negative reservoir at revert", "remaining", g.StateGas, "used", g.UsedStateGas) + log.Warn("Negative reservoir at revert", "remaining", g.StateGas, "used", g.UsedStateGas, "borrowed", g.Spilled) } return GasBudget{ - RegularGas: g.RegularGas, + RegularGas: g.RegularGas + g.Spilled, StateGas: uint64(reservoir), UsedRegularGas: g.UsedRegularGas, UsedStateGas: 0, + Spilled: 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 +// ExitHalt produces the leftover for an exceptional halt. As with a revert, the +// frame's state changes are rolled back and its state gas is refilled to origin +// (EIP-8037); the difference is that the frame's gas_left is consumed rather +// than returned. The portion refilled to RegularGas is therefore burned along +// with the rest of gas_left, leaving only the reservoir portion to survive, +// which equals the reservoir's value at the start of the frame. +func (g GasBudget) ExitHalt() GasBudget { + reservoir := int64(g.StateGas) + g.UsedStateGas - int64(g.Spilled) if reservoir < 0 { // Reservoir should never be negative. By construction it equals - // the initial state-gas allocation plus any spillover to regular - // gas. + // the initial state-gas allocation. 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 + log.Warn("Negative reservoir at halt", "remaining", g.StateGas, "used", g.UsedStateGas, "borrowed", g.Spilled) } return GasBudget{ RegularGas: 0, - StateGas: initStateReservoir, - UsedRegularGas: g.UsedRegularGas + g.RegularGas + spilled, + StateGas: uint64(reservoir), + UsedRegularGas: g.UsedRegularGas + g.RegularGas + g.Spilled, UsedStateGas: 0, + Spilled: 0, } } @@ -263,17 +292,14 @@ func (g GasBudget) ExitHalt(initStateReservoir uint64) GasBudget { // - 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 { +func (g GasBudget) Exit(err error) GasBudget { switch { case err == nil: return g.ExitSuccess() case err == ErrExecutionReverted: return g.ExitRevert() default: - return g.ExitHalt(initStateReservoir) + return g.ExitHalt() } } @@ -281,18 +307,12 @@ func (g GasBudget) Exit(err error, initStateReservoir uint64) GasBudget { // 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 - +func (g *GasBudget) Absorb(child GasBudget) { g.UsedRegularGas -= child.RegularGas g.RegularGas += child.RegularGas g.StateGas = child.StateGas g.UsedStateGas += child.UsedStateGas - g.UsedRegularGas -= spillover + g.UsedRegularGas -= child.Spilled + g.Spilled += child.Spilled } diff --git a/core/vm/instructions.go b/core/vm/instructions.go index 209457f670..b20cce4c0d 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -646,7 +646,7 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { stackvalue := size child := scope.Contract.forwardGas(forward, evm.Config.Tracer, tracing.GasChangeCallContractCreation) - res, addr, result, suberr := evm.Create(scope.Contract.Address(), input, child, &value) + res, addr, result, creation, 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 @@ -661,8 +661,12 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Stack.push(&stackvalue) // Refund the leftover gas back to current frame - scope.Contract.refundGas(result, forward, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + // Refund the state gas of account-creation if creation doesn't happen + if evm.GetRules().IsAmsterdam && !creation { + scope.Contract.refundState(params.AccountCreationSize*evm.Context.CostPerStateByte, evm.Config.Tracer, tracing.GasChangeRefundAccountCreation) + } if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer return res, nil @@ -685,7 +689,7 @@ func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // reuse size int for stackvalue stackvalue := size child := scope.Contract.forwardGas(forward, evm.Config.Tracer, tracing.GasChangeCallContractCreation2) - res, addr, result, suberr := evm.Create2(scope.Contract.Address(), input, child, &endowment, &salt) + res, addr, result, creation, 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() @@ -695,8 +699,12 @@ func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Stack.push(&stackvalue) // Refund the leftover gas back to current frame - scope.Contract.refundGas(result, forward, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + // Refund the state gas of account-creation if creation doesn't happen + if evm.GetRules().IsAmsterdam && !creation { + scope.Contract.refundState(params.AccountCreationSize*evm.Context.CostPerStateByte, evm.Config.Tracer, tracing.GasChangeRefundAccountCreation) + } if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer return res, nil @@ -740,8 +748,13 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + // If the call frame reverts or halts exceptionally, the charged state-gas + // is refilled back to the state reservoir in Amsterdam. + if evm.chainRules.IsAmsterdam && err != nil && !value.IsZero() && evm.StateDB.Empty(toAddr) { + scope.Contract.refundState(params.AccountCreationSize*evm.Context.CostPerStateByte, evm.Config.Tracer, tracing.GasChangeRefundAccountCreation) + } evm.returnData = ret return ret, nil } @@ -776,7 +789,7 @@ func opCallCode(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -808,7 +821,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(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -841,7 +854,7 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil diff --git a/core/vm/jump_table.go b/core/vm/jump_table.go index 9ea8349e3a..5cc5e34ced 100644 --- a/core/vm/jump_table.go +++ b/core/vm/jump_table.go @@ -28,6 +28,9 @@ type ( 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) + + regularGasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (uint64, error) + stateGasFunc func(*EVM, *Contract, *Stack) (uint64, error) ) type operation struct { diff --git a/core/vm/operations_acl.go b/core/vm/operations_acl.go index 2206fb95fa..8b2a627fef 100644 --- a/core/vm/operations_acl.go +++ b/core/vm/operations_acl.go @@ -262,6 +262,7 @@ var ( gasDelegateCallEIP7702 = makeCallVariantGasCallEIP7702(gasDelegateCallIntrinsic) gasStaticCallEIP7702 = makeCallVariantGasCallEIP7702(gasStaticCallIntrinsic) gasCallCodeEIP7702 = makeCallVariantGasCallEIP7702(gasCallCodeIntrinsic) + innerGasCallEIP8037 = makeCallVariantGasCallEIP8037(regularGasCall8037, stateGasCall8037) ) func gasCallEIP7702(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { @@ -276,6 +277,14 @@ func gasCallEIP7702(evm *EVM, contract *Contract, stack *Stack, mem *Memory, mem return innerGasCallEIP7702(evm, contract, stack, mem, memorySize) } +func gasCallEIP8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + transfersValue := !stack.back(2).IsZero() + if evm.readOnly && transfersValue { + return GasCosts{}, ErrWriteProtection + } + return innerGasCallEIP8037(evm, contract, stack, mem, memorySize) +} + func makeCallVariantGasCallEIP7702(intrinsicFunc intrinsicGasFunc) gasFunc { return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { var ( @@ -362,3 +371,89 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc intrinsicGasFunc) gasFunc { return GasCosts{RegularGas: totalCost}, nil } } + +// makeCallVariantGasCallEIP8037 creates a call gas function for Amsterdam (EIP-8037). +// It extends the EIP-7702 pattern with state gas handling and GasUsed tracking. +// intrinsicFunc computes the regular gas (memory + transfer, no new account creation). +// stateGasFunc computes the state gas (new account creation as state gas). +func makeCallVariantGasCallEIP8037(regularFunc regularGasFunc, stateGasFunc stateGasFunc) gasFunc { + return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + var ( + eip2929Cost uint64 + eip7702Cost uint64 + addr = common.Address(stack.back(1).Bytes20()) + ) + // EIP-2929 cold access check. + if !evm.StateDB.AddressInAccessList(addr) { + evm.StateDB.AddAddressToAccessList(addr) + eip2929Cost = params.ColdAccountAccessCostEIP2929 - params.WarmStorageReadCostEIP2929 + if !contract.chargeRegular(eip2929Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + return GasCosts{}, ErrOutOfGas + } + } + + // Compute regular cost (memory + transfer, no new account creation). + regularCost, err := regularFunc(evm, contract, stack, mem, memorySize) + if err != nil { + return GasCosts{}, err + } + + // Charge intrinsic cost directly (regular gas). This must happen + // BEFORE state gas to prevent reservoir inflation, and also serves + // as the OOG guard before stateful operations. + if !contract.chargeRegular(regularCost, evm.Config.Tracer, tracing.GasChangeCallOpCode) { + return GasCosts{}, ErrOutOfGas + } + + // EIP-7702 delegation check. + if target, ok := types.ParseDelegation(evm.StateDB.GetCode(addr)); ok { + if evm.StateDB.AddressInAccessList(target) { + eip7702Cost = params.WarmStorageReadCostEIP2929 + } else { + evm.StateDB.AddAddressToAccessList(target) + eip7702Cost = params.ColdAccountAccessCostEIP2929 + } + if !contract.chargeRegular(eip7702Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + return GasCosts{}, ErrOutOfGas + } + } + + // Compute and charge state gas (new account creation) AFTER regular gas. + stateGas, err := stateGasFunc(evm, contract, stack) + if err != nil { + return GasCosts{}, err + } + if stateGas > 0 { + if _, ok := contract.Gas.ChargeState(stateGas); !ok { + return GasCosts{}, ErrOutOfGas + } + } + + // Calculate the gas budget for the nested call (63/64 rule). + evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, 0, stack.back(0)) + if err != nil { + return GasCosts{}, err + } + + // Temporarily undo direct regular charges for tracer reporting. + // The interpreter will charge the returned totalCost. + contract.Gas.RegularGas += eip2929Cost + eip7702Cost + regularCost + contract.Gas.UsedRegularGas -= eip2929Cost + eip7702Cost + regularCost + + // Aggregate total cost. + var ( + overflow bool + totalCost uint64 + ) + if totalCost, overflow = math.SafeAdd(eip2929Cost, eip7702Cost); overflow { + return GasCosts{}, ErrGasUintOverflow + } + if totalCost, overflow = math.SafeAdd(totalCost, regularCost); overflow { + return GasCosts{}, ErrGasUintOverflow + } + if totalCost, overflow = math.SafeAdd(totalCost, evm.callGasTemp); overflow { + return GasCosts{}, ErrGasUintOverflow + } + return GasCosts{RegularGas: totalCost}, nil + } +} diff --git a/core/vm/runtime/runtime.go b/core/vm/runtime/runtime.go index 34dec1bd4a..9a15f7ac96 100644 --- a/core/vm/runtime/runtime.go +++ b/core/vm/runtime/runtime.go @@ -179,7 +179,7 @@ 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, result, err := vmenv.Create( + code, address, result, _, err := vmenv.Create( cfg.Origin, input, vm.NewGasBudget(cfg.GasLimit, 0), From 12241bde26bf3fc3af7fec77a41136c1056c3142 Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Tue, 23 Jun 2026 14:46:38 +0800 Subject: [PATCH 5/9] core: add debug log and repair the missing hash and receipts (#35190) This PR improves the block download used by snap sync. Specifically, blocks and their associated data (receipts and canonical hash mappings) are now written directly to the database without checking existence. The current implementation could fail in cases where the block header and body were already present (has.Block returns true), but the corresponding canonical hash mapping was missing. One possible scenario is when a newPayload event is processed without a subsequent forkChoiceUpdate. It is still unclear why Geth may re-enter snap sync after Engine API events have been processed after the sync. Anyway, bypassing the existence is a reasonable change. What's more, in the downloader, the presence of canonical hash is also considered for deciding the range of blocks to be downloaded. Specifically: - in the full sync, the block with header and body available but canonical hash missing will be re-inserted; - in the snap sync, the block with header, body and receipt available but canonical hash missing will be re-inserted; --- core/blockchain.go | 34 ++++---------- core/rawdb/chain_freezer.go | 5 ++- core/rawdb/freezer.go | 3 ++ eth/downloader/beaconsync.go | 17 ++++--- eth/downloader/downloader.go | 4 ++ eth/downloader/downloader_test.go | 75 +++++++++++++++++++++++++++++++ eth/downloader/skeleton.go | 8 +++- eth/downloader/skeleton_test.go | 67 +++++++++++++++++++++++++++ 8 files changed, 180 insertions(+), 33 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index ecb33ce0b8..d6edd90133 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -1540,27 +1540,12 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [ // existing local chain segments (reorg around the chain tip). The reorganized part // will be included in the provided chain segment, and stale canonical markers will be // silently rewritten. Therefore, no explicit reorg logic is needed. - writeLive := func(blockChain types.Blocks, receiptChain []rlp.RawValue) (int, error) { - var ( - skipPresenceCheck = false - batch = bc.db.NewBatch() - ) + writeLive := func(blockChain types.Blocks, receiptChain []rlp.RawValue) error { + batch := bc.db.NewBatch() for i, block := range blockChain { // Short circuit insertion if shutting down or processing failed if bc.insertStopped() { - return 0, errInsertionInterrupted - } - if !skipPresenceCheck { - // Ignore if the entire data is already known - if bc.HasBlock(block.Hash(), block.NumberU64()) { - stats.ignored++ - continue - } else { - // If block N is not present, neither are the later blocks. - // This should be true, but if we are mistaken, the shortcut - // here will only cause overwriting of some existing data - skipPresenceCheck = true - } + return errInsertionInterrupted } // Write all the data out into the database rawdb.WriteCanonicalHash(batch, block.Hash(), block.NumberU64()) @@ -1572,7 +1557,7 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [ // except transaction indexes(will be created once sync is finished). if batch.ValueSize() >= ethdb.IdealBatchSize { if err := batch.Write(); err != nil { - return 0, err + return err } size += int64(batch.ValueSize()) batch.Reset() @@ -1585,13 +1570,10 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [ if batch.ValueSize() > 0 { size += int64(batch.ValueSize()) if err := batch.Write(); err != nil { - return 0, err + return err } } - if err := updateHead(blockChain[len(blockChain)-1].Header()); err != nil { - return 0, err - } - return 0, nil + return updateHead(blockChain[len(blockChain)-1].Header()) } // Split the supplied blocks into two groups, according to the @@ -1608,11 +1590,11 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [ } } if index != len(blockChain) { - if n, err := writeLive(blockChain[index:], receiptChain[index:]); err != nil { + if err := writeLive(blockChain[index:], receiptChain[index:]); err != nil { if err == errInsertionInterrupted { return 0, nil } - return n, err + return 0, err } } var ( diff --git a/core/rawdb/chain_freezer.go b/core/rawdb/chain_freezer.go index 4fe85d843b..572b9c4fcd 100644 --- a/core/rawdb/chain_freezer.go +++ b/core/rawdb/chain_freezer.go @@ -311,7 +311,10 @@ func (f *chainFreezer) freezeRange(nfdb *nofreezedb, number, limit uint64) (hash // Retrieve all the components of the canonical block. hash := ReadCanonicalHash(nfdb, number) if hash == (common.Hash{}) { - return fmt.Errorf("canonical hash missing, can't freeze block %d", number) + // A missing canonical mapping at the freeze frontier is almost + // always an orphaned block left by an unclean stop (header/body + // present by hash, but no number->hash mapping). + return fmt.Errorf("canonical hash missing, can't freeze block %d (block data present at height: %v)", number, ReadAllHashes(nfdb, number)) } header := ReadHeaderRLP(nfdb, hash, number) if len(header) == 0 { diff --git a/core/rawdb/freezer.go b/core/rawdb/freezer.go index e5c7548aef..26899fbe73 100644 --- a/core/rawdb/freezer.go +++ b/core/rawdb/freezer.go @@ -351,6 +351,9 @@ func (f *Freezer) TruncateTail(group string, tail uint64) (uint64, error) { // SyncAncient flushes all data tables to disk. func (f *Freezer) SyncAncient() error { + f.writeLock.RLock() + defer f.writeLock.RUnlock() + var errs []error for _, table := range f.tables { if err := table.Sync(); err != nil { diff --git a/eth/downloader/beaconsync.go b/eth/downloader/beaconsync.go index 23daafa8f6..246fe7637b 100644 --- a/eth/downloader/beaconsync.go +++ b/eth/downloader/beaconsync.go @@ -188,12 +188,17 @@ func (d *Downloader) findBeaconAncestor() (uint64, error) { } log.Debug("Searching beacon ancestor", "local", number, "beaconhead", beaconHead.Number, "beacontail", beaconTail.Number) - var linked bool + // Require the canonical mapping, not just presence by hash, so orphans and + // side chains are re-delivered instead of left in place. + var ( + linked bool + num = beaconTail.Number.Uint64() - 1 + ) switch d.getMode() { case ethconfig.FullSync: - linked = d.blockchain.HasBlock(beaconTail.ParentHash, beaconTail.Number.Uint64()-1) + linked = d.blockchain.GetCanonicalHash(num) == beaconTail.ParentHash && d.blockchain.HasBlock(beaconTail.ParentHash, num) case ethconfig.SnapSync: - linked = d.blockchain.HasFastBlock(beaconTail.ParentHash, beaconTail.Number.Uint64()-1) + linked = d.blockchain.GetCanonicalHash(num) == beaconTail.ParentHash && d.blockchain.HasFastBlock(beaconTail.ParentHash, num) default: panic("unknown sync mode") } @@ -226,12 +231,14 @@ func (d *Downloader) findBeaconAncestor() (uint64, error) { } n := h.Number.Uint64() + // Require the canonical mapping, not just presence by hash, so orphans + // and side chains are re-synced instead of treated as already owned. var known bool switch d.getMode() { case ethconfig.FullSync: - known = d.blockchain.HasBlock(h.Hash(), n) + known = d.blockchain.GetCanonicalHash(n) == h.Hash() && d.blockchain.HasBlock(h.Hash(), n) case ethconfig.SnapSync: - known = d.blockchain.HasFastBlock(h.Hash(), n) + known = d.blockchain.GetCanonicalHash(n) == h.Hash() && d.blockchain.HasFastBlock(h.Hash(), n) default: panic("unknown sync mode") } diff --git a/eth/downloader/downloader.go b/eth/downloader/downloader.go index f23d9396ee..60b04e945b 100644 --- a/eth/downloader/downloader.go +++ b/eth/downloader/downloader.go @@ -187,6 +187,10 @@ type BlockChain interface { // HasFastBlock verifies a snap block's presence in the local chain. HasFastBlock(common.Hash, uint64) bool + // GetCanonicalHash returns the canonical hash for the block at the given + // number, or the zero hash if no canonical block is present at that height. + GetCanonicalHash(uint64) common.Hash + // GetBlockByHash retrieves a block from the local chain. GetBlockByHash(common.Hash) *types.Block diff --git a/eth/downloader/downloader_test.go b/eth/downloader/downloader_test.go index de3a3f1991..5a1b6b94c6 100644 --- a/eth/downloader/downloader_test.go +++ b/eth/downloader/downloader_test.go @@ -32,6 +32,7 @@ import ( "github.com/ethereum/go-ethereum/eth/ethconfig" "github.com/ethereum/go-ethereum/eth/protocols/eth" "github.com/ethereum/go-ethereum/eth/protocols/snap" + "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/rlp" @@ -40,6 +41,7 @@ import ( // downloadTester is a test simulator for mocking out local block chain. type downloadTester struct { + db ethdb.Database chain *core.BlockChain downloader *Downloader @@ -77,6 +79,7 @@ func newTesterWithSnap(t *testing.T, mode ethconfig.SyncMode, success func(), sn panic(err) } tester := &downloadTester{ + db: db, chain: chain, peers: make(map[string]*downloadTesterPeer), } @@ -683,6 +686,78 @@ func testBeaconSync(t *testing.T, protocol uint, mode SyncMode) { } } +// TestBeaconSyncRepairFork verifies the end-to-end repair of non-canonical block +// data. The local node sits on fork A, but fork B's blocks below the local head +// are also present by hash (no canonical mapping), as if imported optimistically +// via the engine API. When the beacon chain switches to fork B, sync must not +// anchor on the non-canonical fork-B data; it has to descend to the real common +// ancestor and re-deliver everything, ending with the full fork-B chain present +// and canonical at every height - for both snap and full sync. +func TestBeaconSyncRepairForkFull(t *testing.T) { testBeaconSyncRepairFork(t, eth.ETH69, FullSync) } +func TestBeaconSyncRepairForkSnap(t *testing.T) { testBeaconSyncRepairFork(t, eth.ETH69, SnapSync) } + +func testBeaconSyncRepairFork(t *testing.T, protocol uint, mode SyncMode) { + // Reuse the pre-generated fork chains (new chains can't be generated after the + // package init). Fork A and fork B share the whole testChainBase prefix and + // diverge at height len(testChainBase.blocks); fork B (the beacon target) is + // longer, so it wins the reorg. The exact shortenings used here are the ones + // registered as peer chains during init. + localChain := testChainForkLightA.shorten(len(testChainBase.blocks) + 80) + targetChain := testChainForkLightB.shorten(len(testChainBase.blocks) + MaxHeaderFetch) + + forkPoint := uint64(len(testChainBase.blocks)) // first height the forks differ + localHead := uint64(len(localChain.blocks) - 1) + targetHead := uint64(len(targetChain.blocks) - 1) + + success := make(chan struct{}) + tester := newTesterWithNotification(t, mode, func() { + close(success) + }) + defer tester.terminate() + + tester.newPeer("peer", protocol, targetChain.blocks[1:]) + + // Make fork A the local canonical chain. + if _, err := tester.chain.InsertChain(localChain.blocks[1 : localHead+1]); err != nil { + t.Fatalf("failed to build local chain: %v", err) + } + // Seed fork B's divergent blocks that sit below the local head as scattered, + // non-canonical data: full block data present by hash, but the canonical + // mapping at those heights still points at fork A. + for n := forkPoint; n <= localHead; n++ { + b := targetChain.blocks[n] + rawdb.WriteBlock(tester.db, b) + rawdb.WriteReceipts(tester.db, b.Hash(), b.NumberU64(), types.Receipts{}) + } + + if err := tester.downloader.BeaconSync(targetChain.blocks[targetHead].Header(), nil); err != nil { + t.Fatalf("failed to beacon-sync chain: %v", err) + } + select { + case <-success: + case <-time.NewTimer(10 * time.Second).C: + t.Fatalf("failed to sync chain in ten seconds") + } + // The head must reach fork B's tip. + if got := tester.chain.CurrentBlock().Number.Uint64(); got != targetHead { + t.Fatalf("synced head mismatch: have %d, want %d", got, targetHead) + } + // Every height must be canonical to fork B and carry complete block data, + // proving the non-canonical fork-A / seed data was fully reorged out. + for n := uint64(1); n <= targetHead; n++ { + want := targetChain.blocks[n].Hash() + if got := rawdb.ReadCanonicalHash(tester.db, n); got != want { + t.Fatalf("canonical hash at %d: have %x, want %x", n, got, want) + } + if !rawdb.HasHeader(tester.db, want, n) || !rawdb.HasBody(tester.db, want, n) { + t.Fatalf("incomplete block data at %d after sync", n) + } + if !rawdb.HasReceipts(tester.db, want, n) { + t.Fatalf("missing receipts at %d after sync", n) + } + } +} + // Tests that synchronisation progress (origin block number, current block number // and highest block number) is tracked and updated correctly. func TestSyncProgressFull(t *testing.T) { testSyncProgress(t, eth.ETH69, FullSync) } diff --git a/eth/downloader/skeleton.go b/eth/downloader/skeleton.go index e693bfc066..e28f760d45 100644 --- a/eth/downloader/skeleton.go +++ b/eth/downloader/skeleton.go @@ -365,7 +365,13 @@ func (s *skeleton) Sync(head *types.Header, final *types.Header, force bool) err // linked returns the flag indicating whether the skeleton has been linked with // the local chain. func (s *skeleton) linked(number uint64, hash common.Hash) bool { - linked := rawdb.HasHeader(s.db, hash, number) && + // Require the canonical mapping, not just presence by hash. A block present + // only by hash (side chain or orphan from an unclean shutdown) must not be + // used as the link-up point, otherwise it's left in place forever without its + // canonical mapping ever being rewritten. Keep descending to a real canonical + // block. + linked := rawdb.ReadCanonicalHash(s.db, number) == hash && + rawdb.HasHeader(s.db, hash, number) && rawdb.HasBody(s.db, hash, number) && rawdb.HasReceipts(s.db, hash, number) diff --git a/eth/downloader/skeleton_test.go b/eth/downloader/skeleton_test.go index b9e7dba6ee..9962d4f1a3 100644 --- a/eth/downloader/skeleton_test.go +++ b/eth/downloader/skeleton_test.go @@ -841,6 +841,7 @@ func TestSkeletonSyncRetrievals(t *testing.T) { rawdb.WriteBlock(db, types.NewBlockWithHeader(chain[0])) rawdb.WriteReceipts(db, chain[0].Hash(), chain[0].Number.Uint64(), types.Receipts{}) + rawdb.WriteCanonicalHash(db, chain[0].Hash(), chain[0].Number.Uint64()) // Create a peer set to feed headers through peerset := newPeerSet() @@ -871,6 +872,7 @@ func TestSkeletonSyncRetrievals(t *testing.T) { rawdb.WriteBlock(db, types.NewBlockWithHeader(header)) rawdb.WriteReceipts(db, header.Hash(), header.Number.Uint64(), types.Receipts{}) + rawdb.WriteCanonicalHash(db, header.Hash(), header.Number.Uint64()) rawdb.DeleteSkeletonHeader(db, header.Number.Uint64()) @@ -881,6 +883,7 @@ func TestSkeletonSyncRetrievals(t *testing.T) { rawdb.WriteBlock(db, types.NewBlockWithHeader(filled)) rawdb.WriteReceipts(db, filled.Hash(), filled.Number.Uint64(), types.Receipts{}) + rawdb.WriteCanonicalHash(db, filled.Hash(), filled.Number.Uint64()) }, suspendHook: func() *types.Header { @@ -941,6 +944,70 @@ func TestSkeletonSyncRetrievals(t *testing.T) { } } +// TestSkeletonLinkSkipsNonCanonical verifies that the skeleton only links to a +// local block that is canonical, and descends past block data that is present by +// hash but lacks a canonical number->hash mapping (e.g. blocks imported +// optimistically via the engine API, or orphans left by an unclean shutdown). +// Anchoring on such a block would leave it in place forever without its canonical +// mapping being rewritten, wedging the freezer later on. +func TestSkeletonLinkSkipsNonCanonical(t *testing.T) { + // Build a fake header chain; the skeleton only needs a parent-hash progression. + chain := []*types.Header{{Number: big.NewInt(0)}} + for i := 1; i < 200; i++ { + chain = append(chain, &types.Header{ + ParentHash: chain[i-1].Hash(), + Number: big.NewInt(int64(i)), + }) + } + const ( + canon = 100 // blocks [0..canon] are present AND canonical locally + top = 150 // blocks [canon+1..top] are present by hash but NOT canonical + ) + db := rawdb.NewMemoryDatabase() + + // Canonical prefix: full block data plus the canonical number->hash mapping. + for i := 0; i <= canon; i++ { + rawdb.WriteBlock(db, types.NewBlockWithHeader(chain[i])) + rawdb.WriteReceipts(db, chain[i].Hash(), chain[i].Number.Uint64(), types.Receipts{}) + rawdb.WriteCanonicalHash(db, chain[i].Hash(), chain[i].Number.Uint64()) + } + // Scattered non-canonical data: full block data is present, but the canonical + // mapping is deliberately absent. + for i := canon + 1; i <= top; i++ { + rawdb.WriteBlock(db, types.NewBlockWithHeader(chain[i])) + rawdb.WriteReceipts(db, chain[i].Hash(), chain[i].Number.Uint64(), types.Receipts{}) + } + + // Feed the full chain through a single peer. The fakeChainReader reports an + // effectively infinite snap head, so the canonical mapping is the only thing + // that can prevent the skeleton from linking on a non-canonical block. + peerset := newPeerSet() + drop := func(peer string) { peerset.Unregister(peer) } + peer := newSkeletonTestPeer("peer", chain) + if err := peerset.Register(newPeerConnection(peer.id, eth.ETH69, peer, log.New("id", peer.id))); err != nil { + t.Fatalf("failed to register peer: %v", err) + } + skeleton := newSkeleton(db, peerset, drop, newHookedBackfiller(), &fakeChainReader{}) + skeleton.Sync(chain[len(chain)-1], nil, true) + defer skeleton.Terminate() + + // The skeleton must link at the canonical block, i.e. the resulting subchain + // tail must descend to canon+1 (covering the whole non-canonical region). With + // a hash-only "known" check it would instead stop at top+1. + wantTail := uint64(canon + 1) + var progress skeletonProgress + for deadline := time.Now().Add(2 * time.Second); ; { + json.Unmarshal(rawdb.ReadSkeletonSyncStatus(db), &progress) + if len(progress.Subchains) == 1 && progress.Subchains[0].Tail == wantTail { + break + } + if time.Now().After(deadline) { + t.Fatalf("skeleton did not link at canonical block: subchains=%+v, want single subchain with tail %d", progress.Subchains, wantTail) + } + time.Sleep(20 * time.Millisecond) + } +} + func checkSkeletonProgress(db ethdb.KeyValueReader, unpredictable bool, peers []*skeletonTestPeer, expected skeletonExpect) error { var progress skeletonProgress // Check the post-init end state if it matches the required results From 38678ec51604d6c1d3f0c5ed6233bfd7bb6c334d Mon Sep 17 00:00:00 2001 From: Marius van der Wijden Date: Thu, 25 Jun 2026 13:09:48 +0200 Subject: [PATCH 6/9] params: bump max code size as per EIP-7954 (#35217) --- params/protocol_params.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/params/protocol_params.go b/params/protocol_params.go index 69e10fa5d9..cf6ee21ddc 100644 --- a/params/protocol_params.go +++ b/params/protocol_params.go @@ -139,7 +139,7 @@ const ( MaxCodeSize = 24576 // Maximum bytecode to permit for a contract MaxInitCodeSize = 2 * MaxCodeSize // Maximum initcode to permit in a creation transaction and create instructions - MaxCodeSizeAmsterdam = 32768 // Maximum bytecode to permit for a contract post Amsterdam + MaxCodeSizeAmsterdam = 65536 // Maximum bytecode to permit for a contract post Amsterdam MaxInitCodeSizeAmsterdam = 2 * MaxCodeSizeAmsterdam // Maximum initcode to permit in a creation transaction and create instructions post Amsterdam // Precompiled contract gas prices From a63e2f124716c1f86e0a841d0abad017af21fa05 Mon Sep 17 00:00:00 2001 From: rjl493456442 Date: Thu, 25 Jun 2026 19:10:54 +0800 Subject: [PATCH 7/9] eth/downloader: fix test panic (#35215) This PR addresses the panic in tests. As the eventLoop is spun up when the downloader was closed, the sub will be nil and make the panic happens. ``` goroutine 421 [running]: github.com/ethereum/go-ethereum/eth/downloader.(*DownloaderAPI).eventLoop(0xcb0e4d0) /opt/actions-runner/_work/go-ethereum/go-ethereum/eth/downloader/api.go:91 +0x127 created by github.com/ethereum/go-ethereum/eth/downloader.NewDownloaderAPI in goroutine 352 /opt/actions-runner/_work/go-ethereum/go-ethereum/eth/downloader/api.go:50 +0xf2 ``` --- eth/downloader/api.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/eth/downloader/api.go b/eth/downloader/api.go index 6033e44474..4f950cd671 100644 --- a/eth/downloader/api.go +++ b/eth/downloader/api.go @@ -62,9 +62,12 @@ func NewDownloaderAPI(d *Downloader, chain *core.BlockChain) *DownloaderAPI { // If the node is already synced up, then only a single event subscribers will // receive is {false}. func (api *DownloaderAPI) eventLoop() { + events := make(chan SyncEvent, 16) + sub := api.d.SubscribeSyncEvents(events) + if sub == nil { + return + } var ( - events = make(chan SyncEvent, 16) - sub = api.d.SubscribeSyncEvents(events) syncSubscriptions = make(map[chan interface{}]struct{}) checkInterval = time.Second * 60 checkTimer = time.NewTimer(checkInterval) From 4387f433c927bf4d35db3cf6c21e739715cc0835 Mon Sep 17 00:00:00 2001 From: Marius van der Wijden Date: Thu, 25 Jun 2026 16:12:07 +0200 Subject: [PATCH 8/9] core: implement EIP-8282: Builder Execution Requests (#35175) implements https://github.com/ethereum/EIPs/pull/11760/changes#diff-0c9428673c7c725120dae93fda8a181c38bcfb1759d45e8accaf73b14e1f35cb --------- Co-authored-by: Gary Rong --- core/bal_test.go | 14 ++++++++++---- core/bintrie_witness_test.go | 2 ++ core/genesis.go | 3 +++ core/state_processor.go | 25 +++++++++++++++++++++++-- params/config.go | 5 +++++ params/protocol_params.go | 6 ++++++ 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/core/bal_test.go b/core/bal_test.go index b27901c083..6b79f86c43 100644 --- a/core/bal_test.go +++ b/core/bal_test.go @@ -70,6 +70,8 @@ func newBALTestEnv(extra types.GenesisAlloc) *balTestEnv { params.HistoryStorageAddress: {Nonce: 1, Code: params.HistoryStorageCode, Balance: common.Big0}, params.WithdrawalQueueAddress: {Nonce: 1, Code: params.WithdrawalQueueCode, Balance: common.Big0}, params.ConsolidationQueueAddress: {Nonce: 1, Code: params.ConsolidationQueueCode, Balance: common.Big0}, + params.BuilderDepositAddress: {Nonce: 1, Code: params.BuilderDepositCode, Balance: common.Big0}, + params.BuilderExitAddress: {Nonce: 1, Code: params.BuilderExitCode, Balance: common.Big0}, } maps.Copy(alloc, extra) return &balTestEnv{ @@ -992,10 +994,12 @@ func TestBALMidTxBalanceRoundTrip(t *testing.T) { // means all four of the post-merge system contracts touched by every // Amsterdam block: // -// - EIP-4788 beacon roots (pre-execution, when ParentBeaconRoot is set) -// - EIP-2935 history storage (pre-execution) -// - EIP-7002 withdrawal queue (post-execution) -// - EIP-7251 consolidation queue (post-execution) +// - EIP-4788 beacon roots (pre-execution, when ParentBeaconRoot is set) +// - EIP-2935 history storage (pre-execution) +// - EIP-7002 withdrawal queue (post-execution) +// - EIP-7251 consolidation queue (post-execution) +// - EIP-8282 builder-deposit queue (post-execution) +// - EIP-8282 builder-exit queue (post-execution) func TestBALSystemContractsPresent(t *testing.T) { env := newBALTestEnv(nil) @@ -1013,6 +1017,8 @@ func TestBALSystemContractsPresent(t *testing.T) { {"HistoryStorage (2935)", params.HistoryStorageAddress}, {"WithdrawalQueue (7002)", params.WithdrawalQueueAddress}, {"ConsolidationQueue (7251)", params.ConsolidationQueueAddress}, + {"BuilderDepositQueue (8282)", params.BuilderDepositAddress}, + {"BuilderExitQueue (8282)", params.BuilderExitAddress}, } { if findAccount(b, sys.addr) == nil { t.Errorf("%s (%x) MUST appear in BAL but is missing\n%s", sys.name, sys.addr, b.PrettyPrint()) diff --git a/core/bintrie_witness_test.go b/core/bintrie_witness_test.go index e151a72801..5d82fc1df8 100644 --- a/core/bintrie_witness_test.go +++ b/core/bintrie_witness_test.go @@ -85,6 +85,8 @@ func TestProcessUBT(t *testing.T) { params.HistoryStorageAddress: {Nonce: 1, Code: params.HistoryStorageCode, Balance: common.Big0}, params.WithdrawalQueueAddress: {Nonce: 1, Code: params.WithdrawalQueueCode, Balance: common.Big0}, params.ConsolidationQueueAddress: {Nonce: 1, Code: params.ConsolidationQueueCode, Balance: common.Big0}, + params.BuilderDepositAddress: {Nonce: 1, Code: params.BuilderDepositCode, Balance: common.Big0}, + params.BuilderExitAddress: {Nonce: 1, Code: params.BuilderExitCode, Balance: common.Big0}, }, } ) diff --git a/core/genesis.go b/core/genesis.go index e1c67e57c2..ec3e70a975 100644 --- a/core/genesis.go +++ b/core/genesis.go @@ -718,6 +718,9 @@ func DeveloperGenesisBlock(gasLimit uint64, faucet *common.Address) *Genesis { params.HistoryStorageAddress: {Nonce: 1, Code: params.HistoryStorageCode, Balance: common.Big0}, params.WithdrawalQueueAddress: {Nonce: 1, Code: params.WithdrawalQueueCode, Balance: common.Big0}, params.ConsolidationQueueAddress: {Nonce: 1, Code: params.ConsolidationQueueCode, Balance: common.Big0}, + // EIP-8282 - Builder Execution Requests + params.BuilderDepositAddress: {Nonce: 1, Code: params.BuilderDepositCode, Balance: common.Big0}, + params.BuilderExitAddress: {Nonce: 1, Code: params.BuilderExitCode, Balance: common.Big0}, }, } if faucet != nil { diff --git a/core/state_processor.go b/core/state_processor.go index f40aee0301..48a8da4062 100644 --- a/core/state_processor.go +++ b/core/state_processor.go @@ -167,10 +167,9 @@ func PostExecution(ctx context.Context, config *params.ChainConfig, number *big. if config.IsAmsterdam(number, time) { blockAccessList = bal.NewConstructionBlockAccessList() } + rules := config.Rules(number, true, time) // IsMerge is always true // Read requests if Prague is enabled. if config.IsPrague(number, time) { - rules := config.Rules(number, true, time) // IsMerge is always true - requests = [][]byte{} // EIP-6110 if err := ParseDepositLogs(&requests, allLogs, config); err != nil { @@ -185,6 +184,16 @@ func PostExecution(ctx context.Context, config *params.ChainConfig, number *big. return nil, nil, fmt.Errorf("failed to process consolidation queue: %w", err) } } + + if config.IsAmsterdam(number, time) { + // EIP-8282 + if err := ProcessBuilderDepositQueue(&requests, rules, evm, blockAccessIndex, blockAccessList); err != nil { + return nil, nil, fmt.Errorf("failed to process builder deposit queue: %w", err) + } + if err := ProcessBuilderExitQueue(&requests, rules, evm, blockAccessIndex, blockAccessList); err != nil { + return nil, nil, fmt.Errorf("failed to process builder exit queue: %w", err) + } + } return requests, blockAccessList, nil } @@ -361,6 +370,18 @@ func ProcessConsolidationQueue(requests *[][]byte, rules params.Rules, evm *vm.E return processRequestsSystemCall(requests, rules, evm, 0x02, params.ConsolidationQueueAddress, blockAccessIndex, blockAccessList) } +// ProcessBuilderDepositQueue calls the EIP-8282 builder deposit contract. +// It returns the opaque request data returned by the contract. +func ProcessBuilderDepositQueue(requests *[][]byte, rules params.Rules, evm *vm.EVM, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error { + return processRequestsSystemCall(requests, rules, evm, 0x03, params.BuilderDepositAddress, blockAccessIndex, blockAccessList) +} + +// ProcessBuilderExitQueue calls the EIP-8282 builder exit contract. +// It returns the opaque request data returned by the contract. +func ProcessBuilderExitQueue(requests *[][]byte, rules params.Rules, evm *vm.EVM, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error { + return processRequestsSystemCall(requests, rules, evm, 0x04, params.BuilderExitAddress, blockAccessIndex, blockAccessList) +} + func processRequestsSystemCall(requests *[][]byte, rules params.Rules, evm *vm.EVM, requestType byte, addr common.Address, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error { if tracer := evm.Config.Tracer; tracer != nil { onSystemCallStart(tracer, evm.GetVMContext()) diff --git a/params/config.go b/params/config.go index 17508cbf27..1d69335f97 100644 --- a/params/config.go +++ b/params/config.go @@ -1198,6 +1198,11 @@ func (c *ChainConfig) BlobConfig(fork forks.Fork) *BlobConfig { func (c *ChainConfig) ActiveSystemContracts(time uint64) map[string]common.Address { fork := c.LatestFork(time) active := make(map[string]common.Address) + if fork >= forks.Amsterdam { + // EIP-8282 - Builder Execution Requests + active["BUILDER_DEPOSIT_CONTRACT_ADDRESS"] = BuilderDepositAddress + active["BUILDER_EXIT_CONTRACT_ADDRESS"] = BuilderExitAddress + } if fork >= forks.Osaka { // no new system contracts } diff --git a/params/protocol_params.go b/params/protocol_params.go index cf6ee21ddc..84ded9df48 100644 --- a/params/protocol_params.go +++ b/params/protocol_params.go @@ -240,6 +240,12 @@ var ( // EIP-7251 - Increase the MAX_EFFECTIVE_BALANCE ConsolidationQueueAddress = common.HexToAddress("0x0000BBdDc7CE488642fb579F8B00f3a590007251") ConsolidationQueueCode = common.FromHex("3373fffffffffffffffffffffffffffffffffffffffe1460d35760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461019a57600182026001905f5b5f82111560685781019083028483029004916001019190604d565b9093900492505050366060146088573661019a573461019a575f5260205ff35b341061019a57600154600101600155600354806004026004013381556001015f358155600101602035815560010160403590553360601b5f5260605f60143760745fa0600101600355005b6003546002548082038060021160e7575060025b5f5b8181146101295782810160040260040181607402815460601b815260140181600101548152602001816002015481526020019060030154905260010160e9565b910180921461013b5790600255610146565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561017357505f5b6001546001828201116101885750505f61018e565b01600190035b5f555f6001556074025ff35b5f5ffd") + + // EIP-8282 - Builder Execution Requests + BuilderDepositAddress = common.HexToAddress("0x0000884d2AA32eAa155F59A2f24eFa73D9008282") + BuilderDepositCode = common.FromHex("0x3373fffffffffffffffffffffffffffffffffffffffe146101065760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461023457600182026001905f5b5f82111560695781019083028483029004916001019190604e565b90939004925050503660b814608957366102345734610234575f5260205ff35b8034106102345760383567ffffffffffffffff1680633b9aca001161023457633b9aca00029034031061023457600154600101600155600354806006026004015f358155600101602035815560010160403581556001016060358155600101608035815560010160a035905560b85f5f3760b85fa0600101600355005b600354600254808203806101001161011d57506101005b5f5b8181146101c3578281016006026004018160b8028154815260200181600101548152602001816002015480825260401c67ffffffffffffffff16816010018160381c81600701538160301c81600601538160281c81600501538160201c81600401538160181c81600301538160101c81600201538160081c81600101535360200181600301548152602001816004015481526020019060050154905260010161011f565b91018092146101d557906002556101e0565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561020d57505f5b6001546020828201116102225750505f610228565b01602090035b5f555f60015560b8025ff35b5f5ffd") + BuilderExitAddress = common.HexToAddress("0x000014574A74c805590AFF9499fc7A690f008282") + BuilderExitCode = common.FromHex("0x3373fffffffffffffffffffffffffffffffffffffffe1460cb5760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461018857600182026001905f5b5f82111560685781019083028483029004916001019190604d565b909390049250505036603014608857366101885734610188575f5260205ff35b341061018857600154600101600155600354806003026004013381556001015f35815560010160203590553360601b5f5260305f60143760445fa0600101600355005b6003546002548082038060101160df575060105b5f5b8181146101175782810160030260040181604402815460601b8152601401816001015481526020019060020154905260010160e1565b91018092146101295790600255610134565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561016157505f5b6001546002828201116101765750505f61017c565b01600290035b5f555f6001556044025ff35b5f5ffd") ) // System log events. From ab134b2101b5017d0512ab9b00bfa461632ec902 Mon Sep 17 00:00:00 2001 From: cui Date: Fri, 26 Jun 2026 16:10:56 +0800 Subject: [PATCH 9/9] cmd/geth: release storage iterator in snapshot dump (#35222) ## Summary - Release the storage iterator after iterating slots in `geth snapshot dump`, matching the existing account iterator cleanup. ## Test plan - [x] `go build ./cmd/geth/...` - [ ] Manual: run `geth snapshot dump` on a node with storage data and verify output is unchanged --- cmd/geth/snapshot.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/geth/snapshot.go b/cmd/geth/snapshot.go index 08454f579e..8fedc21c27 100644 --- a/cmd/geth/snapshot.go +++ b/cmd/geth/snapshot.go @@ -922,6 +922,7 @@ func dumpState(ctx *cli.Context) error { for stIt.Next() { da.Storage[stIt.Hash()] = common.Bytes2Hex(stIt.Slot()) } + stIt.Release() } enc.Encode(da) accounts++