diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000000..1002533eaf4 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "WebFetch(domain:gist.githubusercontent.com)" + ] + } +} diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..537ac4d33c7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +## Summary + + +## Executed tests + + +## Rollout notes + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..10e1cc95c39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# AGENTS.md — erigon + +## Team standards + +At the start of every session, fetch the latest PoS team standards from: + +`` + +These rules apply to every change in this repo unless this repo's +`.claude/rules/` explicitly overrides them. + + +## Repo-specific guidance + +Refer to this repo's `README.md` for architecture and commands. Team +standards (above) apply to all changes. `CLAUDE.md` is a thin +`@AGENTS.md` import — both runtimes converge on this file. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..43c994c2d36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/core/state/intra_block_state.go b/core/state/intra_block_state.go index 2fc2e7b7b91..d26d0e6b0aa 100644 --- a/core/state/intra_block_state.go +++ b/core/state/intra_block_state.go @@ -871,6 +871,16 @@ func (sdb *IntraBlockState) setState(addr common.Address, key common.Hash, value return nil } +// SetStateOverride applies an eth_call stateDiff slot as base state. Not for block execution. +func (sdb *IntraBlockState) SetStateOverride(addr common.Address, key common.Hash, value uint256.Int) error { + stateObject, err := sdb.GetOrNewStateObject(addr) + if err != nil { + return err + } + stateObject.setCommittedStorage(key, value) + return nil +} + // SetStorage replaces the entire storage for the specified account with given // storage. This function should only be used for debugging. func (sdb *IntraBlockState) SetStorage(addr common.Address, storage Storage) error { diff --git a/core/state/state_object.go b/core/state/state_object.go index 24b060e917b..b4b526118ce 100644 --- a/core/state/state_object.go +++ b/core/state/state_object.go @@ -283,6 +283,14 @@ func (so *stateObject) setState(key common.Hash, value uint256.Int) { so.dirtyStorage[key] = value } +// setCommittedStorage makes stateDiff visible as both current and original storage. +func (so *stateObject) setCommittedStorage(key common.Hash, value uint256.Int) { + // Drop replayed writes so the override wins after callMany-style replay. + delete(so.dirtyStorage, key) + so.originStorage[key] = value + so.blockOriginStorage[key] = value +} + // updateStotage writes cached storage modifications into the object's storage trie. func (so *stateObject) updateStotage(stateWriter StateWriter) error { for key, value := range so.dirtyStorage { diff --git a/core/state/state_test.go b/core/state/state_test.go index 63839001558..dbe89f7a01a 100644 --- a/core/state/state_test.go +++ b/core/state/state_test.go @@ -147,6 +147,39 @@ func TestSnapshot(t *testing.T) { require.Equal(t, uint256.Int{}, value) } +func TestSetStateOverrideReplacesDirtyStorage(t *testing.T) { + t.Parallel() + _, tx, domains := NewTestRwTx(t) + + err := rawdbv3.TxNums.Append(tx, 1, 1) + require.NoError(t, err) + + state := New(NewReaderV3(domains.AsGetter(tx))) + + addr := toAddr([]byte("aa")) + key := common.BigToHash(uint256.NewInt(1).ToBig()) + replayed := *uint256.NewInt(1) + override := *uint256.NewInt(2) + + require.NoError(t, state.SetState(addr, key, replayed)) + require.NoError(t, state.FinalizeTx(&chain.Rules{}, NewNoopWriter())) + + var value uint256.Int + require.NoError(t, state.GetState(addr, key, &value)) + require.Equal(t, replayed, value) + + require.NoError(t, state.SetStateOverride(addr, key, override)) + require.NoError(t, state.GetState(addr, key, &value)) + require.Equal(t, override, value) + require.NoError(t, state.GetCommittedState(addr, key, &value)) + require.Equal(t, override, value) + + obj, err := state.getStateObject(addr) + require.NoError(t, err) + _, dirty := obj.dirtyStorage[key] + require.False(t, dirty) +} + func TestSnapshotEmpty(t *testing.T) { t.Parallel() _, tx, domains := NewTestRwTx(t) diff --git a/core/vm/runtime/state_override_warm_reset_test.go b/core/vm/runtime/state_override_warm_reset_test.go new file mode 100644 index 00000000000..42e2503165c --- /dev/null +++ b/core/vm/runtime/state_override_warm_reset_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package runtime + +import ( + "encoding/binary" + "testing" + + "github.com/holiman/uint256" + + "github.com/erigontech/erigon-lib/common" + "github.com/erigontech/erigon/core/state" + "github.com/erigontech/erigon/core/vm" +) + +func TestEthCallStateDiffOverride_WarmResetSSTORE(t *testing.T) { + t.Parallel() + + // Warm slot 1, SSTORE slot 1 := 2, and return the gas delta around SSTORE. + code := []byte{ + byte(vm.PUSH1), 0x01, + byte(vm.SLOAD), + byte(vm.POP), + byte(vm.GAS), + byte(vm.PUSH1), 0x02, + byte(vm.PUSH1), 0x01, + byte(vm.SSTORE), + byte(vm.GAS), + byte(vm.SWAP1), + byte(vm.SUB), + byte(vm.PUSH1), 0x00, + byte(vm.MSTORE), + byte(vm.PUSH1), 0x20, + byte(vm.PUSH1), 0x00, + byte(vm.RETURN), + } + + addr := common.HexToAddress("0xaa") + slot := common.BigToHash(uint256.NewInt(1).ToBig()) + one := *uint256.NewInt(1) + + // stateDiff must be visible as the original value, or SSTORE takes the cheap dirty path. + const sstoreResetWarm = 2900 // EIP-2200 reset (warm): SstoreResetGas - ColdSloadCost. + const sstoreDirty = 100 // EIP-2200 dirty update: WarmStorageReadCost. + const sandwich = 3 + 3 + 2 // PUSH1 + PUSH1 + GAS around the inner SSTORE. + + measure := func(seed func(s *state.IntraBlockState)) uint64 { + db := testTemporalDB(t) + tx, domains := testTemporalTxSD(t, db) + s := state.New(state.NewReaderV3(domains.AsGetter(tx))) + s.SetCode(addr, code) + seed(s) + ret, _, err := Call(addr, nil, &Config{State: s}) + if err != nil { + t.Fatalf("Call: %v", err) + } + if len(ret) != 32 { + t.Fatalf("ret len: %d", len(ret)) + } + return binary.BigEndian.Uint64(ret[24:]) + } + + t.Run("broken/raw SetState matches Erigon pre-fix", func(t *testing.T) { + got := measure(func(s *state.IntraBlockState) { + if err := s.SetState(addr, slot, one); err != nil { + t.Fatalf("SetState: %v", err) + } + }) + if want := uint64(sstoreDirty + sandwich); got != want { + t.Fatalf("got %d, want %d", got, want) + } + }) + + t.Run("fixed/SetStateOverride matches Bor/geth", func(t *testing.T) { + got := measure(func(s *state.IntraBlockState) { + if err := s.SetStateOverride(addr, slot, one); err != nil { + t.Fatalf("SetStateOverride: %v", err) + } + }) + if want := uint64(sstoreResetWarm + sandwich); got != want { + t.Fatalf("got %d, want %d", got, want) + } + }) + + t.Run("delta is the EIP-2200 reset/dirty spread", func(t *testing.T) { + broken := measure(func(s *state.IntraBlockState) { _ = s.SetState(addr, slot, one) }) + fixed := measure(func(s *state.IntraBlockState) { _ = s.SetStateOverride(addr, slot, one) }) + if fixed-broken != sstoreResetWarm-sstoreDirty { + t.Fatalf("spread %d, want %d (broken=%d fixed=%d)", + fixed-broken, sstoreResetWarm-sstoreDirty, broken, fixed) + } + }) +} + +// Regression: stateDiff must win over a replay-dirtied slot. +// eth_callMany applies overrides after replay, and FinalizeTx leaves dirtyStorage in memory. +func TestEthCallStateDiffOverride_BeatsReplayDirty(t *testing.T) { + t.Parallel() + + code := []byte{ + byte(vm.PUSH1), 0x01, + byte(vm.SLOAD), + byte(vm.PUSH1), 0x00, + byte(vm.MSTORE), + byte(vm.PUSH1), 0x20, + byte(vm.PUSH1), 0x00, + byte(vm.RETURN), + } + addr := common.HexToAddress("0xaa") + slot := common.BigToHash(uint256.NewInt(1).ToBig()) + replay := *uint256.NewInt(0xdead) + override := *uint256.NewInt(0x42) + + db := testTemporalDB(t) + tx, domains := testTemporalTxSD(t, db) + s := state.New(state.NewReaderV3(domains.AsGetter(tx))) + s.SetCode(addr, code) + + if err := s.SetState(addr, slot, replay); err != nil { + t.Fatalf("seed replay: %v", err) + } + if err := s.SetStateOverride(addr, slot, override); err != nil { + t.Fatalf("SetStateOverride: %v", err) + } + + var got uint256.Int + if err := s.GetCommittedState(addr, slot, &got); err != nil { + t.Fatalf("GetCommittedState: %v", err) + } + if got.Cmp(&override) != 0 { + t.Fatalf("GetCommittedState: got %s, want %s", got.Hex(), override.Hex()) + } + if err := s.GetState(addr, slot, &got); err != nil { + t.Fatalf("GetState: %v", err) + } + if got.Cmp(&override) != 0 { + t.Fatalf("GetState: got %s, want %s", got.Hex(), override.Hex()) + } + + ret, _, err := Call(addr, nil, &Config{State: s}) + if err != nil { + t.Fatalf("Call: %v", err) + } + if len(ret) != 32 { + t.Fatalf("ret len: %d", len(ret)) + } + var sload uint256.Int + sload.SetBytes(ret) + if sload.Cmp(&override) != 0 { + t.Fatalf("SLOAD: got %s, want %s", sload.Hex(), override.Hex()) + } +} diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 7e68d362b7b..6781e90cf94 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -1002,7 +1002,7 @@ func (at *AggregatorRoTx) PruneSmallBatches(ctx context.Context, timeout time.Du furiousPrune := timeout > 5*time.Hour aggressivePrune := !furiousPrune && timeout >= 1*time.Minute - var pruneLimit uint64 = 100 + var pruneLimit uint64 = uint64(dbg.EnvInt("ERIGON_PRUNE_LIMIT", 100)) if furiousPrune { pruneLimit = 1_000_000 } diff --git a/db/version/app.go b/db/version/app.go index e06afec1310..19a02644e87 100644 --- a/db/version/app.go +++ b/db/version/app.go @@ -31,7 +31,7 @@ var ( const ( Major = 3 // Major version component of the current release Minor = 6 // Minor version component of the current release - Micro = 0 // Micro version component of the current release + Micro = 1 // Micro version component of the current release Modifier = "" // Modifier component of the current release DefaultSnapshotGitBranch = "main" // Branch of erigontech/erigon-snapshot to use in OtterSync VersionKeyCreated = "ErigonVersionCreated" diff --git a/execution/chain/chain_config.go b/execution/chain/chain_config.go index fe32bf31c11..e3ad3b2b58a 100644 --- a/execution/chain/chain_config.go +++ b/execution/chain/chain_config.go @@ -217,6 +217,8 @@ type BorConfig interface { IsMadhugiriPro(num uint64) bool GetMadhugiriBlock() *big.Int GetMadhugiriProBlock() *big.Int + IsDandeli(num uint64) bool + GetDandeliBlock() *big.Int IsLisovo(num uint64) bool IsLisovoPro(num uint64) bool GetLisovoBlock() *big.Int diff --git a/execution/stagedsync/exec3.go b/execution/stagedsync/exec3.go index 18e2a182cf6..c6e9248d717 100644 --- a/execution/stagedsync/exec3.go +++ b/execution/stagedsync/exec3.go @@ -763,7 +763,7 @@ Loop: timeStart := time.Now() // allow greedy prune on non-chain-tip - pruneTimeout := 250 * time.Millisecond + pruneTimeout := time.Duration(dbg.EnvInt("ERIGON_PRUNE_TIMEOUT_MS", 250)) * time.Millisecond if initialCycle { pruneTimeout = 10 * time.Hour diff --git a/execution/stagedsync/stage_execute.go b/execution/stagedsync/stage_execute.go index eb614bedff3..276d30b058e 100644 --- a/execution/stagedsync/stage_execute.go +++ b/execution/stagedsync/stage_execute.go @@ -436,12 +436,12 @@ func PruneExecutionStage(s *PruneState, tx kv.RwTx, cfg ExecuteBlockCfg, ctx con // - stop prune when `tx.SpaceDirty()` is big // - and set ~500ms timeout // because on slow disks - prune is slower. but for now - let's tune for nvme first, and add `tx.SpaceDirty()` check later https://github.com/erigontech/erigon/issues/11635 - quickPruneTimeout := 500 * time.Millisecond + quickPruneTimeout := time.Duration(dbg.EnvInt("ERIGON_PRUNE_CHANGESETS_TIMEOUT_MS", 500)) * time.Millisecond if s.ForwardProgress > cfg.syncCfg.MaxReorgDepth && !cfg.syncCfg.AlwaysGenerateChangesets { // (chunkLen is 8Kb) * (1_000 chunks) = 8mb // Some blocks on bor-mainnet have 400 chunks of diff = 3mb - var pruneDiffsLimitOnChainTip = 1_000 + var pruneDiffsLimitOnChainTip = dbg.EnvInt("ERIGON_PRUNE_CHANGESETS_LIMIT", 1000) pruneTimeout := quickPruneTimeout if s.CurrentSyncCycle.IsInitialCycle { pruneDiffsLimitOnChainTip = math.MaxInt diff --git a/execution/types/state_sync_tx.go b/execution/types/state_sync_tx.go index 2e6b1c54687..38c03ad8387 100644 --- a/execution/types/state_sync_tx.go +++ b/execution/types/state_sync_tx.go @@ -134,8 +134,11 @@ func (tx *StateSyncTx) RawSignatureValues() (*uint256.Int, *uint256.Int, *uint25 func (tx *StateSyncTx) EncodingSize() int { var b bytes.Buffer _ = tx.encode(&b) - data := make([]byte, 1+b.Len()) - return rlp.StringLen(data) + // Return envelope size (type byte + encoded payload) without the outer + // string prefix. EncodingSizeGenericList adds the prefix itself, so + // including it here would double-count and produce an oversized RLP + // frame, causing "value size exceeds available input length" error on peers. + return 1 + b.Len() } // EncodeRLP implements rlp.Encoder for database storage. diff --git a/execution/types/transaction_test.go b/execution/types/transaction_test.go index 40c1010260c..9b11cc9acde 100644 --- a/execution/types/transaction_test.go +++ b/execution/types/transaction_test.go @@ -1060,6 +1060,104 @@ func TestStateSyncTx_Encode_DeterministicAcrossCopies(t *testing.T) { } } +// TestStateSyncTx_EncodingSizeMatchesEncodeRLP verifies that EncodingSize() +// returns the envelope size (bytes written by EncodeRLP minus its outer string +// prefix). This is the invariant required by EncodingSizeGenericList / block +// encoding. A mismatch causes "rlp: value size exceeds available input length" +// on peers decoding blocks that contain StateSyncTx transactions. +func TestStateSyncTx_EncodingSizeMatchesEncodeRLP(t *testing.T) { + t.Parallel() + for _, tx := range []*StateSyncTx{ + makeBaseStateSyncTx(), + {StateSyncData: []*StateSyncData{{ID: 1, Contract: common.Address{}, Data: nil, TxHash: common.Hash{}}}}, + {StateSyncData: []*StateSyncData{{ID: 1, Contract: testAddr, Data: make([]byte, 200), TxHash: randHash()}}}, + } { + var buf bytes.Buffer + if err := tx.EncodeRLP(&buf); err != nil { + t.Fatalf("EncodeRLP failed: %v", err) + } + encodedBytes := buf.Bytes() + totalWritten := len(encodedBytes) + + // EncodeRLP writes: StringPrefix(envelopeSize) + envelope + // EncodingSize() must equal envelopeSize (without the outer string prefix) + // so that EncodingSizeGenericList can add the prefix itself. + encodingSize := tx.EncodingSize() + + // The outer string prefix length = totalWritten - envelopeSize + outerPrefixLen := totalWritten - encodingSize + if outerPrefixLen < 1 { + t.Fatalf("EncodingSize (%d) >= total bytes written (%d); must be strictly less (outer prefix missing)", + encodingSize, totalWritten) + } + + // Verify the prefix is a valid RLP string prefix (0x80+ range for len>0) + if encodedBytes[0] < 0x80 || encodedBytes[0] >= 0xc0 { + t.Fatalf("EncodeRLP should write an RLP string, got first byte 0x%02x", encodedBytes[0]) + } + + // Cross-check: ListPrefixLen(encodingSize) + encodingSize == totalWritten + // which this is exactly what EncodingSizeGenericList computes per item + if rlp.ListPrefixLen(encodingSize)+encodingSize != totalWritten { + t.Fatalf("ListPrefixLen(%d) + %d = %d, want %d (total written)", + encodingSize, encodingSize, + rlp.ListPrefixLen(encodingSize)+encodingSize, totalWritten) + } + } +} + +// TestStateSyncTx_BlockRoundTrip verifies that a block containing a StateSyncTx +// can be RLP-encoded and decoded without error. +// This prevents declaring more bytes than were actually written. +func TestStateSyncTx_BlockRoundTrip(t *testing.T) { + t.Parallel() + ssTx := makeBaseStateSyncTx() + + block := NewBlock( + &Header{ + ParentHash: common.HexToHash("0xaaa"), + UncleHash: common.HexToHash("0xbbb"), + Coinbase: common.HexToAddress("0xccc"), + Root: common.HexToHash("0xddd"), + TxHash: common.HexToHash("0xeee"), + ReceiptHash: common.HexToHash("0xfff"), + Difficulty: big.NewInt(1), + Number: big.NewInt(42), + GasLimit: 8_000_000, + GasUsed: 21_000, + Time: 1234567890, + BaseFee: big.NewInt(1000000000), + }, + []Transaction{ssTx}, + nil, // uncles + nil, // receipts + nil, // withdrawals + ) + + encoded, err := rlp.EncodeToBytes(block) + if err != nil { + t.Fatalf("Block.EncodeRLP failed: %v", err) + } + + var decoded Block + if err := rlp.DecodeBytes(encoded, &decoded); err != nil { + t.Fatalf("Block.DecodeRLP failed: %v", err) + } + + if decoded.NumberU64() != 42 { + t.Fatalf("block number mismatch: got %d, want 42", decoded.NumberU64()) + } + if len(decoded.Transactions()) != 1 { + t.Fatalf("tx count mismatch: got %d, want 1", len(decoded.Transactions())) + } + if decoded.Transactions()[0].Type() != StateSyncTxType { + t.Fatalf("tx type mismatch: got %d, want %d", decoded.Transactions()[0].Type(), StateSyncTxType) + } + if decoded.Transactions()[0].Hash() != ssTx.Hash() { + t.Fatalf("tx hash mismatch: got %x, want %x", decoded.Transactions()[0].Hash(), ssTx.Hash()) + } +} + func makeBaseStateSyncTx() *StateSyncTx { return &StateSyncTx{ StateSyncData: []*StateSyncData{ diff --git a/p2p/forkid/forkid.go b/p2p/forkid/forkid.go index a197b2cbbf1..3af6c9c55c5 100644 --- a/p2p/forkid/forkid.go +++ b/p2p/forkid/forkid.go @@ -250,28 +250,21 @@ func GatherForks(config *chain.Config, genesisTime uint64) (heightForks []uint64 heightForks = append(heightForks, *config.Aura.PosdaoTransition) } + // Note: Only include hard forks from bor config which correspond to an Ethereum + // equivalent hard fork and not a bor-specific one. if config.Bor != nil { + // Shanghai equivalent if config.Bor.GetAgraBlock() != nil { heightForks = append(heightForks, config.Bor.GetAgraBlock().Uint64()) } + // Cancun equivalent if config.Bor.GetNapoliBlock() != nil { heightForks = append(heightForks, config.Bor.GetNapoliBlock().Uint64()) } + // Prague equivalent if config.Bor.GetBhilaiBlock() != nil { heightForks = append(heightForks, config.Bor.GetBhilaiBlock().Uint64()) } - if config.Bor.GetLisovoBlock() != nil { - heightForks = append(heightForks, config.Bor.GetLisovoBlock().Uint64()) - } - if config.Bor.GetLisovoProBlock() != nil { - heightForks = append(heightForks, config.Bor.GetLisovoProBlock().Uint64()) - } - if config.Bor.GetGiuglianoBlock() != nil { - heightForks = append(heightForks, config.Bor.GetGiuglianoBlock().Uint64()) - } - if config.Bor.GetChicagoBlock() != nil { - heightForks = append(heightForks, config.Bor.GetChicagoBlock().Uint64()) - } } // Sort the fork block numbers & times to permit chronological XOR diff --git a/p2p/forkid/forkid_test.go b/p2p/forkid/forkid_test.go index 3451c5286a9..b7337fd4b3b 100644 --- a/p2p/forkid/forkid_test.go +++ b/p2p/forkid/forkid_test.go @@ -166,7 +166,9 @@ func TestCreation(t *testing.T) { polychain.Amoy, []testcase{ {0, 0, ID{Hash: ChecksumToBytes(0xbe06a477), Activation: 0, Next: 73100}}, - {73100, 0, ID{Hash: ChecksumToBytes(0x135d2cd5), Activation: 73100, Next: 5423600}}, // First London, Jaipur, Delhi, Indore, Agra + {73100, 0, ID{Hash: ChecksumToBytes(0x135d2cd5), Activation: 73100, Next: 5423600}}, // First London, Jaipur, Delhi, Indore, Agra + {5423600, 0, ID{Hash: ChecksumToBytes(0xb4f6ec4f), Activation: 5423600, Next: 22765056}}, // First Napoli block + {22765056, 0, ID{Hash: ChecksumToBytes(0x8b7e4175), Activation: 22765056, Next: 0}}, // First Bhilai block }, }, { @@ -178,6 +180,7 @@ func TestCreation(t *testing.T) { {23850000, 0, ID{Hash: ChecksumToBytes(0x4f2f71cc), Activation: 23850000, Next: 50523000}}, // First London block {50523000, 0, ID{Hash: ChecksumToBytes(0xdc08865c), Activation: 50523000, Next: 54876000}}, // First Agra block {54876000, 0, ID{Hash: ChecksumToBytes(0xf097bc13), Activation: 54876000, Next: 73440256}}, // First Napoli block + {73440256, 0, ID{Hash: ChecksumToBytes(0x22d523b2), Activation: 73440256, Next: 0}}, // First Bhilai block }, }, } diff --git a/polygon/bor/borcfg/bor_config.go b/polygon/bor/borcfg/bor_config.go index c446273fac1..faf75c1eb3a 100644 --- a/polygon/bor/borcfg/bor_config.go +++ b/polygon/bor/borcfg/bor_config.go @@ -221,6 +221,10 @@ func (c *BorConfig) IsDandeli(number uint64) bool { return isForked(c.DandeliBlock, number) } +func (c *BorConfig) GetDandeliBlock() *big.Int { + return c.DandeliBlock +} + func (c *BorConfig) IsLisovo(number uint64) bool { return isForked(c.LisovoBlock, number) } diff --git a/polygon/bridge/service.go b/polygon/bridge/service.go index 3999963d532..1ac1011f8f0 100644 --- a/polygon/bridge/service.go +++ b/polygon/bridge/service.go @@ -35,6 +35,14 @@ import ( "github.com/erigontech/erigon/polygon/bor/borcfg" ) +// stateSyncScraperLag is how far behind wall-clock the scraper queries +// Heimdall, in seconds. Heimdall's post-HF deterministic state-sync endpoint +// only returns results when latestIndexedTime > cutoff (the stability gate); +// since latestIndexedTime is always behind wall-clock by at least one Heimdall +// block, polling at time.Now() always fires the gate. A 30s lag leaves +// comfortable headroom over Heimdall's ~2-3s block time + indexing latency. +const stateSyncScraperLag = 30 * time.Second + type eventFetcher interface { FetchStateSyncEvents(ctx context.Context, fromId uint64, to time.Time, limit int) ([]*EventRecordWithTime, error) } @@ -180,7 +188,13 @@ func (s *Service) Run(ctx context.Context) error { // start scraping events from := lastFetchedEventId + 1 - to := time.Now() + // Lag the cutoff behind wall-clock so Heimdall's post-HF stability gate + // (which holds responses until a committed block past the cutoff has + // been indexed) can clear. Heimdall block time is ~2-3s on mainnet, so + // a 30s lag leaves comfortable headroom. Without this the gate would + // fire on every scraper poll and the scraper would stop making + // progress. Pre-HF Heimdall ignores the gate, so the lag is harmless. + to := time.Now().Add(-stateSyncScraperLag) events, err := s.eventFetcher.FetchStateSyncEvents(ctx, from, to, StateEventsFetchLimit) if err != nil { if liberrors.IsOneOf(err, s.transientErrors) { @@ -406,7 +420,10 @@ func (s *Service) ProcessNewBlocks(ctx context.Context, blocks []*types.Block) e return err } - endId, err = s.store.LastEventIdWithinWindow(ctx, startId, time.Unix(int64(toTime), 0)) + // Read the window boundary live from Heimdall (the same gated + // clerk/time query bor uses) rather than scanning the local event + // store, which holds not-yet-stable events Heimdall still withholds. + endId, err = s.lastEventIdWithinWindowFromHeimdall(ctx, startId, time.Unix(int64(toTime), 0)) if err != nil { return err } @@ -517,6 +534,34 @@ func (s *Service) blockEventsTimeWindowEnd(last ProcessedBlockInfo, blockNum uin return last.BlockTime, nil } +// lastEventIdWithinWindowFromHeimdall returns the id of the last state-sync +// event in [fromId, toTime), read live from Heimdall's clerk/time endpoint the +// same way bor's CommitStates does. bor consumes Heimdall's server-side +// stability gate, which withholds the most-recent event group until it is +// confirmed; re-deriving the window from the local event store sees those +// not-yet-stable events and over-includes them, diverging from the network and +// producing a bad block on import. Returns 0 when the window contains no events. +func (s *Service) lastEventIdWithinWindowFromHeimdall(ctx context.Context, fromId uint64, toTime time.Time) (uint64, error) { + events, err := s.eventFetcher.FetchStateSyncEvents(ctx, fromId, toTime, 0) + if err != nil { + return 0, err + } + + var eventId uint64 + // Stop at the first id gap or first event at/after toTime, matching the + // sequential, strict-before semantics bor enforces in CommitStates. + expected := fromId + for _, event := range events { + if event.ID != expected || !event.Time.Before(toTime) { + break + } + eventId = event.ID + expected++ + } + + return eventId, nil +} + func (s *Service) waitForScraper(ctx context.Context, toTime uint64) error { logTicker := time.NewTicker(5 * time.Second) defer logTicker.Stop() diff --git a/polygon/bridge/service_test.go b/polygon/bridge/service_test.go index 2fd088b0a0f..88d0fcc636e 100644 --- a/polygon/bridge/service_test.go +++ b/polygon/bridge/service_test.go @@ -82,6 +82,24 @@ func getBlocks(t *testing.T, numBlocks int) []*types.Block { return blocks } +// expectHeimdallEvents wires the mock event fetcher to behave like Heimdall's +// clerk/time endpoint: every call (the scraper and the live window boundary +// query in ProcessNewBlocks) returns the events with id >= fromID. +func expectHeimdallEvents(client *MockClient, events []*EventRecordWithTime) { + client.EXPECT(). + FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, fromID uint64, _ time.Time, _ int) ([]*EventRecordWithTime, error) { + res := make([]*EventRecordWithTime, 0, len(events)) + for _, e := range events { + if e.ID >= fromID { + res = append(res, e) + } + } + return res, nil + }). + AnyTimes() +} + func TestService(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -134,8 +152,7 @@ func TestService(t *testing.T) { events := []*EventRecordWithTime{event1, event2, event3, event4} - heimdallClient.EXPECT().FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(events, nil).Times(1) - heimdallClient.EXPECT().FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*EventRecordWithTime{}, nil).AnyTimes() + expectHeimdallEvents(heimdallClient, events) var wg sync.WaitGroup wg.Add(1) @@ -251,8 +268,7 @@ func TestService_Unwind(t *testing.T) { events := []*EventRecordWithTime{event1, event2, event3, event4} - heimdallClient.EXPECT().FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(events, nil).Times(1) - heimdallClient.EXPECT().FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*EventRecordWithTime{}, nil).AnyTimes() + expectHeimdallEvents(heimdallClient, events) var wg sync.WaitGroup wg.Add(1) @@ -367,8 +383,7 @@ func setupOverrideTest(t *testing.T, ctx context.Context, borConfig borcfg.BorCo events := []*EventRecordWithTime{event1, event2, event3, event4, event5, event6} - heimdallClient.EXPECT().FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(events, nil).Times(1) - heimdallClient.EXPECT().FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*EventRecordWithTime{}, nil).AnyTimes() + expectHeimdallEvents(heimdallClient, events) wg.Add(1) go func(bridge *Service) { @@ -514,8 +529,7 @@ func TestReaderEventsWithinTime(t *testing.T) { events := []*EventRecordWithTime{event1, event2, event3, event4} - heimdallClient.EXPECT().FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(events, nil).Times(1) - heimdallClient.EXPECT().FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*EventRecordWithTime{}, nil).AnyTimes() + expectHeimdallEvents(heimdallClient, events) var wg sync.WaitGroup wg.Add(1) @@ -571,3 +585,95 @@ func TestReaderEventsWithinTime(t *testing.T) { cancel() wg.Wait() } + +// TestService_ProcessNewBlocksGatesUnstableEvents is a regression test for the +// state-sync over-inclusion bug: an event can already be in the local store +// (the scraper runs ahead of the tip) while Heimdall's clerk/time stability +// gate still withholds it for a given window. ProcessNewBlocks must take the +// window boundary from the live, gated Heimdall response rather than from a +// scan of the store, otherwise it includes the not-yet-stable event and +// diverges from bor, producing a bad block on import. +func TestService_ProcessNewBlocksGatesUnstableEvents(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + borConfig := borcfg.BorConfig{ + Sprint: map[string]uint64{"0": 2}, + StateReceiverContract: "0x0000000000000000000000000000000000001001", + IndoreBlock: big.NewInt(0), + StateSyncConfirmationDelay: map[string]uint64{"0": 1}, + } + heimdallClient, b := setup(t, borConfig) + + event1 := &EventRecordWithTime{ + EventRecord: EventRecord{ID: 1, ChainID: "80002", Data: hexutil.MustDecode("0x01")}, + Time: time.Unix(50, 0), + } + event1Data, err := event1.MarshallBytes() + require.NoError(t, err) + // event2 lies inside block 2's window (time 80 < toTime 99) but Heimdall + // withholds it until it stabilises, modelled here as window end >= 150. + event2 := &EventRecordWithTime{ + EventRecord: EventRecord{ID: 2, ChainID: "80002", Data: hexutil.MustDecode("0x02")}, + Time: time.Unix(80, 0), + } + event2Data, err := event2.MarshallBytes() + require.NoError(t, err) + events := []*EventRecordWithTime{event1, event2} + + const gateReleaseToTime = 150 + heimdallClient.EXPECT(). + FetchStateSyncEvents(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, fromID uint64, to time.Time, _ int) ([]*EventRecordWithTime, error) { + res := make([]*EventRecordWithTime, 0, len(events)) + for _, e := range events { + if e.ID < fromID { + continue + } + // Stability gate: event2 is not released for a historical window + // until the window end reaches gateReleaseToTime. The scraper + // queries with to ~= now, so it always sees event2 and the store + // holds it - exactly the condition that tripped the bug. + if e.ID == 2 && to.Unix() < gateReleaseToTime { + continue + } + res = append(res, e) + } + return res, nil + }). + AnyTimes() + + var wg sync.WaitGroup + wg.Add(1) + go func(bridge *Service) { + defer wg.Done() + if err := bridge.Run(ctx); err != nil && !errors.Is(err, ctx.Err()) { + t.Error(err) + } + }(b) + + require.NoError(t, b.store.Prepare(ctx)) + + genesis := types.NewBlockWithHeader(&types.Header{Time: 1, Number: big.NewInt(0)}) + require.NoError(t, b.ReplayInitialBlock(ctx, genesis)) + + blocks := getBlocks(t, 4) + require.NoError(t, b.ProcessNewBlocks(ctx, blocks)) + + // Block 2 window ends at 99: the store holds event2 (time 80 < 99) but the + // gate withholds it, so only event1 is mapped. A store-based boundary would + // wrongly include event2 here. + res, err := b.Events(ctx, blocks[1].Hash(), 2) + require.NoError(t, err) + require.Len(t, res, 1) + require.Equal(t, event1Data, res[0].Data()) + + // Block 4 window ends at 199 (>= release): event2 is included now. + res, err = b.Events(ctx, blocks[3].Hash(), 4) + require.NoError(t, err) + require.Len(t, res, 1) + require.Equal(t, event2Data, res[0].Data()) + + cancel() + wg.Wait() +} diff --git a/polygon/sync/header_time_validator.go b/polygon/sync/header_time_validator.go index 80903efa208..bb57d06d353 100644 --- a/polygon/sync/header_time_validator.go +++ b/polygon/sync/header_time_validator.go @@ -114,6 +114,16 @@ func (htv *HeaderTimeValidator) needToWaitForNewSpan(header *types.Header, paren headerNum := header.Number.Uint64() // the current producer has published a block, but it came too late (i.e. the parent has been evicted from the ttl cache) if author == producer && producer == parentAuthor && !htv.recentVerifiedHeaders.Has(header.ParentHash) { + // Guard against false positives caused by node-internal processing delays. + // The TTL cache eviction only means the node was slow — not that the chain + // itself had a large block-time gap. Check the actual on-chain timestamp + // difference: if consecutive blocks from the same producer have a normal + // gap (≤ VeBlopBlockTimeout), no span rotation could have occurred and we + // should not stall for 12 seconds waiting for one. + onChainGap := time.Duration(header.Time-parent.Time) * time.Second + if onChainGap <= VeBlopBlockTimeout { + return false, 0, nil + } htv.logger.Info("[span-rotation] need to wait for span rotation due to longer than expected block time from current producer", "blockNum", headerNum, "parentHeader", header.ParentHash, "author", author) return true, VeBlopNewSpanTimeout, nil } diff --git a/rpc/ethapi/state_overrides.go b/rpc/ethapi/state_overrides.go index a63f213db74..ab261bfe584 100644 --- a/rpc/ethapi/state_overrides.go +++ b/rpc/ethapi/state_overrides.go @@ -61,12 +61,14 @@ func (overrides *StateOverrides) Override(state *state.IntraBlockState) error { } state.SetStorage(addr, intState) } - // Apply state diff into specified accounts. + // Apply stateDiff as base storage so SSTORE sees the overridden original value. if account.StateDiff != nil { for key, value := range *account.StateDiff { key := key intValue := new(uint256.Int).SetBytes32(value.Bytes()) - state.SetState(addr, key, *intValue) + if err := state.SetStateOverride(addr, key, *intValue); err != nil { + return err + } } } } diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index f7112d2cd79..f6a49aac113 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -19,6 +19,7 @@ package jsonrpc import ( "context" "encoding/json" + "flag" "math" "math/big" "os" @@ -41,6 +42,8 @@ import ( "github.com/erigontech/erigon/execution/types" ) +var updateGolden = flag.Bool("update", false, "update golden test files") + func TestGasPrice(t *testing.T) { cases := []struct { @@ -80,6 +83,13 @@ func TestGasPrice(t *testing.T) { } +// TestEthConfig compares eth_config RPC responses against golden files in testdata/eth_config/. +// Golden files may go stale when fork configs or precompile sets change (e.g., new HF, precompile added). +// To regenerate all golden files after an intentional change: +// +// go test ./rpc/jsonrpc/ -run TestEthConfig -update +// +// Then review the diff under testdata/eth_config/ and commit. func TestEthConfig(t *testing.T) { t.Parallel() toTimeArg := func(t hexutil.Uint64) *hexutil.Uint64 { return &t } @@ -194,9 +204,16 @@ func TestEthConfig(t *testing.T) { require.ErrorIs(t, err, test.wantIsError) haveResponseBytes, err := json.MarshalIndent(result, "", " ") require.NoError(t, err) + have := string(haveResponseBytes) + if *updateGolden { + err := os.WriteFile(test.wantResponseFilePath, haveResponseBytes, 0644) + require.NoError(t, err) + t.Logf("updated golden file: %s", test.wantResponseFilePath) + return + } wantResponseBytes, err := os.ReadFile(test.wantResponseFilePath) require.NoError(t, err) - want, have := string(wantResponseBytes), string(haveResponseBytes) + want := string(wantResponseBytes) // replace \r\n with \n is necessary for CI on windows want, have = strings.ReplaceAll(want, "\r\n", "\n"), strings.ReplaceAll(have, "\r\n", "\n") require.Equal(t, want, have) diff --git a/rpc/jsonrpc/testdata/eth_config/hoodi_osaka_scheduled_with_5_bpos_response_osaka_not_activated_bpo_none_activated.json b/rpc/jsonrpc/testdata/eth_config/hoodi_osaka_scheduled_with_5_bpos_response_osaka_not_activated_bpo_none_activated.json index d6d7307818d..1cab6f32542 100644 --- a/rpc/jsonrpc/testdata/eth_config/hoodi_osaka_scheduled_with_5_bpos_response_osaka_not_activated_bpo_none_activated.json +++ b/rpc/jsonrpc/testdata/eth_config/hoodi_osaka_scheduled_with_5_bpos_response_osaka_not_activated_bpo_none_activated.json @@ -22,6 +22,7 @@ "BN254_PAIRING": "0x0000000000000000000000000000000000000008", "ECREC": "0x0000000000000000000000000000000000000001", "ID": "0x0000000000000000000000000000000000000004", + "KZG_POINT_EVALUATION": "0x000000000000000000000000000000000000000a", "MODEXP": "0x0000000000000000000000000000000000000005", "RIPEMD160": "0x0000000000000000000000000000000000000003", "SHA256": "0x0000000000000000000000000000000000000002" diff --git a/rpc/jsonrpc/testdata/eth_config/hoodi_prague_scheduled_no_osaka_no_bpos_response_prague_not_activated.json b/rpc/jsonrpc/testdata/eth_config/hoodi_prague_scheduled_no_osaka_no_bpos_response_prague_not_activated.json index 2766eb51a38..52f88df04a1 100644 --- a/rpc/jsonrpc/testdata/eth_config/hoodi_prague_scheduled_no_osaka_no_bpos_response_prague_not_activated.json +++ b/rpc/jsonrpc/testdata/eth_config/hoodi_prague_scheduled_no_osaka_no_bpos_response_prague_not_activated.json @@ -47,6 +47,7 @@ "BN254_PAIRING": "0x0000000000000000000000000000000000000008", "ECREC": "0x0000000000000000000000000000000000000001", "ID": "0x0000000000000000000000000000000000000004", + "KZG_POINT_EVALUATION": "0x000000000000000000000000000000000000000a", "MODEXP": "0x0000000000000000000000000000000000000005", "RIPEMD160": "0x0000000000000000000000000000000000000003", "SHA256": "0x0000000000000000000000000000000000000002" @@ -82,6 +83,7 @@ "BN254_PAIRING": "0x0000000000000000000000000000000000000008", "ECREC": "0x0000000000000000000000000000000000000001", "ID": "0x0000000000000000000000000000000000000004", + "KZG_POINT_EVALUATION": "0x000000000000000000000000000000000000000a", "MODEXP": "0x0000000000000000000000000000000000000005", "RIPEMD160": "0x0000000000000000000000000000000000000003", "SHA256": "0x0000000000000000000000000000000000000002" diff --git a/rpc/jsonrpc/testdata/eth_config/mainnet_prague_scheduled_no_osaka_no_bpos_response_head_at_shanghai.json b/rpc/jsonrpc/testdata/eth_config/mainnet_prague_scheduled_no_osaka_no_bpos_response_head_at_shanghai.json index 462e8507c8b..b945049e3ae 100644 --- a/rpc/jsonrpc/testdata/eth_config/mainnet_prague_scheduled_no_osaka_no_bpos_response_head_at_shanghai.json +++ b/rpc/jsonrpc/testdata/eth_config/mainnet_prague_scheduled_no_osaka_no_bpos_response_head_at_shanghai.json @@ -65,6 +65,7 @@ "BN254_PAIRING": "0x0000000000000000000000000000000000000008", "ECREC": "0x0000000000000000000000000000000000000001", "ID": "0x0000000000000000000000000000000000000004", + "KZG_POINT_EVALUATION": "0x000000000000000000000000000000000000000a", "MODEXP": "0x0000000000000000000000000000000000000005", "RIPEMD160": "0x0000000000000000000000000000000000000003", "SHA256": "0x0000000000000000000000000000000000000002" diff --git a/rpc/jsonrpc/testdata/eth_config/mainnet_prague_scheduled_no_osaka_no_bpos_response_prague_activated.json b/rpc/jsonrpc/testdata/eth_config/mainnet_prague_scheduled_no_osaka_no_bpos_response_prague_activated.json index 6929be84c44..af71f46d2cf 100644 --- a/rpc/jsonrpc/testdata/eth_config/mainnet_prague_scheduled_no_osaka_no_bpos_response_prague_activated.json +++ b/rpc/jsonrpc/testdata/eth_config/mainnet_prague_scheduled_no_osaka_no_bpos_response_prague_activated.json @@ -22,6 +22,7 @@ "BN254_PAIRING": "0x0000000000000000000000000000000000000008", "ECREC": "0x0000000000000000000000000000000000000001", "ID": "0x0000000000000000000000000000000000000004", + "KZG_POINT_EVALUATION": "0x000000000000000000000000000000000000000a", "MODEXP": "0x0000000000000000000000000000000000000005", "RIPEMD160": "0x0000000000000000000000000000000000000003", "SHA256": "0x0000000000000000000000000000000000000002"