From a98b369a37eaaa56474519787a8bc80039f7e363 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 23 Oct 2026 11:57:39 +0200 Subject: [PATCH 1/5] fix: follow the Heze slot duration in the wallclock and UI --- api/openapi.yaml | 6 +++ api/rest/oas_json_gen.go | 71 ++++++++++++++++++++++++- api/rest/oas_schemas_gen.go | 74 +++++++++++++++++++++++++-- example_config.yaml | 10 +++- go.mod | 2 +- go.sum | 2 + internal/api/ethereum.go | 17 ++++-- internal/ethereum/config.go | 3 ++ internal/ethereum/ethereum.go | 10 +++- internal/ethereum/ethereum_test.go | 25 +++++++++ internal/service/service.go | 8 +++ internal/source/beacon_node.go | 13 +++++ web/src/App.tsx | 20 +++++--- web/src/api/types.gen.ts | 2 + web/src/api/zod.gen.ts | 24 +++++++++ web/src/components/Control.tsx | 6 +-- web/src/components/EpochDial.tsx | 3 +- web/src/components/Slot.tsx | 7 ++- web/src/components/SlotDial.tsx | 66 ++++++++++++++---------- web/src/contexts/ethereum.tsx | 44 +++++++++++++++- web/src/contexts/focus.tsx | 17 +++--- web/src/parts/Events.tsx | 5 +- web/src/utils/__tests__/slots.test.ts | 35 +++++++++++++ web/src/utils/slots.ts | 40 +++++++++++++++ 24 files changed, 441 insertions(+), 69 deletions(-) create mode 100644 web/src/utils/__tests__/slots.test.ts create mode 100644 web/src/utils/slots.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index 679ee75..eb9f67f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -582,6 +582,12 @@ components: genesis_time: type: string format: date-time + heze_fork_epoch: + type: integer + format: int64 + slot_duration_ms_heze: + type: integer + format: int64 EthereumSpecResult: type: object diff --git a/api/rest/oas_json_gen.go b/api/rest/oas_json_gen.go index 67f9fd7..43a4228 100644 --- a/api/rest/oas_json_gen.go +++ b/api/rest/oas_json_gen.go @@ -445,12 +445,26 @@ func (s *EthereumSpec) encodeFields(e *jx.Encoder) { e.FieldStart("genesis_time") json.EncodeDateTime(e, s.GenesisTime) } + { + if s.HezeForkEpoch.Set { + e.FieldStart("heze_fork_epoch") + s.HezeForkEpoch.Encode(e) + } + } + { + if s.SlotDurationMsHeze.Set { + e.FieldStart("slot_duration_ms_heze") + s.SlotDurationMsHeze.Encode(e) + } + } } -var jsonFieldsNameOfEthereumSpec = [3]string{ +var jsonFieldsNameOfEthereumSpec = [5]string{ 0: "seconds_per_slot", 1: "slots_per_epoch", 2: "genesis_time", + 3: "heze_fork_epoch", + 4: "slot_duration_ms_heze", } // Decode decodes EthereumSpec from json. @@ -498,6 +512,26 @@ func (s *EthereumSpec) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"genesis_time\"") } + case "heze_fork_epoch": + if err := func() error { + s.HezeForkEpoch.Reset() + if err := s.HezeForkEpoch.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"heze_fork_epoch\"") + } + case "slot_duration_ms_heze": + if err := func() error { + s.SlotDurationMsHeze.Reset() + if err := s.SlotDurationMsHeze.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"slot_duration_ms_heze\"") + } default: return d.Skip() } @@ -3585,6 +3619,41 @@ func (s *OptInt) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes int64 as json. +func (o OptInt64) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Int64(int64(o.Value)) +} + +// Decode decodes int64 from json. +func (o *OptInt64) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptInt64 to nil") + } + o.Set = true + v, err := d.Int64() + if err != nil { + return err + } + o.Value = int64(v) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptInt64) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptInt64) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes PaginationCursor as json. func (o OptPaginationCursor) Encode(e *jx.Encoder) { if !o.Set { diff --git a/api/rest/oas_schemas_gen.go b/api/rest/oas_schemas_gen.go index 9efce57..da5c2da 100644 --- a/api/rest/oas_schemas_gen.go +++ b/api/rest/oas_schemas_gen.go @@ -109,9 +109,11 @@ func (s *EthereumNow) SetEpoch(val Epoch) { // Ref: #/components/schemas/EthereumSpec type EthereumSpec struct { - SecondsPerSlot int64 `json:"seconds_per_slot"` - SlotsPerEpoch int64 `json:"slots_per_epoch"` - GenesisTime time.Time `json:"genesis_time"` + SecondsPerSlot int64 `json:"seconds_per_slot"` + SlotsPerEpoch int64 `json:"slots_per_epoch"` + GenesisTime time.Time `json:"genesis_time"` + HezeForkEpoch OptInt64 `json:"heze_fork_epoch"` + SlotDurationMsHeze OptInt64 `json:"slot_duration_ms_heze"` } // GetSecondsPerSlot returns the value of SecondsPerSlot. @@ -129,6 +131,16 @@ func (s *EthereumSpec) GetGenesisTime() time.Time { return s.GenesisTime } +// GetHezeForkEpoch returns the value of HezeForkEpoch. +func (s *EthereumSpec) GetHezeForkEpoch() OptInt64 { + return s.HezeForkEpoch +} + +// GetSlotDurationMsHeze returns the value of SlotDurationMsHeze. +func (s *EthereumSpec) GetSlotDurationMsHeze() OptInt64 { + return s.SlotDurationMsHeze +} + // SetSecondsPerSlot sets the value of SecondsPerSlot. func (s *EthereumSpec) SetSecondsPerSlot(val int64) { s.SecondsPerSlot = val @@ -144,6 +156,16 @@ func (s *EthereumSpec) SetGenesisTime(val time.Time) { s.GenesisTime = val } +// SetHezeForkEpoch sets the value of HezeForkEpoch. +func (s *EthereumSpec) SetHezeForkEpoch(val OptInt64) { + s.HezeForkEpoch = val +} + +// SetSlotDurationMsHeze sets the value of SlotDurationMsHeze. +func (s *EthereumSpec) SetSlotDurationMsHeze(val OptInt64) { + s.SlotDurationMsHeze = val +} + // Ref: #/components/schemas/EthereumSpecResult type EthereumSpecResult struct { NetworkName string `json:"network_name"` @@ -1484,6 +1506,52 @@ func (o OptInt) Or(d int) int { return d } +// NewOptInt64 returns new OptInt64 with value set to v. +func NewOptInt64(v int64) OptInt64 { + return OptInt64{ + Value: v, + Set: true, + } +} + +// OptInt64 is optional int64. +type OptInt64 struct { + Value int64 + Set bool +} + +// IsSet returns true if OptInt64 was set. +func (o OptInt64) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptInt64) Reset() { + var v int64 + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptInt64) SetTo(v int64) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptInt64) Get() (v int64, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptInt64) Or(d int64) int64 { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptPaginationCursor returns new OptPaginationCursor with value set to v. func NewOptPaginationCursor(v PaginationCursor) OptPaginationCursor { return OptPaginationCursor{ diff --git a/example_config.yaml b/example_config.yaml index 21c1d45..07f86b9 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -63,4 +63,12 @@ forky: # spec: # seconds_per_slot: 12 # slots_per_epoch: 32 - # genesis_time: 1655733600 \ No newline at end of file + # genesis_time: 1655733600 + + # name: "heze-devnet-3" + # spec: + # seconds_per_slot: 12 + # slots_per_epoch: 32 + # genesis_time: 1792577902 + # heze_fork_epoch: 5 + # slot_duration_ms_heze: 9000 \ No newline at end of file diff --git a/go.mod b/go.mod index 671a489..5df8ccb 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/aws/smithy-go v1.23.2 github.com/cenkalti/backoff/v4 v4.3.0 github.com/creasty/defaults v1.8.0 - github.com/ethpandaops/ethwallclock v0.4.0 + github.com/ethpandaops/ethwallclock v0.5.0 github.com/ethpandaops/xatu v1.25.0 github.com/glebarez/sqlite v1.10.0 github.com/go-co-op/gocron v1.37.0 diff --git a/go.sum b/go.sum index 84b9434..a12d097 100644 --- a/go.sum +++ b/go.sum @@ -188,6 +188,8 @@ github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 h1:BpbY4wxDlLH github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7/go.mod h1:my+mlolmidi2VzoaGhchtcxDVbp1ZNVQJtnkk3oe6+M= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= +github.com/ethpandaops/ethwallclock v0.5.0 h1:UZmH3uKHZTZczMGz0ZMTIHTY5q5vNkczfo7eZgOGn5o= +github.com/ethpandaops/ethwallclock v0.5.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031839-0b35f0981d55 h1:W1Tc0Vd2JTQheYcKhTQJUzjJgdSqq9GTd1aTfj2f8jE= github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031839-0b35f0981d55/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/ethpandaops/go-eth2-client v0.1.10 h1:AGROjkS5jMkveL1E46wyWNv5JLJNWWyZE/hIVBibIrg= diff --git a/internal/api/ethereum.go b/internal/api/ethereum.go index a0c8200..b9f1cdd 100644 --- a/internal/api/ethereum.go +++ b/internal/api/ethereum.go @@ -28,16 +28,23 @@ func (h *HTTP) GetEthereumNow(ctx context.Context) (rest.GetEthereumNowRes, erro // GetEthereumSpec implements getEthereumSpec: the configured network name and // spec. func (h *HTTP) GetEthereumSpec(ctx context.Context) (rest.GetEthereumSpecRes, error) { + spec := rest.EthereumSpec{ + SecondsPerSlot: safeInt64(h.svc.GetEthereumSpecSecondsPerSlot(ctx)), + SlotsPerEpoch: safeInt64(h.svc.GetEthereumSpecSlotsPerEpoch(ctx)), + GenesisTime: h.svc.GetEthereumSpecGenesisTime(ctx).UTC(), + } + + if hezeForkEpoch, slotDurationMsHeze := h.svc.GetEthereumSpecHezeSlotDuration(ctx); slotDurationMsHeze > 0 { + spec.HezeForkEpoch = rest.NewOptInt64(safeInt64(hezeForkEpoch)) + spec.SlotDurationMsHeze = rest.NewOptInt64(safeInt64(slotDurationMsHeze)) + } + return &rest.GetEthereumSpecOKHeaders{ CacheControl: rest.NewOptString("public, max-age=60, s-maxage=60"), Response: rest.GetEthereumSpecOK{ Data: rest.EthereumSpecResult{ NetworkName: h.svc.GetEthereumNetworkName(ctx), - Spec: rest.EthereumSpec{ - SecondsPerSlot: safeInt64(h.svc.GetEthereumSpecSecondsPerSlot(ctx)), - SlotsPerEpoch: safeInt64(h.svc.GetEthereumSpecSlotsPerEpoch(ctx)), - GenesisTime: h.svc.GetEthereumSpecGenesisTime(ctx).UTC(), - }, + Spec: spec, }, }, }, nil diff --git a/internal/ethereum/config.go b/internal/ethereum/config.go index 955446b..b09b8a9 100644 --- a/internal/ethereum/config.go +++ b/internal/ethereum/config.go @@ -18,6 +18,9 @@ type SpecConfig struct { SecondsPerSlot uint64 `yaml:"seconds_per_slot"` SlotsPerEpoch uint64 `yaml:"slots_per_epoch"` GenesisTime uint64 `yaml:"genesis_time"` + + HezeForkEpoch uint64 `yaml:"heze_fork_epoch"` + SlotDurationMsHeze uint64 `yaml:"slot_duration_ms_heze"` } func (c *Config) Validate() error { diff --git a/internal/ethereum/ethereum.go b/internal/ethereum/ethereum.go index 0a1268e..fba475a 100644 --- a/internal/ethereum/ethereum.go +++ b/internal/ethereum/ethereum.go @@ -30,7 +30,15 @@ func NewBeaconChain(log logrus.FieldLogger, config *Config) (*BeaconChain, error //nolint:gosec // ignore integer overflow conversion uint64 -> int64 genesisTime := time.Unix(int64(config.Network.Spec.GenesisTime), 0) - wc := ethwallclock.NewEthereumBeaconChain(genesisTime, secondsPerSlot, config.Network.Spec.SlotsPerEpoch) + wc := ethwallclock.NewEthereumBeaconChain( + genesisTime, + secondsPerSlot, + config.Network.Spec.SlotsPerEpoch, + ethwallclock.WithHezeSlotDuration( + config.Network.Spec.HezeForkEpoch, + time.Duration(config.Network.Spec.SlotDurationMsHeze)*time.Millisecond, //nolint:gosec // ignore integer overflow conversion uint64 -> int64 + ), + ) return &BeaconChain{ log: log.WithField("component", "ethereum/beaconchain"), diff --git a/internal/ethereum/ethereum_test.go b/internal/ethereum/ethereum_test.go index 135c1cf..76e77ce 100644 --- a/internal/ethereum/ethereum_test.go +++ b/internal/ethereum/ethereum_test.go @@ -2,6 +2,7 @@ package ethereum import ( "testing" + "time" "github.com/ethpandaops/ethwallclock" "github.com/sirupsen/logrus" @@ -85,3 +86,27 @@ func TestBeaconChain_SecondsPerSlot_ReturnsSecondsPerSlot(t *testing.T) { secondsPerSlot := beaconchain.SecondsPerSlot() assert.Equal(t, uint64(12), secondsPerSlot) } + +func TestBeaconChain_Wallclock_FollowsHezeSlotDuration(t *testing.T) { + config := &Config{ + Network: NetworkConfig{ + Name: "TestNet", + Spec: SpecConfig{ + SlotsPerEpoch: 32, + SecondsPerSlot: 12, + GenesisTime: 1590832934, + HezeForkEpoch: 5, + SlotDurationMsHeze: 9000, + }, + }, + } + log := logrus.New() + beaconchain, err := NewBeaconChain(log, config) + require.NoError(t, err) + + forkTime := beaconchain.GenesisTime().Add(160 * 12 * time.Second) + slot, epoch, err := beaconchain.Wallclock().FromTime(forkTime.Add(18799 * 9 * time.Second)) + require.NoError(t, err) + assert.Equal(t, uint64(18959), slot.Number()) + assert.Equal(t, uint64(592), epoch.Number()) +} diff --git a/internal/service/service.go b/internal/service/service.go index 1e1c180..cbde583 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -487,6 +487,14 @@ func (f *ForkChoice) GetEthereumSpecSlotsPerEpoch(_ context.Context) uint64 { return f.eth.Spec().SlotsPerEpoch } +func (f *ForkChoice) GetEthereumSpecHezeSlotDuration(_ context.Context) (forkEpoch, slotDurationMs uint64) { + operation := OperationGetEthereumSpec + + f.metrics.ObserveOperation(operation) + + return f.eth.Spec().HezeForkEpoch, f.eth.Spec().SlotDurationMsHeze +} + func (f *ForkChoice) GetEthereumSpecGenesisTime(_ context.Context) time.Time { operation := OperationGetEthereumSpec diff --git a/internal/source/beacon_node.go b/internal/source/beacon_node.go index abab8a4..25e5b5d 100644 --- a/internal/source/beacon_node.go +++ b/internal/source/beacon_node.go @@ -248,11 +248,24 @@ func (b *BeaconNode) bootstrap(ctx context.Context) error { b.slotsPerEpoch = sslotsPerEpoch + var wallclockOpts []ethwallclock.Option + + hezeForkEpoch, hasHezeForkEpoch := spec["HEZE_FORK_EPOCH"].(uint64) + slotDurationMsHeze, hasSlotDurationMsHeze := spec["SLOT_DURATION_MS_HEZE"].(uint64) + + if hasHezeForkEpoch && hasSlotDurationMsHeze { + wallclockOpts = append(wallclockOpts, ethwallclock.WithHezeSlotDuration( + hezeForkEpoch, + time.Duration(slotDurationMsHeze)*time.Millisecond, //nolint:gosec // ignore integer overflow conversion uint64 -> int64 + )) + } + // Create the wallclock. b.wallclock = ethwallclock.NewEthereumBeaconChain( b.genesis.GenesisTime, b.secondsPerSlot, b.slotsPerEpoch, + wallclockOpts..., ) return nil diff --git a/web/src/App.tsx b/web/src/App.tsx index 3adf875..7196647 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import Selection from '@parts/Selection'; import Stage from '@parts/Stage'; import Timeline from '@parts/Timeline'; import ApplicationProvider from '@providers/application'; +import { slotStartTime } from '@utils/slots'; export default function App({ node, @@ -36,13 +37,21 @@ export default function App({ const formattedData = useMemo(() => { if (!data?.spec) return undefined; - const { seconds_per_slot, slots_per_epoch, genesis_time } = data.spec; + const { + seconds_per_slot, + slots_per_epoch, + genesis_time, + heze_fork_epoch, + slot_duration_ms_heze, + } = data.spec; if (!seconds_per_slot || !slots_per_epoch || !genesis_time) return undefined; return { secondsPerSlot: seconds_per_slot, slotsPerEpoch: slots_per_epoch, genesisTime: new Date(genesis_time).getTime(), networkName: data.network_name ?? 'Unknown', + hezeForkEpoch: heze_fork_epoch === undefined ? undefined : Number(heze_fork_epoch), + hezeSecondsPerSlot: slot_duration_ms_heze ? Number(slot_duration_ms_heze) / 1000 : undefined, }; }, [data]); @@ -58,13 +67,8 @@ export default function App({ // use the server time if it exists if (formattedData && dataNow) { - return [ - formattedData.genesisTime + - dataNow.slot * (formattedData.secondsPerSlot * 1000) - - // offset minus 2 slots - formattedData.secondsPerSlot * 1000 * 2, - true, - ]; + // offset minus 2 slots + return [slotStartTime(formattedData, dataNow.slot - 2), true]; } // worst case when failing to get server now, just use the local time at diff --git a/web/src/api/types.gen.ts b/web/src/api/types.gen.ts index dc07e3b..cb20a64 100644 --- a/web/src/api/types.gen.ts +++ b/web/src/api/types.gen.ts @@ -198,6 +198,8 @@ export type EthereumSpec = { seconds_per_slot: number; slots_per_epoch: number; genesis_time: string; + heze_fork_epoch?: number; + slot_duration_ms_heze?: number; }; export type EthereumSpecResult = { diff --git a/web/src/api/zod.gen.ts b/web/src/api/zod.gen.ts index 497e6a8..992e54c 100644 --- a/web/src/api/zod.gen.ts +++ b/web/src/api/zod.gen.ts @@ -170,6 +170,30 @@ export const zEthereumSpec = z.object({ z.maximum(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }) ), genesis_time: z.iso.datetime(), + heze_fork_epoch: z.optional( + z.coerce + .bigint() + .check( + z.minimum(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }), + z.maximum(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + ) + ), + slot_duration_ms_heze: z.optional( + z.coerce + .bigint() + .check( + z.minimum(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }), + z.maximum(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + ) + ), }); export const zEthereumSpecResult = z.object({ diff --git a/web/src/components/Control.tsx b/web/src/components/Control.tsx index 363e93e..ed2c9f5 100644 --- a/web/src/components/Control.tsx +++ b/web/src/components/Control.tsx @@ -42,10 +42,10 @@ function TimelineControl() { stop: stopTimer, playing, } = useFocus(); - const { genesisTime, secondsPerSlot } = useEthereum(); + const { slotAtTime } = useEthereum(); const now = useNow(); - const slotDiff = Math.floor((now - genesisTime) / 1000 / secondsPerSlot) - focusedSlot; + const slotDiff = slotAtTime(now) - focusedSlot; const isCloseToLiveSlot = slotDiff <= 3 && slotDiff >= -1; const isLive = playing && isCloseToLiveSlot; @@ -63,7 +63,7 @@ function TimelineControl() { const handleLive = () => { if (!isCloseToLiveSlot) { - setCurrentSlot(Math.floor((Date.now() - genesisTime) / 1000 / secondsPerSlot) - 2); + setCurrentSlot(slotAtTime(Date.now()) - 2); playTimer(); } else if (!playing) { playTimer(); diff --git a/web/src/components/EpochDial.tsx b/web/src/components/EpochDial.tsx index 36421f1..0248077 100644 --- a/web/src/components/EpochDial.tsx +++ b/web/src/components/EpochDial.tsx @@ -9,8 +9,9 @@ import useWindowSize from '@hooks/useWindowSize'; const SUB_MARKS = 0; function SlotDial() { - const { slotsPerEpoch, secondsPerSlot } = useEthereum(); + const { slotsPerEpoch, slotDuration } = useEthereum(); const { epoch: focusedEpoch, timeIntoEpoch: focusedTimeIntoEpoch } = useFocus(); + const secondsPerSlot = slotDuration(focusedEpoch * slotsPerEpoch); const [width] = useWindowSize(); const epochWidth = slotsPerEpoch * 4 * (SUB_MARKS + 1); diff --git a/web/src/components/Slot.tsx b/web/src/components/Slot.tsx index 28aac8c..906e6f9 100644 --- a/web/src/components/Slot.tsx +++ b/web/src/components/Slot.tsx @@ -18,13 +18,12 @@ type Props = { }; function Slot({ subMarks, slot, shouldFetch = false, segments }: Props) { - const { secondsPerSlot, genesisTime } = useEthereum(); + const { slotDuration, slotStartTime } = useEthereum(); + const secondsPerSlot = slotDuration(slot); const { node } = useFocus(); const { ids } = useActive(); - const slotStart = useMemo(() => { - return genesisTime + slot * (secondsPerSlot * 1000); - }, [genesisTime, slot, secondsPerSlot]); + const slotStart = useMemo(() => slotStartTime(slot), [slotStartTime, slot]); const { data, isLoading, error } = useMetadataQuery({ slot }, shouldFetch); // prefetch framess diff --git a/web/src/components/SlotDial.tsx b/web/src/components/SlotDial.tsx index 1fba1fd..98c0a23 100644 --- a/web/src/components/SlotDial.tsx +++ b/web/src/components/SlotDial.tsx @@ -11,51 +11,61 @@ const PADDING = 2; const MARKER_WIDTH = 4; const SlotDial = () => { - const { secondsPerSlot } = useEthereum(); + const { slotDuration } = useEthereum(); const { slot: focusedSlot, timeIntoSlot } = useFocus(); const [width] = useWindowSize(); - const slotWidth = secondsPerSlot * MARKER_WIDTH * (SUB_MARKS + 1); - const segments = slotWidth / MARKER_WIDTH; - const slotPadding = Math.ceil(((width / slotWidth) * 1.25) / 2); + const slotWidth = (slot: number) => slotDuration(slot) * MARKER_WIDTH * (SUB_MARKS + 1); + const focusedSlotWidth = slotWidth(focusedSlot); + const slotPadding = Math.ceil(((width / focusedSlotWidth) * 1.25) / 2); const multiplier = 250 / (SUB_MARKS + 1); - const middleSlotX = width / 2 - (slotWidth / (secondsPerSlot * 1000)) * timeIntoSlot; + const middleSlotX = width / 2 - (MARKER_WIDTH * (SUB_MARKS + 1) * timeIntoSlot) / 1000; - const leftSideRulers = Array.from( - { length: focusedSlot < slotPadding ? focusedSlot : slotPadding }, - (_, i) => { - return ( -
- -
- ); - }, - ); + const leftSideRulers: React.ReactNode[] = []; + let leftX = middleSlotX; + for (let i = 0; i < Math.min(focusedSlot, slotPadding); i++) { + const slot = focusedSlot - i - 1; + leftX -= slotWidth(slot); + leftSideRulers.push( +
+ +
, + ); + } - const rightSideRulers = Array.from({ length: slotPadding }, (_, i) => { - return ( -
+ const rightSideRulers: React.ReactNode[] = []; + let rightX = middleSlotX + focusedSlotWidth; + for (let i = 0; i < slotPadding; i++) { + const slot = focusedSlot + i + 1; + rightSideRulers.push( +
-
+
, ); - }); + rightX += slotWidth(slot); + } return (
{leftSideRulers}
- +
{rightSideRulers}
diff --git a/web/src/contexts/ethereum.tsx b/web/src/contexts/ethereum.tsx index 13ef8b1..4734f2f 100644 --- a/web/src/contexts/ethereum.tsx +++ b/web/src/contexts/ethereum.tsx @@ -1,4 +1,10 @@ -import { useContext as reactUseContext, createContext, useState } from 'react'; +import { useContext as reactUseContext, createContext, useCallback, useState } from 'react'; + +import { + slotAtTime as scheduleSlotAtTime, + slotDuration as scheduleSlotDuration, + slotStartTime as scheduleSlotStartTime, +} from '@utils/slots'; export const Context = createContext(undefined); @@ -19,6 +25,9 @@ export interface State { setGenesisTime: (genesisTime: number) => void; networkName: string; setNetworkName: (networkName: string) => void; + slotDuration: (slot: number) => number; + slotStartTime: (slot: number) => number; + slotAtTime: (time: number) => number; } export interface ValueProps { @@ -26,6 +35,8 @@ export interface ValueProps { slotsPerEpoch: number; genesisTime: number; networkName: string; + hezeForkEpoch?: number; + hezeSecondsPerSlot?: number; } export function useValue(props: ValueProps): State { @@ -33,6 +44,34 @@ export function useValue(props: ValueProps): State { const [slotsPerEpoch, setSlotsPerEpoch] = useState(props.slotsPerEpoch); const [genesisTime, setGenesisTime] = useState(props.genesisTime); const [networkName, setNetworkName] = useState(props.networkName); + const { hezeForkEpoch, hezeSecondsPerSlot } = props; + + const slotDuration = useCallback( + (slot: number) => + scheduleSlotDuration( + { genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot }, + slot, + ), + [genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot], + ); + + const slotStartTime = useCallback( + (slot: number) => + scheduleSlotStartTime( + { genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot }, + slot, + ), + [genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot], + ); + + const slotAtTime = useCallback( + (time: number) => + scheduleSlotAtTime( + { genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot }, + time, + ), + [genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot], + ); return { secondsPerSlot, @@ -43,5 +82,8 @@ export function useValue(props: ValueProps): State { setGenesisTime, networkName, setNetworkName, + slotDuration, + slotStartTime, + slotAtTime, }; } diff --git a/web/src/contexts/focus.tsx b/web/src/contexts/focus.tsx index b2d5516..5313eac 100644 --- a/web/src/contexts/focus.tsx +++ b/web/src/contexts/focus.tsx @@ -54,7 +54,7 @@ export function useValue(props: ValueProps): State { const [frameId, setFrameId] = useState(props.frameId); const [byo, setBYO] = useState(props.byo); const [byoData, setBYODataInternal] = useState(); - const { secondsPerSlot, slotsPerEpoch, genesisTime } = useEthereum(); + const { slotsPerEpoch, genesisTime, slotStartTime, slotAtTime } = useEthereum(); const timer = useRef(null); const initialPlayTime = useRef(undefined); const [playing, setPlaying] = useState(props.playing); @@ -84,11 +84,10 @@ export function useValue(props: ValueProps): State { [genesisTime, setTime], ); - const timeDiff = (time - genesisTime) / 1000; - const slot = Math.floor(timeDiff / secondsPerSlot); + const slot = slotAtTime(time); const epoch = Math.floor(slot / slotsPerEpoch); - const timeIntoSlot = Math.ceil((timeDiff % secondsPerSlot) * 1000); - const timeIntoEpoch = (slot % slotsPerEpoch) * secondsPerSlot * 1000 + timeIntoSlot; + const timeIntoSlot = Math.ceil(time - slotStartTime(slot)); + const timeIntoEpoch = Math.ceil(time - slotStartTime(epoch * slotsPerEpoch)); const shiftTime = useCallback( (delta: number) => { @@ -133,10 +132,10 @@ export function useValue(props: ValueProps): State { if (targetSlot < 0) return; if (playing && timer.current) cancelAnimationFrame(timer.current); initialPlayTime.current = undefined; - setTime(genesisTime + targetSlot * secondsPerSlot * 1000); + setTime(slotStartTime(targetSlot)); if (playing) timer.current = requestAnimationFrame(step); }, - [setTime, genesisTime, secondsPerSlot, step, playing], + [setTime, slotStartTime, step, playing], ); const setEpoch = useCallback( @@ -144,10 +143,10 @@ export function useValue(props: ValueProps): State { if (targetEpoch < 0) return; if (playing && timer.current) cancelAnimationFrame(timer.current); initialPlayTime.current = undefined; - setTime(genesisTime + targetEpoch * slotsPerEpoch * secondsPerSlot * 1000); + setTime(slotStartTime(targetEpoch * slotsPerEpoch)); if (playing) timer.current = requestAnimationFrame(step); }, - [setTime, genesisTime, secondsPerSlot, slotsPerEpoch, step, playing], + [setTime, slotStartTime, slotsPerEpoch, step, playing], ); const setBYOData = useCallback( diff --git a/web/src/parts/Events.tsx b/web/src/parts/Events.tsx index 46e08b2..8b3403b 100644 --- a/web/src/parts/Events.tsx +++ b/web/src/parts/Events.tsx @@ -93,7 +93,7 @@ export default function Events({ open, closeTo }: { open: boolean; closeTo: stri }), ); const { node, stop, setTime } = useFocus(); - const { slotsPerEpoch, secondsPerSlot, genesisTime } = useEthereum(); + const { slotsPerEpoch, slotAtTime } = useEthereum(); const setLocation = useAppNavigate(); // Recompute the filter at event time (in the input handlers below) rather @@ -430,8 +430,7 @@ export default function Events({ open, closeTo }: { open: boolean; closeTo: stri {events.map(event => { if (!event) return null; const time = new Date(event.time); - const timeDiff = (time.getTime() - genesisTime) / 1000; - const slot = Math.floor(timeDiff / secondsPerSlot); + const slot = slotAtTime(time.getTime()); const epoch = Math.floor(slot / slotsPerEpoch); return ( { + it('uses the base slot duration without a Heze schedule', () => { + const base = { genesisTime, secondsPerSlot: 12, slotsPerEpoch: 32 }; + expect(slotDuration(base, 1000)).toBe(12); + expect(slotStartTime(base, 1000)).toBe(genesisTime + 1000 * 12_000); + expect(slotAtTime(base, genesisTime + 1000 * 12_000 + 11_999)).toBe(1000); + }); + + it('switches to the Heze slot duration at the fork epoch', () => { + expect(slotDuration(schedule, 159)).toBe(12); + expect(slotDuration(schedule, 160)).toBe(9); + expect(slotStartTime(schedule, 160)).toBe(genesisTime + 160 * 12_000); + expect(slotStartTime(schedule, 161)).toBe(genesisTime + 160 * 12_000 + 9_000); + expect(slotAtTime(schedule, genesisTime + 160 * 12_000 - 1)).toBe(159); + expect(slotAtTime(schedule, genesisTime + 160 * 12_000)).toBe(160); + expect(slotAtTime(schedule, genesisTime + 160 * 12_000 + 18_799 * 9_000)).toBe(18_959); + }); + + it('round-trips slot start times across the fork', () => { + for (const slot of [0, 1, 159, 160, 161, 18_959]) { + expect(slotAtTime(schedule, slotStartTime(schedule, slot))).toBe(slot); + } + }); +}); diff --git a/web/src/utils/slots.ts b/web/src/utils/slots.ts new file mode 100644 index 0000000..b47ce5a --- /dev/null +++ b/web/src/utils/slots.ts @@ -0,0 +1,40 @@ +export interface SlotSchedule { + genesisTime: number; + secondsPerSlot: number; + slotsPerEpoch: number; + hezeForkEpoch?: number; + hezeSecondsPerSlot?: number; +} + +function hezeForkSlot({ slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot }: SlotSchedule) { + if (!hezeSecondsPerSlot || hezeForkEpoch === undefined) return Infinity; + return hezeForkEpoch * slotsPerEpoch; +} + +export function slotDuration(schedule: SlotSchedule, slot: number): number { + if (slot >= hezeForkSlot(schedule) && schedule.hezeSecondsPerSlot) { + return schedule.hezeSecondsPerSlot; + } + return schedule.secondsPerSlot; +} + +export function slotStartTime(schedule: SlotSchedule, slot: number): number { + const { genesisTime, secondsPerSlot, hezeSecondsPerSlot } = schedule; + const forkSlot = hezeForkSlot(schedule); + if (slot < forkSlot || !hezeSecondsPerSlot) { + return genesisTime + slot * secondsPerSlot * 1000; + } + return ( + genesisTime + forkSlot * secondsPerSlot * 1000 + (slot - forkSlot) * hezeSecondsPerSlot * 1000 + ); +} + +export function slotAtTime(schedule: SlotSchedule, time: number): number { + const { genesisTime, secondsPerSlot, hezeSecondsPerSlot } = schedule; + const forkSlot = hezeForkSlot(schedule); + const forkTime = slotStartTime(schedule, forkSlot); + if (time < forkTime || !hezeSecondsPerSlot) { + return Math.floor((time - genesisTime) / 1000 / secondsPerSlot); + } + return forkSlot + Math.floor((time - forkTime) / 1000 / hezeSecondsPerSlot); +} From 8a5c50a043723e9b6e6dd0ef87f8b59fac2835b2 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 23 Oct 2026 12:02:23 +0200 Subject: [PATCH 2/5] ci: bump playwright to 1.64.1 for ubuntu 26.04 runners --- web/package.json | 2 +- web/pnpm-lock.yaml | 42 ++++++++++++++++-------------------------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/web/package.json b/web/package.json index 0668460..2cda3cf 100644 --- a/web/package.json +++ b/web/package.json @@ -79,7 +79,7 @@ "jsdom": "29.1.1", "msw": "2.14.6", "msw-storybook-addon": "2.0.7", - "playwright": "1.60.0", + "playwright": "1.64.1", "prettier": "3.8.3", "prop-types": "15.8.1", "storybook": "10.4.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ba2a77e..630c639 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -119,7 +119,7 @@ importers: version: 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8) '@vitest/browser-playwright': specifier: 4.1.8 - version: 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8) + version: 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(playwright@1.64.1)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8) '@vitest/coverage-v8': specifier: 4.1.8 version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) @@ -163,8 +163,8 @@ importers: specifier: 2.0.7 version: 2.0.7(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3)) playwright: - specifier: 1.60.0 - version: 1.60.0 + specifier: 1.64.1 + version: 1.64.1 prettier: specifier: 3.8.3 version: 3.8.3 @@ -2103,11 +2103,6 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2566,14 +2561,14 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - playwright-core@1.60.0: - resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} - engines: {node: '>=18'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} hasBin: true - playwright@1.60.0: - resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} - engines: {node: '>=18'} + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} hasBin: true pngjs@7.0.0: @@ -3923,7 +3918,7 @@ snapshots: storybook: 10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) optionalDependencies: '@vitest/browser': 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8) - '@vitest/browser-playwright': 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8) + '@vitest/browser-playwright': 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(playwright@1.64.1)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8) '@vitest/runner': 4.1.8 vitest: 4.1.8(@types/node@25.9.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@29.1.1)(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0)) transitivePeerDependencies: @@ -4381,11 +4376,11 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0) - '@vitest/browser-playwright@4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8)': + '@vitest/browser-playwright@4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(playwright@1.64.1)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8)': dependencies: '@vitest/browser': 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8) '@vitest/mocker': 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0)) - playwright: 1.60.0 + playwright: 1.64.1 tinyrainbow: 3.1.0 vitest: 4.1.8(@types/node@25.9.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@29.1.1)(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0)) transitivePeerDependencies: @@ -4945,9 +4940,6 @@ snapshots: fraction.js@4.3.7: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -5391,13 +5383,11 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - playwright-core@1.60.0: {} + playwright-core@1.64.1: {} - playwright@1.60.0: + playwright@1.64.1: dependencies: - playwright-core: 1.60.0 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.64.1 pngjs@7.0.0: {} @@ -5822,7 +5812,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.2 - '@vitest/browser-playwright': 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8) + '@vitest/browser-playwright': 4.1.8(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(playwright@1.64.1)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.27.7)(jiti@2.7.0))(vitest@4.1.8) '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) '@vitest/ui': 4.1.8(vitest@4.1.8) jsdom: 29.1.1 From 1482639c3c0924f9542c0a74c227394e1b0b90b6 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 23 Oct 2026 12:08:19 +0200 Subject: [PATCH 3/5] ci: move actions to node24 releases and check out before setup-go --- .github/workflows/alpha-releases.yaml | 2 +- .github/workflows/goreleaser.yaml | 16 ++++++++-------- .github/workflows/vet.yaml | 27 +++++++++++++-------------- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/.github/workflows/alpha-releases.yaml b/.github/workflows/alpha-releases.yaml index 52cabc4..e566119 100644 --- a/.github/workflows/alpha-releases.yaml +++ b/.github/workflows/alpha-releases.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 ref: ${{ github.ref }} diff --git a/.github/workflows/goreleaser.yaml b/.github/workflows/goreleaser.yaml index bed2203..61b2db5 100644 --- a/.github/workflows/goreleaser.yaml +++ b/.github/workflows/goreleaser.yaml @@ -14,7 +14,7 @@ jobs: - size-l-x64 steps: - name: Checkout - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 ref: ${{ github.ref }} @@ -36,17 +36,17 @@ jobs: echo "Release suffix: $RELEASE_SUFFIX" - name: Set up Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.26' check-latest: true - name: Set up pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: # the packageManager pin lives in web/package.json, not the repo root version: 10.30.1 run_install: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 name: Set up Node with: node-version: 22 @@ -57,18 +57,18 @@ jobs: - name: Install make run: sudo apt-get -y install make - name: Set up QEMU - uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7 # v2.2.0 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Context for Buildx shell: bash id: buildx-context run: | docker context create builders - name: Set up Docker Buildx - uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 with: endpoint: builders - name: Login to DockerHub - uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -84,7 +84,7 @@ jobs: echo " make_latest: false" >> ../.goreleaser.yaml.new fi - name: Run GoReleaser - uses: goreleaser/goreleaser-action@5fdedb94abba051217030cc86d4523cf3f02243d # v4.6.0 + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: distribution: goreleaser version: latest diff --git a/.github/workflows/vet.yaml b/.github/workflows/vet.yaml index 8cd169b..c82a673 100644 --- a/.github/workflows/vet.yaml +++ b/.github/workflows/vet.yaml @@ -15,16 +15,16 @@ jobs: matrix: go_version: [ 1.26.x ] steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go ${{ matrix.go_version }} - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go_version }} check-latest: true - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: golangci-lint # v8+ of the action is required for golangci-lint v2 (older action # versions pass --out-format, which v2 removed) - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: v2.12.2 args: --timeout=5m @@ -34,17 +34,17 @@ jobs: matrix: go_version: [ 1.26.x ] steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go ${{ matrix.go_version }} - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go_version }} check-latest: true - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: run tests run: go test -json ./... > test.json - name: Annotate tests if: always() - uses: guyarb/golang-test-annotations@9ab2ea84a399d03ffd114bf49dd23ffadc794541 # v0.6.0 + uses: guyarb/golang-test-annotations@96fc379b171c49932041d6c789e73331a7bdeec1 # v0.9.0 with: test-results: test.json web-test: @@ -56,14 +56,14 @@ jobs: matrix: node-version: [22.x] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 10.30.1 run_install: false - name: Set up NodeJS ${{ matrix.node-version }} - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} cache: "pnpm" @@ -84,14 +84,14 @@ jobs: matrix: node-version: [22.x] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 10.30.1 run_install: false - name: Set up NodeJS ${{ matrix.node-version }} - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} cache: "pnpm" @@ -104,8 +104,7 @@ jobs: run: pnpm lint:report continue-on-error: true - name: Annotate Results - uses: ataylorme/eslint-annotate-action@5f4dc2e3af8d3c21b727edb597e5503510b1dc9c # 2.2.0 + uses: ataylorme/eslint-annotate-action@5a8d8ab89a57679e90c32750e0f382e9d8570750 # 4.0.0 if: "!github.event.pull_request.head.repo.fork" with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" report-json: "web/eslint_report.json" From df84a14804d2aeca0430201023c27bfd16a4f637 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 23 Oct 2026 12:08:27 +0200 Subject: [PATCH 4/5] ci: drop the invalid branches filter on workflow_dispatch --- .github/workflows/vet.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/vet.yaml b/.github/workflows/vet.yaml index c82a673..7fe14fe 100644 --- a/.github/workflows/vet.yaml +++ b/.github/workflows/vet.yaml @@ -3,7 +3,6 @@ name: Vet on: pull_request: workflow_dispatch: - branches: [ '**' ] permissions: contents: read checks: write From 75b5061098da941102c2ffb83a6de99592524395 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 23 Oct 2026 13:57:06 +0200 Subject: [PATCH 5/5] feat: use SLOT_DURATION_MS instead of the deprecated SECONDS_PER_SLOT --- api/openapi.yaml | 4 ++-- api/rest/oas_json_gen.go | 12 +++++----- api/rest/oas_schemas_gen.go | 14 ++++++------ example_config.yaml | 8 +++---- forky_test.go | 2 +- internal/api/ethereum.go | 2 +- internal/ethereum/config.go | 6 ++--- internal/ethereum/ethereum.go | 12 +++------- internal/ethereum/ethereum_test.go | 16 +++++++------- internal/service/service.go | 4 ++-- internal/source/beacon_node.go | 24 ++++++++------------ web/src/App.tsx | 8 +++---- web/src/api/types.gen.ts | 2 +- web/src/api/zod.gen.ts | 2 +- web/src/components/EpochDial.tsx | 6 ++--- web/src/components/Slot.tsx | 8 +++---- web/src/components/SlotDial.tsx | 2 +- web/src/contexts/ethereum.tsx | 28 +++++++++++------------ web/src/mocks/handlers.ts | 8 +++---- web/src/parts/Graph.stories.tsx | 2 +- web/src/stories/fixtures.ts | 6 ++--- web/src/stories/storybook.tsx | 4 ++-- web/src/utils/__tests__/slots.test.ts | 12 +++++----- web/src/utils/slots.ts | 32 +++++++++++++-------------- web/src/utils/testing.tsx | 7 ++---- 25 files changed, 107 insertions(+), 124 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index eb9f67f..04e6f04 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -571,9 +571,9 @@ components: EthereumSpec: type: object - required: [seconds_per_slot, slots_per_epoch, genesis_time] + required: [slot_duration_ms, slots_per_epoch, genesis_time] properties: - seconds_per_slot: + slot_duration_ms: type: integer format: int64 slots_per_epoch: diff --git a/api/rest/oas_json_gen.go b/api/rest/oas_json_gen.go index 43a4228..bec1390 100644 --- a/api/rest/oas_json_gen.go +++ b/api/rest/oas_json_gen.go @@ -434,8 +434,8 @@ func (s *EthereumSpec) Encode(e *jx.Encoder) { // encodeFields encodes fields. func (s *EthereumSpec) encodeFields(e *jx.Encoder) { { - e.FieldStart("seconds_per_slot") - e.Int64(s.SecondsPerSlot) + e.FieldStart("slot_duration_ms") + e.Int64(s.SlotDurationMs) } { e.FieldStart("slots_per_epoch") @@ -460,7 +460,7 @@ func (s *EthereumSpec) encodeFields(e *jx.Encoder) { } var jsonFieldsNameOfEthereumSpec = [5]string{ - 0: "seconds_per_slot", + 0: "slot_duration_ms", 1: "slots_per_epoch", 2: "genesis_time", 3: "heze_fork_epoch", @@ -476,17 +476,17 @@ func (s *EthereumSpec) Decode(d *jx.Decoder) error { if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { switch string(k) { - case "seconds_per_slot": + case "slot_duration_ms": requiredBitSet[0] |= 1 << 0 if err := func() error { v, err := d.Int64() - s.SecondsPerSlot = int64(v) + s.SlotDurationMs = int64(v) if err != nil { return err } return nil }(); err != nil { - return errors.Wrap(err, "decode field \"seconds_per_slot\"") + return errors.Wrap(err, "decode field \"slot_duration_ms\"") } case "slots_per_epoch": requiredBitSet[0] |= 1 << 1 diff --git a/api/rest/oas_schemas_gen.go b/api/rest/oas_schemas_gen.go index da5c2da..39e0072 100644 --- a/api/rest/oas_schemas_gen.go +++ b/api/rest/oas_schemas_gen.go @@ -109,16 +109,16 @@ func (s *EthereumNow) SetEpoch(val Epoch) { // Ref: #/components/schemas/EthereumSpec type EthereumSpec struct { - SecondsPerSlot int64 `json:"seconds_per_slot"` + SlotDurationMs int64 `json:"slot_duration_ms"` SlotsPerEpoch int64 `json:"slots_per_epoch"` GenesisTime time.Time `json:"genesis_time"` HezeForkEpoch OptInt64 `json:"heze_fork_epoch"` SlotDurationMsHeze OptInt64 `json:"slot_duration_ms_heze"` } -// GetSecondsPerSlot returns the value of SecondsPerSlot. -func (s *EthereumSpec) GetSecondsPerSlot() int64 { - return s.SecondsPerSlot +// GetSlotDurationMs returns the value of SlotDurationMs. +func (s *EthereumSpec) GetSlotDurationMs() int64 { + return s.SlotDurationMs } // GetSlotsPerEpoch returns the value of SlotsPerEpoch. @@ -141,9 +141,9 @@ func (s *EthereumSpec) GetSlotDurationMsHeze() OptInt64 { return s.SlotDurationMsHeze } -// SetSecondsPerSlot sets the value of SecondsPerSlot. -func (s *EthereumSpec) SetSecondsPerSlot(val int64) { - s.SecondsPerSlot = val +// SetSlotDurationMs sets the value of SlotDurationMs. +func (s *EthereumSpec) SetSlotDurationMs(val int64) { + s.SlotDurationMs = val } // SetSlotsPerEpoch sets the value of SlotsPerEpoch. diff --git a/example_config.yaml b/example_config.yaml index 07f86b9..6a03d61 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -49,25 +49,25 @@ forky: network: # name: "mainnet" # spec: - # seconds_per_slot: 12 + # slot_duration_ms: 12000 # slots_per_epoch: 32 # genesis_time: 1606824023 # name: "goerli" # spec: - # seconds_per_slot: 12 + # slot_duration_ms: 12000 # slots_per_epoch: 32 # genesis_time: 1616508000 # name: "sepolia" # spec: - # seconds_per_slot: 12 + # slot_duration_ms: 12000 # slots_per_epoch: 32 # genesis_time: 1655733600 # name: "heze-devnet-3" # spec: - # seconds_per_slot: 12 + # slot_duration_ms: 12000 # slots_per_epoch: 32 # genesis_time: 1792577902 # heze_fork_epoch: 5 diff --git a/forky_test.go b/forky_test.go index 9c07a21..f6a0dd0 100644 --- a/forky_test.go +++ b/forky_test.go @@ -39,7 +39,7 @@ forky: network: name: "mainnet" spec: - seconds_per_slot: 12 + slot_duration_ms: 12000 slots_per_epoch: 32 genesis_time: 1609459200 retention_period: 24h diff --git a/internal/api/ethereum.go b/internal/api/ethereum.go index b9f1cdd..65f5e1f 100644 --- a/internal/api/ethereum.go +++ b/internal/api/ethereum.go @@ -29,7 +29,7 @@ func (h *HTTP) GetEthereumNow(ctx context.Context) (rest.GetEthereumNowRes, erro // spec. func (h *HTTP) GetEthereumSpec(ctx context.Context) (rest.GetEthereumSpecRes, error) { spec := rest.EthereumSpec{ - SecondsPerSlot: safeInt64(h.svc.GetEthereumSpecSecondsPerSlot(ctx)), + SlotDurationMs: safeInt64(h.svc.GetEthereumSpecSlotDurationMs(ctx)), SlotsPerEpoch: safeInt64(h.svc.GetEthereumSpecSlotsPerEpoch(ctx)), GenesisTime: h.svc.GetEthereumSpecGenesisTime(ctx).UTC(), } diff --git a/internal/ethereum/config.go b/internal/ethereum/config.go index b09b8a9..f3f28fd 100644 --- a/internal/ethereum/config.go +++ b/internal/ethereum/config.go @@ -15,7 +15,7 @@ type NetworkConfig struct { } type SpecConfig struct { - SecondsPerSlot uint64 `yaml:"seconds_per_slot"` + SlotDurationMs uint64 `yaml:"slot_duration_ms"` SlotsPerEpoch uint64 `yaml:"slots_per_epoch"` GenesisTime uint64 `yaml:"genesis_time"` @@ -44,8 +44,8 @@ func (c *NetworkConfig) Validate() error { } func (c *SpecConfig) Validate() error { - if c.SecondsPerSlot == 0 { - return errors.New("seconds_per_slot is required") + if c.SlotDurationMs == 0 { + return errors.New("slot_duration_ms is required") } if c.SlotsPerEpoch == 0 { diff --git a/internal/ethereum/ethereum.go b/internal/ethereum/ethereum.go index fba475a..a593ec6 100644 --- a/internal/ethereum/ethereum.go +++ b/internal/ethereum/ethereum.go @@ -1,7 +1,6 @@ package ethereum import ( - "fmt" "time" "github.com/ethpandaops/ethwallclock" @@ -22,17 +21,12 @@ func NewBeaconChain(log logrus.FieldLogger, config *Config) (*BeaconChain, error return nil, err } - secondsPerSlot, err := time.ParseDuration(fmt.Sprintf("%vs", config.Network.Spec.SecondsPerSlot)) - if err != nil { - return nil, fmt.Errorf("failed to parse seconds per slot: %w", err) - } - //nolint:gosec // ignore integer overflow conversion uint64 -> int64 genesisTime := time.Unix(int64(config.Network.Spec.GenesisTime), 0) wc := ethwallclock.NewEthereumBeaconChain( genesisTime, - secondsPerSlot, + time.Duration(config.Network.Spec.SlotDurationMs)*time.Millisecond, //nolint:gosec // ignore integer overflow conversion uint64 -> int64 config.Network.Spec.SlotsPerEpoch, ethwallclock.WithHezeSlotDuration( config.Network.Spec.HezeForkEpoch, @@ -72,8 +66,8 @@ func (b *BeaconChain) SlotsPerEpoch() uint64 { return b.config.Network.Spec.SlotsPerEpoch } -func (b *BeaconChain) SecondsPerSlot() uint64 { - return b.config.Network.Spec.SecondsPerSlot +func (b *BeaconChain) SlotDurationMs() uint64 { + return b.config.Network.Spec.SlotDurationMs } func (b *BeaconChain) GenesisTime() time.Time { diff --git a/internal/ethereum/ethereum_test.go b/internal/ethereum/ethereum_test.go index 76e77ce..a22f579 100644 --- a/internal/ethereum/ethereum_test.go +++ b/internal/ethereum/ethereum_test.go @@ -16,7 +16,7 @@ func TestNewBeaconChain_ValidConfig_ReturnsBeaconChain(t *testing.T) { Name: "TestNet", Spec: SpecConfig{ SlotsPerEpoch: 32, - SecondsPerSlot: 12, + SlotDurationMs: 12000, GenesisTime: 1590832934, }, }, @@ -41,7 +41,7 @@ func TestBeaconChain_Wallclock_ReturnsEthereumBeaconChain(t *testing.T) { Name: "TestNet", Spec: SpecConfig{ SlotsPerEpoch: 32, - SecondsPerSlot: 12, + SlotDurationMs: 12000, GenesisTime: 1590832934, }, }, @@ -59,7 +59,7 @@ func TestBeaconChain_SlotsPerEpoch_ReturnsSlotsPerEpoch(t *testing.T) { Name: "TestNet", Spec: SpecConfig{ SlotsPerEpoch: 32, - SecondsPerSlot: 12, + SlotDurationMs: 12000, GenesisTime: 1590832934, }, }, @@ -70,21 +70,21 @@ func TestBeaconChain_SlotsPerEpoch_ReturnsSlotsPerEpoch(t *testing.T) { assert.Equal(t, uint64(32), slotsPerEpoch) } -func TestBeaconChain_SecondsPerSlot_ReturnsSecondsPerSlot(t *testing.T) { +func TestBeaconChain_SlotDurationMs_ReturnsSlotDurationMs(t *testing.T) { config := &Config{ Network: NetworkConfig{ Name: "TestNet", Spec: SpecConfig{ SlotsPerEpoch: 32, - SecondsPerSlot: 12, + SlotDurationMs: 12000, GenesisTime: 1590832934, }, }, } log := logrus.New() beaconchain, _ := NewBeaconChain(log, config) - secondsPerSlot := beaconchain.SecondsPerSlot() - assert.Equal(t, uint64(12), secondsPerSlot) + slotDurationMs := beaconchain.SlotDurationMs() + assert.Equal(t, uint64(12000), slotDurationMs) } func TestBeaconChain_Wallclock_FollowsHezeSlotDuration(t *testing.T) { @@ -93,7 +93,7 @@ func TestBeaconChain_Wallclock_FollowsHezeSlotDuration(t *testing.T) { Name: "TestNet", Spec: SpecConfig{ SlotsPerEpoch: 32, - SecondsPerSlot: 12, + SlotDurationMs: 12000, GenesisTime: 1590832934, HezeForkEpoch: 5, SlotDurationMsHeze: 9000, diff --git a/internal/service/service.go b/internal/service/service.go index cbde583..a1cf39f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -471,12 +471,12 @@ func (f *ForkChoice) GetEthereumNow(_ context.Context) (phase0.Slot, phase0.Epoc return phase0.Slot(slot.Number()), phase0.Epoch(epoch.Number()), nil } -func (f *ForkChoice) GetEthereumSpecSecondsPerSlot(_ context.Context) uint64 { +func (f *ForkChoice) GetEthereumSpecSlotDurationMs(_ context.Context) uint64 { operation := OperationGetEthereumSpec f.metrics.ObserveOperation(operation) - return f.eth.Spec().SecondsPerSlot + return f.eth.Spec().SlotDurationMs } func (f *ForkChoice) GetEthereumSpecSlotsPerEpoch(_ context.Context) uint64 { diff --git a/internal/source/beacon_node.go b/internal/source/beacon_node.go index 25e5b5d..9ec7621 100644 --- a/internal/source/beacon_node.go +++ b/internal/source/beacon_node.go @@ -43,10 +43,10 @@ type BeaconNode struct { metrics *BasicMetrics // Ethereum network parameters. - genesis *v1.Genesis - secondsPerSlot time.Duration - slotsPerEpoch uint64 - wallclock *ethwallclock.EthereumBeaconChain + genesis *v1.Genesis + slotDuration time.Duration + slotsPerEpoch uint64 + wallclock *ethwallclock.EthereumBeaconChain // forkChoiceV2RetryAt is when to next try the v2 fork choice endpoint, in // unix nanoseconds, after the node reported it as unsupported. @@ -152,7 +152,7 @@ func (b *BeaconNode) Ready(ctx context.Context) bool { return false } - if b.secondsPerSlot == 0 { + if b.slotDuration == 0 { return false } @@ -223,18 +223,12 @@ func (b *BeaconNode) bootstrap(ctx context.Context) error { spec := specRsp.Data - secondsPerSlotSpec, ok := spec["SECONDS_PER_SLOT"] + slotDurationMs, ok := spec["SLOT_DURATION_MS"].(uint64) if !ok { - return errors.New("failed to fetch SECONDS_PER_SLOT") + return errors.New("failed to fetch SLOT_DURATION_MS") } - secondsPerSlot, ok := secondsPerSlotSpec.(time.Duration) - if !ok { - return errors.New("failed to cast SECONDS_PER_SLOT to time.Duration") - } - - //nolint:unconvert //incorrect - b.secondsPerSlot = time.Duration(secondsPerSlot) + b.slotDuration = time.Duration(slotDurationMs) * time.Millisecond //nolint:gosec // ignore integer overflow conversion uint64 -> int64 slotsPerEpoch, ok := spec["SLOTS_PER_EPOCH"] if !ok { @@ -263,7 +257,7 @@ func (b *BeaconNode) bootstrap(ctx context.Context) error { // Create the wallclock. b.wallclock = ethwallclock.NewEthereumBeaconChain( b.genesis.GenesisTime, - b.secondsPerSlot, + b.slotDuration, b.slotsPerEpoch, wallclockOpts..., ) diff --git a/web/src/App.tsx b/web/src/App.tsx index 7196647..ff1671e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -38,20 +38,20 @@ export default function App({ const formattedData = useMemo(() => { if (!data?.spec) return undefined; const { - seconds_per_slot, + slot_duration_ms, slots_per_epoch, genesis_time, heze_fork_epoch, slot_duration_ms_heze, } = data.spec; - if (!seconds_per_slot || !slots_per_epoch || !genesis_time) return undefined; + if (!slot_duration_ms || !slots_per_epoch || !genesis_time) return undefined; return { - secondsPerSlot: seconds_per_slot, + slotDurationMs: slot_duration_ms, slotsPerEpoch: slots_per_epoch, genesisTime: new Date(genesis_time).getTime(), networkName: data.network_name ?? 'Unknown', hezeForkEpoch: heze_fork_epoch === undefined ? undefined : Number(heze_fork_epoch), - hezeSecondsPerSlot: slot_duration_ms_heze ? Number(slot_duration_ms_heze) / 1000 : undefined, + hezeSlotDurationMs: slot_duration_ms_heze ? Number(slot_duration_ms_heze) : undefined, }; }, [data]); diff --git a/web/src/api/types.gen.ts b/web/src/api/types.gen.ts index cb20a64..c27bb80 100644 --- a/web/src/api/types.gen.ts +++ b/web/src/api/types.gen.ts @@ -195,7 +195,7 @@ export type EthereumNow = { }; export type EthereumSpec = { - seconds_per_slot: number; + slot_duration_ms: number; slots_per_epoch: number; genesis_time: string; heze_fork_epoch?: number; diff --git a/web/src/api/zod.gen.ts b/web/src/api/zod.gen.ts index 992e54c..72c25e3 100644 --- a/web/src/api/zod.gen.ts +++ b/web/src/api/zod.gen.ts @@ -153,7 +153,7 @@ export const zEthereumNow = z.object({ }); export const zEthereumSpec = z.object({ - seconds_per_slot: z.coerce + slot_duration_ms: z.coerce .bigint() .check( z.minimum(BigInt('-9223372036854775808'), { diff --git a/web/src/components/EpochDial.tsx b/web/src/components/EpochDial.tsx index 0248077..2a6c343 100644 --- a/web/src/components/EpochDial.tsx +++ b/web/src/components/EpochDial.tsx @@ -11,14 +11,14 @@ const SUB_MARKS = 0; function SlotDial() { const { slotsPerEpoch, slotDuration } = useEthereum(); const { epoch: focusedEpoch, timeIntoEpoch: focusedTimeIntoEpoch } = useFocus(); - const secondsPerSlot = slotDuration(focusedEpoch * slotsPerEpoch); + const slotDurationMs = slotDuration(focusedEpoch * slotsPerEpoch); const [width] = useWindowSize(); const epochWidth = slotsPerEpoch * 4 * (SUB_MARKS + 1); const epochPadding = Math.ceil(((width / epochWidth) * 1.25) / 2); - const multiplier = (250 / (SUB_MARKS + 1)) * secondsPerSlot; + const multiplier = (250 / (SUB_MARKS + 1)) * (slotDurationMs / 1000); const middleSlotX = - width / 2 - (epochWidth / (slotsPerEpoch * secondsPerSlot * 1000)) * focusedTimeIntoEpoch; + width / 2 - (epochWidth / (slotsPerEpoch * slotDurationMs)) * focusedTimeIntoEpoch; const leftSideRulers = Array.from( { length: focusedEpoch < epochPadding ? focusedEpoch : epochPadding }, diff --git a/web/src/components/Slot.tsx b/web/src/components/Slot.tsx index 906e6f9..c0f385f 100644 --- a/web/src/components/Slot.tsx +++ b/web/src/components/Slot.tsx @@ -19,7 +19,7 @@ type Props = { function Slot({ subMarks, slot, shouldFetch = false, segments }: Props) { const { slotDuration, slotStartTime } = useEthereum(); - const secondsPerSlot = slotDuration(slot); + const slotDurationMs = slotDuration(slot); const { node } = useFocus(); const { ids } = useActive(); @@ -36,7 +36,7 @@ function Slot({ subMarks, slot, shouldFetch = false, segments }: Props) { if (node && frame.node !== node) return acc; const timeIntoSlot = new Date(frame.fetched_at).getTime() - slotStart; - const leftPercentage = (timeIntoSlot / (secondsPerSlot * 1000)) * 100; + const leftPercentage = (timeIntoSlot / slotDurationMs) * 100; const groupIndex = Math.floor(leftPercentage / groupSize); const group = groupIndex * groupSize + groupSize / 2; if (!acc[group]) { @@ -49,7 +49,7 @@ function Slot({ subMarks, slot, shouldFetch = false, segments }: Props) { return Object.entries(groups).map(([group, metadata]) => ( )); - }, [data, slotStart, secondsPerSlot, segments, node, ids]); + }, [data, slotStart, slotDurationMs, segments, node, ids]); return ( { const { slot: focusedSlot, timeIntoSlot } = useFocus(); const [width] = useWindowSize(); - const slotWidth = (slot: number) => slotDuration(slot) * MARKER_WIDTH * (SUB_MARKS + 1); + const slotWidth = (slot: number) => (slotDuration(slot) / 1000) * MARKER_WIDTH * (SUB_MARKS + 1); const focusedSlotWidth = slotWidth(focusedSlot); const slotPadding = Math.ceil(((width / focusedSlotWidth) * 1.25) / 2); const multiplier = 250 / (SUB_MARKS + 1); diff --git a/web/src/contexts/ethereum.tsx b/web/src/contexts/ethereum.tsx index 4734f2f..069ff4b 100644 --- a/web/src/contexts/ethereum.tsx +++ b/web/src/contexts/ethereum.tsx @@ -17,8 +17,8 @@ export default function useContext() { } export interface State { - secondsPerSlot: number; - setSecondsPerSlot: (secondsPerSlot: number) => void; + slotDurationMs: number; + setSlotDurationMs: (slotDurationMs: number) => void; slotsPerEpoch: number; setSlotsPerEpoch: (slotsPerEpoch: number) => void; genesisTime: number; @@ -31,51 +31,51 @@ export interface State { } export interface ValueProps { - secondsPerSlot: number; + slotDurationMs: number; slotsPerEpoch: number; genesisTime: number; networkName: string; hezeForkEpoch?: number; - hezeSecondsPerSlot?: number; + hezeSlotDurationMs?: number; } export function useValue(props: ValueProps): State { - const [secondsPerSlot, setSecondsPerSlot] = useState(props.secondsPerSlot); + const [slotDurationMs, setSlotDurationMs] = useState(props.slotDurationMs); const [slotsPerEpoch, setSlotsPerEpoch] = useState(props.slotsPerEpoch); const [genesisTime, setGenesisTime] = useState(props.genesisTime); const [networkName, setNetworkName] = useState(props.networkName); - const { hezeForkEpoch, hezeSecondsPerSlot } = props; + const { hezeForkEpoch, hezeSlotDurationMs } = props; const slotDuration = useCallback( (slot: number) => scheduleSlotDuration( - { genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot }, + { genesisTime, slotDurationMs, slotsPerEpoch, hezeForkEpoch, hezeSlotDurationMs }, slot, ), - [genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot], + [genesisTime, slotDurationMs, slotsPerEpoch, hezeForkEpoch, hezeSlotDurationMs], ); const slotStartTime = useCallback( (slot: number) => scheduleSlotStartTime( - { genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot }, + { genesisTime, slotDurationMs, slotsPerEpoch, hezeForkEpoch, hezeSlotDurationMs }, slot, ), - [genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot], + [genesisTime, slotDurationMs, slotsPerEpoch, hezeForkEpoch, hezeSlotDurationMs], ); const slotAtTime = useCallback( (time: number) => scheduleSlotAtTime( - { genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot }, + { genesisTime, slotDurationMs, slotsPerEpoch, hezeForkEpoch, hezeSlotDurationMs }, time, ), - [genesisTime, secondsPerSlot, slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot], + [genesisTime, slotDurationMs, slotsPerEpoch, hezeForkEpoch, hezeSlotDurationMs], ); return { - secondsPerSlot, - setSecondsPerSlot, + slotDurationMs, + setSlotDurationMs, slotsPerEpoch, setSlotsPerEpoch, genesisTime, diff --git a/web/src/mocks/handlers.ts b/web/src/mocks/handlers.ts index 6db8424..472c9fb 100644 --- a/web/src/mocks/handlers.ts +++ b/web/src/mocks/handlers.ts @@ -15,14 +15,14 @@ import { BASE_URL } from '@utils/environment'; export const networkName = 'goerli'; export const spec: EthereumSpec = { - seconds_per_slot: 12, + slot_duration_ms: 12_000, slots_per_epoch: 32, genesis_time: '2021-03-23T14:00:00Z', }; export function getNow(): EthereumNow { const slot = Math.floor( - (Date.now() - new Date(spec.genesis_time).getTime()) / 1000 / spec.seconds_per_slot, + (Date.now() - new Date(spec.genesis_time).getTime()) / spec.slot_duration_ms, ); return { @@ -57,7 +57,7 @@ export const handlers: Array = [ id: 'bfe734bb-c986-4859-8b3e-44314ceca0b5', node: nodes.nodes[Math.floor(Math.random() * nodes.nodes.length)], fetched_at: new Date( - new Date(spec.genesis_time).getTime() + slot * spec.seconds_per_slot * 1000, + new Date(spec.genesis_time).getTime() + slot * spec.slot_duration_ms, ).toISOString(), wall_clock_slot: slot, wall_clock_epoch: epoch, @@ -82,7 +82,7 @@ export const handlers: Array = [ id, node: 'ams3-teku-001', fetched_at: new Date( - new Date(spec.genesis_time).getTime() + slot * spec.seconds_per_slot * 1000, + new Date(spec.genesis_time).getTime() + slot * spec.slot_duration_ms, ).toISOString(), wall_clock_slot: slot, wall_clock_epoch: epoch, diff --git a/web/src/parts/Graph.stories.tsx b/web/src/parts/Graph.stories.tsx index 51924c0..9899af6 100644 --- a/web/src/parts/Graph.stories.tsx +++ b/web/src/parts/Graph.stories.tsx @@ -26,7 +26,7 @@ function blockRoot(key: string): string { function fetchedAt(slot: number, nodeIndex: number): string { return new Date( - graphGenesisTime + slot * storySpec.seconds_per_slot * 1000 + (nodeIndex + 1) * 1_000, + graphGenesisTime + slot * storySpec.slot_duration_ms + (nodeIndex + 1) * 1_000, ).toISOString(); } diff --git a/web/src/stories/fixtures.ts b/web/src/stories/fixtures.ts index 09cbb2e..67d9949 100644 --- a/web/src/stories/fixtures.ts +++ b/web/src/stories/fixtures.ts @@ -20,7 +20,7 @@ import { processForkChoiceData } from '@utils/graph'; export const storyNetworkName = 'hoodi-story'; export const storySpec = { - seconds_per_slot: 12, + slot_duration_ms: 12_000, slots_per_epoch: 32, genesis_time: '2024-01-01T00:00:00.000Z', }; @@ -38,7 +38,7 @@ function root(value: number): string { } function atSlot(slot: number, offsetMs = 0): string { - return new Date(baseGenesis + slot * storySpec.seconds_per_slot * 1000 + offsetMs).toISOString(); + return new Date(baseGenesis + slot * storySpec.slot_duration_ms + offsetMs).toISOString(); } function forkChoiceNode({ @@ -324,7 +324,7 @@ function frameMetadata({ eventSource?: string; }): FrameMetadata { const slot = Math.floor( - (new Date(fetchedAt).getTime() - baseGenesis) / 1000 / storySpec.seconds_per_slot, + (new Date(fetchedAt).getTime() - baseGenesis) / storySpec.slot_duration_ms, ); return { diff --git a/web/src/stories/storybook.tsx b/web/src/stories/storybook.tsx index b3180de..f93f613 100644 --- a/web/src/stories/storybook.tsx +++ b/web/src/stories/storybook.tsx @@ -20,13 +20,13 @@ const storyGenesisTime = new Date(storySpec.genesis_time).getTime(); export const defaultEthereumProps = { genesisTime: storyGenesisTime, - secondsPerSlot: storySpec.seconds_per_slot, + slotDurationMs: storySpec.slot_duration_ms, slotsPerEpoch: storySpec.slots_per_epoch, networkName: storyNetworkName, }; export const defaultFocusProps = { - initialTime: storyGenesisTime + (storyNow.slot - 2) * storySpec.seconds_per_slot * 1000, + initialTime: storyGenesisTime + (storyNow.slot - 2) * storySpec.slot_duration_ms, playing: false, byo: false, }; diff --git a/web/src/utils/__tests__/slots.test.ts b/web/src/utils/__tests__/slots.test.ts index 05e1728..0b73ef7 100644 --- a/web/src/utils/__tests__/slots.test.ts +++ b/web/src/utils/__tests__/slots.test.ts @@ -3,23 +3,23 @@ import { slotAtTime, slotDuration, slotStartTime } from '@utils/slots'; const genesisTime = 1_000_000; const schedule = { genesisTime, - secondsPerSlot: 12, + slotDurationMs: 12_000, slotsPerEpoch: 32, hezeForkEpoch: 5, - hezeSecondsPerSlot: 9, + hezeSlotDurationMs: 9_000, }; describe('slots', () => { it('uses the base slot duration without a Heze schedule', () => { - const base = { genesisTime, secondsPerSlot: 12, slotsPerEpoch: 32 }; - expect(slotDuration(base, 1000)).toBe(12); + const base = { genesisTime, slotDurationMs: 12_000, slotsPerEpoch: 32 }; + expect(slotDuration(base, 1000)).toBe(12_000); expect(slotStartTime(base, 1000)).toBe(genesisTime + 1000 * 12_000); expect(slotAtTime(base, genesisTime + 1000 * 12_000 + 11_999)).toBe(1000); }); it('switches to the Heze slot duration at the fork epoch', () => { - expect(slotDuration(schedule, 159)).toBe(12); - expect(slotDuration(schedule, 160)).toBe(9); + expect(slotDuration(schedule, 159)).toBe(12_000); + expect(slotDuration(schedule, 160)).toBe(9_000); expect(slotStartTime(schedule, 160)).toBe(genesisTime + 160 * 12_000); expect(slotStartTime(schedule, 161)).toBe(genesisTime + 160 * 12_000 + 9_000); expect(slotAtTime(schedule, genesisTime + 160 * 12_000 - 1)).toBe(159); diff --git a/web/src/utils/slots.ts b/web/src/utils/slots.ts index b47ce5a..abbca25 100644 --- a/web/src/utils/slots.ts +++ b/web/src/utils/slots.ts @@ -1,40 +1,38 @@ export interface SlotSchedule { genesisTime: number; - secondsPerSlot: number; + slotDurationMs: number; slotsPerEpoch: number; hezeForkEpoch?: number; - hezeSecondsPerSlot?: number; + hezeSlotDurationMs?: number; } -function hezeForkSlot({ slotsPerEpoch, hezeForkEpoch, hezeSecondsPerSlot }: SlotSchedule) { - if (!hezeSecondsPerSlot || hezeForkEpoch === undefined) return Infinity; +function hezeForkSlot({ slotsPerEpoch, hezeForkEpoch, hezeSlotDurationMs }: SlotSchedule) { + if (!hezeSlotDurationMs || hezeForkEpoch === undefined) return Infinity; return hezeForkEpoch * slotsPerEpoch; } export function slotDuration(schedule: SlotSchedule, slot: number): number { - if (slot >= hezeForkSlot(schedule) && schedule.hezeSecondsPerSlot) { - return schedule.hezeSecondsPerSlot; + if (slot >= hezeForkSlot(schedule) && schedule.hezeSlotDurationMs) { + return schedule.hezeSlotDurationMs; } - return schedule.secondsPerSlot; + return schedule.slotDurationMs; } export function slotStartTime(schedule: SlotSchedule, slot: number): number { - const { genesisTime, secondsPerSlot, hezeSecondsPerSlot } = schedule; + const { genesisTime, slotDurationMs, hezeSlotDurationMs } = schedule; const forkSlot = hezeForkSlot(schedule); - if (slot < forkSlot || !hezeSecondsPerSlot) { - return genesisTime + slot * secondsPerSlot * 1000; + if (slot < forkSlot || !hezeSlotDurationMs) { + return genesisTime + slot * slotDurationMs; } - return ( - genesisTime + forkSlot * secondsPerSlot * 1000 + (slot - forkSlot) * hezeSecondsPerSlot * 1000 - ); + return genesisTime + forkSlot * slotDurationMs + (slot - forkSlot) * hezeSlotDurationMs; } export function slotAtTime(schedule: SlotSchedule, time: number): number { - const { genesisTime, secondsPerSlot, hezeSecondsPerSlot } = schedule; + const { genesisTime, slotDurationMs, hezeSlotDurationMs } = schedule; const forkSlot = hezeForkSlot(schedule); const forkTime = slotStartTime(schedule, forkSlot); - if (time < forkTime || !hezeSecondsPerSlot) { - return Math.floor((time - genesisTime) / 1000 / secondsPerSlot); + if (time < forkTime || !hezeSlotDurationMs) { + return Math.floor((time - genesisTime) / slotDurationMs); } - return forkSlot + Math.floor((time - forkTime) / 1000 / hezeSecondsPerSlot); + return forkSlot + Math.floor((time - forkTime) / hezeSlotDurationMs); } diff --git a/web/src/utils/testing.tsx b/web/src/utils/testing.tsx index a9996e7..dee8215 100644 --- a/web/src/utils/testing.tsx +++ b/web/src/utils/testing.tsx @@ -19,7 +19,7 @@ interface Props { export function ProviderWrapper({ ethereum, focus, selection }: Props | undefined = {}) { const ethereumProps = ethereum || { genesisTime: new Date(spec.genesis_time).getTime(), - secondsPerSlot: spec.seconds_per_slot, + slotDurationMs: spec.slot_duration_ms, slotsPerEpoch: spec.slots_per_epoch, networkName: networkName, }; @@ -27,10 +27,7 @@ export function ProviderWrapper({ ethereum, focus, selection }: Props | undefine const focusProps = focus || { playing: true, byo: false, - initialTime: - ethereumProps.genesisTime + - 100 * (ethereumProps.secondsPerSlot * 1000) - - ethereumProps.secondsPerSlot * 1000, + initialTime: ethereumProps.genesisTime + 99 * ethereumProps.slotDurationMs, }; return function Provider({ children }: { children: ReactNode }) {