A very experimental PLC implementation which uses BFT consensus for decentralization

Apply logistic curve to validator voting powers and eliminate potentially non-deterministic floating point math

gbl08ma.com 723c38e7 70da204d

verified
+148 -25
+145 -25
abciapp/tx_epoch.go
··· 12 12 "github.com/cometbft/cometbft/crypto/ed25519" 13 13 protocrypto "github.com/cometbft/cometbft/proto/tendermint/crypto" 14 14 "github.com/gbl08ma/stacktrace" 15 + "github.com/govalues/decimal" 15 16 cbornode "github.com/ipfs/go-ipld-cbor" 17 + "github.com/samber/lo" 16 18 "tangled.org/gbl08ma.com/didplcbft/store" 17 19 ) 18 20 19 21 const UpdateValidatorsBlockInterval = 10000 20 22 const MaxActiveValidators = 50 21 - const MinReputationForBecomingValidator = 20000 23 + const MinReputationForBecomingValidator = (ReputationGainPerProvenBlock - ReputationEntropyLossPerBlock) * 60 * 60 * 24 // must solve challenges for about one day, assuming one block per second 22 24 23 25 func init() { 24 26 store.Consensus.ConfigureEpochSize(UpdateValidatorsBlockInterval) ··· 41 43 cbornode.RegisterCborType(Transaction[UpdateValidatorsArguments]{}) 42 44 } 43 45 44 - func computeVotingPowerFromReputation(reputation uint64) uint64 { 46 + var LogisticL = decimal.MustNew(10000000000000, 0) 47 + var LogisticNegK = lo.Must(decimal.NewFromInt64(0, 2, 8)).Neg() // -0.00000002 (already negated!) 48 + var LogisticX0 = lo.Must(decimal.New(60*60*24*365*8, 0)) // LogisticNegK and LogisticX0 were obtained based on vibes; for a target block interval of 1s, validator voting power is expected to plateau after about 1.5 years 49 + var LogisticOne = decimal.MustNew(1, 0) 50 + var LogisticZeroAdjust = decimal.MustNew(63954022804, 0) 51 + 52 + func computeVotingPowerFromReputation(reputation uint64) (uint64, error) { 45 53 if reputation < MinReputationForBecomingValidator { 46 - return 0 54 + return 0, nil 55 + } 56 + 57 + reputation = reputation - MinReputationForBecomingValidator 58 + 59 + rep, err := decimal.New(int64(reputation), 0) 60 + if err != nil { 61 + return 0, stacktrace.Propagate(err) 62 + } 63 + paren, err := rep.Sub(LogisticX0) 64 + if err != nil { 65 + return 0, stacktrace.Propagate(err) 66 + } 67 + exponent, err := paren.Mul(LogisticNegK) 68 + if err != nil { 69 + return 0, stacktrace.Propagate(err) 70 + } 71 + exponent, err = exponent.Exp() 72 + if err != nil { 73 + return 0, stacktrace.Propagate(err) 74 + } 75 + 76 + denominator, err := exponent.Add(LogisticOne) 77 + if err != nil { 78 + return 0, stacktrace.Propagate(err) 79 + } 80 + 81 + votingPower, err := LogisticL.Quo(denominator) 82 + if err != nil { 83 + return 0, stacktrace.Propagate(err) 84 + } 85 + 86 + votingPower, err = votingPower.Sub(LogisticZeroAdjust) 87 + if err != nil { 88 + return 0, stacktrace.Propagate(err) 89 + } 90 + 91 + // just for the voting power values to continue to see some insignificant movement after the top of the logistical curve is reached 92 + votingPower, err = votingPower.Add(rep) 93 + if err != nil { 94 + return 0, stacktrace.Propagate(err) 95 + } 96 + 97 + whole, _, ok := votingPower.Int64(0) 98 + if !ok { 99 + return 0, stacktrace.NewError("voting power can't be represented as a pair of int64") 47 100 } 48 - return reputation - MinReputationForBecomingValidator // TODO design and apply S-curve 101 + 102 + return uint64(whole), nil 49 103 } 104 + 105 + var updateValidatorsBlockIntervalDecimal = decimal.MustNew(UpdateValidatorsBlockInterval, 0) 50 106 51 107 func processUpdateValidatorsTx(ctx context.Context, deps TransactionProcessorDependencies, txBytes []byte) (*processResult, error) { 52 108 _, err := UnmarshalTransaction[UpdateValidatorsArguments](txBytes) ··· 96 152 } 97 153 98 154 voteCount, hasVotingPower := oldActiveValidatorSet[[store.PublicKeyLength]byte(validatorPubKey)] 99 - votesIncludedInFraction := float64(voteCount) / float64(UpdateValidatorsBlockInterval) 100 155 101 - decrease := computeReputationDecrease(uint64(deps.workingHeight), reputation, rangeChallengeCompletion, votesIncludedInFraction, hasVotingPower) 156 + decrease, err := computeReputationDecrease(uint64(deps.workingHeight), reputation, rangeChallengeCompletion, voteCount, hasVotingPower) 157 + if err != nil { 158 + return 0, stacktrace.Propagate(err) 159 + } 160 + 102 161 if decrease > reputation { 103 162 reputation = 0 104 163 } else { 105 164 reputation -= decrease 106 165 } 107 166 108 - votingPower := computeVotingPowerFromReputation(reputation) 167 + votingPower, err := computeVotingPowerFromReputation(reputation) 168 + if err != nil { 169 + return 0, stacktrace.Propagate(err) 170 + } 171 + 109 172 if votingPower > 0 { 110 173 vwvp := validatorWithVotingPower{ 111 174 validatorPubKey: validatorPubKey, ··· 114 177 115 178 if valHeap.Len() < MaxActiveValidators { 116 179 heap.Push(&valHeap, vwvp) 117 - } else if votingPower > valHeap[0].votingPower { 180 + } else if valHeap[0].less(vwvp) { 118 181 heap.Pop(&valHeap) 119 182 heap.Push(&valHeap, vwvp) 120 183 } ··· 194 257 votingPower uint64 195 258 } 196 259 260 + func (v1 validatorWithVotingPower) less(v2 validatorWithVotingPower) bool { 261 + if v1.votingPower == v2.votingPower { 262 + return bytes.Compare(v1.validatorPubKey, v2.validatorPubKey) < 0 263 + } 264 + return v1.votingPower < v2.votingPower 265 + } 266 + 197 267 type validatorHeap []validatorWithVotingPower 198 268 199 - func (h validatorHeap) Len() int { return len(h) } 200 - func (h validatorHeap) Less(i, j int) bool { return h[i].votingPower < h[j].votingPower } 201 - func (h validatorHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } 269 + func (h validatorHeap) Len() int { return len(h) } 270 + func (h validatorHeap) Less(i, j int) bool { 271 + return h[i].less(h[j]) 272 + } 273 + func (h validatorHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } 202 274 203 275 func (h *validatorHeap) Push(x any) { 204 276 *h = append(*h, x.(validatorWithVotingPower)) ··· 229 301 return out, nil 230 302 } 231 303 232 - func computeReputationDecrease(workingHeight uint64, reputation uint64, rangeChallengeCompletion uint64, voteInclusionFrequency float64, validatorHasVotingPower bool) uint64 { 304 + var pointZero3 = decimal.MustNew(3, 2) 305 + var maxMissedRangeChallengePenaltyMul = decimal.MustNew(15, 2) 306 + 307 + var pointOne = decimal.MustNew(1, 1) 308 + var pointSeven = decimal.MustNew(7, 1) 309 + 310 + func computeReputationDecrease(workingHeight uint64, reputation uint64, rangeChallengeCompletion uint64, voteCount uint64, validatorHasVotingPower bool) (uint64, error) { 233 311 const expectedGainForCompletelyActiveValidator = ReputationGainPerProvenBlock * UpdateValidatorsBlockInterval 234 312 entropyLoss := ReputationEntropyLossPerBlock * UpdateValidatorsBlockInterval 235 313 ··· 243 321 244 322 rangeChallengeMissedEpochs := (workingHeight - rangeChallengeCompletion) / UpdateValidatorsBlockInterval 245 323 // allow for missing one epoch without penalty 246 - missedRangeChallengePenalty := float64(max(0, int64(rangeChallengeMissedEpochs)-1)) * 0.03 247 - if missedRangeChallengePenalty > 0.15 { 248 - // avoid a too sharp drop off 249 - missedRangeChallengePenalty = 0.15 324 + missedRangeChallengePenalty, err := decimal.MustNew(max(0, int64(rangeChallengeMissedEpochs)-1), 0).Mul(pointZero3) 325 + if err != nil { 326 + return 0, stacktrace.Propagate(err) 250 327 } 251 - if missedRangeChallengePenalty > 0 { 252 - penaltyInt := uint64(float64(reputation) * missedRangeChallengePenalty) 253 - if reputation < penaltyInt { 254 - return 0 328 + // avoid a too sharp drop off 329 + missedRangeChallengePenalty = missedRangeChallengePenalty.Min(maxMissedRangeChallengePenaltyMul) 330 + 331 + if missedRangeChallengePenalty.IsPos() { 332 + rep, err := decimal.New(int64(reputation), 0) 333 + if err != nil { 334 + return 0, stacktrace.Propagate(err) 335 + } 336 + 337 + penalty, err := rep.Mul(missedRangeChallengePenalty) 338 + if err != nil { 339 + return 0, stacktrace.Propagate(err) 340 + } 341 + 342 + penaltyInt, _, ok := penalty.Int64(0) 343 + if !ok { 344 + return 0, stacktrace.NewError("penalty not representable as an int64") 345 + } 346 + if reputation < uint64(penaltyInt) { 347 + return 0, nil 255 348 } 256 - decrease += penaltyInt 349 + decrease += uint64(penaltyInt) 257 350 } 258 351 259 352 // penalize active validators that haven't been voting ··· 263 356 // 2. MarkValidatorVote only runs on FinalizeBlock, which is called after the epoch transaction has been processed 264 357 // 3. Validator updates take a few blocks to fully take effect, meaning validators might only become (in)active after the epoch transaction has been processed 265 358 if validatorHasVotingPower { 359 + voteCountDecimal := decimal.MustNew(int64(voteCount), 0) 360 + voteInclusionFrequency, err := voteCountDecimal.Quo(updateValidatorsBlockIntervalDecimal) 361 + if err != nil { 362 + return 0, stacktrace.Propagate(err) 363 + } 364 + 266 365 switch { 267 - case voteInclusionFrequency < 0.1: 366 + case voteInclusionFrequency.Less(pointOne): 268 367 decrease += 5 * expectedGainForCompletelyActiveValidator 269 - case voteInclusionFrequency < 0.7: 270 - decrease += uint64(5 * float64(expectedGainForCompletelyActiveValidator) * (0.7 - voteInclusionFrequency) / 0.7) 368 + case voteInclusionFrequency.Less(pointSeven): 369 + dec, err := decimal.New(5*expectedGainForCompletelyActiveValidator, 0) 370 + if err != nil { 371 + return 0, stacktrace.Propagate(err) 372 + } 373 + 374 + penaltyFrac, err := pointSeven.Sub(voteInclusionFrequency) 375 + if err != nil { 376 + return 0, stacktrace.Propagate(err) 377 + } 378 + penaltyFrac, err = penaltyFrac.Quo(pointSeven) 379 + if err != nil { 380 + return 0, stacktrace.Propagate(err) 381 + } 382 + 383 + dec, err = dec.Mul(penaltyFrac) 384 + if err != nil { 385 + return 0, stacktrace.Propagate(err) 386 + } 387 + 388 + decInt, _, _ := dec.Int64(0) 389 + 390 + decrease += uint64(decInt) 271 391 } 272 392 } 273 393 274 - return decrease 394 + return decrease, nil 275 395 }
+1
go.mod
··· 69 69 github.com/google/orderedcode v0.0.1 // indirect 70 70 github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect 71 71 github.com/gorilla/websocket v1.5.3 // indirect 72 + github.com/govalues/decimal v0.1.36 // indirect 72 73 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 73 74 github.com/hashicorp/hcl v1.0.0 // indirect 74 75 github.com/ingonyama-zk/icicle-gnark/v3 v3.2.2 // indirect
+2
go.sum
··· 151 151 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 152 152 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 153 153 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 154 + github.com/govalues/decimal v0.1.36 h1:dojDpsSvrk0ndAx8+saW5h9WDIHdWpIwrH/yhl9olyU= 155 + github.com/govalues/decimal v0.1.36/go.mod h1:Ee7eI3Llf7hfqDZtpj8Q6NCIgJy1iY3kH1pSwDrNqlM= 154 156 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 155 157 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 156 158 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=