From 40c636245ca3027fe157c7c9552bf9d1c797a741 Mon Sep 17 00:00:00 2001 From: wanwiset25 Date: Mon, 29 Jun 2026 02:01:32 +0700 Subject: [PATCH 1/3] make GetMasternodesByNumber api support TipUpgradeReward --- consensus/XDPoS/api.go | 75 +++++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/consensus/XDPoS/api.go b/consensus/XDPoS/api.go index ee27c1e6331e..a78e3f4e293e 100644 --- a/consensus/XDPoS/api.go +++ b/consensus/XDPoS/api.go @@ -74,17 +74,28 @@ type SignerTypes struct { MissingSigners []common.Address } +// MasternodesStatus reports the node set at a block, split into masternodes, +// penalties and standby nodes. From the TIPUpgradeReward fork onwards the standby +// pool is further broken into the protector and observer reward tiers (signalled by +// TipUpgradeReward); candidates beyond the tier caps stay in Standbynodes, so +// Masternodes + Penalty + Protector + Observer + Standbynodes still reconciles to +// the full candidate set. type MasternodesStatus struct { - Epoch uint64 - Number uint64 - Round types.Round - MasternodesLen int - Masternodes []common.Address - PenaltyLen int - Penalty []common.Address - StandbynodesLen int - Standbynodes []common.Address - Error error + Epoch uint64 + Number uint64 + Round types.Round + TipUpgradeReward bool // whether the protector/observer tiers are active at this block + MasternodesLen int + Masternodes []common.Address + PenaltyLen int + Penalty []common.Address + ProtectorLen int + Protector []common.Address + ObserverLen int + Observer []common.Address + StandbynodesLen int + Standbynodes []common.Address + Error error } type AccountEpochReward struct { @@ -198,6 +209,13 @@ func (api *API) GetSignersAtHash(hash common.Hash) ([]common.Address, error) { return api.XDPoS.GetAuthorisedSignersFromSnapshot(api.chain, header) } +// GetMasternodesByNumber reports the node set at the given block: masternodes, +// penalties and standby nodes. From the TIPUpgradeReward fork onwards it also splits +// the standby pool into the protector and observer reward tiers (the standby list is +// already stake-descending, so the split is just a slice). +// +// The tiering is snapshot/consensus-consistent, not reward-payout-identical: it +// matches the epoch snapshot used here, whereas the reward hook reads live state. func (api *API) GetMasternodesByNumber(number *rpc.BlockNumber) MasternodesStatus { var header *types.Header if number == nil || *number == rpc.LatestBlockNumber { @@ -238,16 +256,33 @@ func (api *API) GetMasternodesByNumber(number *rpc.BlockNumber) MasternodesStatu standbynodes := api.XDPoS.EngineV2.GetStandbynodes(api.chain, header) info := MasternodesStatus{ - Epoch: epochNum, - Number: header.Number.Uint64(), - Round: round, - MasternodesLen: len(masterNodes), - Masternodes: masterNodes, - PenaltyLen: len(penalties), - Penalty: penalties, - StandbynodesLen: len(standbynodes), - Standbynodes: standbynodes, - } + Epoch: epochNum, + Number: header.Number.Uint64(), + Round: round, + TipUpgradeReward: api.chain.Config().IsTIPUpgradeReward(header.Number), + MasternodesLen: len(masterNodes), + Masternodes: masterNodes, + PenaltyLen: len(penalties), + Penalty: penalties, + } + + // Before the reward upgrade there are no tiers; the whole standby pool stays standby. + if !info.TipUpgradeReward { + info.Standbynodes = standbynodes + info.StandbynodesLen = len(standbynodes) + return info + } + + cfg := api.XDPoS.config.V2.Config(uint64(round)) + protectorEnd := min(cfg.MaxProtectorNodes, len(standbynodes)) + observerEnd := min(protectorEnd+cfg.MaxObverserNodes, len(standbynodes)) + + info.Protector = standbynodes[:protectorEnd] + info.ProtectorLen = len(info.Protector) + info.Observer = standbynodes[protectorEnd:observerEnd] + info.ObserverLen = len(info.Observer) + info.Standbynodes = standbynodes[observerEnd:] + info.StandbynodesLen = len(info.Standbynodes) return info } From 7672b3958496f3708f045e125124b3b9a706243c Mon Sep 17 00:00:00 2001 From: wanwiset25 Date: Thu, 2 Jul 2026 07:37:57 +0700 Subject: [PATCH 2/3] - unexport tipUpgradeReward - rename Protector/Observer fields to Protectornodes/Observernodes - omit empty outputs - rename standbynodes > standbyPool to prevent confusion --- consensus/XDPoS/api.go | 59 ++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/consensus/XDPoS/api.go b/consensus/XDPoS/api.go index a78e3f4e293e..4ef4e1927225 100644 --- a/consensus/XDPoS/api.go +++ b/consensus/XDPoS/api.go @@ -84,17 +84,17 @@ type MasternodesStatus struct { Epoch uint64 Number uint64 Round types.Round - TipUpgradeReward bool // whether the protector/observer tiers are active at this block + tipUpgradeReward bool // whether the protector/observer tiers are active at this block MasternodesLen int Masternodes []common.Address PenaltyLen int Penalty []common.Address - ProtectorLen int - Protector []common.Address - ObserverLen int - Observer []common.Address - StandbynodesLen int - Standbynodes []common.Address + ProtectorLen int `json:",omitempty"` + Protectornodes []common.Address `json:",omitempty"` + ObserverLen int `json:",omitempty"` + Observernodes []common.Address `json:",omitempty"` + StandbynodesLen int `json:",omitempty"` + Standbynodes []common.Address `json:",omitempty"` Error error } @@ -253,39 +253,54 @@ func (api *API) GetMasternodesByNumber(number *rpc.BlockNumber) MasternodesStatu epochNum := api.XDPoS.config.V2.SwitchEpoch + uint64(round)/api.XDPoS.config.Epoch masterNodes := api.XDPoS.EngineV2.GetMasternodes(api.chain, header) penalties := api.XDPoS.EngineV2.GetPenalties(api.chain, header) - standbynodes := api.XDPoS.EngineV2.GetStandbynodes(api.chain, header) + standbyPool := api.XDPoS.EngineV2.GetStandbynodes(api.chain, header) info := MasternodesStatus{ Epoch: epochNum, Number: header.Number.Uint64(), Round: round, - TipUpgradeReward: api.chain.Config().IsTIPUpgradeReward(header.Number), + tipUpgradeReward: api.chain.Config().IsTIPUpgradeReward(header.Number), MasternodesLen: len(masterNodes), Masternodes: masterNodes, PenaltyLen: len(penalties), Penalty: penalties, } - // Before the reward upgrade there are no tiers; the whole standby pool stays standby. - if !info.TipUpgradeReward { - info.Standbynodes = standbynodes - info.StandbynodesLen = len(standbynodes) + // Before the reward upgrade there are no tiers; the whole standby pool stays + // standby (the caps are ignored in that case, so any value is fine here). + if !info.tipUpgradeReward { + info.splitStandbyPool(standbyPool, 0, 0) return info } cfg := api.XDPoS.config.V2.Config(uint64(round)) - protectorEnd := min(cfg.MaxProtectorNodes, len(standbynodes)) - observerEnd := min(protectorEnd+cfg.MaxObverserNodes, len(standbynodes)) - - info.Protector = standbynodes[:protectorEnd] - info.ProtectorLen = len(info.Protector) - info.Observer = standbynodes[protectorEnd:observerEnd] - info.ObserverLen = len(info.Observer) - info.Standbynodes = standbynodes[observerEnd:] - info.StandbynodesLen = len(info.Standbynodes) + info.splitStandbyPool(standbyPool, cfg.MaxProtectorNodes, cfg.MaxObverserNodes) return info } +// splitStandbyPool partitions the stake-descending standby pool into the reward +// tiers. Before the TIPUpgradeReward fork the whole pool stays standby; from the +// fork onwards the protector and observer tiers take the top maxProtector and +// maxObserver candidates respectively, and any remainder stays standby. The three +// tiers always concatenate back to the full pool, so the totals reconcile. +func (info *MasternodesStatus) splitStandbyPool(standbyPool []common.Address, maxProtector, maxObserver int) { + if !info.tipUpgradeReward { + info.Standbynodes = standbyPool + info.StandbynodesLen = len(standbyPool) + return + } + + protectorEnd := min(maxProtector, len(standbyPool)) + observerEnd := min(protectorEnd+maxObserver, len(standbyPool)) + + info.Protectornodes = standbyPool[:protectorEnd] + info.ProtectorLen = len(info.Protectornodes) + info.Observernodes = standbyPool[protectorEnd:observerEnd] + info.ObserverLen = len(info.Observernodes) + info.Standbynodes = standbyPool[observerEnd:] + info.StandbynodesLen = len(info.Standbynodes) +} + // Get current vote pool and timeout pool content and missing messages func (api *API) GetLatestPoolStatus() MessageStatus { header := api.chain.CurrentHeader() From d60b72dfc9714157da2cbaf99848327e3cd35d26 Mon Sep 17 00:00:00 2001 From: wanwiset25 Date: Thu, 2 Jul 2026 07:38:03 +0700 Subject: [PATCH 3/3] add tests --- consensus/XDPoS/api_test.go | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/consensus/XDPoS/api_test.go b/consensus/XDPoS/api_test.go index 4bd3edf797bc..3b3b818d6407 100644 --- a/consensus/XDPoS/api_test.go +++ b/consensus/XDPoS/api_test.go @@ -258,6 +258,83 @@ func TestJsonNumberToBigInt(t *testing.T) { } } +// standbyAddrs returns n deterministic, distinct addresses standing in for a +// stake-descending standby pool. +func standbyAddrs(n int) []common.Address { + out := make([]common.Address, n) + for i := range out { + out[i][0] = byte(i + 1) + } + return out +} + +// TestSplitStandbyPoolPreUpgrade asserts that before the TIPUpgradeReward fork the +// whole standby pool is returned unsplit and no protector/observer tier is set, +// regardless of the configured caps. +func TestSplitStandbyPoolPreUpgrade(t *testing.T) { + pool := standbyAddrs(5) + + info := MasternodesStatus{tipUpgradeReward: false} + info.splitStandbyPool(pool, 3, 1) // caps must be ignored pre-upgrade + + assert.Equal(t, pool, info.Standbynodes) + assert.Equal(t, 5, info.StandbynodesLen) + assert.Nil(t, info.Protectornodes) + assert.Nil(t, info.Observernodes) + assert.Zero(t, info.ProtectorLen) + assert.Zero(t, info.ObserverLen) +} + +// TestSplitStandbyPoolPostUpgrade asserts that from the TIPUpgradeReward fork the +// standby pool is split deterministically according to the protector/observer caps, +// clamped to the pool size, and that the three tiers always reconcile back to the +// full pool. +func TestSplitStandbyPoolPostUpgrade(t *testing.T) { + pool := standbyAddrs(10) + + tests := []struct { + name string + maxProtector int + maxObserver int + wantProtector int + wantObserver int + wantStandby int + }{ + {"caps within pool", 3, 4, 3, 4, 3}, + {"exact fit", 4, 6, 4, 6, 0}, + {"only protector tier", 4, 0, 4, 0, 6}, + {"zero caps keep all standby", 0, 0, 0, 0, 10}, + {"protector cap exceeds pool", 20, 5, 10, 0, 0}, + {"protector fills, observer overflows", 6, 20, 6, 4, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := MasternodesStatus{tipUpgradeReward: true} + info.splitStandbyPool(pool, tt.maxProtector, tt.maxObserver) + + assert.Equal(t, tt.wantProtector, info.ProtectorLen) + assert.Equal(t, tt.wantObserver, info.ObserverLen) + assert.Equal(t, tt.wantStandby, info.StandbynodesLen) + assert.Len(t, info.Protectornodes, tt.wantProtector) + assert.Len(t, info.Observernodes, tt.wantObserver) + assert.Len(t, info.Standbynodes, tt.wantStandby) + + // Deterministic order: protectors are the top slice, observers the + // next, standbys the remainder. + assert.Equal(t, pool[:tt.wantProtector], info.Protectornodes) + assert.Equal(t, pool[tt.wantProtector:tt.wantProtector+tt.wantObserver], info.Observernodes) + + // Reconciliation: the tiers concatenate back to the full pool. + got := make([]common.Address, 0, len(pool)) + got = append(got, info.Protectornodes...) + got = append(got, info.Observernodes...) + got = append(got, info.Standbynodes...) + assert.Equal(t, pool, got) + }) + } +} + func TestAPIGetConfig(t *testing.T) { chain := newConfigChainMockWithCurrent(1500) api := &API{chain: chain}