diff --git a/pkg/beacon/default.go b/pkg/beacon/default.go index 82aa5c54..324b0761 100644 --- a/pkg/beacon/default.go +++ b/pkg/beacon/default.go @@ -373,7 +373,7 @@ func (d *Default) Healthy(ctx context.Context) (bool, error) { } func (d *Default) Peers(ctx context.Context) (types.Peers, error) { - peers := types.Peers{} + peers := make(types.Peers, 0, len(d.nodes)) for _, node := range d.nodes { status := "connected" @@ -749,9 +749,16 @@ func (d *Default) ListFinalizedSlots(ctx context.Context) ([]phase0.Slot, error) } latestSlot := phase0.Slot(uint64(finality.Finalized.Epoch) * uint64(sp.SlotsPerEpoch)) + slotsPerEpoch := uint64(sp.SlotsPerEpoch) + delta := uint64(0) - for i, val := uint64(latestSlot), uint64(latestSlot)-uint64(sp.SlotsPerEpoch)*uint64(d.config.HistoricalEpochCount); i > val; i -= uint64(sp.SlotsPerEpoch) { - slots = append(slots, phase0.Slot(i)) + for count := 0; count < d.config.HistoricalEpochCount; count++ { + if delta > uint64(latestSlot) { + break + } + + slots = append(slots, phase0.Slot(uint64(latestSlot)-delta)) + delta += slotsPerEpoch } return slots, nil diff --git a/pkg/beacon/download.go b/pkg/beacon/download.go index 2c97dd69..8761b220 100644 --- a/pkg/beacon/download.go +++ b/pkg/beacon/download.go @@ -14,6 +14,10 @@ import ( "github.com/sirupsen/logrus" ) +type beaconStateFetcher interface { + FetchBeaconState(ctx context.Context, stateID string) (*spec.VersionedBeaconState, error) +} + func (d *Default) downloadServingCheckpoint(ctx context.Context, checkpoint *v1.Finality) error { if checkpoint == nil { return errors.New("checkpoint is nil") @@ -172,14 +176,17 @@ func (d *Default) fetchHistoricalCheckpoints(ctx context.Context, checkpoint *v1 // Calculate the epoch boundaries we need to fetch // We'll derive the current finalized slot and then work back in intervals of SLOTS_PER_EPOCH. currentSlot := uint64(checkpoint.Finalized.Epoch) * uint64(sp.SlotsPerEpoch) - for i := 1; i < d.config.HistoricalEpochCount; i++ { - if uint64(i)*uint64(sp.SlotsPerEpoch) > currentSlot { + delta := uint64(sp.SlotsPerEpoch) + + for offset := 1; offset < d.config.HistoricalEpochCount; offset++ { + if delta > currentSlot { break } - slot := phase0.Slot(currentSlot - uint64(i)*uint64(sp.SlotsPerEpoch)) + slot := phase0.Slot(currentSlot - delta) slotsInScope[slot] = struct{}{} + delta += uint64(sp.SlotsPerEpoch) } for slot := range slotsInScope { @@ -329,7 +336,7 @@ func (d *Default) fetchBundle(ctx context.Context, root phase0.Root, upstream *N if d.shouldDownloadStates() { // Download and store beacon state - if err = d.downloadAndStoreBeaconState(ctx, stateRoot, slot, upstream); err != nil { + if err = d.downloadAndStoreBeaconState(ctx, stateRoot, slot, upstream.Beacon); err != nil { return nil, fmt.Errorf("failed to download and store beacon state: %w", err) } } @@ -373,14 +380,14 @@ func (d *Default) fetchBundle(ctx context.Context, root phase0.Root, upstream *N return block, nil } -func (d *Default) downloadAndStoreBeaconState(ctx context.Context, stateRoot phase0.Root, slot phase0.Slot, node *Node) error { +func (d *Default) downloadAndStoreBeaconState(ctx context.Context, stateRoot phase0.Root, slot phase0.Slot, fetcher beaconStateFetcher) error { // If the state already exists, don't bother downloading it again. existingState, err := d.states.GetByStateRoot(stateRoot) if err == nil && existingState != nil { return nil } - beaconState, err := node.Beacon.FetchBeaconState(ctx, eth.SlotAsString(slot)) + beaconState, err := fetcher.FetchBeaconState(ctx, eth.RootAsString(stateRoot)) if err != nil { return fmt.Errorf("failed to fetch beacon state: %w", err) } @@ -389,6 +396,15 @@ func (d *Default) downloadAndStoreBeaconState(ctx context.Context, stateRoot pha return errors.New("beacon state is nil") } + computedStateRoot, err := d.sszEncoder.GetStateRoot(beaconState) + if err != nil { + return fmt.Errorf("failed to compute beacon state root: %w", err) + } + + if computedStateRoot != stateRoot { + return fmt.Errorf("beacon state root does not match: %#x != %#x", computedStateRoot, stateRoot) + } + expiresAt := time.Now().Add(FinalityHaltedServingPeriod) if slot == phase0.Slot(0) { expiresAt = time.Now().Add(999999 * time.Hour) diff --git a/pkg/beacon/download_test.go b/pkg/beacon/download_test.go new file mode 100644 index 00000000..7f8ac53e --- /dev/null +++ b/pkg/beacon/download_test.go @@ -0,0 +1,104 @@ +package beacon + +import ( + "context" + "strings" + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethpandaops/checkpointz/pkg/beacon/ssz" + "github.com/ethpandaops/checkpointz/pkg/beacon/store" + "github.com/ethpandaops/checkpointz/pkg/eth" + "github.com/prysmaticlabs/go-bitfield" + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/require" +) + +type mockBeaconStateFetcher struct { + requestedStateIDs []string + state *spec.VersionedBeaconState + err error +} + +func (m *mockBeaconStateFetcher) FetchBeaconState(_ context.Context, stateID string) (*spec.VersionedBeaconState, error) { + m.requestedStateIDs = append(m.requestedStateIDs, stateID) + + return m.state, m.err +} + +func TestDownloadAndStoreBeaconStateFetchesByRootAndVerifiesState(t *testing.T) { + provider := newTestProvider(t) + state := newTestPhase0BeaconState(phase0.Slot(1)) + expectedStateRoot, err := provider.sszEncoder.GetStateRoot(state) + require.NoError(t, err) + + fetcher := &mockBeaconStateFetcher{ + state: state, + } + + err = provider.downloadAndStoreBeaconState(context.Background(), expectedStateRoot, phase0.Slot(1), fetcher) + require.NoError(t, err) + require.Equal(t, []string{eth.RootAsString(expectedStateRoot)}, fetcher.requestedStateIDs) + + storedState, err := provider.states.GetByStateRoot(expectedStateRoot) + require.NoError(t, err) + require.Same(t, state, storedState) +} + +func TestDownloadAndStoreBeaconStateRejectsMismatchedStateRoot(t *testing.T) { + provider := newTestProvider(t) + state := newTestPhase0BeaconState(phase0.Slot(1)) + unexpectedStateRoot, err := provider.sszEncoder.GetStateRoot(newTestPhase0BeaconState(phase0.Slot(2))) + require.NoError(t, err) + + fetcher := &mockBeaconStateFetcher{ + state: state, + } + + err = provider.downloadAndStoreBeaconState(context.Background(), unexpectedStateRoot, phase0.Slot(1), fetcher) + require.ErrorContains(t, err, "beacon state root does not match") + require.Equal(t, []string{eth.RootAsString(unexpectedStateRoot)}, fetcher.requestedStateIDs) + + _, err = provider.states.GetByStateRoot(unexpectedStateRoot) + require.Error(t, err) +} + +func newTestProvider(t *testing.T) *Default { + t.Helper() + + logger, _ := test.NewNullLogger() + namespace := strings.ReplaceAll(t.Name(), "/", "_") + + return &Default{ + log: logger, + sszEncoder: ssz.NewEncoder(false), + states: store.NewBeaconState(logger, store.Config{MaxItems: 5}, namespace), + } +} + +func newTestPhase0BeaconState(slot phase0.Slot) *spec.VersionedBeaconState { + return &spec.VersionedBeaconState{ + Version: spec.DataVersionPhase0, + Phase0: &phase0.BeaconState{ + Slot: slot, + Fork: &phase0.Fork{}, + LatestBlockHeader: &phase0.BeaconBlockHeader{}, + BlockRoots: make([]phase0.Root, 8192), + StateRoots: make([]phase0.Root, 8192), + HistoricalRoots: []phase0.Root{}, + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, 32)}, + ETH1DataVotes: []*phase0.ETH1Data{}, + Validators: []*phase0.Validator{}, + Balances: []phase0.Gwei{}, + RANDAOMixes: make([]phase0.Root, 65536), + Slashings: make([]phase0.Gwei, 8192), + PreviousEpochAttestations: []*phase0.PendingAttestation{}, + CurrentEpochAttestations: []*phase0.PendingAttestation{}, + JustificationBits: bitfield.NewBitvector4(), + PreviousJustifiedCheckpoint: &phase0.Checkpoint{}, + CurrentJustifiedCheckpoint: &phase0.Checkpoint{}, + FinalizedCheckpoint: &phase0.Checkpoint{}, + }, + } +} diff --git a/pkg/beacon/expire_test.go b/pkg/beacon/expire_test.go index 48dc1e40..66f3e755 100644 --- a/pkg/beacon/expire_test.go +++ b/pkg/beacon/expire_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethpandaops/checkpointz/pkg/eth" ) var ( @@ -13,11 +14,15 @@ var ( ) func CalculateSlotExpiration(slot phase0.Slot, slotsOfHistory int) phase0.Slot { - return slot + phase0.Slot(slotsOfHistory) + if slotsOfHistory < 0 { + return slot + } + + return slot + phase0.Slot(uint64(slotsOfHistory)) } func GetSlotTime(slot phase0.Slot, secondsPerSlot time.Duration, genesis time.Time) time.Time { - return genesis.Add(time.Duration(slot) * secondsPerSlot) + return eth.CalculateSlotTime(slot, genesis, secondsPerSlot).StartTime } func TestExpiresAtSlot(t *testing.T) { diff --git a/pkg/eth/slot.go b/pkg/eth/slot.go index bd15f41e..1ec9e14e 100644 --- a/pkg/eth/slot.go +++ b/pkg/eth/slot.go @@ -1,6 +1,8 @@ package eth import ( + "math" + "math/big" "time" "github.com/attestantio/go-eth2-client/spec/phase0" @@ -14,7 +16,22 @@ type SlotTime struct { } func CalculateSlotTime(slot phase0.Slot, genesisTime time.Time, durationPerSlot time.Duration) SlotTime { - slotStartTime := genesisTime.Add(time.Duration(slot) * durationPerSlot).UTC() + slotOffset := time.Duration(0) + + if durationPerSlot > 0 { + offset := new(big.Int).Mul( + new(big.Int).SetUint64(uint64(slot)), + big.NewInt(int64(durationPerSlot)), + ) + + if offset.IsInt64() { + slotOffset = time.Duration(offset.Int64()) + } else { + slotOffset = time.Duration(math.MaxInt64) + } + } + + slotStartTime := genesisTime.Add(slotOffset).UTC() return SlotTime{ StartTime: slotStartTime, diff --git a/pkg/service/eth/block_id.go b/pkg/service/eth/block_id.go index ebf05a5a..addd4bc6 100644 --- a/pkg/service/eth/block_id.go +++ b/pkg/service/eth/block_id.go @@ -68,7 +68,7 @@ func NewBlockIdentifier(id string) (BlockIdentifier, error) { return newBlockIdentifier(BlockIDRoot, id), nil } - if _, err := strconv.ParseInt(id, 10, 64); err == nil { + if _, err := strconv.ParseUint(id, 10, 64); err == nil { return newBlockIdentifier(BlockIDSlot, id), nil } @@ -83,7 +83,7 @@ func newBlockIdentifier(id BlockIDType, value string) BlockIdentifier { } func NewSlotFromString(id string) (phase0.Slot, error) { - slot, err := strconv.ParseInt(id, 10, 64) + slot, err := strconv.ParseUint(id, 10, 64) if err != nil { return 0, err } diff --git a/pkg/service/eth/eth.go b/pkg/service/eth/eth.go index 12939a59..77be2254 100644 --- a/pkg/service/eth/eth.go +++ b/pkg/service/eth/eth.go @@ -3,6 +3,7 @@ package eth import ( "context" "fmt" + "strconv" v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec" @@ -572,7 +573,12 @@ func (h *Handler) BlobSidecars(ctx context.Context, blockID BlockIdentifier, ind // Find the sidecar with the given index for i, sidecar := range sidecars { - if index == int(sidecar.Index) { + sidecarIndex, err := strconv.Atoi(strconv.FormatUint(uint64(sidecar.Index), 10)) + if err != nil { + continue + } + + if index == sidecarIndex { filtered = append(filtered, sidecars[i]) break