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/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++
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/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/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/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/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/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/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/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 0000000000..ab791d1e00
Binary files /dev/null and b/core/rawdb/eradb/testdata/noreceipts/sepolia-00000-deadbeef-noreceipts.ere differ
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/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/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),
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)
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
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..50b5519762 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,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.8
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 +83,16 @@ 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/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
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/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).
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
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 69e10fa5d9..84ded9df48 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
@@ -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.
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
}