Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6a69084
feat: validator node version attestation with observer demotion
Test0rMaik Jul 19, 2026
1efd025
fix: harden version enforcement per security and correctness audits
Test0rMaik Jul 19, 2026
568fb87
refactor: address automated review feedback
Test0rMaik Jul 19, 2026
7bdd7af
fix: address review — assert LoadPeer error in buildStorage test helper
Test0rMaik Jul 24, 2026
85f95ca
fix: address review — fix buildStorage's t capture, add getValidator-…
Test0rMaik Jul 24, 2026
b5a3212
fix: harden version-enforcement guards against elected-list emptying …
Test0rMaik Aug 15, 2026
088195c
fix: compare pre-release identifiers per semver 11.4 instead of just …
Test0rMaik Aug 15, 2026
f1d5148
docs: document attestation-only txs resetting commission/delegation, …
Test0rMaik Aug 15, 2026
3c0eb2d
chore: regenerate contracts.pb.go/validatorData.pb.go with the canoni…
Test0rMaik Aug 15, 2026
16cba7d
test: pin AttestedEpoch recording from the block header, fix discarde…
Test0rMaik Aug 15, 2026
7c71910
test: cover per-validator error propagation in both epoch processing …
Test0rMaik Aug 15, 2026
a555277
test: pin agreement between requiredVersionForEpoch and headerCheck v…
Test0rMaik Aug 15, 2026
363af86
validators: validate versionsByEpochs table at construction
Test0rMaik Aug 15, 2026
671f10a
docs: correct the attestation freshness-window comment
Test0rMaik Aug 15, 2026
c71914d
docs: clarify the trust model behind attested node versions
Test0rMaik Aug 15, 2026
4515be9
validators: log version demotion decisions at Info
Test0rMaik Aug 15, 2026
bf65553
fix: count unreadable validator records towards the electable guards
Test0rMaik Aug 15, 2026
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
13 changes: 7 additions & 6 deletions cmd/node/startup.go
Original file line number Diff line number Diff line change
Expand Up @@ -577,12 +577,13 @@ func startNode(ctx *cli.Context, log logger.Logger, version string) error {

log.Trace("creating state components")
stateArgs := factory.StateComponentsFactoryArgs{
Config: cfg,
Core: coreComponents,
PathManager: pathManager,
Tries: triesComponents,
RatingsData: ratingsData,
ProcessingMode: processingMode,
Config: cfg,
Core: coreComponents,
PathManager: pathManager,
Tries: triesComponents,
RatingsData: ratingsData,
ProcessingMode: processingMode,
MinElectableNodes: genesisNodesConfig.MinNumberOfNodes(),
}
stateComponentsFactory, err := factory.NewStateComponentsFactory(stateArgs)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const (
ErrFieldInvalidURI = "InvalidURI"
ErrFieldURICountExceeded = "URICountExceeded"
ErrFieldInvalidLogo = "InvalidLogo"
ErrFieldInvalidNodeVersion = "InvalidNodeVersion"

// Proposal errors
ErrFieldInvalidProposal = "InvalidProposal"
Expand Down
5 changes: 5 additions & 0 deletions common/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,11 @@ var ErrEmptyAddress = errors.New("empty address")
// ErrInvalidTransactionVersion signals that an invalid transaction version has been provided
var ErrInvalidTransactionVersion = errors.New("invalid transaction version")

// ErrInvalidVersionsByEpochs signals that the versions.versionsByEpochs config is malformed:
// entries must start with StartEpoch 0 and have strictly increasing StartEpoch values, and
// each version string must not exceed the configured length limit
var ErrInvalidVersionsByEpochs = errors.New("invalid versions by epochs configuration")

// ErrTransactionNotFound signals that a transaction was not found
var ErrTransactionNotFound = errors.New("transaction not found")

Expand Down
10 changes: 10 additions & 0 deletions common/mock/forkControllerStub.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type ForkControllerStub struct {
FixAuditChangesV2Value bool
FixMarketBuyOverflowValue bool
FixAuditChangesV3Value bool
VersionAttestationValue bool
EpochConfirmedCalled bool
LastConfirmedEpoch uint32
}
Expand Down Expand Up @@ -57,6 +58,8 @@ func (s *ForkControllerStub) SetFork(forkName string, value bool) *ForkControlle
s.FixMarketBuyOverflowValue = value
case "FixAuditChangesV3":
s.FixAuditChangesV3Value = value
case "VersionAttestation":
s.VersionAttestationValue = value
}

return s
Expand All @@ -77,6 +80,7 @@ func (s *ForkControllerStub) SetAll(value bool) {
s.FixAuditChangesV2Value = value
s.FixMarketBuyOverflowValue = value
s.FixAuditChangesV3Value = value
s.VersionAttestationValue = value
s.LastConfirmedEpoch = 0
}

Expand All @@ -95,6 +99,7 @@ func (s *ForkControllerStub) SetByConfig(config config.EnableEpochs) {
s.FixAuditChangesV2Value = config.FixAuditChangesV2 == 0
s.FixMarketBuyOverflowValue = config.FixMarketBuyOverflow == 0
s.FixAuditChangesV3Value = config.FixAuditChangesV3 == 0
s.VersionAttestationValue = config.VersionAttestation == 0
s.LastConfirmedEpoch = 0
}

Expand Down Expand Up @@ -163,6 +168,11 @@ func (s *ForkControllerStub) FixAuditChangesV3() bool {
return s.FixAuditChangesV3Value
}

// VersionAttestation returns the stubbed value
func (s *ForkControllerStub) VersionAttestation() bool {
return s.VersionAttestationValue
}

// EpochConfirmed records that the method was called and stores the epoch
func (s *ForkControllerStub) EpochConfirmed(epoch uint32) {
s.EpochConfirmedCalled = true
Expand Down
1 change: 1 addition & 0 deletions config/enableEpochs.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type EnableEpochs struct {
FixAuditChangesV2 uint32 `yaml:"fixAuditChangesV2"`
FixMarketBuyOverflow uint32 `yaml:"fixMarketBuyOverflow"`
FixAuditChangesV3 uint32 `yaml:"fixAuditChangesV3"`
VersionAttestation uint32 `yaml:"versionAttestation"`
}

// GasScheduleByEpochs represents a gas schedule toml entry that will be applied from the provided epoch
Expand Down
4 changes: 4 additions & 0 deletions config/node/enableEpochs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ enableEpochs:
# Audit Changes V3 (reject non-positive kdaFeesPool fee ratios + range-check
# swap price) + block indirect dispatch of the upgrade lifecycle hook
fixAuditChangesV3: 0
# Validator version attestation: validators must attest their node version on-chain;
# non-attested validators are demoted to observer (not jailed) while versions.versionsByEpochs
# requires a specific version. Dormant while versionsByEpochs is the "*" wildcard.
versionAttestation: 0

gasSchedule:
gasScheduleByEpochs:
Expand Down
8 changes: 8 additions & 0 deletions core/fork/forks.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type forkController struct {
flagFixAuditChangesV2 atomic.Flag
flagFixMarketBuyOverflow atomic.Flag
flagFixAuditChangesV3 atomic.Flag
flagVersionAttestation atomic.Flag
}

func NewForkController(cfg config.EnableEpochs, epochNotifier process.EpochNotifier) (*forkController, error) {
Expand Down Expand Up @@ -93,6 +94,10 @@ func (f *forkController) FixAuditChangesV3() bool {
return f.flagFixAuditChangesV3.IsSet()
}

func (f *forkController) VersionAttestation() bool {
return f.flagVersionAttestation.IsSet()
}

// EpochConfirmed is called whenever a new epoch is confirmed
func (f *forkController) EpochConfirmed(epoch uint32) {
f.flagClaimKFIEnabled.Toggle(epoch >= f.enableEpochs.ClaimKFI)
Expand Down Expand Up @@ -133,6 +138,9 @@ func (f *forkController) EpochConfirmed(epoch uint32) {

f.flagFixAuditChangesV3.Toggle(epoch >= f.enableEpochs.FixAuditChangesV3)
log.Debug("forkController: FixAuditChangesV3", "enabled", f.flagFixAuditChangesV3.IsSet())

f.flagVersionAttestation.Toggle(epoch >= f.enableEpochs.VersionAttestation)
log.Debug("forkController: VersionAttestation", "enabled", f.flagVersionAttestation.IsSet())
}

// IsInterfaceNil returns true if there is no value under the interface
Expand Down
1 change: 1 addition & 0 deletions core/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type ForkController interface {
FixAuditChangesV2() bool
FixMarketBuyOverflow() bool
FixAuditChangesV3() bool
VersionAttestation() bool
IsInterfaceNil() bool
}

Expand Down
13 changes: 9 additions & 4 deletions core/kapp/factory/validators.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package factory

import (
"github.com/klever-io/klever-go/config"
"github.com/klever-io/klever-go/core"
"github.com/klever-io/klever-go/core/kapp"
"github.com/klever-io/klever-go/core/kapp/validators"
Expand All @@ -14,13 +15,17 @@ func NewValidatorKApp(
PubkeyConv core.PubkeyConverter,
ForkController core.ForkController,
RatingsData process.RatingsInfoHandler,
VersionsByEpochs []config.VersionByEpochs,
MinElectableNodes uint32,
) (kapp.ValidatorsKapp, error) {

args := &validators.ArgsNewValidatorKApp{
Marshalizer: Marshalizer,
PubkeyConv: PubkeyConv,
RatingsData: RatingsData,
ForkController: ForkController,
Marshalizer: Marshalizer,
PubkeyConv: PubkeyConv,
RatingsData: RatingsData,
ForkController: ForkController,
VersionsByEpochs: VersionsByEpochs,
MinElectableNodes: MinElectableNodes,
}

return validators.NewValidatorKApp(args)
Expand Down
9 changes: 9 additions & 0 deletions core/kapp/kappController/kapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package kappcontroller

import (
logger "github.com/klever-io/klever-go-logger"
"github.com/klever-io/klever-go/config"
"github.com/klever-io/klever-go/core"
"github.com/klever-io/klever-go/core/kapp"
"github.com/klever-io/klever-go/core/kapp/factory"
Expand Down Expand Up @@ -59,6 +60,12 @@ type ArgsNewKApp struct {
// the VM query path (see cmd/node/sc.go). Construction-time only on purpose,
// so the safety cannot be switched off on a live controller.
ReadOnly bool
// VersionsByEpochs is the versions.versionsByEpochs config used by the validators
// KApp for node version attestation; nil disables version enforcement
VersionsByEpochs []config.VersionByEpochs
// MinElectableNodes is the nodes shuffler's minimum electable count (genesis
// MinNumberOfNodes), used as a floor guard for version demotion
MinElectableNodes uint32
}

func NewKappController(args ArgsNewKApp) (kapp.KAppController, error) {
Expand All @@ -69,6 +76,8 @@ func NewKappController(args ArgsNewKApp) (kapp.KAppController, error) {
args.PubkeyConv,
args.ForkController,
args.RatingsData,
args.VersionsByEpochs,
args.MinElectableNodes,
)
if err != nil {
return nil, err
Expand Down
37 changes: 31 additions & 6 deletions core/kapp/validators/peersUpdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,25 @@ func (v *validatorsKApp) updateValidatorJailStatus(val *ValidatorData, peerAcc s
}
}

// updatePeerListStatus updates the peer account's list status based on delegation amounts.
func (v *validatorsKApp) updatePeerListStatus(val *ValidatorData, peerAcc state.PeerAccountHandler, minSelfDelegated, minTotalDelegated, totalDelegated int64) {
// resolveVersionEnforcedList returns the list a stake-satisfying validator belongs on when
// a version requirement is active and the validator does not satisfy it: demoted to observer
// while the demotion guards hold; otherwise no new demotions, and already-demoted observers
// stay demoted until they attest (no oscillation), while elected validators keep their slot.
func resolveVersionEnforcedList(current state.List, enforcement versionEnforcement) state.List {
if enforcement.demote {
// demoted instead of staying electable with an outdated version; the validator
// returns to eligible at the first end-of-epoch after a satisfying attestation
return state.List_observer
}
if current == state.List_observer || current == state.List_elected {
return current
}
return state.List_eligible
}

// updatePeerListStatus updates the peer account's list status based on delegation amounts
// and, when version enforcement is active, on the validator's attested node version.
func (v *validatorsKApp) updatePeerListStatus(val *ValidatorData, peerAcc state.PeerAccountHandler, minSelfDelegated, minTotalDelegated int64, totalDelegated int64, enforcement versionEnforcement) {
if val.Jailed {
return
}
Expand All @@ -322,6 +339,8 @@ func (v *validatorsKApp) updatePeerListStatus(val *ValidatorData, peerAcc state.
peerAcc.SetList(state.List_inactive)
} else if totalDelegated < minTotalDelegated {
peerAcc.SetList(state.List_waiting)
} else if enforcement.active && !enforcement.isSatisfiedBy(val) {
peerAcc.SetList(resolveVersionEnforcedList(peerAcc.GetList(), enforcement))
} else if peerAcc.GetList() != state.List_elected {
peerAcc.SetList(state.List_eligible)
}
Expand Down Expand Up @@ -458,6 +477,7 @@ func (v *validatorsKApp) processValidatorEpochV1(
currentEpoch uint32,
minSelfDelegated, minTotalDelegated int64,
totalDelegations map[string]int64,
enforcement versionEnforcement,
) error {
addr := validatorInfo.GetOwnerAddress()

Expand Down Expand Up @@ -488,7 +508,7 @@ func (v *validatorsKApp) processValidatorEpochV1(
return err
}

v.updatePeerListStatus(val, peerAcc, minSelfDelegated, minTotalDelegated, totalDelegated)
v.updatePeerListStatus(val, peerAcc, minSelfDelegated, minTotalDelegated, totalDelegated, enforcement)

return v.finalizeValidatorEpoch(app, addr, val, peerAcc)
}
Expand Down Expand Up @@ -611,6 +631,7 @@ func (v *validatorsKApp) processValidatorEpochV2(
currentEpoch uint32,
minSelfDelegated, minTotalDelegated int64,
totalDelegations map[string]int64,
enforcement versionEnforcement,
) error {
addr := validatorInfo.GetOwnerAddress()

Expand Down Expand Up @@ -645,7 +666,7 @@ func (v *validatorsKApp) processValidatorEpochV2(
return err
}

v.updatePeerListStatus(val, peerAcc, minSelfDelegated, minTotalDelegated, totalDelegated)
v.updatePeerListStatus(val, peerAcc, minSelfDelegated, minTotalDelegated, totalDelegated, enforcement)

return v.finalizeValidatorEpoch(app, addr, val, peerAcc)
}
Expand Down Expand Up @@ -687,8 +708,10 @@ func (v *validatorsKApp) ProcessEconomicsEndOfEpochV1(currentEpoch uint32, valid
return err
}

enforcement := v.computeVersionEnforcement(app, validatorInfos, currentEpoch)

for _, validatorInfo := range validatorInfos {
if err := v.processValidatorEpochV1(app, validatorInfo, currentEpoch, minSelfDelegated, minTotalDelegated, totalDelegations); err != nil {
if err := v.processValidatorEpochV1(app, validatorInfo, currentEpoch, minSelfDelegated, minTotalDelegated, totalDelegations, enforcement); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion (F9): these are the only lines this PR touches that no test covers

The return err bodies inside both epoch loops (lines 714-716 here, and 743-745 in ProcessEconomicsEndOfEpochV2) have zero coverage. The gap predates this PR, but because the call lines change here (the new enforcement argument), they now land in the new-code coverage denominator that the quality gate measures. Every other executable line this PR touches in non-generated code is covered.

The volume is small (two statements), so this is a cheap fix rather than an important one: one test per version that makes processValidatorEpochV1/V2 fail, for example through a peer account load error, and asserts the error surfaces from ProcessEconomicsEndOfEpochV1/V2. There is a recent precedent for this shape of test on develop (94bc4b1, pinning SetNodes error propagation).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7c71910: added TestProcessEconomicsEndOfEpoch_PropagatesPerValidatorError, table-driven over V1/V2, which forces LoadPeerCalled to return an error and asserts it surfaces via require.ErrorIs from ProcessEconomicsEndOfEpoch. Mutation-tested by swallowing the loop error in each version independently to confirm the test actually catches the regression.

return err
}
}
Expand All @@ -714,8 +737,10 @@ func (v *validatorsKApp) ProcessEconomicsEndOfEpochV2(currentEpoch uint32, valid

totalDelegations := make(map[string]int64)

enforcement := v.computeVersionEnforcement(app, validatorInfos, currentEpoch)

for _, validatorInfo := range validatorInfos {
if err := v.processValidatorEpochV2(app, validatorInfo, currentEpoch, minSelfDelegated, minTotalDelegated, totalDelegations); err != nil {
if err := v.processValidatorEpochV2(app, validatorInfo, currentEpoch, minSelfDelegated, minTotalDelegated, totalDelegations, enforcement); err != nil {
return err
}
}
Expand Down
57 changes: 57 additions & 0 deletions core/kapp/validators/peersUpdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2208,6 +2208,63 @@ func TestProcessEconomicsEndOfEpoch_V1V2(t *testing.T) {

}

// TestProcessEconomicsEndOfEpoch_PropagatesPerValidatorError pins the two lines this
// package's ProcessEconomicsEndOfEpochV1/V2 loops had zero coverage on: the `return err`
// bodies that surface a single validator's processing failure from the whole epoch call,
// for both fork versions. A peer-account load failure (used here as a concrete, easy to
// trigger error) must abort the epoch call instead of being silently skipped.
func TestProcessEconomicsEndOfEpoch_PropagatesPerValidatorError(t *testing.T) {
t.Parallel()

validatorAddress := []byte("validator1")
blsPubKey := []byte("blspubkey1")
const currentEpoch = uint32(10)
loadPeerErr := errors.New("peer load failed")

for _, isV2 := range []bool{false, true} {
t.Run(fmt.Sprintf("V2=%t", isV2), func(t *testing.T) {
v := setupValidatorsKApp(t)
addFunctionalCacher(t, v)
v.forkController.(*mock.ForkControllerStub).EpochRewardsV2Value = isV2
v.KAppController = &stub.KAppControllerStub{
GetProposalControllerCalled: func() kapps.ActiveProposalController {
return &mock.ProposalControllerStub{}
},
}

rawData := make(map[string][]byte)
data, err := v.marshalizer.Marshal(&ValidatorData{
BlsPubKey: blsPubKey,
RewardsAddress: validatorAddress,
SelfStake: 200000,
})
require.NoError(t, err)
rawData["VAL/"+string(validatorAddress)] = data
loadKApp := func(address []byte) (state.KAppAccountHandler, error) {
return &mock.KAppAccountHandlerStub{
GetStorageCalled: func(key []byte) []byte { return rawData[string(key)] },
SetStorageCalled: func(key []byte, value []byte) error {
rawData[string(key)] = value
return nil
},
}, nil
}
v.accountsCacher.(*mock.AccountsCacherStub).LoadKAppCalled = loadKApp
v.accountsCacher.(*mock.AccountsCacherStub).LoadKAppUncachedCalled = loadKApp
v.accountsCacher.(*mock.AccountsCacherStub).LoadPeerCalled = func(peer []byte) (state.PeerAccountHandler, error) {
return nil, loadPeerErr
}

validatorInfos := []*state.ValidatorInfo{
{PublicKey: blsPubKey, OwnerAddress: validatorAddress, List: string(state.List_eligible)},
}

err = v.ProcessEconomicsEndOfEpoch(currentEpoch, validatorInfos)
require.ErrorIs(t, err, loadPeerErr)
})
}
}

func TestV2CommissionBigIntHandlesOverflow(t *testing.T) {
rewardsAddr := makeAddress("rewards-address")
delegatorAddr := makeAddress("delegator-1")
Expand Down
3 changes: 3 additions & 0 deletions core/kapp/validators/proto/validatorData.proto
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ message ValidatorData {
string Name = 24 [json_name = "name"];
string Logo = 25 [json_name = "logo"];
map<string, string> URIs = 26 [json_name = "uris"];

string AttestedVersion = 27 [json_name = "attestedVersion"];
uint32 AttestedEpoch = 28 [json_name = "attestedEpoch"];
Comment on lines +29 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Fix the new protobuf field naming before merge.

The added fields use PascalCase source names and fail Buf's FIELD_LOWER_SNAKE_CASE lint rule. Rename the source identifiers while preserving their field numbers and explicit JSON names, then regenerate protobuf bindings.

  • core/kapp/validators/proto/validatorData.proto#L29-L30: rename AttestedVersion/AttestedEpoch to attested_version/attested_epoch.
  • data/transaction/proto/contracts.proto#L165-L165: rename NodeVersion to node_version.
🧰 Tools
🪛 Buf (1.71.0)

[error] 29-29: Field name "AttestedVersion" should be lower_snake_case, such as "attested_version".

(FIELD_LOWER_SNAKE_CASE)


[error] 30-30: Field name "AttestedEpoch" should be lower_snake_case, such as "attested_epoch".

(FIELD_LOWER_SNAKE_CASE)

📍 Affects 2 files
  • core/kapp/validators/proto/validatorData.proto#L29-L30 (this comment)
  • data/transaction/proto/contracts.proto#L165-L165
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/kapp/validators/proto/validatorData.proto` around lines 29 - 30, Rename
the protobuf source fields AttestedVersion and AttestedEpoch to attested_version
and attested_epoch in core/kapp/validators/proto/validatorData.proto, preserving
field numbers and explicit JSON names; rename NodeVersion to node_version in
data/transaction/proto/contracts.proto, then regenerate all protobuf bindings.

Source: Linters/SAST tools

}

message PeerData {
Expand Down
Loading
Loading