From 6bdcd894a490d907d3ff84800ad4b3a71111f439 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 21 Aug 2026 14:59:16 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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") +}