From 6bdcd894a490d907d3ff84800ad4b3a71111f439 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 21 Aug 2026 14:59:16 +0200 Subject: [PATCH 01/17] Add validator-indexed delegation store and backfill tooling (PLT-980). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dual-write a validator→delegator secondary index on delegation mutations and expose a dev CLI to benchmark one-shot backfill before choosing migration strategy. Co-authored-by: Cursor --- sei-cosmos/x/staking/keeper/delegation.go | 10 ++- .../x/staking/keeper/delegation_index.go | 49 ++++++++++++ .../x/staking/keeper/delegation_index_test.go | 78 ++++++++++++++++++ sei-cosmos/x/staking/simulation/decoder.go | 2 + sei-cosmos/x/staking/types/keys.go | 26 ++++++ sei-cosmos/x/staking/types/keys_test.go | 10 +++ tools/cmd.go | 2 + tools/staking/cmd/backfill.go | 80 +++++++++++++++++++ tools/staking/cmd/staking.go | 15 ++++ tools/staking/loadapp.go | 73 +++++++++++++++++ 10 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 sei-cosmos/x/staking/keeper/delegation_index.go create mode 100644 sei-cosmos/x/staking/keeper/delegation_index_test.go create mode 100644 tools/staking/cmd/backfill.go create mode 100644 tools/staking/cmd/staking.go create mode 100644 tools/staking/loadapp.go diff --git a/sei-cosmos/x/staking/keeper/delegation.go b/sei-cosmos/x/staking/keeper/delegation.go index 51de4ad5ed..c92c1360ba 100644 --- a/sei-cosmos/x/staking/keeper/delegation.go +++ b/sei-cosmos/x/staking/keeper/delegation.go @@ -98,19 +98,23 @@ func (k Keeper) GetDelegatorDelegations(ctx sdk.Context, delegator sdk.AccAddres // SetDelegation sets a delegation. func (k Keeper) SetDelegation(ctx sdk.Context, delegation types.Delegation) { delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) + valAddr := delegation.GetValidatorAddr() store := ctx.KVStore(k.storeKey) b := types.MustMarshalDelegation(k.cdc, delegation) - store.Set(types.GetDelegationKey(delegatorAddress, delegation.GetValidatorAddr()), b) + store.Set(types.GetDelegationKey(delegatorAddress, valAddr), b) + store.Set(types.GetDelegationByValIndexKey(delegatorAddress, valAddr), []byte{}) // index, store empty bytes } // RemoveDelegation removes a delegation. func (k Keeper) RemoveDelegation(ctx sdk.Context, delegation types.Delegation) { delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) + valAddr := delegation.GetValidatorAddr() - k.BeforeDelegationRemoved(ctx, delegatorAddress, delegation.GetValidatorAddr()) + k.BeforeDelegationRemoved(ctx, delegatorAddress, valAddr) store := ctx.KVStore(k.storeKey) - store.Delete(types.GetDelegationKey(delegatorAddress, delegation.GetValidatorAddr())) + store.Delete(types.GetDelegationKey(delegatorAddress, valAddr)) + store.Delete(types.GetDelegationByValIndexKey(delegatorAddress, valAddr)) } // GetUnbondingDelegations returns a given amount of all the delegator unbonding-delegations. diff --git a/sei-cosmos/x/staking/keeper/delegation_index.go b/sei-cosmos/x/staking/keeper/delegation_index.go new file mode 100644 index 0000000000..a16a089515 --- /dev/null +++ b/sei-cosmos/x/staking/keeper/delegation_index.go @@ -0,0 +1,49 @@ +package keeper + +import ( + "time" + + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" +) + +// BackfillDelegationByValIndexResult reports the outcome of a delegation index backfill. +type BackfillDelegationByValIndexResult struct { + TotalDelegations int + IndexWritten int + AlreadyIndexed int + DryRun bool + Elapsed time.Duration +} + +// BackfillDelegationByValIndex writes validator-indexed delegation keys for existing +// delegations. When dryRun is true, delegations are counted but no store writes occur. +func (k Keeper) BackfillDelegationByValIndex(ctx sdk.Context, dryRun bool) BackfillDelegationByValIndexResult { + start := time.Now() + result := BackfillDelegationByValIndexResult{DryRun: dryRun} + + store := ctx.KVStore(k.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.DelegationKey) + defer func() { _ = iterator.Close() }() + + for ; iterator.Valid(); iterator.Next() { + delegation := types.MustUnmarshalDelegation(k.cdc, iterator.Value()) + result.TotalDelegations++ + + delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) + valAddr := delegation.GetValidatorAddr() + indexKey := types.GetDelegationByValIndexKey(delegatorAddress, valAddr) + if store.Has(indexKey) { + result.AlreadyIndexed++ + continue + } + + if !dryRun { + store.Set(indexKey, []byte{}) + } + result.IndexWritten++ + } + + result.Elapsed = time.Since(start) + return result +} diff --git a/sei-cosmos/x/staking/keeper/delegation_index_test.go b/sei-cosmos/x/staking/keeper/delegation_index_test.go new file mode 100644 index 0000000000..d2a8e5929f --- /dev/null +++ b/sei-cosmos/x/staking/keeper/delegation_index_test.go @@ -0,0 +1,78 @@ +package keeper_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" +) + +func TestDelegationByValIndexDualWrite(t *testing.T) { + _, app, ctx := createTestInput(t) + + addrDels, valAddrs := generateAddresses(app, ctx, 1) + delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) + + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + indexKey := types.GetDelegationByValIndexKey(addrDels[0], valAddrs[0]) + + require.False(t, store.Has(indexKey)) + + app.StakingKeeper.SetDelegation(ctx, delegation) + require.True(t, store.Has(indexKey)) + require.NotNil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) + + app.StakingKeeper.RemoveDelegation(ctx, delegation) + require.False(t, store.Has(indexKey)) + require.Nil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) +} + +func TestBackfillDelegationByValIndex(t *testing.T) { + _, app, ctx := createTestInput(t) + + addrDels, valAddrs := generateAddresses(app, ctx, 2) + delegations := []types.Delegation{ + types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)), + types.NewDelegation(addrDels[0], valAddrs[1], sdk.NewDec(2)), + types.NewDelegation(addrDels[1], valAddrs[0], sdk.NewDec(3)), + } + + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + for _, delegation := range delegations { + delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) + valAddr := delegation.GetValidatorAddr() + store.Set( + types.GetDelegationKey(delegatorAddress, valAddr), + types.MustMarshalDelegation(app.AppCodec(), delegation), + ) + } + + dryRunResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, true) + require.Equal(t, 3, dryRunResult.TotalDelegations) + require.Equal(t, 3, dryRunResult.IndexWritten) + require.Equal(t, 0, dryRunResult.AlreadyIndexed) + require.True(t, dryRunResult.DryRun) + for _, delegation := range delegations { + delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) + valAddr := delegation.GetValidatorAddr() + require.False(t, store.Has(types.GetDelegationByValIndexKey(delegatorAddress, valAddr))) + } + + writeResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, false) + require.Equal(t, 3, writeResult.TotalDelegations) + require.Equal(t, 3, writeResult.IndexWritten) + require.Equal(t, 0, writeResult.AlreadyIndexed) + require.False(t, writeResult.DryRun) + for _, delegation := range delegations { + delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) + valAddr := delegation.GetValidatorAddr() + require.True(t, store.Has(types.GetDelegationByValIndexKey(delegatorAddress, valAddr))) + } + + repeatResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, false) + require.Equal(t, 3, repeatResult.TotalDelegations) + require.Equal(t, 0, repeatResult.IndexWritten) + require.Equal(t, 3, repeatResult.AlreadyIndexed) +} diff --git a/sei-cosmos/x/staking/simulation/decoder.go b/sei-cosmos/x/staking/simulation/decoder.go index b6b6de2697..b6c3dd9eaf 100644 --- a/sei-cosmos/x/staking/simulation/decoder.go +++ b/sei-cosmos/x/staking/simulation/decoder.go @@ -41,6 +41,8 @@ func NewDecodeStore(cdc codec.Codec) func(kvA, kvB kv.Pair) string { cdc.MustUnmarshal(kvB.Value, &delegationB) return fmt.Sprintf("%v\n%v", delegationA, delegationB) + case bytes.Equal(kvA.Key[:1], types.DelegationByValIndexKey): + return fmt.Sprintf("%X\n%X", kvA.Key, kvB.Key) case bytes.Equal(kvA.Key[:1], types.UnbondingDelegationKey), bytes.Equal(kvA.Key[:1], types.UnbondingDelegationByValIndexKey): var ubdA, ubdB types.UnbondingDelegation diff --git a/sei-cosmos/x/staking/types/keys.go b/sei-cosmos/x/staking/types/keys.go index 0712b53d36..43b48cfd85 100644 --- a/sei-cosmos/x/staking/types/keys.go +++ b/sei-cosmos/x/staking/types/keys.go @@ -39,6 +39,7 @@ var ( DelegationKey = []byte{0x31} // key for a delegation UnbondingDelegationKey = []byte{0x32} // key for an unbonding-delegation UnbondingDelegationByValIndexKey = []byte{0x33} // prefix for each key for an unbonding-delegation, by validator operator + DelegationByValIndexKey = []byte{0x37} // prefix for each key for a delegation, by validator operator RedelegationKey = []byte{0x34} // key for a redelegation RedelegationByValSrcIndexKey = []byte{0x35} // prefix for each key for an redelegation, by source validator operator RedelegationByValDstIndexKey = []byte{0x36} // prefix for each key for an redelegation, by destination validator operator @@ -198,6 +199,31 @@ func GetDelegationsKey(delAddr sdk.AccAddress) []byte { return append(DelegationKey, address.MustLengthPrefix(delAddr)...) } +// GetDelegationByValIndexKey creates the index-key for a delegation, stored by validator-index. +// VALUE: none (key rearrangement used) +func GetDelegationByValIndexKey(delAddr sdk.AccAddress, valAddr sdk.ValAddress) []byte { + return append(GetDelegationsByValIndexKey(valAddr), address.MustLengthPrefix(delAddr)...) +} + +// GetDelegationKeyFromValIndexKey rearranges the ValIndexKey to get the DelegationKey. +func GetDelegationKeyFromValIndexKey(indexKey []byte) []byte { + kv.AssertKeyAtLeastLength(indexKey, 2) + addrs := indexKey[1:] // remove prefix bytes + + valAddrLen := addrs[0] + kv.AssertKeyAtLeastLength(addrs, 2+int(valAddrLen)) + valAddr := addrs[1 : 1+valAddrLen] + kv.AssertKeyAtLeastLength(addrs, 3+int(valAddrLen)) + delAddr := addrs[valAddrLen+2:] + + return GetDelegationKey(delAddr, valAddr) +} + +// GetDelegationsByValIndexKey creates the prefix keyspace for delegation indexes by validator. +func GetDelegationsByValIndexKey(valAddr sdk.ValAddress) []byte { + return append(DelegationByValIndexKey, address.MustLengthPrefix(valAddr)...) +} + // GetUBDKey creates the key for an unbonding delegation by delegator and validator addr // VALUE: staking/UnbondingDelegation func GetUBDKey(delAddr sdk.AccAddress, valAddr sdk.ValAddress) []byte { diff --git a/sei-cosmos/x/staking/types/keys_test.go b/sei-cosmos/x/staking/types/keys_test.go index 778d16bdbc..932ccae823 100644 --- a/sei-cosmos/x/staking/types/keys_test.go +++ b/sei-cosmos/x/staking/types/keys_test.go @@ -49,6 +49,16 @@ func TestGetValidatorPowerRank(t *testing.T) { } } +func TestGetDelegationKeyFromValIndexKey(t *testing.T) { + delAddr := sdk.AccAddress(keysAddr1) + valAddr := sdk.ValAddress(keysAddr2) + + delegationKey := types.GetDelegationKey(delAddr, valAddr) + indexKey := types.GetDelegationByValIndexKey(delAddr, valAddr) + + require.Equal(t, delegationKey, types.GetDelegationKeyFromValIndexKey(indexKey)) +} + func TestGetREDByValDstIndexKey(t *testing.T) { tests := []struct { delAddr sdk.AccAddress diff --git a/tools/cmd.go b/tools/cmd.go index 882056784d..d0902812b3 100644 --- a/tools/cmd.go +++ b/tools/cmd.go @@ -3,6 +3,7 @@ package tools import ( "github.com/spf13/cobra" + stakingcmd "github.com/sei-protocol/sei-chain/tools/staking/cmd" scanner "github.com/sei-protocol/sei-chain/tools/tx-scanner/cmd" ) @@ -12,5 +13,6 @@ func ToolCmd() *cobra.Command { Short: "A set of useful tools for sei chain", } toolsCmd.AddCommand(scanner.ScanCmd()) + toolsCmd.AddCommand(stakingcmd.StakingCmd()) return toolsCmd } diff --git a/tools/staking/cmd/backfill.go b/tools/staking/cmd/backfill.go new file mode 100644 index 0000000000..34132fb8e2 --- /dev/null +++ b/tools/staking/cmd/backfill.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "fmt" + "os" + + tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" + "github.com/spf13/cobra" + + "github.com/sei-protocol/sei-chain/app" + stakingtool "github.com/sei-protocol/sei-chain/tools/staking" +) + +// BackfillDelegationIndexCmd backfills validator-indexed delegation keys for benchmarking. +func BackfillDelegationIndexCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "backfill-delegation-index", + Short: "Backfill validator-indexed delegation keys (dev/benchmark)", + Long: `Iterate all delegations and write validator-indexed secondary index keys. + +By default this runs in dry-run mode and does not modify state. Pass --write to +persist index keys. When using --write, stop the node and operate on a copy of +the data directory.`, + RunE: runBackfillDelegationIndex, + } + + stakingtool.BindServerFlags(cmd, app.DefaultNodeHome) + cmd.Flags().Int64("height", 0, "Load state at this height (0 = latest committed)") + cmd.Flags().Bool("write", false, "Write index keys and commit state (requires stopped node)") + return cmd +} + +func runBackfillDelegationIndex(cmd *cobra.Command, _ []string) error { + height, err := cmd.Flags().GetInt64("height") + if err != nil { + return err + } + write, err := cmd.Flags().GetBool("write") + if err != nil { + return err + } + dryRun := !write + + if write { + fmt.Fprintln(os.Stderr, "WARNING: --write mutates application state. Stop the node and use a data copy.") + } + + seiApp, err := stakingtool.LoadApp(cmd, height) + if err != nil { + return err + } + + blockHeight := seiApp.LastBlockHeight() + if blockHeight == 0 { + blockHeight = 1 + } + + ctx := seiApp.NewUncachedContext(false, tmproto.Header{Height: blockHeight}) + result := seiApp.StakingKeeper.BackfillDelegationByValIndex(ctx, dryRun) + + fmt.Printf("dry_run=%t height=%d\n", result.DryRun, blockHeight) + fmt.Printf("total_delegations=%d index_written=%d already_indexed=%d elapsed=%s\n", + result.TotalDelegations, + result.IndexWritten, + result.AlreadyIndexed, + result.Elapsed, + ) + if result.TotalDelegations > 0 && result.Elapsed > 0 { + perSec := float64(result.TotalDelegations) / result.Elapsed.Seconds() + fmt.Printf("delegations_per_second=%.2f\n", perSec) + } + + if !write { + return nil + } + + commitID := seiApp.CommitMultiStore().Commit(false) + fmt.Printf("committed_version=%d\n", commitID.Version) + return nil +} diff --git a/tools/staking/cmd/staking.go b/tools/staking/cmd/staking.go new file mode 100644 index 0000000000..f51b63ad9a --- /dev/null +++ b/tools/staking/cmd/staking.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// StakingCmd returns offline staking maintenance commands. +func StakingCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "staking", + Short: "Offline staking maintenance tools", + } + cmd.AddCommand(BackfillDelegationIndexCmd()) + return cmd +} diff --git a/tools/staking/loadapp.go b/tools/staking/loadapp.go new file mode 100644 index 0000000000..65c2b0d70a --- /dev/null +++ b/tools/staking/loadapp.go @@ -0,0 +1,73 @@ +package staking + +import ( + "fmt" + + "github.com/sei-protocol/sei-chain/app" + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" + "github.com/sei-protocol/sei-chain/sei-cosmos/codec" + "github.com/sei-protocol/sei-chain/sei-cosmos/server" + "github.com/spf13/cast" + "github.com/spf13/cobra" +) + +// LoadApp opens the application at the latest committed height, or at height when +// height is positive. +func LoadApp(cmd *cobra.Command, height int64) (*app.App, error) { + serverCtx := server.GetServerContextFromCmd(cmd) + home := cast.ToString(serverCtx.Viper.Get(flags.FlagHome)) + if home == "" { + return nil, fmt.Errorf("home directory is required") + } + + encCfg := app.MakeEncodingConfig() + encCfg.Marshaler = codec.NewProtoCodec(encCfg.InterfaceRegistry) + + var seiApp *app.App + if height > 0 { + seiApp = app.New( + nil, + nil, + false, + map[int64]bool{}, + home, + 1, + true, + serverCtx.Config, + encCfg, + app.GetWasmEnabledProposals(), + serverCtx.Viper, + app.EmptyWasmOpts, + app.EmptyAppOptions, + ) + if err := seiApp.LoadHeight(height); err != nil { + return nil, fmt.Errorf("load height %d: %w", height, err) + } + return seiApp, nil + } + + seiApp = app.New( + nil, + nil, + true, + map[int64]bool{}, + home, + 1, + true, + serverCtx.Config, + encCfg, + app.GetWasmEnabledProposals(), + serverCtx.Viper, + app.EmptyWasmOpts, + app.EmptyAppOptions, + ) + return seiApp, nil +} + +// BindServerFlags wires home and config loading for offline app commands. +func BindServerFlags(cmd *cobra.Command, defaultHome string) { + cmd.PersistentFlags().String(flags.FlagHome, defaultHome, "The application home directory") + cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + return server.InterceptConfigsPreRunHandler(cmd, "", nil) + } +} From 544ba7cb49a534ddbd3ab7419051f4fff2c4ec9e Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 24 Aug 2026 15:49:17 +0200 Subject: [PATCH 02/17] Fix backfill CLI --write commit path under SeiDB. Use Commit(true) so storev2 does not panic, reject --write with --height, and close the app so SeiDB flushes before exit. Co-authored-by: Cursor --- tools/staking/cmd/backfill.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/staking/cmd/backfill.go b/tools/staking/cmd/backfill.go index 34132fb8e2..9a3f44b8a3 100644 --- a/tools/staking/cmd/backfill.go +++ b/tools/staking/cmd/backfill.go @@ -20,7 +20,7 @@ func BackfillDelegationIndexCmd() *cobra.Command { By default this runs in dry-run mode and does not modify state. Pass --write to persist index keys. When using --write, stop the node and operate on a copy of -the data directory.`, +the data directory. --write cannot be combined with --height.`, RunE: runBackfillDelegationIndex, } @@ -41,6 +41,10 @@ func runBackfillDelegationIndex(cmd *cobra.Command, _ []string) error { } dryRun := !write + if write && height > 0 { + return fmt.Errorf("--write cannot be used with --height") + } + if write { fmt.Fprintln(os.Stderr, "WARNING: --write mutates application state. Stop the node and use a data copy.") } @@ -49,6 +53,7 @@ func runBackfillDelegationIndex(cmd *cobra.Command, _ []string) error { if err != nil { return err } + defer seiApp.Close() blockHeight := seiApp.LastBlockHeight() if blockHeight == 0 { @@ -74,7 +79,7 @@ func runBackfillDelegationIndex(cmd *cobra.Command, _ []string) error { return nil } - commitID := seiApp.CommitMultiStore().Commit(false) + commitID := seiApp.CommitMultiStore().Commit(true) fmt.Printf("committed_version=%d\n", commitID.Version) return nil } From 24c0ccc3d709186f6835273d41d4579ea01edce9 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 24 Aug 2026 16:12:31 +0200 Subject: [PATCH 03/17] Gate delegation index dual-write on v6.7 upgrade. Keep SetDelegation and RemoveDelegation app-hash neutral before the upgrade by writing the validator index only once ClosestUpgradeName reaches v6.7. Co-authored-by: Cursor --- sei-cosmos/x/staking/keeper/delegation.go | 8 ++++++-- .../x/staking/keeper/delegation_index.go | 10 ++++++++++ .../x/staking/keeper/delegation_index_test.go | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/sei-cosmos/x/staking/keeper/delegation.go b/sei-cosmos/x/staking/keeper/delegation.go index c92c1360ba..5e89698460 100644 --- a/sei-cosmos/x/staking/keeper/delegation.go +++ b/sei-cosmos/x/staking/keeper/delegation.go @@ -103,7 +103,9 @@ func (k Keeper) SetDelegation(ctx sdk.Context, delegation types.Delegation) { store := ctx.KVStore(k.storeKey) b := types.MustMarshalDelegation(k.cdc, delegation) store.Set(types.GetDelegationKey(delegatorAddress, valAddr), b) - store.Set(types.GetDelegationByValIndexKey(delegatorAddress, valAddr), []byte{}) // index, store empty bytes + if delegationByValIndexActive(ctx) { + store.Set(types.GetDelegationByValIndexKey(delegatorAddress, valAddr), []byte{}) // index, store empty bytes + } } // RemoveDelegation removes a delegation. @@ -114,7 +116,9 @@ func (k Keeper) RemoveDelegation(ctx sdk.Context, delegation types.Delegation) { k.BeforeDelegationRemoved(ctx, delegatorAddress, valAddr) store := ctx.KVStore(k.storeKey) store.Delete(types.GetDelegationKey(delegatorAddress, valAddr)) - store.Delete(types.GetDelegationByValIndexKey(delegatorAddress, valAddr)) + if delegationByValIndexActive(ctx) { + store.Delete(types.GetDelegationByValIndexKey(delegatorAddress, valAddr)) + } } // GetUnbondingDelegations returns a given amount of all the delegator unbonding-delegations. diff --git a/sei-cosmos/x/staking/keeper/delegation_index.go b/sei-cosmos/x/staking/keeper/delegation_index.go index a16a089515..3a309a3849 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index.go +++ b/sei-cosmos/x/staking/keeper/delegation_index.go @@ -3,10 +3,20 @@ package keeper import ( "time" + "golang.org/x/mod/semver" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) +// DelegationByValIndexUpgrade is the upgrade at which SetDelegation and +// RemoveDelegation begin maintaining the validator-indexed delegation store. +const DelegationByValIndexUpgrade = "v6.7" + +func delegationByValIndexActive(ctx sdk.Context) bool { + return semver.Compare(ctx.ClosestUpgradeName(), DelegationByValIndexUpgrade) >= 0 +} + // BackfillDelegationByValIndexResult reports the outcome of a delegation index backfill. type BackfillDelegationByValIndexResult struct { TotalDelegations int diff --git a/sei-cosmos/x/staking/keeper/delegation_index_test.go b/sei-cosmos/x/staking/keeper/delegation_index_test.go index d2a8e5929f..5ce7cd2c65 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index_test.go +++ b/sei-cosmos/x/staking/keeper/delegation_index_test.go @@ -11,6 +11,7 @@ import ( func TestDelegationByValIndexDualWrite(t *testing.T) { _, app, ctx := createTestInput(t) + ctx = ctx.WithClosestUpgradeName("v6.7") addrDels, valAddrs := generateAddresses(app, ctx, 1) delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) @@ -29,6 +30,25 @@ func TestDelegationByValIndexDualWrite(t *testing.T) { require.Nil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) } +func TestDelegationByValIndexPreUpgradeNoDualWrite(t *testing.T) { + _, app, ctx := createTestInput(t) + ctx = ctx.WithClosestUpgradeName("v6.6") + + addrDels, valAddrs := generateAddresses(app, ctx, 1) + delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) + + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + indexKey := types.GetDelegationByValIndexKey(addrDels[0], valAddrs[0]) + + app.StakingKeeper.SetDelegation(ctx, delegation) + require.False(t, store.Has(indexKey)) + require.NotNil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) + + app.StakingKeeper.RemoveDelegation(ctx, delegation) + require.False(t, store.Has(indexKey)) + require.Nil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) +} + func TestBackfillDelegationByValIndex(t *testing.T) { _, app, ctx := createTestInput(t) From 576c63ed51ef3005aa8d365b93dd709b3f86904b Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 24 Aug 2026 16:17:07 +0200 Subject: [PATCH 04/17] Fix staking tools config loading and LoadApp wiring. Drop the subcommand PersistentPreRunE that shadowed the root seid hook and collapsed duplicate app.New paths into one call with optional LoadHeight. Co-authored-by: Cursor --- tools/staking/loadapp.go | 38 +++++++++----------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/tools/staking/loadapp.go b/tools/staking/loadapp.go index 65c2b0d70a..c6981b4f40 100644 --- a/tools/staking/loadapp.go +++ b/tools/staking/loadapp.go @@ -23,33 +23,10 @@ func LoadApp(cmd *cobra.Command, height int64) (*app.App, error) { encCfg := app.MakeEncodingConfig() encCfg.Marshaler = codec.NewProtoCodec(encCfg.InterfaceRegistry) - var seiApp *app.App - if height > 0 { - seiApp = app.New( - nil, - nil, - false, - map[int64]bool{}, - home, - 1, - true, - serverCtx.Config, - encCfg, - app.GetWasmEnabledProposals(), - serverCtx.Viper, - app.EmptyWasmOpts, - app.EmptyAppOptions, - ) - if err := seiApp.LoadHeight(height); err != nil { - return nil, fmt.Errorf("load height %d: %w", height, err) - } - return seiApp, nil - } - - seiApp = app.New( + seiApp := app.New( nil, nil, - true, + false, map[int64]bool{}, home, 1, @@ -61,13 +38,16 @@ func LoadApp(cmd *cobra.Command, height int64) (*app.App, error) { app.EmptyWasmOpts, app.EmptyAppOptions, ) + if height > 0 { + if err := seiApp.LoadHeight(height); err != nil { + return nil, fmt.Errorf("load height %d: %w", height, err) + } + } return seiApp, nil } -// BindServerFlags wires home and config loading for offline app commands. +// BindServerFlags wires the home flag for offline app commands. Config loading +// is handled by the root seid PersistentPreRunE. func BindServerFlags(cmd *cobra.Command, defaultHome string) { cmd.PersistentFlags().String(flags.FlagHome, defaultHome, "The application home directory") - cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - return server.InterceptConfigsPreRunHandler(cmd, "", nil) - } } From 4d5d317e8e54f45fc2402713a77fb5547fc98dc7 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 24 Aug 2026 16:23:36 +0200 Subject: [PATCH 05/17] Order delegation index prefix and add backfill CLI tests. Place 0x37 after the other delegation prefixes and smoke-test dry-run, --write commit, and incompatible flag handling for the backfill tool. Co-authored-by: Cursor --- sei-cosmos/x/staking/types/keys.go | 2 +- tools/staking/cmd/backfill_test.go | 174 +++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tools/staking/cmd/backfill_test.go diff --git a/sei-cosmos/x/staking/types/keys.go b/sei-cosmos/x/staking/types/keys.go index 43b48cfd85..94763d47e0 100644 --- a/sei-cosmos/x/staking/types/keys.go +++ b/sei-cosmos/x/staking/types/keys.go @@ -39,10 +39,10 @@ var ( DelegationKey = []byte{0x31} // key for a delegation UnbondingDelegationKey = []byte{0x32} // key for an unbonding-delegation UnbondingDelegationByValIndexKey = []byte{0x33} // prefix for each key for an unbonding-delegation, by validator operator - DelegationByValIndexKey = []byte{0x37} // prefix for each key for a delegation, by validator operator RedelegationKey = []byte{0x34} // key for a redelegation RedelegationByValSrcIndexKey = []byte{0x35} // prefix for each key for an redelegation, by source validator operator RedelegationByValDstIndexKey = []byte{0x36} // prefix for each key for an redelegation, by destination validator operator + DelegationByValIndexKey = []byte{0x37} // prefix for each key for a delegation, by validator operator UnbondingQueueKey = []byte{0x41} // prefix for the timestamps in unbonding queue RedelegationQueueKey = []byte{0x42} // prefix for the timestamps in redelegations queue diff --git a/tools/staking/cmd/backfill_test.go b/tools/staking/cmd/backfill_test.go new file mode 100644 index 0000000000..86e2fa7e1e --- /dev/null +++ b/tools/staking/cmd/backfill_test.go @@ -0,0 +1,174 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/app" + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" + "github.com/sei-protocol/sei-chain/sei-cosmos/server" + srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" + seidbconfig "github.com/sei-protocol/sei-chain/sei-db/config" + tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" +) + +type testAppToml struct { + srvconfig.Config + + StateCommit seidbconfig.StateCommitConfig `mapstructure:"state-commit"` + StateStore seidbconfig.StateStoreConfig `mapstructure:"state-store"` + ReceiptStore seidbconfig.ReceiptStoreConfig `mapstructure:"receipt-store"` +} + +func writeTestAppToml(t *testing.T, path string) { + t.Helper() + + cfg := testAppToml{ + Config: *srvconfig.DefaultConfig(), + StateCommit: seidbconfig.DefaultStateCommitConfig(), + StateStore: seidbconfig.DefaultStateStoreConfig(), + ReceiptStore: seidbconfig.DefaultReceiptStoreConfig(), + } + cfg.MinGasPrices = "0.01usei" + cfg.StateCommit.Enable = true + cfg.StateCommit.HashLogger.Enable = false + cfg.StateStore.Enable = true + + template := srvconfig.ManualConfigTemplate + + seidbconfig.StateCommitConfigTemplate + + seidbconfig.StateStoreConfigTemplate + + seidbconfig.ReceiptStoreConfigTemplate + srvconfig.SetConfigTemplate(template) + srvconfig.WriteConfigFile(path, cfg) +} + +func seedCommittedSeiDBApp(t *testing.T, home string) int64 { + t.Helper() + + encCfg := app.MakeEncodingConfig() + seiApp := app.New( + nil, + nil, + false, + map[int64]bool{}, + home, + 1, + true, + tmcfg.TestConfig(), + encCfg, + app.GetWasmEnabledProposals(), + app.TestAppOpts{UseSc: true}, + app.EmptyWasmOpts, + app.EmptyAppOptions, + ) + + genesisState := app.NewDefaultGenesisState(encCfg.Marshaler) + stateBytes, err := json.MarshalIndent(genesisState, "", " ") + require.NoError(t, err) + + _, err = seiApp.InitChain(&abci.RequestInitChain{ + ConsensusParams: app.DefaultConsensusParams, + ChainId: "sei-test", + AppStateBytes: stateBytes, + }) + require.NoError(t, err) + + commitID := seiApp.CommitMultiStore().Commit(true) + require.NoError(t, seiApp.Close()) + return commitID.Version +} + +func backfillCmdWithHome(t *testing.T, home string, args ...string) *cobra.Command { + t.Helper() + + cmd := BackfillDelegationIndexCmd() + serverCtx := server.NewDefaultContext() + cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) + serverCtx.Viper.Set(flags.FlagHome, home) + serverCtx.Viper.Set("chain-id", "sei-test") + serverCtx.Viper.SetConfigFile(filepath.Join(home, "config", "app.toml")) + require.NoError(t, serverCtx.Viper.ReadInConfig()) + + cmd.SetArgs(args) + return cmd +} + +func captureCommandOutput(t *testing.T, fn func()) (stdout, stderr string) { + t.Helper() + + stdoutR, stdoutW, err := os.Pipe() + require.NoError(t, err) + stderrR, stderrW, err := os.Pipe() + require.NoError(t, err) + + oldStdout, oldStderr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = stdoutW, stderrW + t.Cleanup(func() { + os.Stdout, os.Stderr = oldStdout, oldStderr + }) + + fn() + + require.NoError(t, stdoutW.Close()) + require.NoError(t, stderrW.Close()) + os.Stdout, os.Stderr = oldStdout, oldStderr + + var stdoutBuf, stderrBuf bytes.Buffer + _, err = io.Copy(&stdoutBuf, stdoutR) + require.NoError(t, err) + _, err = io.Copy(&stderrBuf, stderrR) + require.NoError(t, err) + return stdoutBuf.String(), stderrBuf.String() +} + +func TestBackfillDelegationIndexDryRun(t *testing.T) { + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, "config"), 0o755)) + writeTestAppToml(t, filepath.Join(home, "config", "app.toml")) + seedCommittedSeiDBApp(t, home) + + cmd := backfillCmdWithHome(t, home) + stdout, _ := captureCommandOutput(t, func() { + require.NoError(t, cmd.Execute()) + }) + + require.Contains(t, stdout, "dry_run=true") + require.NotContains(t, stdout, "committed_version=") +} + +func TestBackfillDelegationIndexWrite(t *testing.T) { + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, "config"), 0o755)) + writeTestAppToml(t, filepath.Join(home, "config", "app.toml")) + version := seedCommittedSeiDBApp(t, home) + + cmd := backfillCmdWithHome(t, home, "--write") + stdout, stderr := captureCommandOutput(t, func() { + require.NoError(t, cmd.Execute()) + }) + + require.Contains(t, stdout, "dry_run=false") + require.Contains(t, stdout, "committed_version=") + require.Contains(t, stderr, "WARNING:") + require.Greater(t, version, int64(0)) +} + +func TestBackfillDelegationIndexWriteRejectsHeight(t *testing.T) { + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, "config"), 0o755)) + writeTestAppToml(t, filepath.Join(home, "config", "app.toml")) + + cmd := backfillCmdWithHome(t, home, "--write", "--height", "1") + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "--write cannot be used with --height") +} From e3a0902444fd51871ad2d6b0e3513696576611e5 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 24 Aug 2026 16:29:06 +0200 Subject: [PATCH 06/17] Fix delegation index upgrade gate for live execution. Dual-write on DeliverTx and gate pre-v6.7 behavior only for tracing replay via ClosestUpgradeName, matching the distribution keeper pattern. Co-authored-by: Cursor --- .../x/staking/keeper/delegation_index.go | 11 +++++++++++ .../x/staking/keeper/delegation_index_test.go | 19 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/sei-cosmos/x/staking/keeper/delegation_index.go b/sei-cosmos/x/staking/keeper/delegation_index.go index 3a309a3849..503504226b 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index.go +++ b/sei-cosmos/x/staking/keeper/delegation_index.go @@ -13,7 +13,18 @@ import ( // RemoveDelegation begin maintaining the validator-indexed delegation store. const DelegationByValIndexUpgrade = "v6.7" +// delegationByValIndexActive reports whether SetDelegation and RemoveDelegation +// should maintain the validator-indexed delegation store. +// +// Live execution always does: the current binary only ever executes at/after +// the upgrade that ships this behavior, so a non-tracing block is never a +// pre-upgrade block. The only place pre-upgrade behavior must be reproduced is +// when re-tracing a historical block, where the era is signaled through +// ClosestUpgradeName (see app.RPCContextProvider). func delegationByValIndexActive(ctx sdk.Context) bool { + if !ctx.IsTracing() { + return true + } return semver.Compare(ctx.ClosestUpgradeName(), DelegationByValIndexUpgrade) >= 0 } diff --git a/sei-cosmos/x/staking/keeper/delegation_index_test.go b/sei-cosmos/x/staking/keeper/delegation_index_test.go index 5ce7cd2c65..c1309d5f63 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index_test.go +++ b/sei-cosmos/x/staking/keeper/delegation_index_test.go @@ -11,7 +11,6 @@ import ( func TestDelegationByValIndexDualWrite(t *testing.T) { _, app, ctx := createTestInput(t) - ctx = ctx.WithClosestUpgradeName("v6.7") addrDels, valAddrs := generateAddresses(app, ctx, 1) delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) @@ -30,9 +29,9 @@ func TestDelegationByValIndexDualWrite(t *testing.T) { require.Nil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) } -func TestDelegationByValIndexPreUpgradeNoDualWrite(t *testing.T) { +func TestDelegationByValIndexTracingPreUpgradeNoDualWrite(t *testing.T) { _, app, ctx := createTestInput(t) - ctx = ctx.WithClosestUpgradeName("v6.6") + ctx = ctx.WithIsTracing(true).WithClosestUpgradeName("v6.6") addrDels, valAddrs := generateAddresses(app, ctx, 1) delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) @@ -49,6 +48,20 @@ func TestDelegationByValIndexPreUpgradeNoDualWrite(t *testing.T) { require.Nil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) } +func TestDelegationByValIndexTracingPostUpgradeDualWrite(t *testing.T) { + _, app, ctx := createTestInput(t) + ctx = ctx.WithIsTracing(true).WithClosestUpgradeName("v6.7") + + addrDels, valAddrs := generateAddresses(app, ctx, 1) + delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) + + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + indexKey := types.GetDelegationByValIndexKey(addrDels[0], valAddrs[0]) + + app.StakingKeeper.SetDelegation(ctx, delegation) + require.True(t, store.Has(indexKey)) +} + func TestBackfillDelegationByValIndex(t *testing.T) { _, app, ctx := createTestInput(t) From 7a0eea05afd7b4b6d5d51131c563a1e49ce1cf0b Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Tue, 25 Aug 2026 11:12:48 +0200 Subject: [PATCH 07/17] Fixing lint issue --- tools/staking/cmd/backfill.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/staking/cmd/backfill.go b/tools/staking/cmd/backfill.go index 9a3f44b8a3..cf2a60f657 100644 --- a/tools/staking/cmd/backfill.go +++ b/tools/staking/cmd/backfill.go @@ -53,7 +53,11 @@ func runBackfillDelegationIndex(cmd *cobra.Command, _ []string) error { if err != nil { return err } - defer seiApp.Close() + defer func() { + if err := seiApp.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Error closing sei app: %v", err) + } + }() blockHeight := seiApp.LastBlockHeight() if blockHeight == 0 { From 9b661449adcb82a44bcde556f0826de18c596500 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Tue, 25 Aug 2026 12:14:30 +0200 Subject: [PATCH 08/17] Improve backfill-delegation-index benchmark UX with guidance and progress. Clarify that the CLI is for analysis only, require confirmation before running, and emit stderr heartbeats while scanning delegations so long mainnet runs are observable. Co-authored-by: Cursor --- .../x/staking/keeper/delegation_index.go | 49 +++++++++++-- .../x/staking/keeper/delegation_index_test.go | 6 +- tools/staking/cmd/backfill.go | 68 ++++++++++++++++--- tools/staking/cmd/backfill_test.go | 32 ++++++++- 4 files changed, 135 insertions(+), 20 deletions(-) diff --git a/sei-cosmos/x/staking/keeper/delegation_index.go b/sei-cosmos/x/staking/keeper/delegation_index.go index 503504226b..0801658254 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index.go +++ b/sei-cosmos/x/staking/keeper/delegation_index.go @@ -37,11 +37,41 @@ type BackfillDelegationByValIndexResult struct { Elapsed time.Duration } +// BackfillDelegationByValIndexProgress is a progress snapshot during backfill. +type BackfillDelegationByValIndexProgress struct { + TotalDelegations int + IndexWritten int + AlreadyIndexed int + Elapsed time.Duration +} + +// BackfillProgress reports incremental backfill progress. Nil disables callbacks. +type BackfillProgress func(BackfillDelegationByValIndexProgress) + +const ( + backfillProgressDelegationInterval = 100_000 + backfillProgressMinInterval = 10 * time.Second +) + // BackfillDelegationByValIndex writes validator-indexed delegation keys for existing // delegations. When dryRun is true, delegations are counted but no store writes occur. -func (k Keeper) BackfillDelegationByValIndex(ctx sdk.Context, dryRun bool) BackfillDelegationByValIndexResult { +func (k Keeper) BackfillDelegationByValIndex(ctx sdk.Context, dryRun bool, progress BackfillProgress) BackfillDelegationByValIndexResult { start := time.Now() result := BackfillDelegationByValIndexResult{DryRun: dryRun} + lastProgressReport := start + + reportProgress := func() { + if progress == nil { + return + } + progress(BackfillDelegationByValIndexProgress{ + TotalDelegations: result.TotalDelegations, + IndexWritten: result.IndexWritten, + AlreadyIndexed: result.AlreadyIndexed, + Elapsed: time.Since(start), + }) + lastProgressReport = time.Now() + } store := ctx.KVStore(k.storeKey) iterator := sdk.KVStorePrefixIterator(store, types.DelegationKey) @@ -56,13 +86,22 @@ func (k Keeper) BackfillDelegationByValIndex(ctx sdk.Context, dryRun bool) Backf indexKey := types.GetDelegationByValIndexKey(delegatorAddress, valAddr) if store.Has(indexKey) { result.AlreadyIndexed++ - continue + } else { + if !dryRun { + store.Set(indexKey, []byte{}) + } + result.IndexWritten++ } - if !dryRun { - store.Set(indexKey, []byte{}) + if progress == nil { + continue + } + now := time.Now() + if result.TotalDelegations == 1 || + result.TotalDelegations%backfillProgressDelegationInterval == 0 || + now.Sub(lastProgressReport) >= backfillProgressMinInterval { + reportProgress() } - result.IndexWritten++ } result.Elapsed = time.Since(start) diff --git a/sei-cosmos/x/staking/keeper/delegation_index_test.go b/sei-cosmos/x/staking/keeper/delegation_index_test.go index c1309d5f63..7f08080429 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index_test.go +++ b/sei-cosmos/x/staking/keeper/delegation_index_test.go @@ -82,7 +82,7 @@ func TestBackfillDelegationByValIndex(t *testing.T) { ) } - dryRunResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, true) + dryRunResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, true, nil) require.Equal(t, 3, dryRunResult.TotalDelegations) require.Equal(t, 3, dryRunResult.IndexWritten) require.Equal(t, 0, dryRunResult.AlreadyIndexed) @@ -93,7 +93,7 @@ func TestBackfillDelegationByValIndex(t *testing.T) { require.False(t, store.Has(types.GetDelegationByValIndexKey(delegatorAddress, valAddr))) } - writeResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, false) + writeResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, false, nil) require.Equal(t, 3, writeResult.TotalDelegations) require.Equal(t, 3, writeResult.IndexWritten) require.Equal(t, 0, writeResult.AlreadyIndexed) @@ -104,7 +104,7 @@ func TestBackfillDelegationByValIndex(t *testing.T) { require.True(t, store.Has(types.GetDelegationByValIndexKey(delegatorAddress, valAddr))) } - repeatResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, false) + repeatResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, false, nil) require.Equal(t, 3, repeatResult.TotalDelegations) require.Equal(t, 0, repeatResult.IndexWritten) require.Equal(t, 3, repeatResult.AlreadyIndexed) diff --git a/tools/staking/cmd/backfill.go b/tools/staking/cmd/backfill.go index cf2a60f657..b83f88bd6b 100644 --- a/tools/staking/cmd/backfill.go +++ b/tools/staking/cmd/backfill.go @@ -1,6 +1,7 @@ package cmd import ( + "bufio" "fmt" "os" @@ -8,25 +9,40 @@ import ( "github.com/spf13/cobra" "github.com/sei-protocol/sei-chain/app" + "github.com/sei-protocol/sei-chain/sei-cosmos/client/input" + stakingkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/keeper" stakingtool "github.com/sei-protocol/sei-chain/tools/staking" ) +const backfillDelegationIndexNotice = `This command is for benchmarking and analysis only. It measures how long it +takes to populate the validator-indexed delegation secondary index. Do NOT use +it to migrate live chain state. + +How to use: + 1. Stop the node whose state you want to measure. + 2. Copy the node home directory to a path outside your real Sei data directory + (for example: cp -a ~/.sei /tmp/sei-backfill-bench). + 3. Run a dry-run against the copy (default; does not modify state): + seid tools staking backfill-delegation-index --home /path/to/copy + 4. To measure write and commit cost on the copy only, add --write: + seid tools staking backfill-delegation-index --home /path/to/copy --write + 5. Review elapsed time and delegations_per_second in the output. + +Never point --home at a running node's directory. Never use --write on production +data. --write cannot be combined with --height.` + // BackfillDelegationIndexCmd backfills validator-indexed delegation keys for benchmarking. func BackfillDelegationIndexCmd() *cobra.Command { cmd := &cobra.Command{ Use: "backfill-delegation-index", - Short: "Backfill validator-indexed delegation keys (dev/benchmark)", - Long: `Iterate all delegations and write validator-indexed secondary index keys. - -By default this runs in dry-run mode and does not modify state. Pass --write to -persist index keys. When using --write, stop the node and operate on a copy of -the data directory. --write cannot be combined with --height.`, - RunE: runBackfillDelegationIndex, + Short: "Benchmark validator-indexed delegation index population (not for live migration)", + Long: backfillDelegationIndexNotice, + RunE: runBackfillDelegationIndex, } stakingtool.BindServerFlags(cmd, app.DefaultNodeHome) - cmd.Flags().Int64("height", 0, "Load state at this height (0 = latest committed)") - cmd.Flags().Bool("write", false, "Write index keys and commit state (requires stopped node)") + cmd.Flags().Int64("height", 0, "Load state at this height on the copy (0 = latest committed)") + cmd.Flags().Bool("write", false, "Write index keys and commit state on the copy (benchmark only; never on live data)") return cmd } @@ -45,10 +61,24 @@ func runBackfillDelegationIndex(cmd *cobra.Command, _ []string) error { return fmt.Errorf("--write cannot be used with --height") } + fmt.Fprintln(os.Stderr, backfillDelegationIndexNotice) if write { - fmt.Fprintln(os.Stderr, "WARNING: --write mutates application state. Stop the node and use a data copy.") + fmt.Fprintln(os.Stderr, "WARNING: --write mutates state in the directory passed to --home. Use a copy, not live data.") + } + + confirmed, err := input.GetConfirmation( + "Proceed with this benchmark run on the directory passed to --home?", + bufio.NewReader(cmd.InOrStdin()), + cmd.ErrOrStderr(), + ) + if err != nil { + return err + } + if !confirmed { + return fmt.Errorf("aborted") } + fmt.Fprintln(os.Stderr, "loading application state...") seiApp, err := stakingtool.LoadApp(cmd, height) if err != nil { return err @@ -65,7 +95,8 @@ func runBackfillDelegationIndex(cmd *cobra.Command, _ []string) error { } ctx := seiApp.NewUncachedContext(false, tmproto.Header{Height: blockHeight}) - result := seiApp.StakingKeeper.BackfillDelegationByValIndex(ctx, dryRun) + fmt.Fprintln(os.Stderr, "backfill started") + result := seiApp.StakingKeeper.BackfillDelegationByValIndex(ctx, dryRun, reportBackfillProgress) fmt.Printf("dry_run=%t height=%d\n", result.DryRun, blockHeight) fmt.Printf("total_delegations=%d index_written=%d already_indexed=%d elapsed=%s\n", @@ -87,3 +118,18 @@ func runBackfillDelegationIndex(cmd *cobra.Command, _ []string) error { fmt.Printf("committed_version=%d\n", commitID.Version) return nil } + +func reportBackfillProgress(p stakingkeeper.BackfillDelegationByValIndexProgress) { + perSec := 0.0 + if p.Elapsed > 0 { + perSec = float64(p.TotalDelegations) / p.Elapsed.Seconds() + } + fmt.Fprintf(os.Stderr, + "progress total_delegations=%d index_written=%d already_indexed=%d elapsed=%s delegations_per_second=%.2f\n", + p.TotalDelegations, + p.IndexWritten, + p.AlreadyIndexed, + p.Elapsed, + perSec, + ) +} diff --git a/tools/staking/cmd/backfill_test.go b/tools/staking/cmd/backfill_test.go index 86e2fa7e1e..b12bf09cd6 100644 --- a/tools/staking/cmd/backfill_test.go +++ b/tools/staking/cmd/backfill_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" @@ -93,6 +94,7 @@ func backfillCmdWithHome(t *testing.T, home string, args ...string) *cobra.Comma cmd := BackfillDelegationIndexCmd() serverCtx := server.NewDefaultContext() cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) + cmd.SetIn(strings.NewReader("y\n")) serverCtx.Viper.Set(flags.FlagHome, home) serverCtx.Viper.Set("chain-id", "sei-test") serverCtx.Viper.SetConfigFile(filepath.Join(home, "config", "app.toml")) @@ -137,12 +139,14 @@ func TestBackfillDelegationIndexDryRun(t *testing.T) { seedCommittedSeiDBApp(t, home) cmd := backfillCmdWithHome(t, home) - stdout, _ := captureCommandOutput(t, func() { + stdout, stderr := captureCommandOutput(t, func() { require.NoError(t, cmd.Execute()) }) require.Contains(t, stdout, "dry_run=true") require.NotContains(t, stdout, "committed_version=") + require.Contains(t, stderr, "benchmarking and analysis only") + require.Contains(t, stderr, "--home /path/to/copy") } func TestBackfillDelegationIndexWrite(t *testing.T) { @@ -158,6 +162,7 @@ func TestBackfillDelegationIndexWrite(t *testing.T) { require.Contains(t, stdout, "dry_run=false") require.Contains(t, stdout, "committed_version=") + require.Contains(t, stderr, "benchmarking and analysis only") require.Contains(t, stderr, "WARNING:") require.Greater(t, version, int64(0)) } @@ -172,3 +177,28 @@ func TestBackfillDelegationIndexWriteRejectsHeight(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "--write cannot be used with --height") } + +func TestBackfillDelegationIndexAbortsWithoutConfirmation(t *testing.T) { + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, "config"), 0o755)) + writeTestAppToml(t, filepath.Join(home, "config", "app.toml")) + seedCommittedSeiDBApp(t, home) + + cmd := BackfillDelegationIndexCmd() + serverCtx := server.NewDefaultContext() + cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) + cmd.SetIn(strings.NewReader("n\n")) + serverCtx.Viper.Set(flags.FlagHome, home) + serverCtx.Viper.Set("chain-id", "sei-test") + serverCtx.Viper.SetConfigFile(filepath.Join(home, "config", "app.toml")) + require.NoError(t, serverCtx.Viper.ReadInConfig()) + + stdout, stderr := captureCommandOutput(t, func() { + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "aborted") + }) + + require.Empty(t, stdout) + require.Contains(t, stderr, "benchmarking and analysis only") +} From 09f745d6e8a9d048f6ee7b50cfdc833a016a39d4 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 31 Aug 2026 16:10:51 +0200 Subject: [PATCH 09/17] Populate delegation-by-validator index at v6.7 upgrade. Backfill the secondary index in the upgrade handler so operators never need a separate migration step, gate dual-writes on a store marker, and remove the CLI backfill command that could mutate live state. Co-authored-by: Cursor --- app/upgrade_test.go | 32 +++ app/upgrades.go | 24 +++ precompiles/staking/legacy/v67/staking.go | 2 +- precompiles/staking/staking.go | 2 +- precompiles/staking/staking_test.go | 4 + precompiles/utils/expected_keepers.go | 1 + sei-cosmos/x/staking/keeper/delegation.go | 4 +- .../x/staking/keeper/delegation_index.go | 112 +++------- .../x/staking/keeper/delegation_index_test.go | 198 ++++++++++++----- sei-cosmos/x/staking/keeper/grpc_query.go | 55 +++++ sei-cosmos/x/staking/simulation/decoder.go | 3 +- sei-cosmos/x/staking/types/keys.go | 1 + tools/cmd.go | 2 - tools/staking/cmd/backfill.go | 135 ------------ tools/staking/cmd/backfill_test.go | 204 ------------------ tools/staking/cmd/staking.go | 15 -- tools/staking/loadapp.go | 53 ----- 17 files changed, 306 insertions(+), 541 deletions(-) delete mode 100644 tools/staking/cmd/backfill.go delete mode 100644 tools/staking/cmd/backfill_test.go delete mode 100644 tools/staking/cmd/staking.go delete mode 100644 tools/staking/loadapp.go diff --git a/app/upgrade_test.go b/app/upgrade_test.go index f63a847559..29974968ff 100644 --- a/app/upgrade_test.go +++ b/app/upgrade_test.go @@ -7,6 +7,7 @@ import ( "github.com/sei-protocol/sei-chain/app" "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/keys/secp256k1" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade/types" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" @@ -109,3 +110,34 @@ func TestSkipOptimisticProcessingOnUpgrade(t *testing.T) { require.False(t, testWrapper.App.GetOptimisticProcessingInfo().Aborted) }) } + +func TestV67PopulatesDelegationByValIndex(t *testing.T) { + t.Setenv("UPGRADE_VERSION_LIST", "v6.7") + tm := time.Now().UTC() + valPub := secp256k1.GenPrivKey().PubKey() + testWrapper := app.NewTestWrapper(t, tm, valPub, false) + testWrapper.App.RegisterUpgradeHandlers() + + ctx := testWrapper.Ctx + stakingKeeper := testWrapper.App.StakingKeeper + delAddr := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + valAddr := sdk.ValAddress(secp256k1.GenPrivKey().PubKey().Address()) + delegation := stakingtypes.NewDelegation(delAddr, valAddr, sdk.NewDec(1)) + + // Seed through the store rather than SetDelegation, so the index entry can only + // come from the upgrade handler. + store := ctx.KVStore(stakingKeeper.GetStoreKey()) + store.Set( + stakingtypes.GetDelegationKey(delAddr, valAddr), + stakingtypes.MustMarshalDelegation(testWrapper.App.AppCodec(), delegation), + ) + require.False(t, stakingKeeper.DelegationByValIndexReady(ctx)) + + testWrapper.App.UpgradeKeeper.ApplyUpgrade(ctx, types.Plan{ + Name: "v6.7", + Height: ctx.BlockHeight(), + }) + + require.True(t, stakingKeeper.DelegationByValIndexReady(ctx)) + require.True(t, store.Has(stakingtypes.GetDelegationByValIndexKey(delAddr, valAddr))) +} diff --git a/app/upgrades.go b/app/upgrades.go index a219a61caf..a7d33d8b04 100644 --- a/app/upgrades.go +++ b/app/upgrades.go @@ -99,6 +99,9 @@ func (app *App) RegisterUpgradeHandlers() { } app.UpgradeKeeper.DeleteModuleVersion(ctx, capabilityModuleName) app.UpgradeKeeper.DeleteModuleVersion(ctx, feegrantModuleName) + if err := migrateDelegationByValIndex(ctx, app); err != nil { + return nil, err + } return newVM, nil } @@ -108,3 +111,24 @@ func (app *App) RegisterUpgradeHandlers() { } const v606UpgradeHeight = 151573570 + +// migrateDelegationByValIndex populates the validator-indexed delegation store and +// marks it ready, so the staking precompile's validatorDelegations can answer from a +// per-validator prefix instead of scanning every delegation. +// +// It runs on an infinite gas meter: the cost is a property of chain size at the +// upgrade height, not of anything a transaction chose to spend. +func migrateDelegationByValIndex(ctx sdk.Context, app *App) error { + result, err := app.StakingKeeper.MigrateDelegationByValIndex(ctx.WithGasMeter(sdk.NewInfiniteGasMeter(1, 1))) + if err != nil { + return err + } + logger.Info( + "populated delegation-by-validator index", + "total_delegations", result.TotalDelegations, + "index_written", result.IndexWritten, + "already_ready", result.AlreadyReady, + "elapsed", result.Elapsed.String(), + ) + return nil +} diff --git a/precompiles/staking/legacy/v67/staking.go b/precompiles/staking/legacy/v67/staking.go index 2f21c16d1e..2da8cc99b8 100644 --- a/precompiles/staking/legacy/v67/staking.go +++ b/precompiles/staking/legacy/v67/staking.go @@ -1004,7 +1004,7 @@ func (p PrecompileExecutor) validatorDelegations(ctx sdk.Context, method *abi.Me }, } - response, err := p.stakingQuerier.ValidatorDelegations(sdk.WrapSDKContext(ctx), request) + response, err := p.stakingQuerier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), request) if err != nil { return nil, 0, err } diff --git a/precompiles/staking/staking.go b/precompiles/staking/staking.go index d13b5d129a..cfd8e55cbf 100644 --- a/precompiles/staking/staking.go +++ b/precompiles/staking/staking.go @@ -1002,7 +1002,7 @@ func (p PrecompileExecutor) validatorDelegations(ctx sdk.Context, method *abi.Me }, } - response, err := p.stakingQuerier.ValidatorDelegations(sdk.WrapSDKContext(ctx), request) + response, err := p.stakingQuerier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), request) if err != nil { return nil, 0, err } diff --git a/precompiles/staking/staking_test.go b/precompiles/staking/staking_test.go index 6b35604056..78fbb6aeee 100644 --- a/precompiles/staking/staking_test.go +++ b/precompiles/staking/staking_test.go @@ -297,6 +297,10 @@ func (tq *TestStakingQuerier) ValidatorDelegations(c context.Context, _ *staking return tq.ValidatorDelegationsResponse, tq.Err } +func (tq *TestStakingQuerier) ValidatorDelegationsIndexed(c context.Context, _ *stakingtypes.QueryValidatorDelegationsRequest) (*stakingtypes.QueryValidatorDelegationsResponse, error) { + return tq.ValidatorDelegationsResponse, tq.Err +} + func (tq *TestStakingQuerier) ValidatorUnbondingDelegations(c context.Context, _ *stakingtypes.QueryValidatorUnbondingDelegationsRequest) (*stakingtypes.QueryValidatorUnbondingDelegationsResponse, error) { return tq.ValidatorUnbondingDelegationsResponse, tq.Err } diff --git a/precompiles/utils/expected_keepers.go b/precompiles/utils/expected_keepers.go index 2d2b07959b..3a93e44872 100644 --- a/precompiles/utils/expected_keepers.go +++ b/precompiles/utils/expected_keepers.go @@ -197,6 +197,7 @@ type StakingQuerier interface { Validators(c context.Context, req *stakingtypes.QueryValidatorsRequest) (*stakingtypes.QueryValidatorsResponse, error) Validator(c context.Context, req *stakingtypes.QueryValidatorRequest) (*stakingtypes.QueryValidatorResponse, error) ValidatorDelegations(c context.Context, req *stakingtypes.QueryValidatorDelegationsRequest) (*stakingtypes.QueryValidatorDelegationsResponse, error) + ValidatorDelegationsIndexed(c context.Context, req *stakingtypes.QueryValidatorDelegationsRequest) (*stakingtypes.QueryValidatorDelegationsResponse, error) ValidatorUnbondingDelegations(c context.Context, req *stakingtypes.QueryValidatorUnbondingDelegationsRequest) (*stakingtypes.QueryValidatorUnbondingDelegationsResponse, error) UnbondingDelegation(c context.Context, req *stakingtypes.QueryUnbondingDelegationRequest) (*stakingtypes.QueryUnbondingDelegationResponse, error) DelegatorDelegations(c context.Context, req *stakingtypes.QueryDelegatorDelegationsRequest) (*stakingtypes.QueryDelegatorDelegationsResponse, error) diff --git a/sei-cosmos/x/staking/keeper/delegation.go b/sei-cosmos/x/staking/keeper/delegation.go index 5e89698460..33baff53d6 100644 --- a/sei-cosmos/x/staking/keeper/delegation.go +++ b/sei-cosmos/x/staking/keeper/delegation.go @@ -103,7 +103,7 @@ func (k Keeper) SetDelegation(ctx sdk.Context, delegation types.Delegation) { store := ctx.KVStore(k.storeKey) b := types.MustMarshalDelegation(k.cdc, delegation) store.Set(types.GetDelegationKey(delegatorAddress, valAddr), b) - if delegationByValIndexActive(ctx) { + if k.DelegationByValIndexReady(ctx) { store.Set(types.GetDelegationByValIndexKey(delegatorAddress, valAddr), []byte{}) // index, store empty bytes } } @@ -116,7 +116,7 @@ func (k Keeper) RemoveDelegation(ctx sdk.Context, delegation types.Delegation) { k.BeforeDelegationRemoved(ctx, delegatorAddress, valAddr) store := ctx.KVStore(k.storeKey) store.Delete(types.GetDelegationKey(delegatorAddress, valAddr)) - if delegationByValIndexActive(ctx) { + if k.DelegationByValIndexReady(ctx) { store.Delete(types.GetDelegationByValIndexKey(delegatorAddress, valAddr)) } } diff --git a/sei-cosmos/x/staking/keeper/delegation_index.go b/sei-cosmos/x/staking/keeper/delegation_index.go index 0801658254..00054349ae 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index.go +++ b/sei-cosmos/x/staking/keeper/delegation_index.go @@ -1,109 +1,67 @@ package keeper import ( + "fmt" "time" - "golang.org/x/mod/semver" - sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) -// DelegationByValIndexUpgrade is the upgrade at which SetDelegation and -// RemoveDelegation begin maintaining the validator-indexed delegation store. -const DelegationByValIndexUpgrade = "v6.7" - -// delegationByValIndexActive reports whether SetDelegation and RemoveDelegation -// should maintain the validator-indexed delegation store. -// -// Live execution always does: the current binary only ever executes at/after -// the upgrade that ships this behavior, so a non-tracing block is never a -// pre-upgrade block. The only place pre-upgrade behavior must be reproduced is -// when re-tracing a historical block, where the era is signaled through -// ClosestUpgradeName (see app.RPCContextProvider). -func delegationByValIndexActive(ctx sdk.Context) bool { - if !ctx.IsTracing() { - return true - } - return semver.Compare(ctx.ClosestUpgradeName(), DelegationByValIndexUpgrade) >= 0 -} - -// BackfillDelegationByValIndexResult reports the outcome of a delegation index backfill. -type BackfillDelegationByValIndexResult struct { +// MigrateDelegationByValIndexResult reports the outcome of populating the +// validator-indexed delegation store. +type MigrateDelegationByValIndexResult struct { TotalDelegations int IndexWritten int - AlreadyIndexed int - DryRun bool + AlreadyReady bool Elapsed time.Duration } -// BackfillDelegationByValIndexProgress is a progress snapshot during backfill. -type BackfillDelegationByValIndexProgress struct { - TotalDelegations int - IndexWritten int - AlreadyIndexed int - Elapsed time.Duration +// DelegationByValIndexReady reports whether the validator-indexed delegation store +// is populated at the version this context reads. +// +// The marker is versioned state written by MigrateDelegationByValIndex, so a context +// reading a height before that migration observes it absent. That makes the answer +// correct for historical queries and re-traced blocks without the caller supplying +// an upgrade name or height. +func (k Keeper) DelegationByValIndexReady(ctx sdk.Context) bool { + return ctx.KVStore(k.storeKey).Has(types.DelegationByValIndexReadyKey) } -// BackfillProgress reports incremental backfill progress. Nil disables callbacks. -type BackfillProgress func(BackfillDelegationByValIndexProgress) - -const ( - backfillProgressDelegationInterval = 100_000 - backfillProgressMinInterval = 10 * time.Second -) - -// BackfillDelegationByValIndex writes validator-indexed delegation keys for existing -// delegations. When dryRun is true, delegations are counted but no store writes occur. -func (k Keeper) BackfillDelegationByValIndex(ctx sdk.Context, dryRun bool, progress BackfillProgress) BackfillDelegationByValIndexResult { +// MigrateDelegationByValIndex writes a validator-indexed key for every existing +// delegation and then marks the index ready. It is a no-op once the marker is set. +func (k Keeper) MigrateDelegationByValIndex(ctx sdk.Context) (MigrateDelegationByValIndexResult, error) { start := time.Now() - result := BackfillDelegationByValIndexResult{DryRun: dryRun} - lastProgressReport := start + store := ctx.KVStore(k.storeKey) - reportProgress := func() { - if progress == nil { - return - } - progress(BackfillDelegationByValIndexProgress{ - TotalDelegations: result.TotalDelegations, - IndexWritten: result.IndexWritten, - AlreadyIndexed: result.AlreadyIndexed, - Elapsed: time.Since(start), - }) - lastProgressReport = time.Now() + if store.Has(types.DelegationByValIndexReadyKey) { + return MigrateDelegationByValIndexResult{AlreadyReady: true, Elapsed: time.Since(start)}, nil } - store := ctx.KVStore(k.storeKey) + result := MigrateDelegationByValIndexResult{} iterator := sdk.KVStorePrefixIterator(store, types.DelegationKey) defer func() { _ = iterator.Close() }() for ; iterator.Valid(); iterator.Next() { - delegation := types.MustUnmarshalDelegation(k.cdc, iterator.Value()) - result.TotalDelegations++ - - delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) - valAddr := delegation.GetValidatorAddr() - indexKey := types.GetDelegationByValIndexKey(delegatorAddress, valAddr) - if store.Has(indexKey) { - result.AlreadyIndexed++ - } else { - if !dryRun { - store.Set(indexKey, []byte{}) - } - result.IndexWritten++ + delegation, err := types.UnmarshalDelegation(k.cdc, iterator.Value()) + if err != nil { + return result, fmt.Errorf("unmarshal delegation at key %X: %w", iterator.Key(), err) + } + delAddr, err := sdk.AccAddressFromBech32(delegation.DelegatorAddress) + if err != nil { + return result, fmt.Errorf("parse delegator address %q: %w", delegation.DelegatorAddress, err) } - if progress == nil { + result.TotalDelegations++ + indexKey := types.GetDelegationByValIndexKey(delAddr, delegation.GetValidatorAddr()) + if store.Has(indexKey) { continue } - now := time.Now() - if result.TotalDelegations == 1 || - result.TotalDelegations%backfillProgressDelegationInterval == 0 || - now.Sub(lastProgressReport) >= backfillProgressMinInterval { - reportProgress() - } + store.Set(indexKey, []byte{}) + result.IndexWritten++ } + store.Set(types.DelegationByValIndexReadyKey, []byte{}) result.Elapsed = time.Since(start) - return result + return result, nil } diff --git a/sei-cosmos/x/staking/keeper/delegation_index_test.go b/sei-cosmos/x/staking/keeper/delegation_index_test.go index 7f08080429..8e2037906a 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index_test.go +++ b/sei-cosmos/x/staking/keeper/delegation_index_test.go @@ -1,15 +1,33 @@ package keeper_test import ( + "bytes" + "encoding/binary" "testing" "github.com/stretchr/testify/require" + seiapp "github.com/sei-protocol/sei-chain/app" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/types/query" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/keeper" "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) -func TestDelegationByValIndexDualWrite(t *testing.T) { +// seedDelegations writes delegations straight to the store, bypassing SetDelegation, +// so the index state under test is only what the migration produced. +func seedDelegations(t *testing.T, app *seiapp.App, ctx sdk.Context, delegations []types.Delegation) { + t.Helper() + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + for _, delegation := range delegations { + store.Set( + types.GetDelegationKey(sdk.MustAccAddressFromBech32(delegation.DelegatorAddress), delegation.GetValidatorAddr()), + types.MustMarshalDelegation(app.AppCodec(), delegation), + ) + } +} + +func TestDelegationByValIndexNotReadyNoDualWrite(t *testing.T) { _, app, ctx := createTestInput(t) addrDels, valAddrs := generateAddresses(app, ctx, 1) @@ -18,20 +36,19 @@ func TestDelegationByValIndexDualWrite(t *testing.T) { store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) indexKey := types.GetDelegationByValIndexKey(addrDels[0], valAddrs[0]) - require.False(t, store.Has(indexKey)) + require.False(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) app.StakingKeeper.SetDelegation(ctx, delegation) - require.True(t, store.Has(indexKey)) - require.NotNil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) - - app.StakingKeeper.RemoveDelegation(ctx, delegation) require.False(t, store.Has(indexKey)) - require.Nil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) + require.NotNil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) } -func TestDelegationByValIndexTracingPreUpgradeNoDualWrite(t *testing.T) { +func TestDelegationByValIndexDualWriteAfterMigration(t *testing.T) { _, app, ctx := createTestInput(t) - ctx = ctx.WithIsTracing(true).WithClosestUpgradeName("v6.6") + + _, err := app.StakingKeeper.MigrateDelegationByValIndex(ctx) + require.NoError(t, err) + require.True(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) addrDels, valAddrs := generateAddresses(app, ctx, 1) delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) @@ -40,7 +57,7 @@ func TestDelegationByValIndexTracingPreUpgradeNoDualWrite(t *testing.T) { indexKey := types.GetDelegationByValIndexKey(addrDels[0], valAddrs[0]) app.StakingKeeper.SetDelegation(ctx, delegation) - require.False(t, store.Has(indexKey)) + require.True(t, store.Has(indexKey)) require.NotNil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) app.StakingKeeper.RemoveDelegation(ctx, delegation) @@ -48,64 +65,145 @@ func TestDelegationByValIndexTracingPreUpgradeNoDualWrite(t *testing.T) { require.Nil(t, store.Get(types.GetDelegationKey(addrDels[0], valAddrs[0]))) } -func TestDelegationByValIndexTracingPostUpgradeDualWrite(t *testing.T) { +func TestMigrateDelegationByValIndex(t *testing.T) { _, app, ctx := createTestInput(t) - ctx = ctx.WithIsTracing(true).WithClosestUpgradeName("v6.7") - addrDels, valAddrs := generateAddresses(app, ctx, 1) - delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) + addrDels, valAddrs := generateAddresses(app, ctx, 2) + delegations := []types.Delegation{ + types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)), + types.NewDelegation(addrDels[0], valAddrs[1], sdk.NewDec(2)), + types.NewDelegation(addrDels[1], valAddrs[0], sdk.NewDec(3)), + } + seedDelegations(t, app, ctx, delegations) + + result, err := app.StakingKeeper.MigrateDelegationByValIndex(ctx) + require.NoError(t, err) + require.Equal(t, 3, result.TotalDelegations) + require.Equal(t, 3, result.IndexWritten) + require.False(t, result.AlreadyReady) + require.True(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) - indexKey := types.GetDelegationByValIndexKey(addrDels[0], valAddrs[0]) + for _, delegation := range delegations { + delAddr := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) + require.True(t, store.Has(types.GetDelegationByValIndexKey(delAddr, delegation.GetValidatorAddr()))) + } - app.StakingKeeper.SetDelegation(ctx, delegation) - require.True(t, store.Has(indexKey)) + repeat, err := app.StakingKeeper.MigrateDelegationByValIndex(ctx) + require.NoError(t, err) + require.True(t, repeat.AlreadyReady) + require.Equal(t, 0, repeat.IndexWritten) } -func TestBackfillDelegationByValIndex(t *testing.T) { +// TestMigrateDelegationByValIndexNoOrphans pins the invariant the index exists to +// uphold: every delegation is indexed, and every index entry resolves to a delegation. +func TestMigrateDelegationByValIndexNoOrphans(t *testing.T) { _, app, ctx := createTestInput(t) - addrDels, valAddrs := generateAddresses(app, ctx, 2) - delegations := []types.Delegation{ + addrDels, valAddrs := generateAddresses(app, ctx, 3) + seedDelegations(t, app, ctx, []types.Delegation{ types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)), - types.NewDelegation(addrDels[0], valAddrs[1], sdk.NewDec(2)), - types.NewDelegation(addrDels[1], valAddrs[0], sdk.NewDec(3)), + types.NewDelegation(addrDels[1], valAddrs[0], sdk.NewDec(2)), + types.NewDelegation(addrDels[2], valAddrs[1], sdk.NewDec(3)), + }) + + _, err := app.StakingKeeper.MigrateDelegationByValIndex(ctx) + require.NoError(t, err) + + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + + delegationKeys := map[string]bool{} + delIter := sdk.KVStorePrefixIterator(store, types.DelegationKey) + for ; delIter.Valid(); delIter.Next() { + delegationKeys[string(delIter.Key())] = true } + require.NoError(t, delIter.Close()) + + indexed := map[string]bool{} + idxIter := sdk.KVStorePrefixIterator(store, types.DelegationByValIndexKey) + for ; idxIter.Valid(); idxIter.Next() { + resolved := types.GetDelegationKeyFromValIndexKey(idxIter.Key()) + require.True(t, store.Has(resolved), "index entry resolves to no delegation") + indexed[string(resolved)] = true + } + require.NoError(t, idxIter.Close()) + + require.Equal(t, delegationKeys, indexed) +} +// maxScanDelegator sorts after every test delegator address, so a filtered scan for +// its validator must traverse the whole delegation keyspace to reach it. +var maxScanDelegator = sdk.AccAddress(bytes.Repeat([]byte{0xff}, 20)) + +// TestGRPCQueryValidatorDelegationsIndexedBeyondScanLimit pins the defect the index +// closes: past query.MaxScanLimit entries the filtered scan reverts, and reading the +// per-validator prefix answers the same request. +func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexedBeyondScanLimit() { + app, ctx := suite.app, suite.ctx + querier := keeper.Querier{Keeper: app.StakingKeeper} + + targetVal := suite.vals[1].GetOperator() + fillerVal := suite.vals[0].GetOperator() store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) - for _, delegation := range delegations { - delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) - valAddr := delegation.GetValidatorAddr() + + for i := 0; i <= int(query.MaxScanLimit); i++ { + delAddr := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(delAddr[12:], uint64(i)) + delegation := types.NewDelegation(delAddr, fillerVal, sdk.NewDec(1)) store.Set( - types.GetDelegationKey(delegatorAddress, valAddr), + types.GetDelegationKey(delAddr, fillerVal), types.MustMarshalDelegation(app.AppCodec(), delegation), ) } - - dryRunResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, true, nil) - require.Equal(t, 3, dryRunResult.TotalDelegations) - require.Equal(t, 3, dryRunResult.IndexWritten) - require.Equal(t, 0, dryRunResult.AlreadyIndexed) - require.True(t, dryRunResult.DryRun) - for _, delegation := range delegations { - delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) - valAddr := delegation.GetValidatorAddr() - require.False(t, store.Has(types.GetDelegationByValIndexKey(delegatorAddress, valAddr))) + target := types.NewDelegation(maxScanDelegator, targetVal, sdk.NewDec(1)) + store.Set( + types.GetDelegationKey(maxScanDelegator, targetVal), + types.MustMarshalDelegation(app.AppCodec(), target), + ) + + req := &types.QueryValidatorDelegationsRequest{ValidatorAddr: targetVal.String()} + + // Before the migration the indexed query falls through to the scan, so it reverts + // exactly as the query does at heights below the one that populated the index. + _, err := querier.ValidatorDelegations(sdk.WrapSDKContext(ctx), req) + suite.Error(err) + _, err = querier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), req) + suite.Error(err) + + _, err = app.StakingKeeper.MigrateDelegationByValIndex(ctx) + suite.NoError(err) + + res, err := querier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), req) + suite.NoError(err) + delegators := make([]string, 0, len(res.DelegationResponses)) + for _, dr := range res.DelegationResponses { + suite.Equal(targetVal.String(), dr.Delegation.ValidatorAddress) + delegators = append(delegators, dr.Delegation.DelegatorAddress) } + suite.Contains(delegators, maxScanDelegator.String()) - writeResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, false, nil) - require.Equal(t, 3, writeResult.TotalDelegations) - require.Equal(t, 3, writeResult.IndexWritten) - require.Equal(t, 0, writeResult.AlreadyIndexed) - require.False(t, writeResult.DryRun) - for _, delegation := range delegations { - delegatorAddress := sdk.MustAccAddressFromBech32(delegation.DelegatorAddress) - valAddr := delegation.GetValidatorAddr() - require.True(t, store.Has(types.GetDelegationByValIndexKey(delegatorAddress, valAddr))) - } + // The unindexed query is untouched, so the legacy precompiles that call it keep + // the behavior they have today. + _, err = querier.ValidatorDelegations(sdk.WrapSDKContext(ctx), req) + suite.Error(err) +} + +// TestGRPCQueryValidatorDelegationsIndexedMatchesScan pins that the two read paths +// agree on a validator small enough for both to answer. +func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexedMatchesScan() { + app, ctx := suite.app, suite.ctx + querier := keeper.Querier{Keeper: app.StakingKeeper} + + req := &types.QueryValidatorDelegationsRequest{ValidatorAddr: suite.vals[1].GetOperator().String()} + + scanned, err := querier.ValidatorDelegations(sdk.WrapSDKContext(ctx), req) + suite.NoError(err) + + _, err = app.StakingKeeper.MigrateDelegationByValIndex(ctx) + suite.NoError(err) + + indexed, err := querier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), req) + suite.NoError(err) - repeatResult := app.StakingKeeper.BackfillDelegationByValIndex(ctx, false, nil) - require.Equal(t, 3, repeatResult.TotalDelegations) - require.Equal(t, 0, repeatResult.IndexWritten) - require.Equal(t, 3, repeatResult.AlreadyIndexed) + suite.ElementsMatch(scanned.DelegationResponses, indexed.DelegationResponses) } diff --git a/sei-cosmos/x/staking/keeper/grpc_query.go b/sei-cosmos/x/staking/keeper/grpc_query.go index a52ce76371..c1e5c0ca5c 100644 --- a/sei-cosmos/x/staking/keeper/grpc_query.go +++ b/sei-cosmos/x/staking/keeper/grpc_query.go @@ -145,6 +145,61 @@ func (k Querier) ValidatorDelegations(c context.Context, req *types.QueryValidat DelegationResponses: delResponses, Pagination: pageRes}, nil } +// ValidatorDelegationsIndexed queries delegate info for a given validator, reading the +// validator-indexed delegation store when it is populated. +// +// The indexed prefix holds only this validator's delegations, so iteration tracks page +// size instead of the total delegation count. That keeps the scan inside the limit +// query.FilteredPaginateV66 enforces on consensus execution. +func (k Querier) ValidatorDelegationsIndexed(c context.Context, req *types.QueryValidatorDelegationsRequest) (*types.QueryValidatorDelegationsResponse, error) { + if req == nil { + return nil, status.Error(codes.InvalidArgument, "empty request") + } + + if req.ValidatorAddr == "" { + return nil, status.Error(codes.InvalidArgument, "validator address cannot be empty") + } + + ctx := sdk.UnwrapSDKContext(c) + if !k.DelegationByValIndexReady(ctx) { + // Below the height that populated the index the entries do not exist, so the + // full scan is the only readable source. It reverts on validators too large to + // scan within the limit, which is what the same query did at those heights. + return k.ValidatorDelegations(c, req) + } + + valAddr, err := sdk.ValAddressFromBech32(req.ValidatorAddr) + if err != nil { + return nil, err + } + + var delegations []types.Delegation + store := ctx.KVStore(k.storeKey) + valPrefix := types.GetDelegationsByValIndexKey(valAddr) + indexStore := prefix.NewStore(store, valPrefix) + + pageRes, err := query.Paginate(indexStore, req.Pagination, func(key []byte, _ []byte) error { + storeKey := types.GetDelegationKeyFromValIndexKey(append(valPrefix, key...)) + delegation, err := types.UnmarshalDelegation(k.cdc, store.Get(storeKey)) + if err != nil { + return err + } + delegations = append(delegations, delegation) + return nil + }) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + delResponses, err := DelegationsToDelegationResponses(ctx, k.Keeper, delegations) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + return &types.QueryValidatorDelegationsResponse{ + DelegationResponses: delResponses, Pagination: pageRes}, nil +} + // ValidatorUnbondingDelegations queries unbonding delegations of a validator func (k Querier) ValidatorUnbondingDelegations(c context.Context, req *types.QueryValidatorUnbondingDelegationsRequest) (*types.QueryValidatorUnbondingDelegationsResponse, error) { if req == nil { diff --git a/sei-cosmos/x/staking/simulation/decoder.go b/sei-cosmos/x/staking/simulation/decoder.go index b6c3dd9eaf..ee595a27b1 100644 --- a/sei-cosmos/x/staking/simulation/decoder.go +++ b/sei-cosmos/x/staking/simulation/decoder.go @@ -41,7 +41,8 @@ func NewDecodeStore(cdc codec.Codec) func(kvA, kvB kv.Pair) string { cdc.MustUnmarshal(kvB.Value, &delegationB) return fmt.Sprintf("%v\n%v", delegationA, delegationB) - case bytes.Equal(kvA.Key[:1], types.DelegationByValIndexKey): + case bytes.Equal(kvA.Key[:1], types.DelegationByValIndexKey), + bytes.Equal(kvA.Key[:1], types.DelegationByValIndexReadyKey): return fmt.Sprintf("%X\n%X", kvA.Key, kvB.Key) case bytes.Equal(kvA.Key[:1], types.UnbondingDelegationKey), bytes.Equal(kvA.Key[:1], types.UnbondingDelegationByValIndexKey): diff --git a/sei-cosmos/x/staking/types/keys.go b/sei-cosmos/x/staking/types/keys.go index 94763d47e0..76c0a64022 100644 --- a/sei-cosmos/x/staking/types/keys.go +++ b/sei-cosmos/x/staking/types/keys.go @@ -43,6 +43,7 @@ var ( RedelegationByValSrcIndexKey = []byte{0x35} // prefix for each key for an redelegation, by source validator operator RedelegationByValDstIndexKey = []byte{0x36} // prefix for each key for an redelegation, by destination validator operator DelegationByValIndexKey = []byte{0x37} // prefix for each key for a delegation, by validator operator + DelegationByValIndexReadyKey = []byte{0x38} // marker: the delegation-by-validator index is populated UnbondingQueueKey = []byte{0x41} // prefix for the timestamps in unbonding queue RedelegationQueueKey = []byte{0x42} // prefix for the timestamps in redelegations queue diff --git a/tools/cmd.go b/tools/cmd.go index d0902812b3..882056784d 100644 --- a/tools/cmd.go +++ b/tools/cmd.go @@ -3,7 +3,6 @@ package tools import ( "github.com/spf13/cobra" - stakingcmd "github.com/sei-protocol/sei-chain/tools/staking/cmd" scanner "github.com/sei-protocol/sei-chain/tools/tx-scanner/cmd" ) @@ -13,6 +12,5 @@ func ToolCmd() *cobra.Command { Short: "A set of useful tools for sei chain", } toolsCmd.AddCommand(scanner.ScanCmd()) - toolsCmd.AddCommand(stakingcmd.StakingCmd()) return toolsCmd } diff --git a/tools/staking/cmd/backfill.go b/tools/staking/cmd/backfill.go deleted file mode 100644 index b83f88bd6b..0000000000 --- a/tools/staking/cmd/backfill.go +++ /dev/null @@ -1,135 +0,0 @@ -package cmd - -import ( - "bufio" - "fmt" - "os" - - tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" - "github.com/spf13/cobra" - - "github.com/sei-protocol/sei-chain/app" - "github.com/sei-protocol/sei-chain/sei-cosmos/client/input" - stakingkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/keeper" - stakingtool "github.com/sei-protocol/sei-chain/tools/staking" -) - -const backfillDelegationIndexNotice = `This command is for benchmarking and analysis only. It measures how long it -takes to populate the validator-indexed delegation secondary index. Do NOT use -it to migrate live chain state. - -How to use: - 1. Stop the node whose state you want to measure. - 2. Copy the node home directory to a path outside your real Sei data directory - (for example: cp -a ~/.sei /tmp/sei-backfill-bench). - 3. Run a dry-run against the copy (default; does not modify state): - seid tools staking backfill-delegation-index --home /path/to/copy - 4. To measure write and commit cost on the copy only, add --write: - seid tools staking backfill-delegation-index --home /path/to/copy --write - 5. Review elapsed time and delegations_per_second in the output. - -Never point --home at a running node's directory. Never use --write on production -data. --write cannot be combined with --height.` - -// BackfillDelegationIndexCmd backfills validator-indexed delegation keys for benchmarking. -func BackfillDelegationIndexCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "backfill-delegation-index", - Short: "Benchmark validator-indexed delegation index population (not for live migration)", - Long: backfillDelegationIndexNotice, - RunE: runBackfillDelegationIndex, - } - - stakingtool.BindServerFlags(cmd, app.DefaultNodeHome) - cmd.Flags().Int64("height", 0, "Load state at this height on the copy (0 = latest committed)") - cmd.Flags().Bool("write", false, "Write index keys and commit state on the copy (benchmark only; never on live data)") - return cmd -} - -func runBackfillDelegationIndex(cmd *cobra.Command, _ []string) error { - height, err := cmd.Flags().GetInt64("height") - if err != nil { - return err - } - write, err := cmd.Flags().GetBool("write") - if err != nil { - return err - } - dryRun := !write - - if write && height > 0 { - return fmt.Errorf("--write cannot be used with --height") - } - - fmt.Fprintln(os.Stderr, backfillDelegationIndexNotice) - if write { - fmt.Fprintln(os.Stderr, "WARNING: --write mutates state in the directory passed to --home. Use a copy, not live data.") - } - - confirmed, err := input.GetConfirmation( - "Proceed with this benchmark run on the directory passed to --home?", - bufio.NewReader(cmd.InOrStdin()), - cmd.ErrOrStderr(), - ) - if err != nil { - return err - } - if !confirmed { - return fmt.Errorf("aborted") - } - - fmt.Fprintln(os.Stderr, "loading application state...") - seiApp, err := stakingtool.LoadApp(cmd, height) - if err != nil { - return err - } - defer func() { - if err := seiApp.Close(); err != nil { - fmt.Fprintf(os.Stderr, "Error closing sei app: %v", err) - } - }() - - blockHeight := seiApp.LastBlockHeight() - if blockHeight == 0 { - blockHeight = 1 - } - - ctx := seiApp.NewUncachedContext(false, tmproto.Header{Height: blockHeight}) - fmt.Fprintln(os.Stderr, "backfill started") - result := seiApp.StakingKeeper.BackfillDelegationByValIndex(ctx, dryRun, reportBackfillProgress) - - fmt.Printf("dry_run=%t height=%d\n", result.DryRun, blockHeight) - fmt.Printf("total_delegations=%d index_written=%d already_indexed=%d elapsed=%s\n", - result.TotalDelegations, - result.IndexWritten, - result.AlreadyIndexed, - result.Elapsed, - ) - if result.TotalDelegations > 0 && result.Elapsed > 0 { - perSec := float64(result.TotalDelegations) / result.Elapsed.Seconds() - fmt.Printf("delegations_per_second=%.2f\n", perSec) - } - - if !write { - return nil - } - - commitID := seiApp.CommitMultiStore().Commit(true) - fmt.Printf("committed_version=%d\n", commitID.Version) - return nil -} - -func reportBackfillProgress(p stakingkeeper.BackfillDelegationByValIndexProgress) { - perSec := 0.0 - if p.Elapsed > 0 { - perSec = float64(p.TotalDelegations) / p.Elapsed.Seconds() - } - fmt.Fprintf(os.Stderr, - "progress total_delegations=%d index_written=%d already_indexed=%d elapsed=%s delegations_per_second=%.2f\n", - p.TotalDelegations, - p.IndexWritten, - p.AlreadyIndexed, - p.Elapsed, - perSec, - ) -} diff --git a/tools/staking/cmd/backfill_test.go b/tools/staking/cmd/backfill_test.go deleted file mode 100644 index b12bf09cd6..0000000000 --- a/tools/staking/cmd/backfill_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "encoding/json" - "io" - "os" - "path/filepath" - "strings" - "testing" - - abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/app" - "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" - "github.com/sei-protocol/sei-chain/sei-cosmos/server" - srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" - seidbconfig "github.com/sei-protocol/sei-chain/sei-db/config" - tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" -) - -type testAppToml struct { - srvconfig.Config - - StateCommit seidbconfig.StateCommitConfig `mapstructure:"state-commit"` - StateStore seidbconfig.StateStoreConfig `mapstructure:"state-store"` - ReceiptStore seidbconfig.ReceiptStoreConfig `mapstructure:"receipt-store"` -} - -func writeTestAppToml(t *testing.T, path string) { - t.Helper() - - cfg := testAppToml{ - Config: *srvconfig.DefaultConfig(), - StateCommit: seidbconfig.DefaultStateCommitConfig(), - StateStore: seidbconfig.DefaultStateStoreConfig(), - ReceiptStore: seidbconfig.DefaultReceiptStoreConfig(), - } - cfg.MinGasPrices = "0.01usei" - cfg.StateCommit.Enable = true - cfg.StateCommit.HashLogger.Enable = false - cfg.StateStore.Enable = true - - template := srvconfig.ManualConfigTemplate + - seidbconfig.StateCommitConfigTemplate + - seidbconfig.StateStoreConfigTemplate + - seidbconfig.ReceiptStoreConfigTemplate - srvconfig.SetConfigTemplate(template) - srvconfig.WriteConfigFile(path, cfg) -} - -func seedCommittedSeiDBApp(t *testing.T, home string) int64 { - t.Helper() - - encCfg := app.MakeEncodingConfig() - seiApp := app.New( - nil, - nil, - false, - map[int64]bool{}, - home, - 1, - true, - tmcfg.TestConfig(), - encCfg, - app.GetWasmEnabledProposals(), - app.TestAppOpts{UseSc: true}, - app.EmptyWasmOpts, - app.EmptyAppOptions, - ) - - genesisState := app.NewDefaultGenesisState(encCfg.Marshaler) - stateBytes, err := json.MarshalIndent(genesisState, "", " ") - require.NoError(t, err) - - _, err = seiApp.InitChain(&abci.RequestInitChain{ - ConsensusParams: app.DefaultConsensusParams, - ChainId: "sei-test", - AppStateBytes: stateBytes, - }) - require.NoError(t, err) - - commitID := seiApp.CommitMultiStore().Commit(true) - require.NoError(t, seiApp.Close()) - return commitID.Version -} - -func backfillCmdWithHome(t *testing.T, home string, args ...string) *cobra.Command { - t.Helper() - - cmd := BackfillDelegationIndexCmd() - serverCtx := server.NewDefaultContext() - cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) - cmd.SetIn(strings.NewReader("y\n")) - serverCtx.Viper.Set(flags.FlagHome, home) - serverCtx.Viper.Set("chain-id", "sei-test") - serverCtx.Viper.SetConfigFile(filepath.Join(home, "config", "app.toml")) - require.NoError(t, serverCtx.Viper.ReadInConfig()) - - cmd.SetArgs(args) - return cmd -} - -func captureCommandOutput(t *testing.T, fn func()) (stdout, stderr string) { - t.Helper() - - stdoutR, stdoutW, err := os.Pipe() - require.NoError(t, err) - stderrR, stderrW, err := os.Pipe() - require.NoError(t, err) - - oldStdout, oldStderr := os.Stdout, os.Stderr - os.Stdout, os.Stderr = stdoutW, stderrW - t.Cleanup(func() { - os.Stdout, os.Stderr = oldStdout, oldStderr - }) - - fn() - - require.NoError(t, stdoutW.Close()) - require.NoError(t, stderrW.Close()) - os.Stdout, os.Stderr = oldStdout, oldStderr - - var stdoutBuf, stderrBuf bytes.Buffer - _, err = io.Copy(&stdoutBuf, stdoutR) - require.NoError(t, err) - _, err = io.Copy(&stderrBuf, stderrR) - require.NoError(t, err) - return stdoutBuf.String(), stderrBuf.String() -} - -func TestBackfillDelegationIndexDryRun(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(home, "config"), 0o755)) - writeTestAppToml(t, filepath.Join(home, "config", "app.toml")) - seedCommittedSeiDBApp(t, home) - - cmd := backfillCmdWithHome(t, home) - stdout, stderr := captureCommandOutput(t, func() { - require.NoError(t, cmd.Execute()) - }) - - require.Contains(t, stdout, "dry_run=true") - require.NotContains(t, stdout, "committed_version=") - require.Contains(t, stderr, "benchmarking and analysis only") - require.Contains(t, stderr, "--home /path/to/copy") -} - -func TestBackfillDelegationIndexWrite(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(home, "config"), 0o755)) - writeTestAppToml(t, filepath.Join(home, "config", "app.toml")) - version := seedCommittedSeiDBApp(t, home) - - cmd := backfillCmdWithHome(t, home, "--write") - stdout, stderr := captureCommandOutput(t, func() { - require.NoError(t, cmd.Execute()) - }) - - require.Contains(t, stdout, "dry_run=false") - require.Contains(t, stdout, "committed_version=") - require.Contains(t, stderr, "benchmarking and analysis only") - require.Contains(t, stderr, "WARNING:") - require.Greater(t, version, int64(0)) -} - -func TestBackfillDelegationIndexWriteRejectsHeight(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(home, "config"), 0o755)) - writeTestAppToml(t, filepath.Join(home, "config", "app.toml")) - - cmd := backfillCmdWithHome(t, home, "--write", "--height", "1") - err := cmd.Execute() - require.Error(t, err) - require.Contains(t, err.Error(), "--write cannot be used with --height") -} - -func TestBackfillDelegationIndexAbortsWithoutConfirmation(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(home, "config"), 0o755)) - writeTestAppToml(t, filepath.Join(home, "config", "app.toml")) - seedCommittedSeiDBApp(t, home) - - cmd := BackfillDelegationIndexCmd() - serverCtx := server.NewDefaultContext() - cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) - cmd.SetIn(strings.NewReader("n\n")) - serverCtx.Viper.Set(flags.FlagHome, home) - serverCtx.Viper.Set("chain-id", "sei-test") - serverCtx.Viper.SetConfigFile(filepath.Join(home, "config", "app.toml")) - require.NoError(t, serverCtx.Viper.ReadInConfig()) - - stdout, stderr := captureCommandOutput(t, func() { - err := cmd.Execute() - require.Error(t, err) - require.Contains(t, err.Error(), "aborted") - }) - - require.Empty(t, stdout) - require.Contains(t, stderr, "benchmarking and analysis only") -} diff --git a/tools/staking/cmd/staking.go b/tools/staking/cmd/staking.go deleted file mode 100644 index f51b63ad9a..0000000000 --- a/tools/staking/cmd/staking.go +++ /dev/null @@ -1,15 +0,0 @@ -package cmd - -import ( - "github.com/spf13/cobra" -) - -// StakingCmd returns offline staking maintenance commands. -func StakingCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "staking", - Short: "Offline staking maintenance tools", - } - cmd.AddCommand(BackfillDelegationIndexCmd()) - return cmd -} diff --git a/tools/staking/loadapp.go b/tools/staking/loadapp.go deleted file mode 100644 index c6981b4f40..0000000000 --- a/tools/staking/loadapp.go +++ /dev/null @@ -1,53 +0,0 @@ -package staking - -import ( - "fmt" - - "github.com/sei-protocol/sei-chain/app" - "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" - "github.com/sei-protocol/sei-chain/sei-cosmos/codec" - "github.com/sei-protocol/sei-chain/sei-cosmos/server" - "github.com/spf13/cast" - "github.com/spf13/cobra" -) - -// LoadApp opens the application at the latest committed height, or at height when -// height is positive. -func LoadApp(cmd *cobra.Command, height int64) (*app.App, error) { - serverCtx := server.GetServerContextFromCmd(cmd) - home := cast.ToString(serverCtx.Viper.Get(flags.FlagHome)) - if home == "" { - return nil, fmt.Errorf("home directory is required") - } - - encCfg := app.MakeEncodingConfig() - encCfg.Marshaler = codec.NewProtoCodec(encCfg.InterfaceRegistry) - - seiApp := app.New( - nil, - nil, - false, - map[int64]bool{}, - home, - 1, - true, - serverCtx.Config, - encCfg, - app.GetWasmEnabledProposals(), - serverCtx.Viper, - app.EmptyWasmOpts, - app.EmptyAppOptions, - ) - if height > 0 { - if err := seiApp.LoadHeight(height); err != nil { - return nil, fmt.Errorf("load height %d: %w", height, err) - } - } - return seiApp, nil -} - -// BindServerFlags wires the home flag for offline app commands. Config loading -// is handled by the root seid PersistentPreRunE. -func BindServerFlags(cmd *cobra.Command, defaultHome string) { - cmd.PersistentFlags().String(flags.FlagHome, defaultHome, "The application home directory") -} From 8143243e3020f8da8fd774d4883d8f5de1f5d766 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Tue, 29 Sep 2026 12:37:07 +0200 Subject: [PATCH 10/17] Fixed compile issue --- sei-cosmos/x/staking/keeper/grpc_query.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-cosmos/x/staking/keeper/grpc_query.go b/sei-cosmos/x/staking/keeper/grpc_query.go index 5acc9ee106..82dacadfb3 100644 --- a/sei-cosmos/x/staking/keeper/grpc_query.go +++ b/sei-cosmos/x/staking/keeper/grpc_query.go @@ -168,7 +168,7 @@ func (k Querier) ValidatorDelegationsIndexed(c context.Context, req *types.Query valPrefix := types.GetDelegationsByValIndexKey(valAddr) indexStore := prefix.NewStore(store, valPrefix) - pageRes, err := query.Paginate(indexStore, req.Pagination, func(key []byte, _ []byte) error { + pageRes, err := query.Paginate(ctx, indexStore, req.Pagination, func(key []byte, _ []byte) error { storeKey := types.GetDelegationKeyFromValIndexKey(append(valPrefix, key...)) delegation, err := types.UnmarshalDelegation(k.cdc, store.Get(storeKey)) if err != nil { From 8fd6d5bd3bc4c0e4616da30b3f9825f5835d13dc Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Tue, 29 Sep 2026 12:59:51 +0200 Subject: [PATCH 11/17] Fix goimports ordering in app/upgrade_test.go CI Go/Lint and lint jobs failed the gofmt -s -l check because the x/upgrade import was out of alphabetical order relative to x/staking/types. Co-Authored-By: Claude Sonnet 5 --- app/upgrade_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/upgrade_test.go b/app/upgrade_test.go index 6893334530..deffd40814 100644 --- a/app/upgrade_test.go +++ b/app/upgrade_test.go @@ -9,8 +9,8 @@ import ( "github.com/sei-protocol/sei-chain/app" "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/keys/secp256k1" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" - "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade" stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade" "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade/types" storekeys "github.com/sei-protocol/sei-chain/sei-db/common/keys" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" From a22a32dcb66fa598aa4e953a8a23177d69e304b7 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 9 Oct 2026 12:36:08 -0700 Subject: [PATCH 12/17] Build the delegation-by-validator index at genesis and pin it at v6.8 InitGenesis now populates the index and sets its ready marker, so chains started from genesis read validator delegations from the index rather than falling back to the full scan for their whole life. The migration no longer probes for an existing entry before each write: no entry can exist while the index is not ready. ValidatorDelegationsIndexed builds each lookup key in a fresh slice and wraps pagination errors as ValidatorDelegations does. The v6.8 upgrade tests cover the handler populating one index entry per delegation, and the offline target phase accepts the index keys as part of the upgrade block's specified writes. Co-Authored-By: Claude Opus 5.5 --- app/upgrade_v68_offline_target_test.go | 39 +++++++++- app/upgrade_v68_test.go | 77 ++++++++++++++++++- sei-cosmos/x/staking/genesis.go | 4 + sei-cosmos/x/staking/genesis_test.go | 30 ++++++++ .../x/staking/keeper/delegation_index.go | 28 +++---- .../x/staking/keeper/delegation_index_test.go | 30 +++++++- sei-cosmos/x/staking/keeper/grpc_query.go | 19 ++--- 7 files changed, 189 insertions(+), 38 deletions(-) diff --git a/app/upgrade_v68_offline_target_test.go b/app/upgrade_v68_offline_target_test.go index 68bb5cbed5..9371d4dea6 100644 --- a/app/upgrade_v68_offline_target_test.go +++ b/app/upgrade_v68_offline_target_test.go @@ -22,6 +22,7 @@ import ( banktypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/bank/types" distrtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/distribution/types" govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" upgradetypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade/types" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" @@ -135,8 +136,8 @@ func applyV68OfflineUpgradeCrashReplay(t *testing.T, root string, artifact offli // is recorded, the retired module versions and stores are gone, the vesting // accounts are rewritten, the retired IBC proposal reads back as a text // proposal, the upgraded IBC client record is pruned, the IBC voucher balance -// and supply are intact and spendable, and nothing else in the retained -// stores changed. +// and supply are intact and spendable, every delegation is indexed by +// validator, and nothing else in the retained stores changed. func requireV68OfflineMigrated(t *testing.T, testApp *App, artifact offlineUpgradeArtifact) { t.Helper() requireV68OfflineAppliedName(t, testApp, artifact) @@ -146,14 +147,16 @@ func requireV68OfflineMigrated(t *testing.T, testApp *App, artifact offlineUpgra requireV68OfflineProposalRewritten(t, testApp, artifact.Retained) requireV68OfflineUpgradedIBCStatePruned(t, testApp, artifact.Retained) requireV68OfflineVoucher(t, testApp, artifact.Retained) + requireV68OfflineDelegationIndex(t, testApp) requireOfflineUpgradeRetainedStoresExcept(t, testApp, artifact.Stores, v68OfflineTouchedKey(t, artifact)) } // v68OfflineTouchedKey reports the retained-store keys the v6.8 upgrade block // is specified to change: the vesting accounts the auth migration rewrites, // the rewritten IBC proposal, the pruned upgraded IBC state, the plan, done -// and version-map entries of the upgrade store, and the bank entries block -// rewards move every block. +// and version-map entries of the upgrade store, the staking +// delegation-by-validator index and its ready marker, and the bank entries +// block rewards move every block. func v68OfflineTouchedKey(t *testing.T, artifact offlineUpgradeArtifact) func(storeName string, key []byte) bool { t.Helper() rewritten := make(map[string]struct{}, len(artifact.VestingAccounts)) @@ -175,6 +178,9 @@ func v68OfflineTouchedKey(t *testing.T, artifact offlineUpgradeArtifact) func(st return len(key) > 0 && (key[0] == upgradetypes.PlanByte || key[0] == upgradetypes.DoneByte || key[0] == upgradetypes.VersionMapByte) case banktypes.StoreKey: return v68OfflineBlockRewardKey(key) + case stakingtypes.StoreKey: + return bytes.HasPrefix(key, stakingtypes.DelegationByValIndexKey) || + bytes.Equal(key, stakingtypes.DelegationByValIndexReadyKey) } return false } @@ -273,6 +279,31 @@ func requireV68OfflineStoresDeleted(t *testing.T, testApp *App) { } } +// requireV68OfflineDelegationIndex requires the delegation-by-validator index +// to be ready and to hold exactly one entry per stored delegation. +func requireV68OfflineDelegationIndex(t *testing.T, testApp *App) { + t.Helper() + ctx := offlineUpgradeReadContext(testApp, testApp.LastBlockHeight()) + require.True(t, testApp.StakingKeeper.DelegationByValIndexReady(ctx)) + store := ctx.KVStore(testApp.GetKey(stakingtypes.StoreKey)) + + delegations := map[string]struct{}{} + iterator := sdk.KVStorePrefixIterator(store, stakingtypes.DelegationKey) + for ; iterator.Valid(); iterator.Next() { + delegations[string(iterator.Key())] = struct{}{} + } + require.NoError(t, iterator.Close()) + require.NotEmpty(t, delegations, "the source phase stored no delegations") + + indexed := map[string]struct{}{} + iterator = sdk.KVStorePrefixIterator(store, stakingtypes.DelegationByValIndexKey) + for ; iterator.Valid(); iterator.Next() { + indexed[string(stakingtypes.GetDelegationKeyFromValIndexKey(iterator.Key()))] = struct{}{} + } + require.NoError(t, iterator.Close()) + require.Equal(t, delegations, indexed, "the index does not hold exactly one entry per delegation") +} + func requireV68OfflineProposalRewritten(t *testing.T, testApp *App, retained offlineUpgradeRetainedState) { t.Helper() ctx := offlineUpgradeReadContext(testApp, testApp.LastBlockHeight()) diff --git a/app/upgrade_v68_test.go b/app/upgrade_v68_test.go index fda271f968..8df1948d38 100644 --- a/app/upgrade_v68_test.go +++ b/app/upgrade_v68_test.go @@ -19,6 +19,8 @@ import ( authtestutil "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/testutil" authtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/types" govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + stakingkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/keeper" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" upgradetypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade/types" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/testutil/processblock" @@ -35,7 +37,7 @@ import ( // the base accounts they embed. These tests cover the rewrite of every vesting // account type, spending a balance a schedule had locked, the messages both // modules served, genesis export over the migrated store, and the handler -// itself. +// itself. The handler also populates the staking delegation-by-validator index. // v68RemovedStoreModules are the module version map entries the v6.8 handler // deletes along with their stores. @@ -312,6 +314,79 @@ func TestV68ApplyUpgradeTwice(t *testing.T) { "second ApplyUpgrade is not a no-op: ApplyUpgrade increments protocol version on every call") } +// unreadyV68DelegationIndex deletes the delegation-by-validator index and its +// ready marker, leaving the staking store as v6.7 left it. +func unreadyV68DelegationIndex(t *testing.T, a *processblock.App) { + t.Helper() + store := a.Ctx().KVStore(a.GetKey(stakingtypes.StoreKey)) + iterator := sdk.KVStorePrefixIterator(store, stakingtypes.DelegationByValIndexKey) + var keys [][]byte + for ; iterator.Valid(); iterator.Next() { + keys = append(keys, iterator.Key()) + } + require.NoError(t, iterator.Close()) + for _, key := range keys { + store.Delete(key) + } + store.Delete(stakingtypes.DelegationByValIndexReadyKey) +} + +// v68DelegationKeys returns the keys of every stored delegation. +func v68DelegationKeys(t *testing.T, store sdk.KVStore) map[string]struct{} { + t.Helper() + return v68PrefixKeys(t, store, stakingtypes.DelegationKey, func(key []byte) []byte { return key }) +} + +// v68IndexedDelegationKeys returns the delegation key every index entry points at. +func v68IndexedDelegationKeys(t *testing.T, store sdk.KVStore) map[string]struct{} { + t.Helper() + return v68PrefixKeys(t, store, stakingtypes.DelegationByValIndexKey, stakingtypes.GetDelegationKeyFromValIndexKey) +} + +func v68PrefixKeys(t *testing.T, store sdk.KVStore, prefix []byte, resolve func([]byte) []byte) map[string]struct{} { + t.Helper() + iterator := sdk.KVStorePrefixIterator(store, prefix) + defer func() { require.NoError(t, iterator.Close()) }() + keys := map[string]struct{}{} + for ; iterator.Valid(); iterator.Next() { + keys[string(resolve(iterator.Key()))] = struct{}{} + } + return keys +} + +func TestV68PopulatesDelegationByValIndex(t *testing.T) { + a := newV68Chain(t) + unreadyV68DelegationIndex(t, a) + ctx := a.Ctx() + store := ctx.KVStore(a.GetKey(stakingtypes.StoreKey)) + + validators := a.StakingKeeper.GetAllValidators(ctx) + require.NotEmpty(t, validators) + valAddr := validators[0].GetOperator() + delAddr := a.NewAccount() + a.StakingKeeper.SetDelegation(ctx, stakingtypes.NewDelegation(delAddr, valAddr, sdk.NewDec(68))) + require.False(t, store.Has(stakingtypes.GetDelegationByValIndexKey(delAddr, valAddr)), + "a delegation written before v6.8 is indexed") + + applyV68(t, a) + + require.True(t, a.StakingKeeper.DelegationByValIndexReady(ctx)) + delegations := v68DelegationKeys(t, store) + require.Contains(t, delegations, string(stakingtypes.GetDelegationKey(delAddr, valAddr))) + require.Equal(t, delegations, v68IndexedDelegationKeys(t, store), + "the index does not hold exactly one entry per delegation") + + querier := stakingkeeper.Querier{Keeper: a.StakingKeeper} + res, err := querier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), + &stakingtypes.QueryValidatorDelegationsRequest{ValidatorAddr: valAddr.String()}) + require.NoError(t, err) + var delegators []string + for _, response := range res.DelegationResponses { + delegators = append(delegators, response.Delegation.DelegatorAddress) + } + require.Contains(t, delegators, delAddr.String()) +} + // TestV68RejectsOracleTxsWithoutCharging pins that retired oracle transactions // are refused before fees are charged. func TestV68RejectsOracleTxsWithoutCharging(t *testing.T) { diff --git a/sei-cosmos/x/staking/genesis.go b/sei-cosmos/x/staking/genesis.go index d0f29315bd..48d6451113 100644 --- a/sei-cosmos/x/staking/genesis.go +++ b/sei-cosmos/x/staking/genesis.go @@ -76,6 +76,10 @@ func InitGenesis( } } + if _, err := keeper.MigrateDelegationByValIndex(ctx); err != nil { + panic(err) + } + for _, ubd := range data.UnbondingDelegations { keeper.SetUnbondingDelegation(ctx, ubd) diff --git a/sei-cosmos/x/staking/genesis_test.go b/sei-cosmos/x/staking/genesis_test.go index 9608dd5906..f8638e9fb2 100644 --- a/sei-cosmos/x/staking/genesis_test.go +++ b/sei-cosmos/x/staking/genesis_test.go @@ -97,6 +97,36 @@ func TestInitGenesis(t *testing.T) { require.Equal(t, abcivals, vals) } +func TestInitGenesisIndexesDelegationsByValidator(t *testing.T) { + app, ctx, addrs := bootstrapGenesisTest(t, 2) + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + store.Delete(types.DelegationByValIndexReadyKey) + + valTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 1) + params := app.StakingKeeper.GetParams(ctx) + pk, err := codectypes.NewAnyWithValue(PKs[0]) + require.NoError(t, err) + valAddr := sdk.ValAddress(addrs[0]) + validator := types.Validator{ + OperatorAddress: valAddr.String(), + ConsensusPubkey: pk, + Status: types.Bonded, + Tokens: valTokens, + DelegatorShares: valTokens.ToDec(), + Description: types.NewDescription("hoop", "", "", "", ""), + } + validators := append(app.StakingKeeper.GetAllValidators(ctx), validator) + require.NoError(t, apptesting.FundModuleAccount(app.BankKeeper, ctx, types.BondedPoolName, + sdk.NewCoins(sdk.NewCoin(params.BondDenom, valTokens.MulRaw(int64(len(validators))))))) + delegation := types.NewDelegation(addrs[1], valAddr, valTokens.ToDec()) + + staking.InitGenesis(ctx, app.StakingKeeper, app.AccountKeeper, app.BankKeeper, + types.NewGenesisState(params, validators, []types.Delegation{delegation})) + + require.True(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) + require.True(t, store.Has(types.GetDelegationByValIndexKey(addrs[1], valAddr))) +} + func TestInitGenesis_PoolsBalanceMismatch(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.NewContext(false, tmproto.Header{}) diff --git a/sei-cosmos/x/staking/keeper/delegation_index.go b/sei-cosmos/x/staking/keeper/delegation_index.go index 00054349ae..933a6d55d1 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index.go +++ b/sei-cosmos/x/staking/keeper/delegation_index.go @@ -9,32 +9,26 @@ import ( ) // MigrateDelegationByValIndexResult reports the outcome of populating the -// validator-indexed delegation store. +// delegation-by-validator index. type MigrateDelegationByValIndexResult struct { TotalDelegations int - IndexWritten int AlreadyReady bool Elapsed time.Duration } -// DelegationByValIndexReady reports whether the validator-indexed delegation store -// is populated at the version this context reads. -// -// The marker is versioned state written by MigrateDelegationByValIndex, so a context -// reading a height before that migration observes it absent. That makes the answer -// correct for historical queries and re-traced blocks without the caller supplying -// an upgrade name or height. +// DelegationByValIndexReady reports whether the delegation-by-validator index is +// populated in the state this context reads. func (k Keeper) DelegationByValIndexReady(ctx sdk.Context) bool { return ctx.KVStore(k.storeKey).Has(types.DelegationByValIndexReadyKey) } -// MigrateDelegationByValIndex writes a validator-indexed key for every existing -// delegation and then marks the index ready. It is a no-op once the marker is set. +// MigrateDelegationByValIndex writes an index entry for every stored delegation and +// marks the index ready. It is a no-op once the index is ready. func (k Keeper) MigrateDelegationByValIndex(ctx sdk.Context) (MigrateDelegationByValIndexResult, error) { start := time.Now() store := ctx.KVStore(k.storeKey) - if store.Has(types.DelegationByValIndexReadyKey) { + if k.DelegationByValIndexReady(ctx) { return MigrateDelegationByValIndexResult{AlreadyReady: true, Elapsed: time.Since(start)}, nil } @@ -42,6 +36,8 @@ func (k Keeper) MigrateDelegationByValIndex(ctx sdk.Context) (MigrateDelegationB iterator := sdk.KVStorePrefixIterator(store, types.DelegationKey) defer func() { _ = iterator.Close() }() + // SetDelegation writes no index entry while the index is not ready, so none + // exists yet and each one can be written without checking for it first. for ; iterator.Valid(); iterator.Next() { delegation, err := types.UnmarshalDelegation(k.cdc, iterator.Value()) if err != nil { @@ -51,14 +47,8 @@ func (k Keeper) MigrateDelegationByValIndex(ctx sdk.Context) (MigrateDelegationB if err != nil { return result, fmt.Errorf("parse delegator address %q: %w", delegation.DelegatorAddress, err) } - + store.Set(types.GetDelegationByValIndexKey(delAddr, delegation.GetValidatorAddr()), []byte{}) result.TotalDelegations++ - indexKey := types.GetDelegationByValIndexKey(delAddr, delegation.GetValidatorAddr()) - if store.Has(indexKey) { - continue - } - store.Set(indexKey, []byte{}) - result.IndexWritten++ } store.Set(types.DelegationByValIndexReadyKey, []byte{}) diff --git a/sei-cosmos/x/staking/keeper/delegation_index_test.go b/sei-cosmos/x/staking/keeper/delegation_index_test.go index 8e2037906a..3b9a742669 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index_test.go +++ b/sei-cosmos/x/staking/keeper/delegation_index_test.go @@ -27,8 +27,27 @@ func seedDelegations(t *testing.T, app *seiapp.App, ctx sdk.Context, delegations } } +// unreadyDelegationByValIndex deletes the index and its ready marker, leaving the +// store as a chain that has not run the migration holds it. +func unreadyDelegationByValIndex(t *testing.T, app *seiapp.App, ctx sdk.Context) { + t.Helper() + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + iterator := sdk.KVStorePrefixIterator(store, types.DelegationByValIndexKey) + var keys [][]byte + for ; iterator.Valid(); iterator.Next() { + keys = append(keys, iterator.Key()) + } + require.NoError(t, iterator.Close()) + for _, key := range keys { + store.Delete(key) + } + store.Delete(types.DelegationByValIndexReadyKey) + require.False(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) +} + func TestDelegationByValIndexNotReadyNoDualWrite(t *testing.T) { _, app, ctx := createTestInput(t) + unreadyDelegationByValIndex(t, app, ctx) addrDels, valAddrs := generateAddresses(app, ctx, 1) delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) @@ -45,6 +64,7 @@ func TestDelegationByValIndexNotReadyNoDualWrite(t *testing.T) { func TestDelegationByValIndexDualWriteAfterMigration(t *testing.T) { _, app, ctx := createTestInput(t) + unreadyDelegationByValIndex(t, app, ctx) _, err := app.StakingKeeper.MigrateDelegationByValIndex(ctx) require.NoError(t, err) @@ -67,6 +87,8 @@ func TestDelegationByValIndexDualWriteAfterMigration(t *testing.T) { func TestMigrateDelegationByValIndex(t *testing.T) { _, app, ctx := createTestInput(t) + unreadyDelegationByValIndex(t, app, ctx) + genesisDelegations := len(app.StakingKeeper.GetAllDelegations(ctx)) addrDels, valAddrs := generateAddresses(app, ctx, 2) delegations := []types.Delegation{ @@ -78,8 +100,7 @@ func TestMigrateDelegationByValIndex(t *testing.T) { result, err := app.StakingKeeper.MigrateDelegationByValIndex(ctx) require.NoError(t, err) - require.Equal(t, 3, result.TotalDelegations) - require.Equal(t, 3, result.IndexWritten) + require.Equal(t, genesisDelegations+3, result.TotalDelegations) require.False(t, result.AlreadyReady) require.True(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) @@ -92,13 +113,14 @@ func TestMigrateDelegationByValIndex(t *testing.T) { repeat, err := app.StakingKeeper.MigrateDelegationByValIndex(ctx) require.NoError(t, err) require.True(t, repeat.AlreadyReady) - require.Equal(t, 0, repeat.IndexWritten) + require.Zero(t, repeat.TotalDelegations) } // TestMigrateDelegationByValIndexNoOrphans pins the invariant the index exists to // uphold: every delegation is indexed, and every index entry resolves to a delegation. func TestMigrateDelegationByValIndexNoOrphans(t *testing.T) { _, app, ctx := createTestInput(t) + unreadyDelegationByValIndex(t, app, ctx) addrDels, valAddrs := generateAddresses(app, ctx, 3) seedDelegations(t, app, ctx, []types.Delegation{ @@ -141,6 +163,7 @@ var maxScanDelegator = sdk.AccAddress(bytes.Repeat([]byte{0xff}, 20)) func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexedBeyondScanLimit() { app, ctx := suite.app, suite.ctx querier := keeper.Querier{Keeper: app.StakingKeeper} + unreadyDelegationByValIndex(suite.T(), app, ctx) targetVal := suite.vals[1].GetOperator() fillerVal := suite.vals[0].GetOperator() @@ -194,6 +217,7 @@ func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexedMatchesSca app, ctx := suite.app, suite.ctx querier := keeper.Querier{Keeper: app.StakingKeeper} + unreadyDelegationByValIndex(suite.T(), app, ctx) req := &types.QueryValidatorDelegationsRequest{ValidatorAddr: suite.vals[1].GetOperator().String()} scanned, err := querier.ValidatorDelegations(sdk.WrapSDKContext(ctx), req) diff --git a/sei-cosmos/x/staking/keeper/grpc_query.go b/sei-cosmos/x/staking/keeper/grpc_query.go index 82dacadfb3..5005dd9edd 100644 --- a/sei-cosmos/x/staking/keeper/grpc_query.go +++ b/sei-cosmos/x/staking/keeper/grpc_query.go @@ -135,12 +135,9 @@ func (k Querier) ValidatorDelegations(c context.Context, req *types.QueryValidat DelegationResponses: delResponses, Pagination: pageRes}, nil } -// ValidatorDelegationsIndexed queries delegate info for a given validator, reading the -// validator-indexed delegation store when it is populated. -// -// The indexed prefix holds only this validator's delegations, so iteration tracks page -// size instead of the total delegation count. That keeps the scan inside the limit -// query.FilteredPaginateV66 enforces on consensus execution. +// ValidatorDelegationsIndexed queries the delegations of a validator through the +// delegation-by-validator index. It answers as ValidatorDelegations does until the +// index is ready. func (k Querier) ValidatorDelegationsIndexed(c context.Context, req *types.QueryValidatorDelegationsRequest) (*types.QueryValidatorDelegationsResponse, error) { if req == nil { return nil, status.Error(codes.InvalidArgument, "empty request") @@ -152,9 +149,8 @@ func (k Querier) ValidatorDelegationsIndexed(c context.Context, req *types.Query ctx := sdk.UnwrapSDKContext(c) if !k.DelegationByValIndexReady(ctx) { - // Below the height that populated the index the entries do not exist, so the - // full scan is the only readable source. It reverts on validators too large to - // scan within the limit, which is what the same query did at those heights. + // Before the index is populated the full scan is the only source, and it fails + // exactly where this query failed at those heights. return k.ValidatorDelegations(c, req) } @@ -169,7 +165,8 @@ func (k Querier) ValidatorDelegationsIndexed(c context.Context, req *types.Query indexStore := prefix.NewStore(store, valPrefix) pageRes, err := query.Paginate(ctx, indexStore, req.Pagination, func(key []byte, _ []byte) error { - storeKey := types.GetDelegationKeyFromValIndexKey(append(valPrefix, key...)) + indexKey := append(append([]byte{}, valPrefix...), key...) + storeKey := types.GetDelegationKeyFromValIndexKey(indexKey) delegation, err := types.UnmarshalDelegation(k.cdc, store.Get(storeKey)) if err != nil { return err @@ -178,7 +175,7 @@ func (k Querier) ValidatorDelegationsIndexed(c context.Context, req *types.Query return nil }) if err != nil { - return nil, status.Error(codes.Internal, err.Error()) + return nil, query.WrapGRPCError(err) } delResponses, err := DelegationsToDelegationResponses(ctx, k.Keeper, delegations) From 020f9ad3b668d067c1580f65f939010dcda46b9e Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 9 Oct 2026 13:07:05 -0700 Subject: [PATCH 13/17] Seed delegations in the v6.8 offline source phase The v6.7 fixture held no delegations, so the target phase's index check had nothing to compare. The source phase now stores delegations to two validators through the v6.7 keeper. The staking store is not among the snapshotted retained stores, so the touched-key list does not need an index entry. Co-Authored-By: Claude Opus 5.5 --- app/testdata/upgrade_v68_offline_source_test.go | 16 ++++++++++++++++ app/upgrade_v68_offline_target_test.go | 8 ++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/testdata/upgrade_v68_offline_source_test.go b/app/testdata/upgrade_v68_offline_source_test.go index 7979b55b9f..d95771b5fe 100644 --- a/app/testdata/upgrade_v68_offline_source_test.go +++ b/app/testdata/upgrade_v68_offline_source_test.go @@ -21,6 +21,7 @@ import ( authtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/types" vestingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/vesting/types" govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" upgradetypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade/types" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" @@ -58,6 +59,7 @@ func TestV68OfflineUpgradeSource(t *testing.T) { seedV68IBCProposal(t, testApp, ctx, &retained) seedV68UpgradedIBCState(t, testApp, ctx, &retained) seedV68Voucher(t, testApp, ctx, &retained) + seedV68OfflineDelegations(testApp, ctx) requireV68OfflineBalancesLocked(t, testApp, ctx, retained) upgradeHeight := ctx.BlockHeight() + 2 require.NoError(t, testApp.UpgradeKeeper.ScheduleUpgrade(ctx, upgradetypes.Plan{ @@ -187,6 +189,20 @@ func seedV68Voucher(t *testing.T, testApp *App, ctx sdk.Context, retained *offli retained.VoucherSupply = testApp.BankKeeper.GetSupply(ctx, voucher.Denom).Amount.String() } +// seedV68OfflineDelegations stores delegations to two validators, which v6.7 +// writes without any delegation-by-validator index entry. +func seedV68OfflineDelegations(testApp *App, ctx sdk.Context) { + validators := []sdk.ValAddress{ + sdk.ValAddress(secp256k1.GenPrivKey().PubKey().Address()), + sdk.ValAddress(secp256k1.GenPrivKey().PubKey().Address()), + } + for i := 0; i < 3; i++ { + delegator := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + testApp.StakingKeeper.SetDelegation(ctx, + stakingtypes.NewDelegation(delegator, validators[i%len(validators)], sdk.NewDec(int64(i+1)))) + } +} + // seedV68OfflineVestingAccounts writes one account of every vesting type // through the v6.7 keepers, each keyed and holding a balance its schedule // locks. The delayed vesting account is recorded as the sender of a diff --git a/app/upgrade_v68_offline_target_test.go b/app/upgrade_v68_offline_target_test.go index 9371d4dea6..8b383f439f 100644 --- a/app/upgrade_v68_offline_target_test.go +++ b/app/upgrade_v68_offline_target_test.go @@ -154,9 +154,8 @@ func requireV68OfflineMigrated(t *testing.T, testApp *App, artifact offlineUpgra // v68OfflineTouchedKey reports the retained-store keys the v6.8 upgrade block // is specified to change: the vesting accounts the auth migration rewrites, // the rewritten IBC proposal, the pruned upgraded IBC state, the plan, done -// and version-map entries of the upgrade store, the staking -// delegation-by-validator index and its ready marker, and the bank entries -// block rewards move every block. +// and version-map entries of the upgrade store, and the bank entries block +// rewards move every block. func v68OfflineTouchedKey(t *testing.T, artifact offlineUpgradeArtifact) func(storeName string, key []byte) bool { t.Helper() rewritten := make(map[string]struct{}, len(artifact.VestingAccounts)) @@ -178,9 +177,6 @@ func v68OfflineTouchedKey(t *testing.T, artifact offlineUpgradeArtifact) func(st return len(key) > 0 && (key[0] == upgradetypes.PlanByte || key[0] == upgradetypes.DoneByte || key[0] == upgradetypes.VersionMapByte) case banktypes.StoreKey: return v68OfflineBlockRewardKey(key) - case stakingtypes.StoreKey: - return bytes.HasPrefix(key, stakingtypes.DelegationByValIndexKey) || - bytes.Equal(key, stakingtypes.DelegationByValIndexReadyKey) } return false } From 4828d71ab6db71eda550fd95ea090bee7cf4692e Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 9 Oct 2026 14:23:38 -0700 Subject: [PATCH 14/17] Keep pre-v6.8 delegation gas unchanged by the delegation index The index readiness check on every delegation write was a metered read, and genesis marks the index ready, so EVM regression replays of v6.0.x transactions that touch delegations reported extra gas. Read the marker without charging gas, and clear it in the regression setup for versions before v6.8, alongside the incremental-tally marker. Co-Authored-By: Claude Opus 5.5 --- evmrpc/tests/regression_test.go | 10 +++++++--- sei-cosmos/x/staking/keeper/delegation_index.go | 7 +++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/evmrpc/tests/regression_test.go b/evmrpc/tests/regression_test.go index f1792e0a64..619000a9ff 100644 --- a/evmrpc/tests/regression_test.go +++ b/evmrpc/tests/regression_test.go @@ -13,6 +13,7 @@ import ( "github.com/sei-protocol/sei-chain/evmrpc" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" "github.com/stretchr/testify/require" ) @@ -387,7 +388,7 @@ func testTx(t *testing.T, txHash string, version string, expectedGasUsed string, blockHeight := mockStatesFromTxJson(ctx, txHash, a, mc) ctx = setLegacySstoreIfNeeded(ctx, a, version) ctx = withCapturedConsensusParams(ctx, mc, blockHeight) - removeFutureGovernanceActivation(ctx, a, version) + removeFutureActivations(ctx, a, version) return ctx.WithBlockHeight(blockHeight) }) s.Run( @@ -428,7 +429,7 @@ func testBlock( ) ctx = setLegacySstoreIfNeeded(ctx, a, version) ctx = withCapturedConsensusParams(ctx, mc, blockHeight) - removeFutureGovernanceActivation(ctx, a, version) + removeFutureActivations(ctx, a, version) return ctx.WithBlockHeight(blockHeight) }, ) @@ -460,9 +461,12 @@ func setLegacySstoreIfNeeded(ctx sdk.Context, a *app.App, version string) sdk.Co return ctx } -func removeFutureGovernanceActivation(ctx sdk.Context, a *app.App, version string) { +// removeFutureActivations clears the markers genesis sets for features that mainnet +// only turns on at a later upgrade than version. +func removeFutureActivations(ctx sdk.Context, a *app.App, version string) { if semver.Compare(version, "v6.8") < 0 { ctx.KVStore(a.GetKey(govtypes.StoreKey)).Delete(govtypes.IncrementalTallyEnabledKey) + ctx.KVStore(a.GetKey(stakingtypes.StoreKey)).Delete(stakingtypes.DelegationByValIndexReadyKey) } } diff --git a/sei-cosmos/x/staking/keeper/delegation_index.go b/sei-cosmos/x/staking/keeper/delegation_index.go index 933a6d55d1..eebc596634 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index.go +++ b/sei-cosmos/x/staking/keeper/delegation_index.go @@ -17,9 +17,12 @@ type MigrateDelegationByValIndexResult struct { } // DelegationByValIndexReady reports whether the delegation-by-validator index is -// populated in the state this context reads. +// populated in the state this context reads. It consumes no gas. func (k Keeper) DelegationByValIndexReady(ctx sdk.Context) bool { - return ctx.KVStore(k.storeKey).Has(types.DelegationByValIndexReadyKey) + // Delegation writes call this at every height, so charging for the read would change + // the gas of pre-upgrade transactions that touch a delegation. + gasless := ctx.WithGasMeter(sdk.NewInfiniteGasMeter(1, 1)) + return gasless.KVStore(k.storeKey).Has(types.DelegationByValIndexReadyKey) } // MigrateDelegationByValIndex writes an index entry for every stored delegation and From 43106105f380d4aab0828b25b4d0da66ec51c503 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 9 Oct 2026 15:12:23 -0700 Subject: [PATCH 15/17] fix(staking): preserve v6.8 indexed query --- precompiles/staking/legacy/v68/staking.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/precompiles/staking/legacy/v68/staking.go b/precompiles/staking/legacy/v68/staking.go index d3ca2c4833..651061517b 100644 --- a/precompiles/staking/legacy/v68/staking.go +++ b/precompiles/staking/legacy/v68/staking.go @@ -1004,7 +1004,7 @@ func (p PrecompileExecutor) validatorDelegations(ctx sdk.Context, method *abi.Me }, } - response, err := p.stakingQuerier.ValidatorDelegations(sdk.WrapSDKContext(ctx), request) + response, err := p.stakingQuerier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), request) if err != nil { return nil, 0, err } From 7da5416c668529c83b0da394ddebad0e5883f732 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 12 Oct 2026 16:04:09 -0700 Subject: [PATCH 16/17] Serve ValidatorDelegations from the delegation index once it is ready Fold the indexed read into ValidatorDelegations behind DelegationByValIndexReady instead of a separate ValidatorDelegationsIndexed query, so the precompiles, REST and gRPC share one path. Heights before the index is ready still take the scan and reproduce its results. Co-Authored-By: Claude Opus 5.5 --- app/upgrade_v68_test.go | 2 +- precompiles/staking/legacy/v68/staking.go | 2 +- precompiles/staking/staking.go | 2 +- precompiles/staking/staking_test.go | 4 - precompiles/utils/expected_keepers.go | 1 - .../x/staking/keeper/delegation_index_test.go | 27 ++--- sei-cosmos/x/staking/keeper/grpc_query.go | 103 +++++++++--------- 7 files changed, 63 insertions(+), 78 deletions(-) diff --git a/app/upgrade_v68_test.go b/app/upgrade_v68_test.go index 8df1948d38..bd0d95db8e 100644 --- a/app/upgrade_v68_test.go +++ b/app/upgrade_v68_test.go @@ -377,7 +377,7 @@ func TestV68PopulatesDelegationByValIndex(t *testing.T) { "the index does not hold exactly one entry per delegation") querier := stakingkeeper.Querier{Keeper: a.StakingKeeper} - res, err := querier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), + res, err := querier.ValidatorDelegations(sdk.WrapSDKContext(ctx), &stakingtypes.QueryValidatorDelegationsRequest{ValidatorAddr: valAddr.String()}) require.NoError(t, err) var delegators []string diff --git a/precompiles/staking/legacy/v68/staking.go b/precompiles/staking/legacy/v68/staking.go index 4b63de17bd..054f93d2f4 100644 --- a/precompiles/staking/legacy/v68/staking.go +++ b/precompiles/staking/legacy/v68/staking.go @@ -1022,7 +1022,7 @@ func (p PrecompileExecutor) validatorDelegations(ctx sdk.Context, method *abi.Me }, } - response, err := p.stakingQuerier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), request) + response, err := p.stakingQuerier.ValidatorDelegations(sdk.WrapSDKContext(ctx), request) if err != nil { return nil, 0, err } diff --git a/precompiles/staking/staking.go b/precompiles/staking/staking.go index a058349694..9c0aa4c056 100644 --- a/precompiles/staking/staking.go +++ b/precompiles/staking/staking.go @@ -1020,7 +1020,7 @@ func (p PrecompileExecutor) validatorDelegations(ctx sdk.Context, method *abi.Me }, } - response, err := p.stakingQuerier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), request) + response, err := p.stakingQuerier.ValidatorDelegations(sdk.WrapSDKContext(ctx), request) if err != nil { return nil, 0, err } diff --git a/precompiles/staking/staking_test.go b/precompiles/staking/staking_test.go index 789165230a..136a7cf686 100644 --- a/precompiles/staking/staking_test.go +++ b/precompiles/staking/staking_test.go @@ -298,10 +298,6 @@ func (tq *TestStakingQuerier) ValidatorDelegations(c context.Context, _ *staking return tq.ValidatorDelegationsResponse, tq.Err } -func (tq *TestStakingQuerier) ValidatorDelegationsIndexed(c context.Context, _ *stakingtypes.QueryValidatorDelegationsRequest) (*stakingtypes.QueryValidatorDelegationsResponse, error) { - return tq.ValidatorDelegationsResponse, tq.Err -} - func (tq *TestStakingQuerier) ValidatorUnbondingDelegations(c context.Context, _ *stakingtypes.QueryValidatorUnbondingDelegationsRequest) (*stakingtypes.QueryValidatorUnbondingDelegationsResponse, error) { return tq.ValidatorUnbondingDelegationsResponse, tq.Err } diff --git a/precompiles/utils/expected_keepers.go b/precompiles/utils/expected_keepers.go index f21f11e1fc..f6e5696e13 100644 --- a/precompiles/utils/expected_keepers.go +++ b/precompiles/utils/expected_keepers.go @@ -175,7 +175,6 @@ type StakingQuerier interface { Validators(c context.Context, req *stakingtypes.QueryValidatorsRequest) (*stakingtypes.QueryValidatorsResponse, error) Validator(c context.Context, req *stakingtypes.QueryValidatorRequest) (*stakingtypes.QueryValidatorResponse, error) ValidatorDelegations(c context.Context, req *stakingtypes.QueryValidatorDelegationsRequest) (*stakingtypes.QueryValidatorDelegationsResponse, error) - ValidatorDelegationsIndexed(c context.Context, req *stakingtypes.QueryValidatorDelegationsRequest) (*stakingtypes.QueryValidatorDelegationsResponse, error) ValidatorUnbondingDelegations(c context.Context, req *stakingtypes.QueryValidatorUnbondingDelegationsRequest) (*stakingtypes.QueryValidatorUnbondingDelegationsResponse, error) UnbondingDelegation(c context.Context, req *stakingtypes.QueryUnbondingDelegationRequest) (*stakingtypes.QueryUnbondingDelegationResponse, error) DelegatorDelegations(c context.Context, req *stakingtypes.QueryDelegatorDelegationsRequest) (*stakingtypes.QueryDelegatorDelegationsResponse, error) diff --git a/sei-cosmos/x/staking/keeper/delegation_index_test.go b/sei-cosmos/x/staking/keeper/delegation_index_test.go index 3b9a742669..481ebfa338 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index_test.go +++ b/sei-cosmos/x/staking/keeper/delegation_index_test.go @@ -157,10 +157,10 @@ func TestMigrateDelegationByValIndexNoOrphans(t *testing.T) { // its validator must traverse the whole delegation keyspace to reach it. var maxScanDelegator = sdk.AccAddress(bytes.Repeat([]byte{0xff}, 20)) -// TestGRPCQueryValidatorDelegationsIndexedBeyondScanLimit pins the defect the index -// closes: past query.MaxScanLimit entries the filtered scan reverts, and reading the -// per-validator prefix answers the same request. -func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexedBeyondScanLimit() { +// TestGRPCQueryValidatorDelegationsBeyondScanLimit pins the defect the index closes: +// past query.MaxScanLimit entries the filtered scan reverts, and once the index is +// ready the same request is answered from the per-validator prefix. +func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsBeyondScanLimit() { app, ctx := suite.app, suite.ctx querier := keeper.Querier{Keeper: app.StakingKeeper} unreadyDelegationByValIndex(suite.T(), app, ctx) @@ -186,17 +186,15 @@ func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexedBeyondScan req := &types.QueryValidatorDelegationsRequest{ValidatorAddr: targetVal.String()} - // Before the migration the indexed query falls through to the scan, so it reverts - // exactly as the query does at heights below the one that populated the index. + // Before the migration the query scans, so it reverts exactly as it does at + // heights below the one that populated the index. _, err := querier.ValidatorDelegations(sdk.WrapSDKContext(ctx), req) suite.Error(err) - _, err = querier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), req) - suite.Error(err) _, err = app.StakingKeeper.MigrateDelegationByValIndex(ctx) suite.NoError(err) - res, err := querier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), req) + res, err := querier.ValidatorDelegations(sdk.WrapSDKContext(ctx), req) suite.NoError(err) delegators := make([]string, 0, len(res.DelegationResponses)) for _, dr := range res.DelegationResponses { @@ -204,16 +202,11 @@ func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexedBeyondScan delegators = append(delegators, dr.Delegation.DelegatorAddress) } suite.Contains(delegators, maxScanDelegator.String()) - - // The unindexed query is untouched, so the legacy precompiles that call it keep - // the behavior they have today. - _, err = querier.ValidatorDelegations(sdk.WrapSDKContext(ctx), req) - suite.Error(err) } -// TestGRPCQueryValidatorDelegationsIndexedMatchesScan pins that the two read paths +// TestGRPCQueryValidatorDelegationsIndexMatchesScan pins that the scan and the index // agree on a validator small enough for both to answer. -func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexedMatchesScan() { +func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexMatchesScan() { app, ctx := suite.app, suite.ctx querier := keeper.Querier{Keeper: app.StakingKeeper} @@ -226,7 +219,7 @@ func (suite *KeeperTestSuite) TestGRPCQueryValidatorDelegationsIndexedMatchesSca _, err = app.StakingKeeper.MigrateDelegationByValIndex(ctx) suite.NoError(err) - indexed, err := querier.ValidatorDelegationsIndexed(sdk.WrapSDKContext(ctx), req) + indexed, err := querier.ValidatorDelegations(sdk.WrapSDKContext(ctx), req) suite.NoError(err) suite.ElementsMatch(scanned.DelegationResponses, indexed.DelegationResponses) diff --git a/sei-cosmos/x/staking/keeper/grpc_query.go b/sei-cosmos/x/staking/keeper/grpc_query.go index 5005dd9edd..802066df37 100644 --- a/sei-cosmos/x/staking/keeper/grpc_query.go +++ b/sei-cosmos/x/staking/keeper/grpc_query.go @@ -94,36 +94,20 @@ func (k Querier) ValidatorDelegations(c context.Context, req *types.QueryValidat if req.ValidatorAddr == "" { return nil, status.Error(codes.InvalidArgument, "validator address cannot be empty") } - var delegations []types.Delegation ctx := sdk.UnwrapSDKContext(c) - store := ctx.KVStore(k.storeKey) - valStore := prefix.NewStore(store, types.DelegationKey) - - // Consensus execution defaults to release/v6.6 behavior. Only contexts - // created by BaseApp.Query may use the repaired LCD/gRPC behavior. - pageRes, err := query.FilteredPaginateForContext(ctx, valStore, req.Pagination, func(key []byte, value []byte, accumulate bool) (bool, error) { - delegation, err := types.UnmarshalDelegation(k.cdc, value) - if err != nil { - return false, err - } - - valAddr, err := sdk.ValAddressFromBech32(req.ValidatorAddr) - if err != nil { - return false, err - } - - if !delegation.GetValidatorAddr().Equals(valAddr) { - return false, nil - } - - if accumulate { - delegations = append(delegations, delegation) - } - return true, nil - }) + var ( + delegations []types.Delegation + pageRes *query.PageResponse + err error + ) + if k.DelegationByValIndexReady(ctx) { + delegations, pageRes, err = k.validatorDelegationsFromIndex(ctx, req) + } else { + delegations, pageRes, err = k.validatorDelegationsFromScan(ctx, req) + } if err != nil { - return nil, query.WrapGRPCError(err) + return nil, err } delResponses, err := DelegationsToDelegationResponses(ctx, k.Keeper, delegations) @@ -135,28 +119,12 @@ func (k Querier) ValidatorDelegations(c context.Context, req *types.QueryValidat DelegationResponses: delResponses, Pagination: pageRes}, nil } -// ValidatorDelegationsIndexed queries the delegations of a validator through the -// delegation-by-validator index. It answers as ValidatorDelegations does until the -// index is ready. -func (k Querier) ValidatorDelegationsIndexed(c context.Context, req *types.QueryValidatorDelegationsRequest) (*types.QueryValidatorDelegationsResponse, error) { - if req == nil { - return nil, status.Error(codes.InvalidArgument, "empty request") - } - - if req.ValidatorAddr == "" { - return nil, status.Error(codes.InvalidArgument, "validator address cannot be empty") - } - - ctx := sdk.UnwrapSDKContext(c) - if !k.DelegationByValIndexReady(ctx) { - // Before the index is populated the full scan is the only source, and it fails - // exactly where this query failed at those heights. - return k.ValidatorDelegations(c, req) - } - +// validatorDelegationsFromIndex pages over the delegation-by-validator index prefix of +// the requested validator. +func (k Querier) validatorDelegationsFromIndex(ctx sdk.Context, req *types.QueryValidatorDelegationsRequest) ([]types.Delegation, *query.PageResponse, error) { valAddr, err := sdk.ValAddressFromBech32(req.ValidatorAddr) if err != nil { - return nil, err + return nil, nil, err } var delegations []types.Delegation @@ -175,16 +143,45 @@ func (k Querier) ValidatorDelegationsIndexed(c context.Context, req *types.Query return nil }) if err != nil { - return nil, query.WrapGRPCError(err) + return nil, nil, query.WrapGRPCError(err) } + return delegations, pageRes, nil +} - delResponses, err := DelegationsToDelegationResponses(ctx, k.Keeper, delegations) +// validatorDelegationsFromScan filters the whole delegation store for the requested +// validator. It is the only source at heights before the delegation-by-validator index +// is ready, and reproduces the results and failures those heights had. +func (k Querier) validatorDelegationsFromScan(ctx sdk.Context, req *types.QueryValidatorDelegationsRequest) ([]types.Delegation, *query.PageResponse, error) { + var delegations []types.Delegation + store := ctx.KVStore(k.storeKey) + valStore := prefix.NewStore(store, types.DelegationKey) + + // Consensus execution defaults to release/v6.6 behavior. Only contexts + // created by BaseApp.Query may use the repaired LCD/gRPC behavior. + pageRes, err := query.FilteredPaginateForContext(ctx, valStore, req.Pagination, func(key []byte, value []byte, accumulate bool) (bool, error) { + delegation, err := types.UnmarshalDelegation(k.cdc, value) + if err != nil { + return false, err + } + + valAddr, err := sdk.ValAddressFromBech32(req.ValidatorAddr) + if err != nil { + return false, err + } + + if !delegation.GetValidatorAddr().Equals(valAddr) { + return false, nil + } + + if accumulate { + delegations = append(delegations, delegation) + } + return true, nil + }) if err != nil { - return nil, status.Error(codes.Internal, err.Error()) + return nil, nil, query.WrapGRPCError(err) } - - return &types.QueryValidatorDelegationsResponse{ - DelegationResponses: delResponses, Pagination: pageRes}, nil + return delegations, pageRes, nil } // ValidatorUnbondingDelegations queries unbonding delegations of a validator From 8d470721a7fba15df700195c7124bc9ab80fe64f Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Tue, 13 Oct 2026 11:18:07 -0700 Subject: [PATCH 17/17] Backfill the delegation index across blocks instead of in the upgrade The handler now only starts a backfill: a cursor under 0x39 marks it in progress, delegation writes maintain the index from then on, and the staking EndBlocker indexes 1000 delegations per block after the cursor until it reaches the end and marks the index ready. ValidatorDelegations keeps the scan until the index is ready. Genesis still indexes in one pass. A non-positive backfill limit is rejected. Co-Authored-By: Claude Opus 5.5 --- app/upgrade_v68_test.go | 14 +- app/upgrades.go | 22 +-- sei-cosmos/x/staking/abci.go | 20 ++- sei-cosmos/x/staking/keeper/delegation.go | 4 +- .../x/staking/keeper/delegation_index.go | 107 +++++++++-- .../x/staking/keeper/delegation_index_test.go | 166 +++++++++++++++++- sei-cosmos/x/staking/types/keys.go | 1 + 7 files changed, 290 insertions(+), 44 deletions(-) diff --git a/app/upgrade_v68_test.go b/app/upgrade_v68_test.go index bd0d95db8e..17f3fc4eba 100644 --- a/app/upgrade_v68_test.go +++ b/app/upgrade_v68_test.go @@ -19,6 +19,7 @@ import ( authtestutil "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/testutil" authtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/types" govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking" stakingkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/keeper" stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" upgradetypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade/types" @@ -37,7 +38,8 @@ import ( // the base accounts they embed. These tests cover the rewrite of every vesting // account type, spending a balance a schedule had locked, the messages both // modules served, genesis export over the migrated store, and the handler -// itself. The handler also populates the staking delegation-by-validator index. +// itself. The handler also starts the staking delegation-by-validator index +// backfill, which the staking EndBlocker completes. // v68RemovedStoreModules are the module version map entries the v6.8 handler // deletes along with their stores. @@ -315,7 +317,7 @@ func TestV68ApplyUpgradeTwice(t *testing.T) { } // unreadyV68DelegationIndex deletes the delegation-by-validator index and its -// ready marker, leaving the staking store as v6.7 left it. +// markers, leaving the staking store as v6.7 left it. func unreadyV68DelegationIndex(t *testing.T, a *processblock.App) { t.Helper() store := a.Ctx().KVStore(a.GetKey(stakingtypes.StoreKey)) @@ -329,6 +331,7 @@ func unreadyV68DelegationIndex(t *testing.T, a *processblock.App) { store.Delete(key) } store.Delete(stakingtypes.DelegationByValIndexReadyKey) + store.Delete(stakingtypes.DelegationByValIndexCursorKey) } // v68DelegationKeys returns the keys of every stored delegation. @@ -370,7 +373,14 @@ func TestV68PopulatesDelegationByValIndex(t *testing.T) { applyV68(t, a) + require.False(t, a.StakingKeeper.DelegationByValIndexReady(ctx), + "v6.8 populated the index inside the upgrade handler") + require.True(t, store.Has(stakingtypes.DelegationByValIndexCursorKey), + "v6.8 did not start the index backfill") + staking.EndBlocker(ctx, a.StakingKeeper) + require.True(t, a.StakingKeeper.DelegationByValIndexReady(ctx)) + require.False(t, store.Has(stakingtypes.DelegationByValIndexCursorKey)) delegations := v68DelegationKeys(t, store) require.Contains(t, delegations, string(stakingtypes.GetDelegationKey(delAddr, valAddr))) require.Equal(t, delegations, v68IndexedDelegationKeys(t, store), diff --git a/app/upgrades.go b/app/upgrades.go index a2fe709605..eda50467ad 100644 --- a/app/upgrades.go +++ b/app/upgrades.go @@ -156,9 +156,7 @@ func (app *App) RegisterUpgradeHandlers() { app.UpgradeKeeper.DeleteModuleVersion(ctx, feegrantModuleName) app.UpgradeKeeper.DeleteModuleVersion(ctx, vestingModuleName) app.pruneUpgradedIBCState(ctx) - if err := app.migrateDelegationByValIndex(ctx); err != nil { - return nil, err - } + app.StakingKeeper.StartDelegationByValIndexBackfill(ctx) return newVM, nil } @@ -264,21 +262,3 @@ func deleteByPrefix(store sdk.KVStore, prefix []byte) { } const v606UpgradeHeight = 151573570 - -// migrateDelegationByValIndex populates the staking module's -// delegation-by-validator index and marks it ready. It runs on an infinite gas -// meter, since its cost is set by the number of delegations at the upgrade -// height. -func (app *App) migrateDelegationByValIndex(ctx sdk.Context) error { - result, err := app.StakingKeeper.MigrateDelegationByValIndex(ctx.WithGasMeter(sdk.NewInfiniteGasMeter(1, 1))) - if err != nil { - return err - } - logger.Info( - "populated delegation-by-validator index", - "total_delegations", result.TotalDelegations, - "already_ready", result.AlreadyReady, - "elapsed", result.Elapsed.String(), - ) - return nil -} diff --git a/sei-cosmos/x/staking/abci.go b/sei-cosmos/x/staking/abci.go index 74caf3590c..828abf28ec 100644 --- a/sei-cosmos/x/staking/abci.go +++ b/sei-cosmos/x/staking/abci.go @@ -19,12 +19,28 @@ func BeginBlocker(ctx sdk.Context, k keeper.Keeper) { k.TrackHistoricalInfo(ctx) } -// Called every block, update validator set +// backfillDelegationByValIndex indexes the next batch of delegations while the +// delegation-by-validator index is backfilled. It runs after every transaction in the +// block, so no transaction iterates the staking store while the batch's writes are +// pending in it. A delegation that cannot be decoded halts the chain, as it would at +// any other read. +func backfillDelegationByValIndex(ctx sdk.Context, k keeper.Keeper) { + // The batch is block-level work, so it is not charged to any gas meter. + ctx = ctx.WithGasMeter(sdk.NewInfiniteGasMeter(1, 1)) + if _, err := k.BackfillDelegationByValIndex(ctx, keeper.DelegationByValIndexBackfillBatchSize); err != nil { + panic(err) + } +} + +// Called every block, update validator set. It also advances the +// delegation-by-validator index backfill while one is in progress. func EndBlocker(ctx sdk.Context, k keeper.Keeper) []abci.ValidatorUpdate { endBlockerStart := time.Now() defer func() { stakingMetrics.endBlockerDuration.Record(ctx.Context(), time.Since(endBlockerStart).Seconds()) }() - return k.BlockValidatorUpdates(ctx) + updates := k.BlockValidatorUpdates(ctx) + backfillDelegationByValIndex(ctx, k) + return updates } diff --git a/sei-cosmos/x/staking/keeper/delegation.go b/sei-cosmos/x/staking/keeper/delegation.go index 9ba29d36ee..467d2c5854 100644 --- a/sei-cosmos/x/staking/keeper/delegation.go +++ b/sei-cosmos/x/staking/keeper/delegation.go @@ -103,7 +103,7 @@ func (k Keeper) SetDelegation(ctx sdk.Context, delegation types.Delegation) { store := ctx.KVStore(k.storeKey) b := types.MustMarshalDelegation(k.cdc, delegation) store.Set(types.GetDelegationKey(delegatorAddress, valAddr), b) - if k.DelegationByValIndexReady(ctx) { + if k.delegationByValIndexMaintained(ctx) { store.Set(types.GetDelegationByValIndexKey(delegatorAddress, valAddr), []byte{}) // index, store empty bytes } } @@ -116,7 +116,7 @@ func (k Keeper) RemoveDelegation(ctx sdk.Context, delegation types.Delegation) { k.BeforeDelegationRemoved(ctx, delegatorAddress, valAddr) store := ctx.KVStore(k.storeKey) store.Delete(types.GetDelegationKey(delegatorAddress, valAddr)) - if k.DelegationByValIndexReady(ctx) { + if k.delegationByValIndexMaintained(ctx) { store.Delete(types.GetDelegationByValIndexKey(delegatorAddress, valAddr)) } } diff --git a/sei-cosmos/x/staking/keeper/delegation_index.go b/sei-cosmos/x/staking/keeper/delegation_index.go index eebc596634..3fea21e45b 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index.go +++ b/sei-cosmos/x/staking/keeper/delegation_index.go @@ -8,6 +8,10 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) +// DelegationByValIndexBackfillBatchSize is the number of delegations the staking +// EndBlocker indexes per block while the delegation-by-validator index is backfilled. +const DelegationByValIndexBackfillBatchSize = 1000 + // MigrateDelegationByValIndexResult reports the outcome of populating the // delegation-by-validator index. type MigrateDelegationByValIndexResult struct { @@ -19,14 +23,80 @@ type MigrateDelegationByValIndexResult struct { // DelegationByValIndexReady reports whether the delegation-by-validator index is // populated in the state this context reads. It consumes no gas. func (k Keeper) DelegationByValIndexReady(ctx sdk.Context) bool { - // Delegation writes call this at every height, so charging for the read would change - // the gas of pre-upgrade transactions that touch a delegation. + return k.hasGasless(ctx, types.DelegationByValIndexReadyKey) +} + +// delegationByValIndexMaintained reports whether delegation writes keep the +// delegation-by-validator index current: the index is ready, or its backfill has started. +// It consumes no gas. +func (k Keeper) delegationByValIndexMaintained(ctx sdk.Context) bool { + return k.DelegationByValIndexReady(ctx) || k.hasGasless(ctx, types.DelegationByValIndexCursorKey) +} + +// hasGasless reports whether key is in the staking store without charging for the read. +func (k Keeper) hasGasless(ctx sdk.Context, key []byte) bool { + // Delegation writes check the index markers at every height, so charging for the read + // would change the gas of pre-upgrade transactions that touch a delegation. gasless := ctx.WithGasMeter(sdk.NewInfiniteGasMeter(1, 1)) - return gasless.KVStore(k.storeKey).Has(types.DelegationByValIndexReadyKey) + return gasless.KVStore(k.storeKey).Has(key) +} + +// StartDelegationByValIndexBackfill begins populating the delegation-by-validator +// index across blocks. From this call on, delegation writes maintain the index, and +// BackfillDelegationByValIndex indexes the delegations stored before it. It is a no-op +// once the index is ready or its backfill has started. +func (k Keeper) StartDelegationByValIndexBackfill(ctx sdk.Context) { + if k.delegationByValIndexMaintained(ctx) { + return + } + ctx.KVStore(k.storeKey).Set(types.DelegationByValIndexCursorKey, []byte{}) } -// MigrateDelegationByValIndex writes an index entry for every stored delegation and -// marks the index ready. It is a no-op once the index is ready. +// BackfillDelegationByValIndex indexes up to limit stored delegations that follow the +// backfill cursor, and marks the index ready once every delegation is indexed. It +// returns the number of delegations indexed, and is a no-op when no backfill is in +// progress. limit must be positive. +func (k Keeper) BackfillDelegationByValIndex(ctx sdk.Context, limit int) (int, error) { + if limit <= 0 { + return 0, fmt.Errorf("delegation-by-validator index backfill limit must be positive, got %d", limit) + } + store := ctx.KVStore(k.storeKey) + cursor := store.Get(types.DelegationByValIndexCursorKey) + if cursor == nil { + return 0, nil + } + + start := types.DelegationKey + if len(cursor) > 0 { + // The cursor holds the last delegation key indexed; the next batch starts just after it. + start = append(append([]byte{}, cursor...), 0x00) + } + iterator := store.Iterator(start, sdk.PrefixEndBytes(types.DelegationKey)) + defer func() { _ = iterator.Close() }() + + indexed := 0 + var last []byte + for ; iterator.Valid() && indexed < limit; iterator.Next() { + if err := k.indexStoredDelegation(store, iterator.Key(), iterator.Value()); err != nil { + return indexed, err + } + last = append([]byte{}, iterator.Key()...) + indexed++ + } + + if iterator.Valid() { + store.Set(types.DelegationByValIndexCursorKey, last) + return indexed, nil + } + store.Delete(types.DelegationByValIndexCursorKey) + store.Set(types.DelegationByValIndexReadyKey, []byte{}) + return indexed, nil +} + +// MigrateDelegationByValIndex writes an index entry for every stored delegation in one +// pass and marks the index ready. Its cost grows with the number of delegations, so it +// is for genesis only; an upgrade starts a paced backfill with +// StartDelegationByValIndexBackfill instead. It is a no-op once the index is ready. func (k Keeper) MigrateDelegationByValIndex(ctx sdk.Context) (MigrateDelegationByValIndexResult, error) { start := time.Now() store := ctx.KVStore(k.storeKey) @@ -39,22 +109,29 @@ func (k Keeper) MigrateDelegationByValIndex(ctx sdk.Context) (MigrateDelegationB iterator := sdk.KVStorePrefixIterator(store, types.DelegationKey) defer func() { _ = iterator.Close() }() - // SetDelegation writes no index entry while the index is not ready, so none - // exists yet and each one can be written without checking for it first. for ; iterator.Valid(); iterator.Next() { - delegation, err := types.UnmarshalDelegation(k.cdc, iterator.Value()) - if err != nil { - return result, fmt.Errorf("unmarshal delegation at key %X: %w", iterator.Key(), err) - } - delAddr, err := sdk.AccAddressFromBech32(delegation.DelegatorAddress) - if err != nil { - return result, fmt.Errorf("parse delegator address %q: %w", delegation.DelegatorAddress, err) + if err := k.indexStoredDelegation(store, iterator.Key(), iterator.Value()); err != nil { + return result, err } - store.Set(types.GetDelegationByValIndexKey(delAddr, delegation.GetValidatorAddr()), []byte{}) result.TotalDelegations++ } + store.Delete(types.DelegationByValIndexCursorKey) store.Set(types.DelegationByValIndexReadyKey, []byte{}) result.Elapsed = time.Since(start) return result, nil } + +// indexStoredDelegation writes the index entry for the delegation stored at key. +func (k Keeper) indexStoredDelegation(store sdk.KVStore, key, value []byte) error { + delegation, err := types.UnmarshalDelegation(k.cdc, value) + if err != nil { + return fmt.Errorf("unmarshal delegation at key %X: %w", key, err) + } + delAddr, err := sdk.AccAddressFromBech32(delegation.DelegatorAddress) + if err != nil { + return fmt.Errorf("parse delegator address %q: %w", delegation.DelegatorAddress, err) + } + store.Set(types.GetDelegationByValIndexKey(delAddr, delegation.GetValidatorAddr()), []byte{}) + return nil +} diff --git a/sei-cosmos/x/staking/keeper/delegation_index_test.go b/sei-cosmos/x/staking/keeper/delegation_index_test.go index 481ebfa338..2d768cd29b 100644 --- a/sei-cosmos/x/staking/keeper/delegation_index_test.go +++ b/sei-cosmos/x/staking/keeper/delegation_index_test.go @@ -27,8 +27,8 @@ func seedDelegations(t *testing.T, app *seiapp.App, ctx sdk.Context, delegations } } -// unreadyDelegationByValIndex deletes the index and its ready marker, leaving the -// store as a chain that has not run the migration holds it. +// unreadyDelegationByValIndex deletes the index and its markers, leaving the store as +// a chain that has not run the migration holds it. func unreadyDelegationByValIndex(t *testing.T, app *seiapp.App, ctx sdk.Context) { t.Helper() store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) @@ -42,6 +42,7 @@ func unreadyDelegationByValIndex(t *testing.T, app *seiapp.App, ctx sdk.Context) store.Delete(key) } store.Delete(types.DelegationByValIndexReadyKey) + store.Delete(types.DelegationByValIndexCursorKey) require.False(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) } @@ -153,6 +154,167 @@ func TestMigrateDelegationByValIndexNoOrphans(t *testing.T) { require.Equal(t, delegationKeys, indexed) } +// delegatorAt returns a delegator address whose last byte is b and whose other bytes +// are fill, so its delegation keys sort by fill and then b. +func delegatorAt(fill, b byte) sdk.AccAddress { + addr := sdk.AccAddress(bytes.Repeat([]byte{fill}, 20)) + addr[19] = b + return addr +} + +// requireIndexMatchesDelegations requires the index to hold exactly one entry per +// stored delegation. +func requireIndexMatchesDelegations(t *testing.T, store sdk.KVStore) { + t.Helper() + delegationKeys := map[string]bool{} + delIter := sdk.KVStorePrefixIterator(store, types.DelegationKey) + for ; delIter.Valid(); delIter.Next() { + delegationKeys[string(delIter.Key())] = true + } + require.NoError(t, delIter.Close()) + + indexed := map[string]bool{} + idxIter := sdk.KVStorePrefixIterator(store, types.DelegationByValIndexKey) + for ; idxIter.Valid(); idxIter.Next() { + indexed[string(types.GetDelegationKeyFromValIndexKey(idxIter.Key()))] = true + } + require.NoError(t, idxIter.Close()) + + require.Equal(t, delegationKeys, indexed) +} + +func TestStartDelegationByValIndexBackfillMaintainsWrites(t *testing.T) { + _, app, ctx := createTestInput(t) + unreadyDelegationByValIndex(t, app, ctx) + + app.StakingKeeper.StartDelegationByValIndexBackfill(ctx) + require.False(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) + + addrDels, valAddrs := generateAddresses(app, ctx, 1) + delegation := types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)) + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + indexKey := types.GetDelegationByValIndexKey(addrDels[0], valAddrs[0]) + + app.StakingKeeper.SetDelegation(ctx, delegation) + require.True(t, store.Has(indexKey)) + + app.StakingKeeper.RemoveDelegation(ctx, delegation) + require.False(t, store.Has(indexKey)) +} + +func TestStartDelegationByValIndexBackfillOnceReady(t *testing.T) { + _, app, ctx := createTestInput(t) + require.True(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) + + app.StakingKeeper.StartDelegationByValIndexBackfill(ctx) + + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + require.False(t, store.Has(types.DelegationByValIndexCursorKey)) + indexed, err := app.StakingKeeper.BackfillDelegationByValIndex(ctx, 1) + require.NoError(t, err) + require.Zero(t, indexed) +} + +func TestBackfillDelegationByValIndexNotStarted(t *testing.T) { + _, app, ctx := createTestInput(t) + unreadyDelegationByValIndex(t, app, ctx) + + indexed, err := app.StakingKeeper.BackfillDelegationByValIndex(ctx, 100) + require.NoError(t, err) + require.Zero(t, indexed) + require.False(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) +} + +func TestBackfillDelegationByValIndexRejectsNonPositiveLimit(t *testing.T) { + _, app, ctx := createTestInput(t) + unreadyDelegationByValIndex(t, app, ctx) + addrDels, valAddrs := generateAddresses(app, ctx, 1) + seedDelegations(t, app, ctx, []types.Delegation{types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1))}) + app.StakingKeeper.StartDelegationByValIndexBackfill(ctx) + + for _, limit := range []int{0, -1} { + indexed, err := app.StakingKeeper.BackfillDelegationByValIndex(ctx, limit) + require.Error(t, err) + require.Zero(t, indexed) + } + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + require.Equal(t, []byte{}, store.Get(types.DelegationByValIndexCursorKey)) + require.False(t, store.Has(types.GetDelegationByValIndexKey(addrDels[0], valAddrs[0]))) +} + +// TestBackfillDelegationByValIndexInBatches pins that the backfill indexes at most +// limit delegations per call, and that delegations written, changed or removed while it +// runs, on either side of the cursor, leave the finished index exact. +func TestBackfillDelegationByValIndexInBatches(t *testing.T) { + _, app, ctx := createTestInput(t) + unreadyDelegationByValIndex(t, app, ctx) + store := ctx.KVStore(app.StakingKeeper.GetStoreKey()) + _, valAddrs := generateAddresses(app, ctx, 1) + valAddr := valAddrs[0] + + // The 0x00 delegators sort before every other delegation and the 0xff ones after. + first, second := delegatorAt(0x00, 1), delegatorAt(0x00, 2) + ahead, last := delegatorAt(0xff, 0xfe), delegatorAt(0xff, 0xff) + seedDelegations(t, app, ctx, []types.Delegation{ + types.NewDelegation(first, valAddr, sdk.NewDec(1)), + types.NewDelegation(second, valAddr, sdk.NewDec(2)), + types.NewDelegation(ahead, valAddr, sdk.NewDec(3)), + types.NewDelegation(last, valAddr, sdk.NewDec(4)), + }) + total := len(app.StakingKeeper.GetAllDelegations(ctx)) + + app.StakingKeeper.StartDelegationByValIndexBackfill(ctx) + indexed, err := app.StakingKeeper.BackfillDelegationByValIndex(ctx, 2) + require.NoError(t, err) + require.Equal(t, 2, indexed) + require.False(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) + require.Equal(t, types.GetDelegationKey(second, valAddr), store.Get(types.DelegationByValIndexCursorKey)) + require.True(t, store.Has(types.GetDelegationByValIndexKey(first, valAddr))) + require.False(t, store.Has(types.GetDelegationByValIndexKey(last, valAddr))) + + // Behind the cursor: a removal of an indexed delegation and a new delegation. + app.StakingKeeper.RemoveDelegation(ctx, types.NewDelegation(first, valAddr, sdk.NewDec(1))) + behind := delegatorAt(0x00, 0) + app.StakingKeeper.SetDelegation(ctx, types.NewDelegation(behind, valAddr, sdk.NewDec(5))) + // Ahead of the cursor: a change and a removal of delegations not yet indexed. + app.StakingKeeper.SetDelegation(ctx, types.NewDelegation(ahead, valAddr, sdk.NewDec(6))) + app.StakingKeeper.RemoveDelegation(ctx, types.NewDelegation(last, valAddr, sdk.NewDec(4))) + + remaining := total - 2 - 1 // the two indexed and the one removed ahead of the cursor + for calls := 0; !app.StakingKeeper.DelegationByValIndexReady(ctx); calls++ { + require.Less(t, calls, total, "the backfill did not finish") + indexed, err = app.StakingKeeper.BackfillDelegationByValIndex(ctx, 2) + require.NoError(t, err) + require.LessOrEqual(t, indexed, 2) + remaining -= indexed + } + require.Zero(t, remaining) + require.False(t, store.Has(types.DelegationByValIndexCursorKey)) + require.False(t, store.Has(types.GetDelegationByValIndexKey(first, valAddr))) + require.False(t, store.Has(types.GetDelegationByValIndexKey(last, valAddr))) + requireIndexMatchesDelegations(t, store) +} + +// TestBackfillDelegationByValIndexEndsOnBatchBoundary pins that a batch that indexes +// the last delegation finishes the backfill without a further call. +func TestBackfillDelegationByValIndexEndsOnBatchBoundary(t *testing.T) { + _, app, ctx := createTestInput(t) + unreadyDelegationByValIndex(t, app, ctx) + addrDels, valAddrs := generateAddresses(app, ctx, 2) + seedDelegations(t, app, ctx, []types.Delegation{ + types.NewDelegation(addrDels[0], valAddrs[0], sdk.NewDec(1)), + types.NewDelegation(addrDels[1], valAddrs[1], sdk.NewDec(2)), + }) + total := len(app.StakingKeeper.GetAllDelegations(ctx)) + + app.StakingKeeper.StartDelegationByValIndexBackfill(ctx) + indexed, err := app.StakingKeeper.BackfillDelegationByValIndex(ctx, total) + require.NoError(t, err) + require.Equal(t, total, indexed) + require.True(t, app.StakingKeeper.DelegationByValIndexReady(ctx)) + requireIndexMatchesDelegations(t, ctx.KVStore(app.StakingKeeper.GetStoreKey())) +} + // maxScanDelegator sorts after every test delegator address, so a filtered scan for // its validator must traverse the whole delegation keyspace to reach it. var maxScanDelegator = sdk.AccAddress(bytes.Repeat([]byte{0xff}, 20)) diff --git a/sei-cosmos/x/staking/types/keys.go b/sei-cosmos/x/staking/types/keys.go index dac40f5c54..9b7d104dd3 100644 --- a/sei-cosmos/x/staking/types/keys.go +++ b/sei-cosmos/x/staking/types/keys.go @@ -44,6 +44,7 @@ var ( RedelegationByValDstIndexKey = []byte{0x36} // prefix for each key for an redelegation, by destination validator operator DelegationByValIndexKey = []byte{0x37} // prefix for each key for a delegation, by validator operator DelegationByValIndexReadyKey = []byte{0x38} // marker: the delegation-by-validator index is populated + DelegationByValIndexCursorKey = []byte{0x39} // last delegation key indexed while the delegation-by-validator index is backfilled UnbondingQueueKey = []byte{0x41} // prefix for the timestamps in unbonding queue RedelegationQueueKey = []byte{0x42} // prefix for the timestamps in redelegations queue