Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New proposer selection algorithm #4

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions cmd/themisd/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
17 changes: 15 additions & 2 deletions helper/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -356,7 +359,7 @@ func InitThemisConfigWith(homeDir string, themisConfigFileFromFLag string) {
case MainChain:
case TestChain:
default:
newSelectionAlgoHeight = 0
newSelectionAlgoHeight = conf.SelectionAlgoV1Height
spanOverrideHeight = 0
}

Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions metis/clt_rand.go
Original file line number Diff line number Diff line change
@@ -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
}
181 changes: 181 additions & 0 deletions metis/clt_rand_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion metis/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
24 changes: 22 additions & 2 deletions metis/selection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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())
}
Expand Down