diff --git a/cmd/themisd/service/service.go b/cmd/themisd/service/service.go index 76a467d..c684a5b 100644 --- a/cmd/themisd/service/service.go +++ b/cmd/themisd/service/service.go @@ -89,13 +89,14 @@ var ( // Tendermint full-node start flags const ( - flagAddress = "address" - flagTraceStore = "trace-store" - flagPruning = "pruning" - flagCPUProfile = "cpu-profile" - FlagMinGasPrices = "minimum-gas-prices" - FlagHaltHeight = "halt-height" - FlagHaltTime = "halt-time" + flagAddress = "address" + flagTraceStore = "trace-store" + flagPruning = "pruning" + flagCPUProfile = "cpu-profile" + FlagMinGasPrices = "minimum-gas-prices" + FlagHaltHeight = "halt-height" + FlagHaltTime = "halt-time" + FlagSelectionAlgoV1Height = "selection-algo-v1-height" ) // Open Collector Flags @@ -322,6 +323,7 @@ which accepts a path for the resulting pprof file. FlagMinGasPrices, "", "Minimum gas prices to accept for transactions; Any fee in a tx must meet this minimum (e.g. 0.01photino;0.0001stake)", ) + cmd.Flags().Uint64(FlagSelectionAlgoV1Height, 0, "Height at which to switch to election algorithm v1") cmd.Flags().Uint64(FlagHaltHeight, 0, "Height at which to gracefully halt the chain and shutdown the node") cmd.Flags().Uint64(FlagHaltTime, 0, "Minimum block time (in Unix seconds) at which to gracefully halt the chain and shutdown the node") cmd.Flags().String(flagCPUProfile, "", "Enable CPU profiling and write to the provided file") diff --git a/helper/config.go b/helper/config.go index ffa3a8e..40844b1 100644 --- a/helper/config.go +++ b/helper/config.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "time" @@ -105,7 +106,7 @@ const ( DefaultSHStakeUpdateInterval = 3 * time.Hour DefaultSHMaxDepthDuration = time.Hour - DefaultMainchainGasLimit = uint64(5000000) + DefaultMainchainGasLimit = uint64(5000000) DefaultMetischainGasLimit = uint64(5000000) MainchainBuildGasLimit = uint64(30_000_000) @@ -166,6 +167,8 @@ type Configuration struct { ThemisServerURL string `mapstructure:"themis_rest_server"` // themis server url MpcServerURL string `mapstructure:"mpc_rpc_server"` // mpc server url + SelectionAlgoV1Height int64 `mapstructure:"selection_algo_v1_height"` // height to switch to new selection algo + MainchainGasLimit uint64 `mapstructure:"main_chain_gas_limit"` // gas limit to mainchain transaction. MainchainMaxGasPrice int64 `mapstructure:"main_chain_max_gas_price"` // max gas price to mainchain transaction. @@ -356,7 +359,7 @@ func InitThemisConfigWith(homeDir string, themisConfigFileFromFLag string) { case MainChain: case TestChain: default: - newSelectionAlgoHeight = 0 + newSelectionAlgoHeight = conf.SelectionAlgoV1Height spanOverrideHeight = 0 } @@ -426,6 +429,16 @@ func (c *Configuration) MergeFromEnv() { c.MpcServerURL = envMpcRpcUrl } + envSelectionAlgoV1Height := os.Getenv("SELECTION_ALGO_V1_HEIGHT") + if envSelectionAlgoV1Height != "" { + var err error + height, err := strconv.Atoi(envSelectionAlgoV1Height) + if err != nil { + panic("invalid SELECTION_ALGO_V1_HEIGHT" + err.Error()) + } + c.SelectionAlgoV1Height = int64(height) + } + envSpanPollInterval := os.Getenv("SPAN_POLL_INTERNAL") if envSpanPollInterval != "" { var err error diff --git a/metis/clt_rand.go b/metis/clt_rand.go new file mode 100644 index 0000000..04c5540 --- /dev/null +++ b/metis/clt_rand.go @@ -0,0 +1,66 @@ +package metis + +import ( + "errors" + "math/rand" +) + +var ( + ErrOverflow = errors.New("value is too large") + ErrInvalidRange = errors.New("invalid range") +) + +const ( + maxInt63 = int64(1<<62 - 1) +) + +// CLTGenerator generates random integers that follow an approximate normal distribution +type CLTGenerator struct{} + +// NewCLTGenerator initializes a CLT generator without state dependency +func NewCLTGenerator() *CLTGenerator { + return &CLTGenerator{} +} + +// GenerateRandomInt generates a random integer from min to max (inclusive), based on a provided seed. +// This simulates a normal distribution by summing multiple uniform random variables. +func (g *CLTGenerator) GenerateRandomInt(seed int64, min, max uint64) (int64, error) { + if min > max { + return 0, ErrInvalidRange + } + + rangeDiff := max - min + if max > uint64(maxInt63) || min > uint64(maxInt63) || rangeDiff > uint64(maxInt63) { + return 0, ErrOverflow + } + + // Number of samples to sum (higher means closer to normal distribution) + // default to 6 because this is a balanced value between performance and accuracy + numSamples := int64(6) + maxSampleSize := maxInt63 / numSamples + + if rangeDiff > uint64(maxSampleSize) { + // if range is too large change sample size to fit within maxInt63 + // this trades accuracy with safety + numSamples = maxInt63 / int64(rangeDiff) + maxSampleSize = maxInt63 / numSamples + } + + // Create a local new source to avoid disruption from the global random status + r := rand.New(rand.NewSource(seed)) + sum, minInt, maxInt := int64(0), int64(min), int64(max) + for i := int64(0); i < numSamples; i++ { + // rand.Int63n returns a value in the range [0, n), so we need to add 2 here to include max + randValue := r.Int63n(int64(rangeDiff) + 2) + sum += randValue + } + + // Average the sum to bring it back within the desired range, apply adjustment factor + result := (sum / numSamples) + minInt + if result < minInt { + return minInt, nil + } else if result > maxInt { + return maxInt, nil + } + return result, nil +} diff --git a/metis/clt_rand_test.go b/metis/clt_rand_test.go new file mode 100644 index 0000000..9967d3d --- /dev/null +++ b/metis/clt_rand_test.go @@ -0,0 +1,181 @@ +package metis + +import ( + "encoding/csv" + "fmt" + "os" + "sort" + "sync" + "testing" + "time" +) + +// TestMultipleGoroutinesConsistency runs the generator in multiple goroutines simulating different machines +func TestMultipleGoroutinesConsistency(t *testing.T) { + seedBase := time.Now().UnixNano() + numGoroutines := 10 + iterations := 1000 + min := uint64(0) + max := uint64(100) + + var wg sync.WaitGroup + results := make([][]int64, numGoroutines) + + mu := sync.Mutex{} + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + gen := NewCLTGenerator() + localResult := make([]int64, iterations) + for j := 0; j < iterations; j++ { + seed := seedBase + int64(j) + value, err := gen.GenerateRandomInt(seed, min, max) + if err != nil { + t.Errorf("Error generating random int: %v", err) + } + localResult[j] = value + } + + mu.Lock() + results[index] = localResult + mu.Unlock() + }(i) + } + + wg.Wait() + + for i := 1; i < numGoroutines; i++ { + if !compareResults(results[0], results[i]) { + t.Errorf("Inconsistent results between goroutine 0 and goroutine %d", i) + return + } + } +} + +// compareResults checks if two slices have the same values +func compareResults(a, b []int64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestGenerateRandomDistribution tests the generator and outputs the frequency distribution to CSV +func TestGenerateRandomDistribution(t *testing.T) { + seedBase := time.Now().UnixNano() + testCases := []struct { + iterations int + filename string + min uint64 + max uint64 + }{ + {500, "test_500_iterations.csv", 0, 10}, + {5000, "test_5000_iterations.csv", 0, 100}, + {50000, "test_50000_iterations.csv", 0, 5000}, + {100000, "test_100000_iterations.csv", 0, 10000}, + // Additional test case for exceeding maxSampleSize to verify normal distribution + {1000000, "test_1000000_max_sample_size_iterations.csv", 0, uint64(maxInt63) / 6}, + {1000000, "test_1000000_double_max_sample_size_iterations.csv", 0, uint64(maxInt63) / 2}, + {1000000, "test_1000000_max_limit_iterations.csv", 0, uint64(maxInt63)}, + } + + gen := NewCLTGenerator() + + for _, tc := range testCases { + counts := make(map[int64]int) + for i := 0; i < tc.iterations; i++ { + seed := seedBase + int64(i) + value, err := gen.GenerateRandomInt(seed, tc.min, tc.max) + if err != nil { + t.Errorf("Error generating random int: %v", err) + } + counts[value]++ + } + + // Save results to CSV file + file, err := os.Create(tc.filename) + if err != nil { + t.Errorf("Error creating file %s: %v", tc.filename, err) + continue + } + defer file.Close() + + writer := csv.NewWriter(file) + defer writer.Flush() + + writer.Write([]string{"Number", "Occurrences"}) + + type pair struct { + num int64 + count int + } + + pairs := make([]pair, 0, len(counts)) + + for num, count := range counts { + pairs = append(pairs, pair{num, count}) + } + + sort.Slice(pairs, func(i, j int) bool { + return pairs[i].num < pairs[j].num + }) + + for _, p := range pairs { + writer.Write([]string{fmt.Sprintf("%d", p.num), fmt.Sprintf("%d", p.count)}) + } + } +} + +// TestGenerateRandomIntEdgeCases tests edge cases for the GenerateRandomInt function +func TestGenerateRandomIntEdgeCases(t *testing.T) { + gen := NewCLTGenerator() + seed := time.Now().UnixNano() + + testCases := []struct { + min uint64 + max uint64 + expectsError bool + }{ + // Edge cases + {0, 0, false}, // Min equals Max + {0, uint64(maxInt63), false}, // Max is max int63 + {0, uint64(maxInt63) + 1, true}, // Max exceeds int63 + {uint64(maxInt63) + 1, uint64(maxInt63) + 2, true}, // Both Min and Max exceed int63 + {100, 50, true}, // Min greater than Max (invalid range) + {uint64(maxInt63) - 10, uint64(maxInt63), false}, // Near max int63, should work + {1, uint64(maxInt63) / 2, false}, // Large range but within int63, should work + {uint64(maxInt63) / 2, uint64(maxInt63), false}, // Upper half of int63 range + // Small range with large min and max values + {uint64(maxInt63) - 5, uint64(maxInt63), false}, // Small range with large values + {uint64(maxInt63) - 1, uint64(maxInt63), false}, // Min close to max + } + + for i, tc := range testCases { + _, err := gen.GenerateRandomInt(seed, tc.min, tc.max) + if (err != nil) != tc.expectsError { + t.Errorf("Unexpected error for case %d: min: %d, max: %d, got error: %v", i, tc.min, tc.max, err) + } + } +} + +// BenchmarkGenerateRandomInt benchmarks the efficiency of the random generation function +func BenchmarkGenerateRandomInt(b *testing.B) { + gen := NewCLTGenerator() + seed := time.Now().UnixNano() + min := uint64(0) + max := uint64(100) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := gen.GenerateRandomInt(seed+int64(i), min, max) + if err != nil { + b.Errorf("Error generating random int: %v", err) + } + } +} diff --git a/metis/keeper.go b/metis/keeper.go index d5cfd2c..1541bc5 100644 --- a/metis/keeper.go +++ b/metis/keeper.go @@ -558,7 +558,7 @@ func (k *Keeper) SelectNextProducers(ctx sdk.Context, seed common.Hash, unEligib // select next producers using seed as blockheader hash fn := SelectNextProducers - newProducersIds, err := fn(seed, finalSpanEligibleVals, producerCount) + newProducersIds, err := fn(ctx, seed, finalSpanEligibleVals, producerCount) if err != nil { return vals, err } diff --git a/metis/selection.go b/metis/selection.go index af21202..8b63a08 100644 --- a/metis/selection.go +++ b/metis/selection.go @@ -5,6 +5,7 @@ import ( "math" "math/rand" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/ethereum/go-ethereum/common" "github.com/metis-seq/themis/helper" @@ -52,7 +53,9 @@ func convertToSlots(vals []hmTypes.Validator) (validatorIndices []uint64) { // // SelectNextProducers selects producers for next span by converting power to tickets -func SelectNextProducers(blkHash common.Hash, spanEligibleValidators []hmTypes.Validator, producerCount uint64) ([]uint64, error) { +func SelectNextProducers(ctx sdk.Context, blkHash common.Hash, spanEligibleValidators []hmTypes.Validator, producerCount uint64) ([]uint64, error) { + randGen := NewCLTGenerator() + selectedProducers := make([]uint64, 0) if len(spanEligibleValidators) <= int(producerCount) { @@ -83,7 +86,24 @@ func SelectNextProducers(blkHash common.Hash, spanEligibleValidators []hmTypes.V Weighted range will look like (1, 2) Rolling inclusive will have a range of 0 - 2, making validator with staking power 1 chance of selection = 66% */ - targetWeight := randomRangeInclusive(1, totalVotingPower) + + var targetWeight uint64 + if helper.GetNewSelectionAlgoHeight() == 0 || ctx.BlockHeight() < helper.GetNewSelectionAlgoHeight() { + targetWeight = randomRangeInclusive(1, totalVotingPower) + ctx.Logger().Debug("Selecting new proposer", "algoVersion", 0, "totalVotingPower", + totalVotingPower, "targetWeight", targetWeight, + "v1ElectionUpgradeHeight", helper.GetNewSelectionAlgoHeight()) + } else { + targetWeightI64, err := randGen.GenerateRandomInt(seed, 1, totalVotingPower) + if err != nil { + return nil, err + } + targetWeight = uint64(targetWeightI64) + ctx.Logger().Debug("Selecting new proposer", "algoVersion", 1, "totalVotingPower", + totalVotingPower, "targetWeight", targetWeight, + "v1ElectionUpgradeHeight", helper.GetNewSelectionAlgoHeight()) + } + index := binarySearch(weightedRanges, targetWeight) selectedProducers = append(selectedProducers, spanEligibleValidators[index].ID.Uint64()) }