Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions cl/das/mock_services/peer_das_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 12 additions & 6 deletions cl/das/peer_das.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type gloasBlockData struct {

//go:generate mockgen -typed=true -destination=mock_services/peer_das_mock.go -package=mock_services . PeerDas
type PeerDas interface {
Start(ctx context.Context)
// [Modified in Gloas:EIP7732] Changed from []*SignedBlindedBeaconBlock to []ColumnSyncableSignedBlock
// to support both pre-GLOAS (blinded) and GLOAS (non-blinded) blocks
DownloadColumnsAndRecoverBlobs(ctx context.Context, blocks []cltypes.ColumnSyncableSignedBlock) error
Expand Down Expand Up @@ -88,10 +89,10 @@ type peerdas struct {
blockReader freezeblocks.BeaconSnapshotReader
indiciesDB kv.RoDB
gloasDataCache *lru.Cache[common.Hash, *gloasBlockData] // cache for GLOAS block data (~1KB per entry)
startOnce sync.Once
}

func NewPeerDas(
ctx context.Context,
rpc *rpc.BeaconRpcP2P,
beaconConfig *clparams.BeaconChainConfig,
caplinConfig *clparams.CaplinConfig,
Expand Down Expand Up @@ -128,14 +129,19 @@ func NewPeerDas(
indiciesDB: indiciesDB,
gloasDataCache: gloasDataCache,
}
p.resubscribeGossip()
for range numOfBlobRecoveryWorkers {
go p.blobsRecoverWorker(ctx)
}
go p.syncColumnDataWorker(ctx)
return p
}

func (d *peerdas) Start(ctx context.Context) {
d.startOnce.Do(func() {
d.resubscribeGossip()
for range numOfBlobRecoveryWorkers {
go d.blobsRecoverWorker(ctx)
}
go d.syncColumnDataWorker(ctx)
})
}

func (d *peerdas) StateReader() peerdasstate.PeerDasStateReader {
return d.state
}
Expand Down
45 changes: 45 additions & 0 deletions cl/das/peer_das_start_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 <http://www.gnu.org/licenses/>.

package das

import (
"context"
"testing"

"go.uber.org/mock/gomock"

"github.com/erigontech/erigon/cl/clparams"
peerdasstate "github.com/erigontech/erigon/cl/das/state"
gossipmock "github.com/erigontech/erigon/cl/phase1/network/gossip/mock_services"
)

func TestPeerDasSubscribesOnlyAfterStart(t *testing.T) {
ctrl := gomock.NewController(t)
gossipManager := gossipmock.NewMockGossip(ctrl)
beaconConfig := clparams.MainnetBeaconConfig
beaconConfig.DataColumnSidecarSubnetCount = 2
caplinConfig := clparams.CaplinConfig{ArchiveBlobs: true}
peerDasState := peerdasstate.NewPeerDasState(&beaconConfig, &clparams.NetworkConfig{})

peerDas := NewPeerDas(nil, &beaconConfig, &caplinConfig, nil, nil, nil, [32]byte{}, nil, peerDasState, gossipManager, nil, nil)

gossipManager.EXPECT().SubscribeWithExpiry(gomock.Any(), gomock.Any()).Times(2)
ctx, cancel := context.WithCancel(context.Background())
peerDas.Start(ctx)
peerDas.Start(ctx)
cancel()
}
83 changes: 83 additions & 0 deletions cl/phase1/core/checkpoint_sync/finalized_state_root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package checkpoint_sync

import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"path/filepath"

"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/phase1/core/state"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/dir"
"github.com/spf13/afero"
)

const finalizedStateRootPrefix = ".finalized-state-root-"

var ErrFinalizedGloasStateRootMissing = errors.New("finalized Gloas state is missing its authoritative state root")

func FinalizedStateRootFileName(snappyState []byte) string {
digest := sha256.Sum256(snappyState)
return finalizedStateRootPrefix + hex.EncodeToString(digest[:])
}

func RemoveObsoleteFinalizedStateRoots(directory, keepPath string) error {
rootFiles, err := filepath.Glob(filepath.Join(directory, finalizedStateRootPrefix+"*"))
if err != nil {
return err
}
for _, rootPath := range rootFiles {
if rootPath == keepPath {
continue
}
if err := dir.RemoveFile(rootPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
}
return nil
}

func RestoreFinalizedStateRoot(storage afero.Fs, snappyState []byte, st *state.CachingBeaconState) error {
if st.Version() < clparams.GloasVersion {
return nil
}
record, err := afero.ReadFile(storage, FinalizedStateRootFileName(snappyState))
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
header := st.LatestBlockHeader()
if st.Version() >= clparams.GloasVersion && header.Slot == st.Slot() && header.Root == (common.Hash{}) {
return ErrFinalizedGloasStateRootMissing
}
return nil
}
return fmt.Errorf("read finalized state root: %w", err)
}
if len(record) != 2*len(common.Hash{}) {
return fmt.Errorf("invalid finalized state root record length %d", len(record))
}
digest := sha256.Sum256(snappyState)
checksumInput := make([]byte, 0, len(digest)+len(common.Hash{}))
checksumInput = append(checksumInput, digest[:]...)
checksumInput = append(checksumInput, record[:len(common.Hash{})]...)
wantChecksum := sha256.Sum256(checksumInput)
if !bytes.Equal(wantChecksum[:], record[len(common.Hash{}):]) {
return errors.New("invalid finalized state root checksum")
}
st.SetPreviousStateRoot(common.BytesToHash(record[:len(common.Hash{})]))
return nil
}

func EncodeFinalizedStateRoot(snappyState []byte, root common.Hash) []byte {
digest := sha256.Sum256(snappyState)
checksumInput := make([]byte, 0, len(digest)+len(root))
checksumInput = append(checksumInput, digest[:]...)
checksumInput = append(checksumInput, root[:]...)
checksum := sha256.Sum256(checksumInput)
record := make([]byte, 0, 2*len(root))
record = append(record, root[:]...)
return append(record, checksum[:]...)
}
8 changes: 8 additions & 0 deletions cl/phase1/core/checkpoint_sync/local_checkpoint_syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package checkpoint_sync

import (
"context"
"errors"
"fmt"

"github.com/erigontech/erigon/cl/clparams"
Expand Down Expand Up @@ -45,6 +46,13 @@ func (l *LocalCheckpointSyncer) GetLatestBeaconState(ctx context.Context) (*stat
if err := bs.DecodeSSZ(decompressedSnappy, int(beaconCfg.GetCurrentStateVersion(slot/beaconCfg.SlotsPerEpoch))); err != nil {
return nil, fmt.Errorf("could not deserialize state: %w", err)
}
if err := RestoreFinalizedStateRoot(l.dir, snappyEncoded, bs); err != nil {
if errors.Is(err, ErrFinalizedGloasStateRootMissing) {
log.Warn("Local finalized Gloas state predates state-root persistence, starting sync from genesis.")
return l.genesisState.Copy()
}
return nil, err
}
// Same-network gate as the remote-sync resume paths: a file left by another chain must never
// anchor the node. Staleness is not gated here — there is no remote to fall back to, and a stale
// same-network finalized anchor beats replaying from genesis.
Expand Down
5 changes: 5 additions & 0 deletions cl/phase1/core/checkpoint_sync/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ func readLocalStateFile(dirs datadir.Dirs, beaconCfg *clparams.BeaconChainConfig
if err := bs.DecodeSSZ(decompressed, int(beaconCfg.GetCurrentStateVersion(epoch))); err != nil {
return nil, fmt.Errorf("could not decode local %s state: %w", kind, err)
}
if fileName == clparams.LatestFinalizedStateFileName {
if err := RestoreFinalizedStateRoot(afero.NewBasePathFs(afero.NewOsFs(), dirs.CaplinLatest), snappyEncoded, bs); err != nil {
return nil, err
}
}
return bs, nil
}

Expand Down
31 changes: 31 additions & 0 deletions cl/phase1/execution_client/execution_client_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,37 @@ func TestExecutionPayloadFromSSZBlock_BlockAccessListGloasOnly(t *testing.T) {
}
}

func TestExecutionPayloadFromSSZBlock_TransactionsAreJSONArray(t *testing.T) {
beaconCfg := clparams.MainnetBeaconConfig
tests := []struct {
name string
version clparams.StateVersion
json string
want []any
}{
{name: "pre-Gloas empty", version: clparams.ElectraVersion, json: "[]", want: []any{}},
{name: "pre-Gloas non-empty", version: clparams.ElectraVersion, json: `["0x0102"]`, want: []any{"0x0102"}},
{name: "Gloas empty", version: clparams.GloasVersion, json: "[]", want: []any{}},
{name: "Gloas non-empty", version: clparams.GloasVersion, json: `["0x0102"]`, want: []any{"0x0102"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := cltypes.NewEth1Block(tt.version, &beaconCfg)
payload.Extra = solid.NewExtraData()
payload.Transactions = &solid.TransactionsSSZ{}
payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](int(beaconCfg.MaxWithdrawalsPerPayload), 44)
require.NoError(t, payload.Transactions.UnmarshalJSON([]byte(tt.json)))

raw, err := json.Marshal(engine_types.ExecutionPayloadFromSSZBlock(payload, tt.version))
require.NoError(t, err)

var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
require.Equal(t, tt.want, decoded["transactions"])
})
}
}

func gloas(cfg *clparams.BeaconChainConfig, balData []byte) *cltypes.Eth1Block {
block := cltypes.NewEth1Block(clparams.GloasVersion, cfg)
block.Extra = solid.NewExtraData()
Expand Down
2 changes: 1 addition & 1 deletion cl/phase1/forkchoice/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ verified against the Go code in this repository.
| `on_block.go`: `verifyKzgCommitmentsAgainstTransactions` | Deneb execution payload blob versioned-hash checks; Electra/Fulu maximum blob count plumbing |
| `on_block.go`: `isDataAvailable` | Deneb `is_data_available` for blob sidecars; pre-Gloas local blob storage path |
| `on_block.go`: PeerDAS `IsDataAvailable` and `SyncColumnDataLater` branch inside `OnBlock` | Fulu modified `is_data_available`: data availability is checked by block root through data column sidecars, without passing `blob_kzg_commitments`; modified Fulu `on_block` calls it as `is_data_available(hash_tree_root(block))` |
| `on_execution_payload.go`: `OnExecutionPayload`, `applyEnvelope`, `applyEnvelopeLocked`, `ApplyLocalSelfBuildEnvelope`, `StoreAnchorEnvelope` | Gloas `on_execution_payload_envelope`, `Store.payloads`, `Store.payload_timeliness_vote`, `Store.payload_data_availability_vote` |
| `on_execution_payload.go`: `OnExecutionPayload`, `applyEnvelope`, `applyEnvelopeCoordinated`, `ApplyLocalSelfBuildEnvelope`, `StoreAnchorEnvelope` | Gloas `on_execution_payload_envelope`, `Store.payloads`, `Store.payload_timeliness_vote`, `Store.payload_data_availability_vote` |
| `on_execution_payload.go`: `validateEnvelopeAgainstBlock`, `verifyEnvelopeBuilderSignature`, `checkDataAvailability`, `validatePayloadWithEL` | Gloas `on_execution_payload_envelope`; Gloas/Fulu data availability for committed bid blob data; Bellatrix `ExecutionEngine.notify_forkchoice_updated`/payload validation context |
| `on_payload_attestation_message.go`: `OnPayloadAttestationMessage` | Gloas `on_payload_attestation_message`, PTC membership/signature/current-slot checks |
| `on_attestation.go`: `OnAttestation`, `ProcessAttestingIndicies`, `ValidateOnAttestation`, `validateTargetEpochAgainstCurrentTime` | Phase0 `on_attestation`, `validate_on_attestation`, `validate_target_epoch_against_current_time`; Gloas modified `validate_on_attestation` |
Expand Down
33 changes: 12 additions & 21 deletions cl/phase1/forkchoice/fork_graph/fork_graph_disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,30 +150,21 @@ func NewForkGraphDisk(anchorState *state.CachingBeaconState, syncedData synced_d
}
anchorHeader := anchorState.LatestBlockHeader()
if anchorState.Version() >= clparams.GloasVersion && anchorState.Slot() > 0 {
// GLOAS checkpoint/anchor sync fix: the first transitionSlot for this
// anchor needs to record the correct state root (computed with
// LatestBlockHeader.Root == zero) into stateRoots. Two cases arise:
//
// Fresh checkpoint sync: Root is zero per spec (process_block_header
// zeroes it). We compute HashSSZ with Root=0 (the correct value),
// fill in Root, and cache it as PreviousStateRoot.
//
// Restart from disk: a previous run already filled in Root and
// serialized the state. Root is now that same correct hash (the one
// originally computed with Root=0). HashSSZ would return a different
// (wrong) value because Root is non-zero, so we must NOT recompute;
// instead we use the stored Root directly as PreviousStateRoot.
if anchorHeader.Root == [32]byte{} {
stateHash, err := anchorState.HashSSZ()
if err != nil {
panic(err)
stateHash := anchorState.PeekPreviousStateRoot()
if stateHash == (common.Hash{}) {
if anchorHeader.Slot == anchorState.Slot() && anchorHeader.Root != (common.Hash{}) {
stateHash = anchorHeader.Root
} else {
stateHash, err = anchorState.HashSSZ()
if err != nil {
panic(err)
}
}
}
if anchorHeader.Root == (common.Hash{}) {
anchorHeader.Root = stateHash
anchorState.SetLatestBlockHeader(&anchorHeader)
anchorState.SetPreviousStateRoot(stateHash)
} else {
anchorState.SetPreviousStateRoot(anchorHeader.Root)
}
anchorState.SetPreviousStateRoot(stateHash)
} else {
if anchorHeader.Root, err = anchorState.HashSSZ(); err != nil {
panic(err)
Expand Down
10 changes: 4 additions & 6 deletions cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,11 @@ func (f *forkGraphDisk) DumpBeaconStateOnDisk(blockRoot common.Hash, bs *state.C
log.Error("failed to write ssz buffer", "err", err)
return err
}
// Write the authoritative state root so it can be restored on load.
// Use the stored block header's Root (set from block.StateRoot in AddChainSegment)
// rather than the state's PreviousStateRoot cache field, which can be stale if
// a concurrent block arrival modified f.currentState between GetStateAtBlockRoot
// and the copy in OnHeadStateWithBlockRoot.
// A skipped-slot state root differs from the latest block header's state root.
var stateRootToWrite common.Hash
if hdr, ok := f.GetHeader(blockRoot); ok {
if bs.Version() >= clparams.GloasVersion && bs.LatestBlockHeader().Slot < bs.Slot() {
stateRootToWrite = bs.PeekPreviousStateRoot()
Comment thread
domiwei marked this conversation as resolved.
} else if hdr, ok := f.GetHeader(blockRoot); ok {
stateRootToWrite = hdr.Root
} else {
// Fallback for anchor state or cases where header isn't stored yet
Expand Down
Loading
Loading