From c2ab4d278121aa97e0b501879e61651193bdd709 Mon Sep 17 00:00:00 2001 From: Chris Gianelloni Date: Thu, 6 Nov 2025 15:37:11 -0500 Subject: [PATCH] fix: use stability window for slot threshold - Replace hard-coded blockfetchBatchSlotThreshold with dynamic calculation - Use correct security parameters based on current era: * Byron era: K parameter from ByronGenesis.ProtocolConsts.K * Shelley+ eras: SecurityParam from ShelleyGenesis.SecurityParam - Update stability window calculation in both chainsync and validation logic - Calculate stability window as 3k/f for Shelley+ eras, k for Byron era - Ensures blockfetch operations respect protocol-defined stability window Fixes the TODO to calculate slot threshold from protocol params Signed-off-by: Chris Gianelloni Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- ledger/chainsync.go | 26 +- ledger/chainsync_test.go | 723 +++++++++++++++++++++++++++++++++++++++ ledger/state.go | 125 +++++-- ledger/state_test.go | 721 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 1557 insertions(+), 38 deletions(-) create mode 100644 ledger/chainsync_test.go create mode 100644 ledger/state_test.go diff --git a/ledger/chainsync.go b/ledger/chainsync.go index 6b582f40..32ac6e0c 100644 --- a/ledger/chainsync.go +++ b/ledger/chainsync.go @@ -18,7 +18,6 @@ import ( "encoding/hex" "errors" "fmt" - "math/big" "slices" "time" @@ -36,9 +35,8 @@ const ( // This prevents us exceeding the configured recv queue size in the block-fetch protocol blockfetchBatchSize = 500 - // TODO: calculate from protocol params - // Number of slots from upstream tip to stop doing blockfetch batches - blockfetchBatchSlotThreshold = 2500 * 20 + // Default/fallback slot threshold for blockfetch batches + blockfetchBatchSlotThresholdDefault = 2500 * 20 // Timeout for updates on a blockfetch operation. This is based on a 2s BatchStart // and a 2s Block timeout for blockfetch @@ -141,8 +139,10 @@ func (ls *LedgerState) handleEventChainsyncBlockHeader(e ChainsyncEvent) error { } // Wait for additional block headers before fetching block bodies if we're // far enough out from upstream tip + // Use security window as slot threshold if available + slotThreshold := ls.calculateStabilityWindow() if e.Point.Slot < e.Tip.Point.Slot && - (e.Tip.Point.Slot-e.Point.Slot > blockfetchBatchSlotThreshold) && + (e.Tip.Point.Slot-e.Point.Slot > slotThreshold) && (headerCount+1) < allowedHeaderCount { return nil } @@ -258,6 +258,9 @@ func (ls *LedgerState) calculateEpochNonce( } // Use Shelley genesis hash for initial epoch nonce if len(ls.currentEpoch.Nonce) == 0 { + if ls.config.CardanoNodeConfig.ShelleyGenesisHash == "" { + return nil, errors.New("could not get Shelley genesis hash") + } genesisHashBytes, err := hex.DecodeString( ls.config.CardanoNodeConfig.ShelleyGenesisHash, ) @@ -267,18 +270,7 @@ func (ls *LedgerState) calculateEpochNonce( return genesisHashBytes, nil } // Calculate stability window - byronGenesis := ls.config.CardanoNodeConfig.ByronGenesis() - shelleyGenesis := ls.config.CardanoNodeConfig.ShelleyGenesis() - if byronGenesis == nil || shelleyGenesis == nil { - return nil, errors.New("could not get genesis config") - } - stabilityWindow := new(big.Rat).Quo( - big.NewRat( - int64(3*byronGenesis.ProtocolConsts.K), - 1, - ), - shelleyGenesis.ActiveSlotsCoeff.Rat, - ).Num().Uint64() + stabilityWindow := ls.calculateStabilityWindow() var stabilityWindowStartSlot uint64 if epochStartSlot > stabilityWindow { stabilityWindowStartSlot = epochStartSlot - stabilityWindow diff --git a/ledger/chainsync_test.go b/ledger/chainsync_test.go new file mode 100644 index 00000000..6eab9b5e --- /dev/null +++ b/ledger/chainsync_test.go @@ -0,0 +1,723 @@ +// 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 ( + "encoding/hex" + "fmt" + "io" + "log/slog" + "strings" + "testing" + + "github.com/blinklabs-io/dingo/config/cardano" + "github.com/blinklabs-io/dingo/database/models" + "github.com/blinklabs-io/dingo/ledger/eras" +) + +// TestCalculateEpochNonce_ByronEra tests epoch nonce calculation in Byron era +func TestCalculateEpochNonce_ByronEra(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + shelleyGenesisHash := "363498d1024f84bb39d3fa9593ce391483cb40d479b87233f868d6e57c3a400d" + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: shelleyGenesisHash, + } + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ByronEraDesc, + currentEpoch: models.Epoch{ + EpochId: 0, + StartSlot: 0, + Nonce: nil, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + // Byron era should return nil nonce + nonce, err := ls.calculateEpochNonce(nil, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if nonce != nil { + t.Errorf("expected nil nonce for Byron era, got %v", nonce) + } +} + +// TestCalculateEpochNonce_InitialEpochWithoutNonce tests initial Shelley epoch +func TestCalculateEpochNonce_InitialEpochWithoutNonce(t *testing.T) { + shelleyGenesisHash := "363498d1024f84bb39d3fa9593ce391483cb40d479b87233f868d6e57c3a400d" + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: shelleyGenesisHash, + } + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + currentEpoch: models.Epoch{ + EpochId: 0, + StartSlot: 0, + Nonce: nil, // No nonce means initial epoch + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + // Initial epoch should return genesis hash + nonce, err := ls.calculateEpochNonce(nil, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedNonce, err := hex.DecodeString(shelleyGenesisHash) + if err != nil { + t.Fatalf("failed to decode expected nonce: %v", err) + } + + if len(nonce) != len(expectedNonce) { + t.Fatalf( + "nonce length mismatch: expected %d, got %d", + len(expectedNonce), + len(nonce), + ) + } + for i := range nonce { + if nonce[i] != expectedNonce[i] { + t.Errorf( + "nonce mismatch at byte %d: expected %x, got %x", + i, + expectedNonce[i], + nonce[i], + ) + } + } +} + +// TestCalculateEpochNonce_InvalidGenesisHash tests handling of invalid genesis hash +func TestCalculateEpochNonce_InvalidGenesisHash(t *testing.T) { + invalidHash := "not-a-valid-hex-string" + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: invalidHash, + } + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + currentEpoch: models.Epoch{ + EpochId: 0, + StartSlot: 0, + Nonce: nil, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + _, err := ls.calculateEpochNonce(nil, 0) + if err == nil { + t.Fatal("expected error for invalid genesis hash, got nil") + } +} + +// TestCalculateEpochNonce_MissingShelleyGenesis tests handling of missing Shelley genesis +func TestCalculateEpochNonce_MissingShelleyGenesis(t *testing.T) { + cfg := &cardano.CardanoNodeConfig{} + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + currentEpoch: models.Epoch{ + EpochId: 1, + StartSlot: 86400, + Nonce: nil, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + _, err := ls.calculateEpochNonce(nil, 86400) + if err == nil { + t.Fatal("expected error for missing Shelley genesis, got nil") + } + if !strings.Contains(err.Error(), "genesis hash") { + t.Errorf("expected error about Shelley genesis, got: %v", err) + } +} + +// TestCalculateEpochNonce_NegativeSecurityParam tests handling of negative security parameter +func TestCalculateEpochNonce_NegativeSecurityParam(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": -1, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: "363498d1024f84bb39d3fa9593ce391483cb40d479b87233f868d6e57c3a400d", + } + _ = cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)) + _ = cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)) + + ls := &LedgerState{ + currentEra: eras.ByronEraDesc, + currentEpoch: models.Epoch{ + EpochId: 1, + StartSlot: 86400, + Nonce: []byte{0x01, 0x02, 0x03}, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + // Test will depend on whether the genesis loads successfully + // If it loads, we expect an error about negative k + _, err := ls.calculateEpochNonce(nil, 86400) + // Either genesis loading fails or calculateEpochNonce catches negative k + if err == nil { + t.Log("Note: negative k may be caught during genesis loading") + } +} + +// TestCalculateEpochNonce_ShelleyEraDifferentParams tests Shelley era with various parameters +func TestCalculateEpochNonce_ShelleyEraDifferentParams(t *testing.T) { + testCases := []struct { + name string + k int + activeSlotsCoeff float64 + description string + }{ + { + name: "Standard testnet parameters", + k: 432, + activeSlotsCoeff: 0.05, + description: "k=432, f=0.05", + }, + { + name: "Mainnet parameters", + k: 2160, + activeSlotsCoeff: 0.05, + description: "k=2160, f=0.05", + }, + { + name: "High activity coefficient", + k: 432, + activeSlotsCoeff: 0.2, + description: "k=432, f=0.2", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + byronGenesisJSON := fmt.Sprintf(`{ + "protocolConsts": { + "k": %d, + "protocolMagic": 2 + } + }`, tc.k) + shelleyGenesisJSON := fmt.Sprintf(`{ + "activeSlotsCoeff": %f, + "securityParam": %d, + "systemStart": "2022-10-25T00:00:00Z" + }`, tc.activeSlotsCoeff, tc.k) + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: "363498d1024f84bb39d3fa9593ce391483cb40d479b87233f868d6e57c3a400d", + } + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + currentEpoch: models.Epoch{ + EpochId: 0, + StartSlot: 0, + Nonce: nil, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + // Initial epoch should return genesis hash + nonce, err := ls.calculateEpochNonce(nil, 0) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tc.description, err) + } + if nonce == nil { + t.Errorf( + "%s: expected non-nil nonce for initial Shelley epoch", + tc.description, + ) + } + }) + } +} + +// TestCalculateEpochNonce_ZeroActiveSlots tests handling of zero active slots coefficient +func TestCalculateEpochNonce_ZeroActiveSlots(t *testing.T) { + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: "363498d1024f84bb39d3fa9593ce391483cb40d479b87233f868d6e57c3a400d", + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + currentEpoch: models.Epoch{ + EpochId: 1, + StartSlot: 86400, + Nonce: nil, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + // Verify fallback behavior when ActiveSlotsCoeff cannot be used + window := ls.calculateStabilityWindow() + if window != blockfetchBatchSlotThresholdDefault { + t.Fatalf( + "expected fallback window %d, got %d", + blockfetchBatchSlotThresholdDefault, + window, + ) + } +} + +// TestCalculateEpochNonce_StabilityWindowCalculation tests the stability window calculation logic +func TestCalculateEpochNonce_StabilityWindowCalculation(t *testing.T) { + testCases := []struct { + name string + era eras.EraDesc + k int + activeSlotsCoeff float64 + expectedFormula string + }{ + { + name: "Byron era uses k directly", + era: eras.ByronEraDesc, + k: 432, + activeSlotsCoeff: 0.05, + expectedFormula: "stability_window = k = 432", + }, + { + name: "Shelley era uses 3k/f", + era: eras.ShelleyEraDesc, + k: 432, + activeSlotsCoeff: 0.05, + expectedFormula: "stability_window = 3*432/0.05 = 25920", + }, + { + name: "Allegra era uses 3k/f", + era: eras.AllegraEraDesc, + k: 2160, + activeSlotsCoeff: 0.05, + expectedFormula: "stability_window = 3*2160/0.05 = 129600", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + byronGenesisJSON := fmt.Sprintf(`{ + "protocolConsts": { + "k": %d, + "protocolMagic": 2 + } + }`, tc.k) + shelleyGenesisJSON := fmt.Sprintf(`{ + "activeSlotsCoeff": %f, + "securityParam": %d, + "systemStart": "2022-10-25T00:00:00Z" + }`, tc.activeSlotsCoeff, tc.k) + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: "363498d1024f84bb39d3fa9593ce391483cb40d479b87233f868d6e57c3a400d", + } + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: tc.era, + currentEpoch: models.Epoch{ + EpochId: 0, + StartSlot: 0, + Nonce: nil, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + // Test for Byron era - should return nil + if tc.era.Id == 0 { + nonce, err := ls.calculateEpochNonce(nil, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if nonce != nil { + t.Errorf( + "Byron era should return nil nonce, got: %v", + nonce, + ) + } + return + } + + // For non-Byron eras, test initial epoch returns genesis hash + nonce, err := ls.calculateEpochNonce(nil, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if nonce == nil { + t.Error("expected non-nil nonce for initial non-Byron epoch") + } + t.Logf("Formula: %s", tc.expectedFormula) + }) + } +} + +// TestCalculateEpochNonce_IntegerArithmeticPrecision tests precision of integer arithmetic +func TestCalculateEpochNonce_IntegerArithmeticPrecision(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 1000, + "protocolMagic": 2 + } + }` + // Use a coefficient that produces fractional results + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.333333, + "securityParam": 1000, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: "363498d1024f84bb39d3fa9593ce391483cb40d479b87233f868d6e57c3a400d", + } + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + currentEpoch: models.Epoch{ + EpochId: 0, + StartSlot: 0, + Nonce: nil, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + // Should handle fractional coefficients correctly using integer arithmetic + nonce, err := ls.calculateEpochNonce(nil, 0) + if err != nil { + t.Fatalf("unexpected error with fractional coefficient: %v", err) + } + if nonce == nil { + t.Error("expected non-nil nonce") + } +} + +// TestHandleEventChainsyncBlockHeader_StabilityWindowUsage tests the stability window usage in block header handling +func TestHandleEventChainsyncBlockHeader_StabilityWindowUsage(t *testing.T) { + // This test verifies that the handleEventChainsyncBlockHeader function + // correctly uses calculateStabilityWindow instead of the old constant + + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + // Verify that calculateStabilityWindow returns correct value + window := ls.calculateStabilityWindow() + expectedWindow := uint64(25920) // 3*432/0.05 + if window != expectedWindow { + t.Errorf("expected stability window %d, got %d", expectedWindow, window) + } + + // Verify it's different from the old constant + if window == blockfetchBatchSlotThresholdDefault { + t.Error( + "stability window should not equal the old constant for Shelley era", + ) + } +} + +// TestCalculateEpochNonce_AllEras tests epoch nonce calculation across all eras +func TestCalculateEpochNonce_AllEras(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: "363498d1024f84bb39d3fa9593ce391483cb40d479b87233f868d6e57c3a400d", + } + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + testCases := []struct { + name string + era eras.EraDesc + expectNil bool + description string + }{ + { + name: "Byron era returns nil", + era: eras.ByronEraDesc, + expectNil: true, + description: "Byron has no epoch nonce", + }, + { + name: "Shelley era returns nonce", + era: eras.ShelleyEraDesc, + expectNil: false, + description: "Shelley uses epoch nonce", + }, + { + name: "Allegra era returns nonce", + era: eras.AllegraEraDesc, + expectNil: false, + description: "Allegra uses epoch nonce", + }, + { + name: "Mary era returns nonce", + era: eras.MaryEraDesc, + expectNil: false, + description: "Mary uses epoch nonce", + }, + { + name: "Alonzo era returns nonce", + era: eras.AlonzoEraDesc, + expectNil: false, + description: "Alonzo uses epoch nonce", + }, + { + name: "Babbage era returns nonce", + era: eras.BabbageEraDesc, + expectNil: false, + description: "Babbage uses epoch nonce", + }, + { + name: "Conway era returns nonce", + era: eras.ConwayEraDesc, + expectNil: false, + description: "Conway uses epoch nonce", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ls := &LedgerState{ + currentEra: tc.era, + currentEpoch: models.Epoch{ + EpochId: 0, + StartSlot: 0, + Nonce: nil, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + nonce, err := ls.calculateEpochNonce(nil, 0) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tc.description, err) + } + + if tc.expectNil { + if nonce != nil { + t.Errorf( + "%s: expected nil nonce, got %v", + tc.description, + nonce, + ) + } + } else { + if nonce == nil { + t.Errorf("%s: expected non-nil nonce", tc.description) + } + } + }) + } +} + +// TestCalculateEpochNonce_MissingByronGenesisInByronEra tests missing Byron genesis during Byron era +func TestCalculateEpochNonce_MissingByronGenesisInByronEra(t *testing.T) { + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{ + ShelleyGenesisHash: "363498d1024f84bb39d3fa9593ce391483cb40d479b87233f868d6e57c3a400d", + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ByronEraDesc, + currentEpoch: models.Epoch{ + EpochId: 1, + StartSlot: 86400, + Nonce: nil, + }, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + // Byron era returns nil nonce immediately without genesis validation + nonce, err := ls.calculateEpochNonce(nil, 86400) + if err != nil { + t.Fatalf("unexpected error for Byron era: %v", err) + } + if nonce != nil { + t.Errorf("expected nil nonce for Byron era, got: %v", nonce) + } +} diff --git a/ledger/state.go b/ledger/state.go index d040e30e..2ae6b2bf 100644 --- a/ledger/state.go +++ b/ledger/state.go @@ -406,6 +406,103 @@ func (ls *LedgerState) consumeUtxo( ) } +// calculateStabilityWindow returns the stability window based on the current era. +// For Byron era, returns 2k. For Shelley+ eras, returns 3k/f. +// Returns the default threshold if genesis data is unavailable or invalid. +func (ls *LedgerState) calculateStabilityWindow() uint64 { + if ls.config.CardanoNodeConfig == nil { + ls.config.Logger.Warn( + "cardano node config is nil, using default stability window", + ) + return blockfetchBatchSlotThresholdDefault + } + + // Byron era only needs Byron genesis + if ls.currentEra.Id == 0 { + byronGenesis := ls.config.CardanoNodeConfig.ByronGenesis() + if byronGenesis == nil { + return blockfetchBatchSlotThresholdDefault + } + k := byronGenesis.ProtocolConsts.K + if k < 0 { + ls.config.Logger.Warn("invalid negative security parameter", "k", k) + return blockfetchBatchSlotThresholdDefault + } + if k == 0 { + ls.config.Logger.Warn("security parameter is zero", "k", k) + return blockfetchBatchSlotThresholdDefault + } + // Byron stability window is 2k slots + return uint64(k) * 2 // #nosec G115 + } + + // Shelley+ eras only need Shelley genesis + shelleyGenesis := ls.config.CardanoNodeConfig.ShelleyGenesis() + if shelleyGenesis == nil { + return blockfetchBatchSlotThresholdDefault + } + k := shelleyGenesis.SecurityParam + if k < 0 { + ls.config.Logger.Warn("invalid negative security parameter", "k", k) + return blockfetchBatchSlotThresholdDefault + } + if k == 0 { + ls.config.Logger.Warn("security parameter is zero", "k", k) + return blockfetchBatchSlotThresholdDefault + } + securityParam := uint64(k) + + // Calculate 3k/f + activeSlotsCoeff := shelleyGenesis.ActiveSlotsCoeff.Rat + if activeSlotsCoeff == nil { + ls.config.Logger.Warn("ActiveSlotsCoeff.Rat is nil") + return blockfetchBatchSlotThresholdDefault + } + + if activeSlotsCoeff.Num().Sign() <= 0 { + ls.config.Logger.Warn( + "ActiveSlotsCoeff must be positive", + "active_slots_coeff", + activeSlotsCoeff.String(), + ) + return blockfetchBatchSlotThresholdDefault + } + + numerator := new(big.Int).SetUint64(securityParam) + numerator.Mul(numerator, big.NewInt(3)) + numerator.Mul(numerator, activeSlotsCoeff.Denom()) + denominator := new(big.Int).Set(activeSlotsCoeff.Num()) + window, remainder := new( + big.Int, + ).QuoRem(numerator, denominator, new(big.Int)) + if remainder.Sign() != 0 { + window.Add(window, big.NewInt(1)) + } + if window.Sign() <= 0 { + ls.config.Logger.Warn( + "stability window calculation produced non-positive result", + "security_param", + securityParam, + "active_slots_coeff", + activeSlotsCoeff.String(), + ) + return blockfetchBatchSlotThresholdDefault + } + if !window.IsUint64() { + ls.config.Logger.Warn( + "stability window calculation overflowed uint64", + "security_param", + securityParam, + "active_slots_coeff", + activeSlotsCoeff.String(), + "window_num", + window.String(), + ) + return blockfetchBatchSlotThresholdDefault + } + return window.Uint64() +} + type readChainResult struct { rollbackPoint ocommon.Point blocks []ledger.Block @@ -594,26 +691,11 @@ func (ls *LedgerState) ledgerProcessBlocks() { // Enable validation using the k-slot window from ShelleyGenesis. if !shouldValidate && i == 0 { var cutoffSlot uint64 - // Get parameters from Shelley Genesis - shelleyGenesis := ls.config.CardanoNodeConfig.ShelleyGenesis() - if shelleyGenesis == nil { - return errors.New( - "failed to get Shelley Genesis config", - ) - } - // Get security parameter (k) - k := shelleyGenesis.SecurityParam - if k < 0 { - return fmt.Errorf( - "security param must be non-negative: %d", - k, - ) - } - securityParam := uint64(k) + stabilityWindow := ls.calculateStabilityWindow() currentTipSlot := ls.currentTip.Point.Slot blockSlot := next.SlotNumber() - if currentTipSlot >= securityParam { - cutoffSlot = currentTipSlot - securityParam + if currentTipSlot >= stabilityWindow { + cutoffSlot = currentTipSlot - stabilityWindow } else { cutoffSlot = 0 } @@ -623,8 +705,8 @@ func (ls *LedgerState) ledgerProcessBlocks() { shouldValidate = true ls.config.Logger.Debug( "enabling validation as block within k-slot window", - "security_param", - securityParam, + "stability_window", + stabilityWindow, "currentTipSlot", currentTipSlot, "cutoffSlot", @@ -636,7 +718,8 @@ func (ls *LedgerState) ledgerProcessBlocks() { shouldValidate = false ls.config.Logger.Debug( "skipping validation as block is older than k-slot window", - "security_param", securityParam, + "stability_window", + stabilityWindow, "currentTipSlot", currentTipSlot, "cutoffSlot", cutoffSlot, "blockSlot", blockSlot, diff --git a/ledger/state_test.go b/ledger/state_test.go new file mode 100644 index 00000000..2f3d5bc6 --- /dev/null +++ b/ledger/state_test.go @@ -0,0 +1,721 @@ +// 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 ( + "fmt" + "io" + "log/slog" + "math/big" + "strings" + "testing" + + "github.com/blinklabs-io/dingo/config/cardano" + "github.com/blinklabs-io/dingo/ledger/eras" +) + +// TestCalculateStabilityWindow_ByronEra tests the stability window calculation for Byron era +func TestCalculateStabilityWindow_ByronEra(t *testing.T) { + testCases := []struct { + name string + k int + expectedWindow uint64 + }{ + { + name: "Byron era with k=432", + k: 432, + expectedWindow: 864, + }, + { + name: "Byron era with k=2160", + k: 2160, + expectedWindow: 4320, + }, + { + name: "Byron era with k=1", + k: 1, + expectedWindow: 2, + }, + { + name: "Byron era with k=100", + k: 100, + expectedWindow: 200, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + byronGenesisJSON := fmt.Sprintf(`{ + "protocolConsts": { + "k": %d, + "protocolMagic": 2 + } + }`, tc.k) + + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ByronEraDesc, // Byron era has Id = 0 + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + if result != tc.expectedWindow { + t.Errorf( + "expected stability window %d, got %d", + tc.expectedWindow, + result, + ) + } + }) + } +} + +// TestCalculateStabilityWindow_ShelleyEra tests the stability window calculation for Shelley+ eras +func TestCalculateStabilityWindow_ShelleyEra(t *testing.T) { + testCases := []struct { + name string + k int + activeSlotsCoeff float64 + expectedWindow uint64 + description string + }{ + { + name: "Shelley era with k=432, f=0.05", + k: 432, + activeSlotsCoeff: 0.05, + // 3k/f = 3*432/0.05 = 1296/0.05 = 25920 + expectedWindow: 25920, + description: "Standard Shelley parameters", + }, + { + name: "Shelley era with k=2160, f=0.05", + k: 2160, + activeSlotsCoeff: 0.05, + // 3k/f = 3*2160/0.05 = 6480/0.05 = 129600 + expectedWindow: 129600, + description: "Mainnet parameters", + }, + { + name: "Shelley era with k=100, f=0.1", + k: 100, + activeSlotsCoeff: 0.1, + // 3k/f = 3*100/0.1 = 300/0.1 = 3000 + expectedWindow: 3000, + description: "Higher active slots coefficient", + }, + { + name: "Shelley era with k=432, f=0.2", + k: 432, + activeSlotsCoeff: 0.2, + // 3k/f = 3*432/0.2 = 1296/0.2 = 6480 + expectedWindow: 6480, + description: "Even higher active slots coefficient", + }, + { + name: "Shelley era with k=50, f=0.5", + k: 50, + activeSlotsCoeff: 0.5, + // 3k/f = 3*50/0.5 = 150/0.5 = 300 + expectedWindow: 300, + description: "Very high active slots coefficient", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + + shelleyGenesisJSON := fmt.Sprintf(`{ + "activeSlotsCoeff": %f, + "securityParam": %d, + "systemStart": "2022-10-25T00:00:00Z" + }`, tc.activeSlotsCoeff, tc.k) + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, // Shelley era has Id = 1 + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + if result != tc.expectedWindow { + t.Errorf( + "%s: expected stability window %d, got %d", + tc.description, + tc.expectedWindow, + result, + ) + } + }) + } +} + +// TestCalculateStabilityWindow_EdgeCases tests edge cases and error conditions +func TestCalculateStabilityWindow_EdgeCases(t *testing.T) { + t.Run("Missing Byron genesis returns default", func(t *testing.T) { + cfg := &cardano.CardanoNodeConfig{} + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ByronEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + if result != blockfetchBatchSlotThresholdDefault { + t.Errorf( + "expected default threshold %d, got %d", + blockfetchBatchSlotThresholdDefault, + result, + ) + } + }) + + t.Run("Missing Shelley genesis returns default", func(t *testing.T) { + cfg := &cardano.CardanoNodeConfig{} + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ByronEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + if result != 864 { + t.Errorf("expected default threshold %d, got %d", 864, result) + } + }) + + t.Run("Zero k in Byron era returns default", func(t *testing.T) { + cfg := &cardano.CardanoNodeConfig{} + byronGenesisJSON := `{ + "protocolConsts": { + "k": 0, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + _ = cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)) + _ = cfg.LoadShelleyGenesisFromReader( + strings.NewReader(shelleyGenesisJSON), + ) + + ls := &LedgerState{ + currentEra: eras.ByronEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + if result != blockfetchBatchSlotThresholdDefault { + t.Errorf( + "expected default threshold %d for zero k, got %d", + blockfetchBatchSlotThresholdDefault, + result, + ) + } + }) + + t.Run("Zero k in Shelley era returns default", func(t *testing.T) { + cfg := &cardano.CardanoNodeConfig{} + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 0, + "systemStart": "2022-10-25T00:00:00Z" + }` + + _ = cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)) + _ = cfg.LoadShelleyGenesisFromReader( + strings.NewReader(shelleyGenesisJSON), + ) + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + if result != blockfetchBatchSlotThresholdDefault { + t.Errorf( + "expected default threshold %d for zero k, got %d", + blockfetchBatchSlotThresholdDefault, + result, + ) + } + }) +} + +// TestCalculateStabilityWindow_ActiveSlotsCoefficientEdgeCases tests various active slots coefficient scenarios +func TestCalculateStabilityWindow_ActiveSlotsCoefficientEdgeCases( + t *testing.T, +) { + t.Run("Very small active slots coefficient", func(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.01, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + // 3*432/0.01 = 129600 + expectedWindow := uint64(129600) + if result != expectedWindow { + t.Errorf( + "expected stability window %d, got %d", + expectedWindow, + result, + ) + } + }) + + t.Run("Rounding up with remainder", func(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.07, + "securityParam": 100, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + // 3*100/0.07 = 300/0.07 = 4285.714... should round up to 4286 + if result < 4285 || result > 4287 { + t.Errorf("expected stability window around 4286, got %d", result) + } + }) + + t.Run("Precision with fractional coefficient", func(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.333333, + "securityParam": 1000, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + // 3*1000/0.333333 ≈ 9000 + if result == 0 { + t.Error("expected non-zero stability window") + } + if result < 8999 || result > 9002 { + t.Errorf("expected stability window around 9000, got %d", result) + } + }) +} + +// TestCalculateStabilityWindow_AllEras tests calculation across different eras +func TestCalculateStabilityWindow_AllEras(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + testCases := []struct { + name string + era eras.EraDesc + expectedWindow uint64 + }{ + { + name: "Byron era", + era: eras.ByronEraDesc, + expectedWindow: 864, // 2k + }, + { + name: "Shelley era", + era: eras.ShelleyEraDesc, + expectedWindow: 25920, // 3k/f + }, + { + name: "Allegra era", + era: eras.AllegraEraDesc, + expectedWindow: 25920, // 3k/f + }, + { + name: "Mary era", + era: eras.MaryEraDesc, + expectedWindow: 25920, // 3k/f + }, + { + name: "Alonzo era", + era: eras.AlonzoEraDesc, + expectedWindow: 25920, // 3k/f + }, + { + name: "Babbage era", + era: eras.BabbageEraDesc, + expectedWindow: 25920, // 3k/f + }, + { + name: "Conway era", + era: eras.ConwayEraDesc, + expectedWindow: 25920, // 3k/f + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ls := &LedgerState{ + currentEra: tc.era, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := ls.calculateStabilityWindow() + if result != tc.expectedWindow { + t.Errorf( + "era %s: expected stability window %d, got %d", + tc.era.Name, + tc.expectedWindow, + result, + ) + } + }) + } +} + +// TestCalculateStabilityWindow_Integration tests the function in realistic scenarios +func TestCalculateStabilityWindow_Integration(t *testing.T) { + t.Run("Mainnet-like configuration", func(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 2160, + "protocolMagic": 764824073 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 2160, + "systemStart": "2017-09-23T21:44:51Z" + }` + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + // Test Byron era with mainnet params + lsByron := &LedgerState{ + currentEra: eras.ByronEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + resultByron := lsByron.calculateStabilityWindow() + if resultByron != 4320 { + t.Errorf( + "Byron era: expected stability window 4320, got %d", + resultByron, + ) + } + + // Test Shelley era with mainnet params + lsShelley := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + resultShelley := lsShelley.calculateStabilityWindow() + // 3*2160/0.05 = 129600 + if resultShelley != 129600 { + t.Errorf( + "Shelley era: expected stability window 129600, got %d", + resultShelley, + ) + } + }) + + t.Run("Preview testnet configuration", func(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 432, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + lsShelley := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New( + slog.NewJSONHandler(io.Discard, nil), + ), + }, + } + + result := lsShelley.calculateStabilityWindow() + // 3*432/0.05 = 25920 + if result != 25920 { + t.Errorf( + "Preview testnet: expected stability window 25920, got %d", + result, + ) + } + }) +} + +// TestCalculateStabilityWindow_LargeValues tests with large but valid values +func TestCalculateStabilityWindow_LargeValues(t *testing.T) { + byronGenesisJSON := `{ + "protocolConsts": { + "k": 432, + "protocolMagic": 2 + } + }` + shelleyGenesisJSON := `{ + "activeSlotsCoeff": 0.05, + "securityParam": 1000000, + "systemStart": "2022-10-25T00:00:00Z" + }` + + cfg := &cardano.CardanoNodeConfig{} + if err := cfg.LoadByronGenesisFromReader(strings.NewReader(byronGenesisJSON)); err != nil { + t.Fatalf("failed to load Byron genesis: %v", err) + } + if err := cfg.LoadShelleyGenesisFromReader(strings.NewReader(shelleyGenesisJSON)); err != nil { + t.Fatalf("failed to load Shelley genesis: %v", err) + } + + ls := &LedgerState{ + currentEra: eras.ShelleyEraDesc, + config: LedgerStateConfig{ + CardanoNodeConfig: cfg, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }, + } + + result := ls.calculateStabilityWindow() + // 3*1000000/0.05 = 60000000 + expectedWindow := uint64(60000000) + if result != expectedWindow { + t.Errorf("expected stability window %d, got %d", expectedWindow, result) + } +} + +// Helper function to calculate expected window using big.Rat for precision +func calculateExpectedWindow(k int, activeSlotsCoeff float64) uint64 { + // Convert activeSlotsCoeff to big.Rat + rat := new(big.Rat).SetFloat64(activeSlotsCoeff) + + // Calculate 3*k + numerator := new(big.Int).SetInt64(int64(3 * k)) + + // Multiply by denominator of activeSlotsCoeff + numerator.Mul(numerator, rat.Denom()) + + // Divide by numerator of activeSlotsCoeff + quotient, remainder := new( + big.Int, + ).QuoRem(numerator, rat.Num(), new(big.Int)) + + // Round up if there's a remainder + if remainder.Sign() != 0 { + quotient.Add(quotient, big.NewInt(1)) + } + + return quotient.Uint64() +}