diff --git a/action/protocol/rewarding/pending_block_reward.go b/action/protocol/rewarding/pending_block_reward.go index 3089ac0e89..fddb17a80e 100644 --- a/action/protocol/rewarding/pending_block_reward.go +++ b/action/protocol/rewarding/pending_block_reward.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "math/big" + "sort" "github.com/pkg/errors" "google.golang.org/protobuf/proto" @@ -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" ) @@ -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) { diff --git a/action/protocol/rewarding/pending_block_reward_read_test.go b/action/protocol/rewarding/pending_block_reward_read_test.go new file mode 100644 index 0000000000..740da0ce66 --- /dev/null +++ b/action/protocol/rewarding/pending_block_reward_read_test.go @@ -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) +} diff --git a/action/protocol/rewarding/protocol.go b/action/protocol/rewarding/protocol.go index 32496b5d83..2990f5fb94 100644 --- a/action/protocol/rewarding/protocol.go +++ b/action/protocol/rewarding/protocol.go @@ -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 } diff --git a/action/protocol/staking/candidate_statereader.go b/action/protocol/staking/candidate_statereader.go index 4930b2c0da..136a488684 100644 --- a/action/protocol/staking/candidate_statereader.go +++ b/action/protocol/staking/candidate_statereader.go @@ -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 } diff --git a/e2etest/iip59_archive_read_test.go b/e2etest/iip59_archive_read_test.go new file mode 100644 index 0000000000..684c857221 --- /dev/null +++ b/e2etest/iip59_archive_read_test.go @@ -0,0 +1,136 @@ +// 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 e2etest + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/iotexproject/iotex-core/v2/action/protocol" + "github.com/iotexproject/iotex-core/v2/action/protocol/rewarding" + "github.com/iotexproject/iotex-core/v2/action/protocol/rewarding/rewardingpb" + "github.com/iotexproject/iotex-core/v2/blockchain/genesis" +) + +// readPendingVoterRewardDelegates asks the rewarding protocol for the delegate +// list at height, through the archive state reader the API uses for a historical +// eth_call. +func readPendingVoterRewardDelegates( + t *testing.T, + test *e2etest, + g genesis.Genesis, + p *rewarding.Protocol, + height uint64, +) [][]byte { + t.Helper() + r := require.New(t) + // Same context the API assembles for a historical read: the erigon store + // needs the blockchain context at that height to build its contract backend. + ctx := protocol.WithRegistry(context.Background(), test.cs.Registry()) + ctx = genesis.WithGenesisContext(ctx, g) + ctx, err := test.cs.Blockchain().ContextAtHeight(ctx, height) + r.NoErrorf(err, "context at height %d", height) + bcCtx := protocol.MustGetBlockchainCtx(ctx) + ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{ + BlockHeight: bcCtx.Tip.Height, + BlockTimeStamp: bcCtx.Tip.Timestamp, + }) + ctx = protocol.WithFeatureCtx(ctx) + + ws, err := test.cs.StateFactory().WorkingSetAtHeight(ctx, height) + r.NoErrorf(err, "working set at height %d", height) + defer ws.Close() + + data, _, err := p.ReadState(ctx, ws, []byte("PendingVoterRewardDelegates")) + r.NoErrorf(err, "PendingVoterRewardDelegates at height %d", height) + decoded := &rewardingpb.PendingVoterRewardDelegates{} + r.NoError(proto.Unmarshal(data, decoded)) + return decoded.GetDelegateIdentifiers() +} + +// TestIIP59PendingVoterRewardDelegatesArchiveRead pins that the delegate list is +// readable at a past height on an archive node. +// +// The archive reader serves history out of erigon, whose objects are addressed +// by contract slot rather than by an ordered key space, so it refuses the +// ordered range scan the enumeration is built on. Every other IIP-59 read is a +// point read and was unaffected; this one used to fail outright with "erigon +// store does not support ordered range scan". +// +// The claim under test is not just "does not error": the answer at a height must +// still be the answer that height had, so the test freezes an expectation while +// that height is the tip and re-reads it after the chain has moved past it. +func TestIIP59PendingVoterRewardDelegatesArchiveRead(t *testing.T) { + r := require.New(t) + tier := iip59PerfTiers["small"] + cfg := newIIP59PerfCfg(r, tier) + historyIndexPath, err := os.MkdirTemp("", "historyindex") + r.NoError(err) + cfg.Chain.HistoryIndexPath = historyIndexPath + defer clearDBPaths(&cfg) + + test := newE2ETest(t, cfg, iip59PerfBuildOptions(t, tier, cfg.Genesis)...) + defer test.teardown() + registerEpochProtocols(r, test) + registerIIP59EraFreezer(r, test, tier) + + rewardProto := rewarding.FindProtocol(test.cs.Registry()) + r.NotNil(rewardProto) + bc := test.cs.Blockchain() + ap := test.cs.ActionPool() + + blkTime := time.Unix(cfg.Genesis.Timestamp, 0) + mint := func() uint64 { + blkTime = blkTime.Add(time.Second) + _, err := mintOne(bc, ap, blkTime) + r.NoErrorf(err, "mint at height %d", bc.TipHeight()) + return bc.TipHeight() + } + + // Block rewards credit a pending pool per producing delegate, so a handful + // of blocks is enough to make the list non-empty. + var ( + observedHeight uint64 + observed [][]byte + ) + for i := 0; i < 12; i++ { + observedHeight = mint() + observed = readPendingVoterRewardDelegates(t, test, cfg.Genesis, rewardProto, observedHeight) + if len(observed) > 0 { + break + } + } + r.NotEmptyf(observed, "fixture must accrue at least one pending pool by height %d", observedHeight) + + // The state factory itself still answers the tip from the statedb, where the + // ordered range scan works. Pinning the archive answer against it at the same + // height is what makes this more than a smoke test. + ctx := protocol.WithRegistry(context.Background(), test.cs.Registry()) + ctx = genesis.WithGenesisContext(ctx, cfg.Genesis) + ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{BlockHeight: observedHeight}) + ctx = protocol.WithFeatureCtx(ctx) + scanned, _, err := rewardProto.ReadState( + ctx, test.cs.StateFactory(), []byte("PendingVoterRewardDelegates")) + r.NoError(err) + viaScan := &rewardingpb.PendingVoterRewardDelegates{} + r.NoError(proto.Unmarshal(scanned, viaScan)) + r.Equal(viaScan.GetDelegateIdentifiers(), observed, + "archive read at the tip must match the range scan over the same state") + + for i := 0; i < 10; i++ { + mint() + } + r.Greater(bc.TipHeight(), observedHeight) + + historical := readPendingVoterRewardDelegates(t, test, cfg.Genesis, rewardProto, observedHeight) + r.Equal(observed, historical, + "reading height %d after the chain moved on must return what that height held", observedHeight) +}