diff --git a/config/cardano/node.go b/config/cardano/node.go index 07535f85..bebcc885 100644 --- a/config/cardano/node.go +++ b/config/cardano/node.go @@ -132,17 +132,61 @@ func (c *CardanoNodeConfig) ByronGenesis() *byron.ByronGenesis { return c.byronGenesis } +// LoadByronGenesisFromReader loads a Byron genesis config from an io.Reader +// This is useful mostly for tests +func (c *CardanoNodeConfig) LoadByronGenesisFromReader(r io.Reader) error { + byronGenesis, err := byron.NewByronGenesisFromReader(r) + if err != nil { + return err + } + c.byronGenesis = &byronGenesis + return nil +} + // ShelleyGenesis returns the Shelley genesis config specified in the cardano-node config func (c *CardanoNodeConfig) ShelleyGenesis() *shelley.ShelleyGenesis { return c.shelleyGenesis } +// LoadShelleyGenesisFromReader loads a Shelley genesis config from an io.Reader +// This is useful mostly for tests +func (c *CardanoNodeConfig) LoadShelleyGenesisFromReader(r io.Reader) error { + shelleyGenesis, err := shelley.NewShelleyGenesisFromReader(r) + if err != nil { + return err + } + c.shelleyGenesis = &shelleyGenesis + return nil +} + // AlonzoGenesis returns the Alonzo genesis config specified in the cardano-node config func (c *CardanoNodeConfig) AlonzoGenesis() *alonzo.AlonzoGenesis { return c.alonzoGenesis } +// LoadAlonzoGenesisFromReader loads a Alonzo genesis config from an io.Reader +// This is useful mostly for tests +func (c *CardanoNodeConfig) LoadAlonzoGenesisFromReader(r io.Reader) error { + alonzoGenesis, err := alonzo.NewAlonzoGenesisFromReader(r) + if err != nil { + return err + } + c.alonzoGenesis = &alonzoGenesis + return nil +} + // ConwayGenesis returns the Conway genesis config specified in the cardano-node config func (c *CardanoNodeConfig) ConwayGenesis() *conway.ConwayGenesis { return c.conwayGenesis } + +// LoadConwayGenesisFromReader loads a Conway genesis config from an io.Reader +// This is useful mostly for tests +func (c *CardanoNodeConfig) LoadConwayGenesisFromReader(r io.Reader) error { + conwayGenesis, err := conway.NewConwayGenesisFromReader(r) + if err != nil { + return err + } + c.conwayGenesis = &conwayGenesis + return nil +} diff --git a/database/epoch.go b/database/epoch.go index 8ce2e1f3..108faa41 100644 --- a/database/epoch.go +++ b/database/epoch.go @@ -70,6 +70,28 @@ func (d *Database) GetEpochsByEra(eraId uint, txn *Txn) ([]Epoch, error) { return tmpEpochs, nil } +func (d *Database) GetEpochs(txn *Txn) ([]Epoch, error) { + tmpEpochs := []Epoch{} + if txn == nil { + epochs, err := d.metadata.GetEpochs(nil) + if err != nil { + return tmpEpochs, err + } + for _, epoch := range epochs { + tmpEpochs = append(tmpEpochs, Epoch(epoch)) + } + } else { + epochs, err := txn.db.metadata.GetEpochs(txn.Metadata()) + if err != nil { + return tmpEpochs, err + } + for _, epoch := range epochs { + tmpEpochs = append(tmpEpochs, Epoch(epoch)) + } + } + return tmpEpochs, nil +} + func (d *Database) SetEpoch( slot, epoch uint64, nonce []byte, diff --git a/database/plugin/metadata/sqlite/epoch.go b/database/plugin/metadata/sqlite/epoch.go index e0c22e38..3ff8f79a 100644 --- a/database/plugin/metadata/sqlite/epoch.go +++ b/database/plugin/metadata/sqlite/epoch.go @@ -65,6 +65,25 @@ func (d *MetadataStoreSqlite) GetEpochsByEra( return ret, nil } +// GetEpochs returns the list of epochs +func (d *MetadataStoreSqlite) GetEpochs( + txn *gorm.DB, +) ([]models.Epoch, error) { + ret := []models.Epoch{} + if txn != nil { + result := txn.Order("epoch_id").Find(&ret) + if result.Error != nil { + return ret, result.Error + } + } else { + result := d.DB().Order("epoch_id").Find(&ret) + if result.Error != nil { + return ret, result.Error + } + } + return ret, nil +} + // SetEpoch saves an epoch func (d *MetadataStoreSqlite) SetEpoch( slot, epoch uint64, diff --git a/database/plugin/metadata/store.go b/database/plugin/metadata/store.go index ea3355f0..dd37ca3d 100644 --- a/database/plugin/metadata/store.go +++ b/database/plugin/metadata/store.go @@ -146,6 +146,7 @@ type MetadataStore interface { DeleteUtxosBeforeSlot(uint64, *gorm.DB) error GetEpochLatest(*gorm.DB) (models.Epoch, error) GetEpochsByEra(uint, *gorm.DB) ([]models.Epoch, error) + GetEpochs(*gorm.DB) ([]models.Epoch, error) GetUtxosAddedAfterSlot(uint64, *gorm.DB) ([]models.Utxo, error) GetUtxosByAddress(ledger.Address, *gorm.DB) ([]models.Utxo, error) GetUtxosDeletedBeforeSlot(uint64, *gorm.DB) ([]models.Utxo, error) diff --git a/ledger/chainsync.go b/ledger/chainsync.go index 62ff6276..8ed5835f 100644 --- a/ledger/chainsync.go +++ b/ledger/chainsync.go @@ -337,15 +337,13 @@ func (ls *LedgerState) processEpochRollover( if err != nil { return err } - newEpoch, err := ls.db.GetEpochLatest(txn) - if err != nil { + // Reload epoch info + if err := ls.loadEpochs(txn); err != nil { return err } - ls.currentEpoch = newEpoch - ls.metrics.epochNum.Set(float64(newEpoch.EpochId)) ls.config.Logger.Debug( "added initial epoch to DB", - "epoch", fmt.Sprintf("%+v", newEpoch), + "epoch", fmt.Sprintf("%+v", ls.currentEpoch), "component", "ledger", ) } @@ -392,15 +390,13 @@ func (ls *LedgerState) processEpochRollover( if err != nil { return err } - newEpoch, err := ls.db.GetEpochLatest(txn) - if err != nil { + // Reload epoch info + if err := ls.loadEpochs(txn); err != nil { return err } - ls.currentEpoch = newEpoch - ls.metrics.epochNum.Set(float64(newEpoch.EpochId)) ls.config.Logger.Debug( "added next epoch to DB", - "epoch", fmt.Sprintf("%+v", newEpoch), + "epoch", fmt.Sprintf("%+v", ls.currentEpoch), "component", "ledger", ) } diff --git a/ledger/slot.go b/ledger/slot.go new file mode 100644 index 00000000..928d48d6 --- /dev/null +++ b/ledger/slot.go @@ -0,0 +1,118 @@ +// Copyright 2025 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ledger + +import ( + "errors" + "math" + "time" + + "github.com/blinklabs-io/dingo/database" +) + +// SlotToTime returns the current time for a given slot based on known epochs +func (ls *LedgerState) SlotToTime(slot uint64) (time.Time, error) { + if slot > math.MaxInt64 { + return time.Time{}, errors.New("slot is larger than time.Duration") + } + shelleyGenesis := ls.config.CardanoNodeConfig.ShelleyGenesis() + if shelleyGenesis == nil { + return time.Time{}, errors.New("could not get genesis config") + } + slotTime := shelleyGenesis.SystemStart + // Special case for chain genesis + if slot == 0 { + return slotTime, nil + } + foundSlot := false + for _, epoch := range ls.epochCache { + if epoch.StartSlot > math.MaxInt64 || + epoch.LengthInSlots > math.MaxInt64 || + epoch.SlotLength > math.MaxInt64 { + return time.Time{}, errors.New("epoch slot values are larger than time.Duration") + } + if slot < epoch.StartSlot+uint64(epoch.LengthInSlots) { + slotTime = slotTime.Add( + time.Duration(int64(slot)-int64(epoch.StartSlot)) * (time.Duration(epoch.SlotLength) * time.Millisecond), + ) + foundSlot = true + break + } + slotTime = slotTime.Add( + time.Duration(epoch.LengthInSlots) * (time.Duration(epoch.SlotLength) * time.Millisecond), + ) + } + if !foundSlot { + return slotTime, errors.New("slot not found in known epochs") + } + return slotTime, nil +} + +// TimeToSlot returns the slot number for a given time based on known epochs +func (ls *LedgerState) TimeToSlot(t time.Time) (uint64, error) { + shelleyGenesis := ls.config.CardanoNodeConfig.ShelleyGenesis() + if shelleyGenesis == nil { + return 0, errors.New("could not get genesis config") + } + epochStartTime := shelleyGenesis.SystemStart + // Special case for chain genesis + if t.Equal(epochStartTime) { + return 0, nil + } + var timeSlot uint64 + foundTime := false + for _, epoch := range ls.epochCache { + if epoch.LengthInSlots > math.MaxInt64 || + epoch.SlotLength > math.MaxInt64 { + return 0, errors.New("epoch slot values are larger than time.Duration") + } + slotDuration := time.Duration(epoch.SlotLength) * time.Millisecond + if slotDuration < 0 { + return 0, errors.New("slot duration is negative") + } + epochEndTime := epochStartTime.Add( + time.Duration(epoch.LengthInSlots) * slotDuration, + ) + if (t.Equal(epochStartTime) || t.After(epochStartTime)) && t.Before(epochEndTime) { + // Figure out how far into the epoch the specified time is + timeDiff := t.Sub(epochStartTime) + // nolint:gosec + // This will never overflow using 2 positive int64 values, but gosec seems determined + // to complain about it + timeSlot += uint64(timeDiff / slotDuration) + foundTime = true + break + } + epochStartTime = epochEndTime + timeSlot += uint64(epoch.LengthInSlots) + } + if !foundTime { + return timeSlot, errors.New("time not found in known epochs") + } + return timeSlot, nil +} + +// SlotToEpoch returns a known epoch by slot number +func (ls *LedgerState) SlotToEpoch(slot uint64) (database.Epoch, error) { + for _, epoch := range ls.epochCache { + if slot < epoch.StartSlot { + continue + } + if slot < epoch.StartSlot+uint64(epoch.LengthInSlots) { + return epoch, nil + } + } + return database.Epoch{}, errors.New("slot not found in known epochs") +} diff --git a/ledger/slot_test.go b/ledger/slot_test.go new file mode 100644 index 00000000..f823920b --- /dev/null +++ b/ledger/slot_test.go @@ -0,0 +1,126 @@ +// Copyright 2025 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ledger + +import ( + "strings" + "testing" + "time" + + "github.com/blinklabs-io/dingo/config/cardano" + "github.com/blinklabs-io/dingo/database" +) + +func TestSlotCalc(t *testing.T) { + testLedgerState := &LedgerState{ + epochCache: []database.Epoch{ + { + EpochId: 0, + StartSlot: 0, + SlotLength: 1000, + LengthInSlots: 86400, + }, + { + EpochId: 1, + StartSlot: 86400, + SlotLength: 1000, + LengthInSlots: 86400, + }, + { + EpochId: 2, + StartSlot: 172800, + SlotLength: 1000, + LengthInSlots: 86400, + }, + { + EpochId: 3, + StartSlot: 259200, + SlotLength: 1000, + LengthInSlots: 86400, + }, + { + EpochId: 4, + StartSlot: 345600, + SlotLength: 1000, + LengthInSlots: 86400, + }, + { + EpochId: 5, + StartSlot: 432000, + SlotLength: 1000, + LengthInSlots: 86400, + }, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: &cardano.CardanoNodeConfig{}, + }, + } + testShelleyGenesis := `{"systemStart": "2022-10-25T00:00:00Z"}` + if err := testLedgerState.config.CardanoNodeConfig.LoadShelleyGenesisFromReader(strings.NewReader(testShelleyGenesis)); err != nil { + t.Fatalf("unexpected error loading cardano node config: %s", err) + } + testDefs := []struct { + slot uint64 + slotTime time.Time + epoch uint64 + }{ + { + slot: 0, + slotTime: time.Date(2022, time.October, 25, 0, 0, 0, 0, time.UTC), + epoch: 0, + }, + { + slot: 86399, + slotTime: time.Date(2022, time.October, 25, 23, 59, 59, 0, time.UTC), + epoch: 0, + }, + { + slot: 86400, + slotTime: time.Date(2022, time.October, 26, 0, 0, 0, 0, time.UTC), + epoch: 1, + }, + { + slot: 432001, + slotTime: time.Date(2022, time.October, 30, 0, 0, 1, 0, time.UTC), + epoch: 5, + }, + } + for _, testDef := range testDefs { + // Slot to time + tmpSlotToTime, err := testLedgerState.SlotToTime(testDef.slot) + if err != nil { + t.Errorf("unexpected error converting slot to time: %s", err) + } + if !tmpSlotToTime.Equal(testDef.slotTime) { + t.Errorf("did not get expected time from slot: got %s, wanted %s", tmpSlotToTime, testDef.slotTime) + } + // Time to slot + tmpTimeToSlot, err := testLedgerState.TimeToSlot(testDef.slotTime) + if err != nil { + t.Errorf("unexpected error converting time to slot: %s", err) + } + if tmpTimeToSlot != testDef.slot { + t.Errorf("did not get expected slot from time: got %d, wanted %d", tmpTimeToSlot, testDef.slot) + } + // Slot to epoch + tmpSlotToEpoch, err := testLedgerState.SlotToEpoch(testDef.slot) + if err != nil { + t.Errorf("unexpected error getting epoch from slot: %s", err) + } + if tmpSlotToEpoch.EpochId != testDef.epoch { + t.Errorf("did not get expected epoch from slot: got %d, wanted %d", tmpSlotToEpoch.EpochId, testDef.epoch) + } + } +} diff --git a/ledger/state.go b/ledger/state.go index 7b870006..ec441977 100644 --- a/ledger/state.go +++ b/ledger/state.go @@ -65,6 +65,7 @@ type LedgerState struct { timerCleanupConsumedUtxos *time.Timer currentPParams lcommon.ProtocolParameters currentEpoch database.Epoch + epochCache []database.Epoch currentEra eras.EraDesc currentTip ochainsync.Tip currentTipBlockNonce []byte @@ -143,8 +144,8 @@ func NewLedgerState(cfg LedgerStateConfig) (*LedgerState, error) { ) // Schedule periodic process to purge consumed UTxOs outside of the rollback window ls.scheduleCleanupConsumedUtxos() - // Load current epoch from DB - if err := ls.loadEpoch(); err != nil { + // Load epoch info from DB + if err := ls.loadEpochs(nil); err != nil { return nil, err } // Load current protocol parameters from DB @@ -545,13 +546,18 @@ func (ls *LedgerState) loadPParams() error { return nil } -func (ls *LedgerState) loadEpoch() error { - tmpEpoch, err := ls.db.GetEpochLatest(nil) +func (ls *LedgerState) loadEpochs(txn *database.Txn) error { + // Load and cache all epochs + epochs, err := ls.db.GetEpochs(txn) if err != nil { return err } - ls.currentEpoch = tmpEpoch - ls.currentEra = eras.Eras[tmpEpoch.EraId] + ls.epochCache = epochs + // Set current epoch + if len(epochs) > 0 { + ls.currentEpoch = epochs[len(epochs)-1] + ls.currentEra = eras.Eras[ls.currentEpoch.EraId] + } // Update metrics ls.metrics.epochNum.Set(float64(ls.currentEpoch.EpochId)) return nil