diff --git a/consensus/XDPoS/api.go b/consensus/XDPoS/api.go index ee27c1e6331e..4ef4e1927225 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 `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 } 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 { @@ -235,22 +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, - 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 (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)) + 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() 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}