Skip to content
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
66 changes: 66 additions & 0 deletions action/protocol/rewarding/pending_block_reward.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"bytes"
"context"
"math/big"
"sort"

"github.com/pkg/errors"
"google.golang.org/protobuf/proto"
Expand All @@ -17,6 +18,8 @@ import (

"github.com/iotexproject/iotex-core/v2/action/protocol"
"github.com/iotexproject/iotex-core/v2/action/protocol/rewarding/rewardingpb"
"github.com/iotexproject/iotex-core/v2/action/protocol/staking"
"github.com/iotexproject/iotex-core/v2/db"
"github.com/iotexproject/iotex-core/v2/state"
"github.com/iotexproject/iotex-core/v2/systemcontracts"
)
Expand Down Expand Up @@ -228,6 +231,69 @@ func (p *Protocol) listPendingBlockRewardPoolIDs(
return ids, nil
}

// listPendingBlockRewardPoolIDsForRead answers the same question as
// listPendingBlockRewardPoolIDs for the read-only ReadState path, on state
// readers that cannot serve an ordered range scan.
//
// An archive node serves a historical height out of the erigon store, whose
// objects are addressed by contract slot instead of by an ordered iotex key
// space; it rejects RangeOption rather than hand back a differently-ordered
// answer. Point reads are unaffected, so the same set is recoverable by probing
// one key per candidate: a pool is only ever credited under a candidate
// identifier and candidate records are not deleted, so the candidate set at that
// height contains every delegate that can hold a pool.
//
// Read paths only. freezePendingPoolDrainWork must keep failing on a rejected
// scan: an era freeze that quietly switched enumeration source would make block
// validity depend on which storage backend a node happens to run.
func (p *Protocol) listPendingBlockRewardPoolIDsForRead(
ctx context.Context,
sr protocol.StateReader,
) ([][]byte, error) {
// Only the scan itself is retried differently. Anything else the scan
// reports -- a malformed key, a decode failure -- is real damage in state
// and must reach the caller instead of being papered over by a second,
// weaker enumeration.
ids, err := p.listPendingBlockRewardPoolIDs(ctx, sr)
if err == nil || !errors.Is(err, db.ErrNotSupported) {
return ids, err
}
return p.probePendingBlockRewardPoolIDs(sr)
}

// probePendingBlockRewardPoolIDs rebuilds the pool set with one point read per
// candidate. O(candidates) round-trips where the range scan costs one, which is
// why it stays behind the ErrNotSupported fallback instead of becoming the
// default.
func (p *Protocol) probePendingBlockRewardPoolIDs(sr protocol.StateReader) ([][]byte, error) {
candidates, err := staking.CandidateIdentifiersFor(sr)
if err != nil {
return nil, errors.Wrap(err, "rewarding: enumerate candidates for pending block reward pool probe")
}
ids := make([][]byte, 0, len(candidates))
for _, cand := range candidates {
if cand == nil {
continue
}
candID := cand.Bytes()
// stateV2 rather than state: the scan being stood in for covers the V2
// namespace alone, and the legacy fallback inside p.state would double the
// round-trips looking for keys that cannot be there.
var entry pendingBlockRewardPool
switch _, err := p.stateV2(sr, pendingBlockRewardPoolKey(candID), &entry); {
case err == nil:
ids = append(ids, candID)
case errors.Is(err, state.ErrStateNotExist):
default:
return nil, err
}
}
// The scan contract is ascending by state key; the shared key prefix makes
// that the same order as ascending by candidate identifier.
sort.Slice(ids, func(i, j int) bool { return bytes.Compare(ids[i], ids[j]) < 0 })
return ids, nil
}

// candidateIdentifierBytes resolves the stable candidate identity used by
// candidate-scoped IIP-59 state.
func candidateIdentifierBytes(candidateIdentity string) ([]byte, error) {
Expand Down
150 changes: 150 additions & 0 deletions action/protocol/rewarding/pending_block_reward_read_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (c) 2026 IoTeX Foundation
// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability
// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed.
// This source code is governed by Apache License 2.0 that can be found in the LICENSE file.

package rewarding

import (
"bytes"
"context"
"math/big"
"testing"

"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"

"github.com/iotexproject/iotex-address/address"

"github.com/iotexproject/iotex-core/v2/action/protocol"
"github.com/iotexproject/iotex-core/v2/action/protocol/rewarding/rewardingpb"
"github.com/iotexproject/iotex-core/v2/action/protocol/staking"
"github.com/iotexproject/iotex-core/v2/db"
"github.com/iotexproject/iotex-core/v2/state"
"github.com/iotexproject/iotex-core/v2/test/identityset"
)

// rangeScanRejectingReader stands in for the erigon-backed reader an archive
// node uses at a historical height: point reads and full-namespace reads work,
// ordered range scans are refused because the backing store cannot honour
// [min, max) ordering.
type rangeScanRejectingReader struct {
protocol.StateManager
refused int
}

func (r *rangeScanRejectingReader) States(opts ...protocol.StateOption) (uint64, state.Iterator, error) {
cfg, err := protocol.CreateStateConfig(opts...)
if err != nil {
return 0, nil, err
}
if cfg.RangeMin != nil || cfg.RangeMax != nil || cfg.Limit > 0 {
r.refused++
return 0, nil, errors.Wrap(db.ErrNotSupported, "erigon store does not support ordered range scan")
}
return r.StateManager.States(opts...)
}

// seedPendingPoolDelegates registers a candidate record for every id and credits
// a pool to all but the last one, which is the negative control.
func seedPendingPoolDelegates(
t *testing.T,
ctx context.Context,
sm protocol.StateManager,
p *Protocol,
withPool []address.Address,
withoutPool address.Address,
) {
t.Helper()
r := require.New(t)
for _, id := range withPool {
r.NoError(staking.TestOnlyPutCandidateRewardAddress(ctx, sm, id, id, id, false, true))
r.NoError(p.creditPendingBlockRewardPool(ctx, sm, id.Bytes(), big.NewInt(10)))
}
r.NoError(staking.TestOnlyPutCandidateRewardAddress(
ctx, sm, withoutPool, withoutPool, withoutPool, false, true))
}

// TestPendingVoterRewardDelegates_ReadWithoutRangeScan — a reader that cannot
// serve an ordered range scan (an archive node at a historical height) must
// still answer PendingVoterRewardDelegates, with the same bytes the scan-capable
// reader produces at the same state.
func TestPendingVoterRewardDelegates_ReadWithoutRangeScan(t *testing.T) {
r := require.New(t)
ctx, sm, p, _, _ := newVoterRewardCtx(t, true)

// Deliberately not in ascending byte order, so an implementation that just
// echoed the candidate enumeration order would fail the equality check.
withPool := []address.Address{
identityset.Address(9), identityset.Address(2), identityset.Address(7),
}
seedPendingPoolDelegates(t, ctx, sm, p, withPool, identityset.Address(11))

want, wantHeight, err := p.ReadState(ctx, sm, []byte("PendingVoterRewardDelegates"))
r.NoError(err)

restricted := &rangeScanRejectingReader{StateManager: sm}
got, gotHeight, err := p.ReadState(ctx, restricted, []byte("PendingVoterRewardDelegates"))
r.NoError(err)
r.Positive(restricted.refused, "fallback must engage only after the range scan is refused")
r.Equal(want, got)
r.Equal(wantHeight, gotHeight)

decoded := &rewardingpb.PendingVoterRewardDelegates{}
r.NoError(proto.Unmarshal(got, decoded))
ids := decoded.GetDelegateIdentifiers()
r.Len(ids, len(withPool), "a candidate without a pool must not be listed")
for i := 1; i < len(ids); i++ {
r.Less(bytes.Compare(ids[i-1], ids[i]), 0,
"fallback enumeration not ascending at position %d: %x vs %x", i, ids[i-1], ids[i])
}
}

// TestPendingVoterRewardDelegates_ReadWithoutRangeScanEmpty — no pools is an
// empty list, not an error, on the fallback path as well.
func TestPendingVoterRewardDelegates_ReadWithoutRangeScanEmpty(t *testing.T) {
r := require.New(t)
ctx, sm, p, _, _ := newVoterRewardCtx(t, true)

restricted := &rangeScanRejectingReader{StateManager: sm}
data, _, err := p.ReadState(ctx, restricted, []byte("PendingVoterRewardDelegates"))
r.NoError(err)
decoded := &rewardingpb.PendingVoterRewardDelegates{}
r.NoError(proto.Unmarshal(data, decoded))
r.Empty(decoded.GetDelegateIdentifiers())
}

// TestPendingVoterRewardDelegates_ReadDoesNotMaskOtherErrors — only an
// unsupported scan degrades to point reads. A scan that runs and finds corrupt
// state must still surface, or the read API would paper over real damage.
func TestPendingVoterRewardDelegates_ReadDoesNotMaskOtherErrors(t *testing.T) {
r := require.New(t)
ctx, sm, p, _, _ := newVoterRewardCtx(t, true)

malformedKey := append(append([]byte(nil), _pendingBlockRewardPoolKeyPrefix...), 0x01)
r.NoError(p.putState(ctx, sm, malformedKey, &pendingBlockRewardPool{amount: big.NewInt(1)}))

_, _, err := p.ReadState(ctx, sm, []byte("PendingVoterRewardDelegates"))
r.ErrorContains(err, "malformed pending block reward pool key")
}

// TestFreezePendingPoolDrainWork_RangeScanFailureStillHalts — the era freeze is
// consensus, and must keep treating a refused range scan as a hard failure. It
// must never pick up the read-only point-read fallback: enumeration source would
// then depend on a node's storage backend.
func TestFreezePendingPoolDrainWork_RangeScanFailureStillHalts(t *testing.T) {
r := require.New(t)
ctx, sm, p, _, _ := newVoterRewardCtx(t, true)

seedPendingPoolDelegates(t, ctx, sm, p,
[]address.Address{identityset.Address(9), identityset.Address(2)}, identityset.Address(11))

restricted := &rangeScanRejectingReader{StateManager: sm}

_, err := p.listPendingBlockRewardPoolIDs(ctx, restricted)
r.ErrorIs(err, db.ErrNotSupported)

_, err = p.freezePendingPoolDrainWork(ctx, restricted, iip59FixtureFreezeHeight)
r.ErrorIs(err, db.ErrNotSupported)
}
2 changes: 1 addition & 1 deletion action/protocol/rewarding/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ func (p *Protocol) ReadState(
if len(args) != 0 {
return nil, uint64(0), errors.Errorf("invalid number of arguments %d", len(args))
}
ids, err := p.listPendingBlockRewardPoolIDs(ctx, sr)
ids, err := p.listPendingBlockRewardPoolIDsForRead(ctx, sr)
if err != nil {
return nil, uint64(0), err
}
Expand Down
31 changes: 31 additions & 0 deletions action/protocol/staking/candidate_statereader.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,37 @@ func NewCandidateByAddressReader(sr protocol.StateReader) CandidateByAddressRead
return newCandidateStateReader(sr)
}

// CandidateIdentifiersFor enumerates the identifier of every candidate record sr
// can see. Like NewCandidateByAddressReader it goes straight to state, so it is
// usable by historical and archive readers that carry no live staking view; it is
// also a plain full-namespace read rather than an ordered range scan, which the
// erigon-backed historical store does not serve.
func CandidateIdentifiersFor(sr protocol.StateReader) ([]address.Address, error) {
if sr == nil {
return nil, ErrMissingField
}
_, iter, err := sr.States(
protocol.NamespaceOption(_candidateNameSpace),
protocol.ObjectOption(&Candidate{}),
)
switch errors.Cause(err) {
case nil:
case state.ErrStateNotExist:
return nil, nil
default:
return nil, err
}
ids := make([]address.Address, 0, iter.Size())
for i := 0; i < iter.Size(); i++ {
c := &Candidate{}
if _, err := iter.Next(c); err != nil {
return nil, errors.Wrap(err, "failed to deserialize candidate")
}
ids = append(ids, c.GetIdentifier())
}
return ids, nil
}

func (c *candSR) Height() uint64 {
return c.height
}
Expand Down
Loading
Loading