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 }