Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a8983ae
Add gov proposal based migration trigger
yzang2019 Jun 26, 2026
1ab407e
Fix build issues
yzang2019 Jun 26, 2026
6388061
Remove config for num-keys-to-migrate
yzang2019 Jun 26, 2026
358df23
Fix gov proposal
yzang2019 Jun 26, 2026
e09fa19
Merge branch 'main' into yzang/add-migration-trigger
yzang2019 Jun 26, 2026
1e6546d
Merge branch 'main' into yzang/add-migration-trigger
masih Jun 26, 2026
91e1766
Address comment
yzang2019 Jun 26, 2026
e74e608
Revert "Address comment"
yzang2019 Jun 26, 2026
3327b28
Rename rs to rootStore
yzang2019 Jun 26, 2026
a5cf528
Fix negative batch size
yzang2019 Jun 26, 2026
c20c5a8
Default to migrateEVM mode
yzang2019 Jun 29, 2026
76e5040
Use auto as the default sc-write-mode
yzang2019 Jun 30, 2026
20390c1
Address comments
yzang2019 Jun 30, 2026
63ba76a
Fix unit test
yzang2019 Jun 30, 2026
453bb76
Fix test cases
yzang2019 Jun 30, 2026
9a276ac
Address comments
yzang2019 Jun 30, 2026
8f43d5b
Address comments
yzang2019 Jun 30, 2026
42e52fb
Merge branch 'main' into yzang/add-migration-trigger
yzang2019 Jun 30, 2026
81bccda
Fix integration test
yzang2019 Jun 30, 2026
8986df1
Merge branch 'main' into yzang/add-migration-trigger
yzang2019 Jun 30, 2026
f08929b
Enhance integration test
yzang2019 Jul 1, 2026
293fe78
Merge branch 'main' into yzang/add-migration-trigger
yzang2019 Jul 1, 2026
f0d0ff7
Merge branch 'main' into yzang/add-migration-trigger
yzang2019 Jul 1, 2026
714e959
Make test idempodent
yzang2019 Jul 1, 2026
a3c7291
Fix integration test
yzang2019 Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,25 @@ progressively the deeper you go. Existing package guides include:

## Code style

All Go files must be `gofmt`-compliant. After modifying any `.go` file, run:
All Go files must be both `gofmt`- and `goimports`-compliant (`.golangci.yml`
enables the `gofmt` and `goimports` formatters). After modifying **any** `.go`
file, run **both** tools on **every** file you touched — not just the ones you
think changed formatting:

```bash
gofmt -s -w <file>
gofmt -s -w <file>...
goimports -w <file>... # groups/orders imports; catches the goimports linter
```

Or verify the whole tree (prints nothing when everything is clean):
`goimports` is required in addition to `gofmt`: `gofmt` alone does not separate
the stdlib import group from third-party imports, so a `gofmt`-clean file can
still fail the `goimports` linter.

Verify the whole tree (each prints nothing when everything is clean):

```bash
gofmt -s -l .
goimports -l .
```

## Lint, build & test
Expand Down
44 changes: 44 additions & 0 deletions app/abci.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
otelmetrics "go.opentelemetry.io/otel/metric"

"github.com/sei-protocol/sei-chain/app/legacyabci"
"github.com/sei-protocol/sei-chain/app/migration"
"github.com/sei-protocol/sei-chain/sei-cosmos/tasks"
"github.com/sei-protocol/sei-chain/sei-cosmos/telemetry"
sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types"
Expand Down Expand Up @@ -53,12 +54,55 @@ func (app *App) BeginBlock(
if app.HardForkManager.TargetHeightReached(ctx) {
app.HardForkManager.ExecuteForTargetHeight(ctx)
}
app.applyMigrationBatchSize(ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] applyMigrationBatchSize writes to committed state in BeginBlock: when the param is unset it calls subspace.Set(...), adding a key to the x/params store and changing the AppHash at the first block the new binary runs. That is state-machine-breaking and would diverge nodes on a rolling/uncoordinated upgrade, which seems at odds with the PR's non-app-hash-breaking label. Consider seeding the param via an upgrade handler / InitGenesis instead of lazily here, or confirm this ships at a coordinated upgrade height.

legacyabci.BeginBlock(ctx, height, votes, byzantineValidators, app.BeginBlockKeepers)
return abci.ResponseBeginBlock{
Events: sdk.MarkEventsToIndex(ctx.EventManager().ABCIEvents(), app.IndexEvents),
}
}

// applyMigrationBatchSize paces the SC store's background data migration at the network-agreed rate.
// The NumKeysToMigratePerBlock gov param is read from chain state so every node
// applies the same value each block; a per-node rate would diverge the
// AppHash. 0 (the default until a gov proposal raises it) leaves the migration
// paused; it is the sole source of the rate (there is no node-local fallback).
func (app *App) applyMigrationBatchSize(ctx sdk.Context) {
if app.rootStore == nil {
return
}
numKeys := migration.DefaultNumKeysToMigratePerBlock

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Minor: this lazily writes the default param into the params store during BeginBlock the first time it's unset. It's deterministic across nodes and correctly gated by subspace.Has, so it's fine — but note that the very first ParameterChangeProposal can only be accepted after at least one block has run to seed the key. On a brand-new chain that's block 1+, which is fine; just worth a one-line comment so this ordering dependency isn't surprising later.

if subspace, ok := app.ParamsKeeper.GetSubspace(migration.SubspaceName); ok {
// The migration subspace has no owning module to seed it in InitGenesis,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The lazy seed correctly handles a fresh/never-set param deterministically, but because this subspace has no module ExportGenesis (x/params only exports Fees/CosmosGas params), the value is lost across a seid export/import: a mid-migration rate resets to the 0 default here and the drain pauses until governance re-raises it. Consider adding genesis import/export plumbing for this subspace, or documenting the caveat.

// so lazily persist the default the first time we see it unset. This is
// deterministic across nodes (every node runs BeginBlock identically) and
// makes the param visible to gov: ParameterChangeProposal submission only
// accepts a change when subspace.Has reports the key is already stored.
if !subspace.Has(ctx, migration.KeyNumKeysToMigratePerBlock) {
subspace.Set(ctx, migration.KeyNumKeysToMigratePerBlock, migration.DefaultNumKeysToMigratePerBlock)
}
subspace.GetIfExists(ctx, migration.KeyNumKeysToMigratePerBlock, &numKeys)
}
// Defense-in-depth: gov validation already rejects values above
// MaxNumKeysToMigratePerBlock, but clamp here too so an out-of-range value
// reaching state via any path can never overflow the int cast or trigger an
// oversized preallocation in the migration iterator. The clamp is
// deterministic across nodes.
if numKeys > migration.MaxNumKeysToMigratePerBlock {
numKeys = migration.MaxNumKeysToMigratePerBlock
}
if err := app.rootStore.SetMigrationBatchSize(int(numKeys)); err != nil {
// Never panic on the migration-rate update: log and continue. AppHash
// verification is the safety net. If the rate/mode update fails on only
// some nodes, those nodes' AppHash diverges and the normal AppHash
// comparison halts them at the next block — no proactive panic needed.
// If it fails on every node, all stay in the same (old) mode with an
// identical AppHash, so the chain keeps moving and the level-triggered
// trigger re-fires on a later block. Panicking here would needlessly
// halt the whole chain in that all-fail case.
logger.Error("failed to set SC migration batch size; continuing", "err", err)
}
}

func (app *App) MidBlock(ctx sdk.Context, height int64) []abci.Event {
_, span := app.GetBaseApp().TracingInfo.StartWithContext("MidBlock", ctx.TraceSpanContext())
defer span.End()
Expand Down
125 changes: 125 additions & 0 deletions app/abci_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package app

import (
"context"
"math"
"testing"
"time"

"github.com/sei-protocol/sei-chain/app/migration"
abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types"
tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types"
"github.com/stretchr/testify/require"
)

// TestMigrationSubspaceRegistered verifies the generic "migration" params
// subspace is wired with its key table so governance can edit
// NumKeysToMigratePerBlock via a ParameterChangeProposal.
func TestMigrationSubspaceRegistered(t *testing.T) {
a := Setup(t, false, false, false)
subspace, ok := a.ParamsKeeper.GetSubspace(migration.SubspaceName)
require.True(t, ok, "migration subspace must be registered")
require.True(t, subspace.HasKeyTable(), "migration subspace must have a key table")

ctx := a.NewContext(false, tmproto.Header{Height: 1, ChainID: "sei-test", Time: time.Now()})
subspace.Set(ctx, migration.KeyNumKeysToMigratePerBlock, uint64(123))
var got uint64
subspace.GetIfExists(ctx, migration.KeyNumKeysToMigratePerBlock, &got)
require.Equal(t, uint64(123), got)
}

// TestApplyMigrationBatchSize covers the BeginBlock push: the gov param is
// read from chain state and forwarded into the SC commit store.
func TestApplyMigrationBatchSize(t *testing.T) {
a := Setup(t, false, false, false)
ctx := a.NewContext(false, tmproto.Header{Height: 1, ChainID: "sei-test", Time: time.Now()})

subspace, ok := a.ParamsKeeper.GetSubspace(migration.SubspaceName)
require.True(t, ok)

// Unset param: the store receives the default (0 = paused).
a.applyMigrationBatchSize(ctx)
got, ok := a.rootStore.GetMigrationBatchSize()
require.True(t, ok, "SC store should track a migration batch size")
require.Equal(t, 0, got)

// Governance raises the rate: BeginBlock forwards the new value.
subspace.Set(ctx, migration.KeyNumKeysToMigratePerBlock, uint64(500))
a.applyMigrationBatchSize(ctx)
got, _ = a.rootStore.GetMigrationBatchSize()
require.Equal(t, 500, got)

// Defense-in-depth: an out-of-range value reaching state (gov validation
// already rejects these) is clamped to the sane maximum, never overflowing
// the int cast or the migration iterator preallocation.
subspace.Set(ctx, migration.KeyNumKeysToMigratePerBlock, uint64(math.MaxUint64))
a.applyMigrationBatchSize(ctx)
got, _ = a.rootStore.GetMigrationBatchSize()
require.Equal(t, int(migration.MaxNumKeysToMigratePerBlock), got)
}

// TestBeginBlockAppliesMigrationBatchSize exercises the full BeginBlock path
// (not the helper in isolation): it mimics a governance ParameterChangeProposal
// having set NumKeysToMigratePerBlock, then runs app.BeginBlock and asserts the
// new rate landed in the SC commit store.
func TestBeginBlockAppliesMigrationBatchSize(t *testing.T) {
a := Setup(t, false, false, false)
ctx := a.NewContext(false, tmproto.Header{Height: 2, ChainID: "sei-test", Time: time.Now()})

// Sanity: nothing set yet, so the store is paused at 0.
before, ok := a.rootStore.GetMigrationBatchSize()
require.True(t, ok)
require.Equal(t, 0, before)

// Simulate the gov proposal landing in chain state.
subspace, ok := a.ParamsKeeper.GetSubspace(migration.SubspaceName)
require.True(t, ok)
subspace.Set(ctx, migration.KeyNumKeysToMigratePerBlock, uint64(321))

// Run the real BeginBlock (checkHeight=false to skip height validation).
require.NotPanics(t, func() {
a.BeginBlock(ctx, 2, nil, nil, false)
})

after, _ := a.rootStore.GetMigrationBatchSize()
require.Equal(t, 321, after, "BeginBlock should push the gov param into the SC store")
}

// TestMigrationBatchSizeTakesEffectNextBlock is the full end-to-end timing
// check: a governance proposal committed in block N (written into the block's
// deliver state, then Commit) only changes the SC store's migration rate when
// block N+1's BeginBlock runs and reads it from committed state.
func TestMigrationBatchSizeTakesEffectNextBlock(t *testing.T) {
a := Setup(t, false, false, false)
bg := context.Background()

// Block 1: BeginBlock runs first (param still unset), then the gov
// proposal lands by writing into this block's deliver state, then Commit
// persists it to the committed multistore.
_, err := a.FinalizeBlock(bg, &abci.RequestFinalizeBlock{
Header: &tmproto.Header{ChainID: "sei-test", Height: 1, Time: time.Now()},
})
require.NoError(t, err)

subspace, ok := a.ParamsKeeper.GetSubspace(migration.SubspaceName)
require.True(t, ok)
subspace.Set(a.GetContextForDeliverTx([]byte{}), migration.KeyNumKeysToMigratePerBlock, uint64(640))

_, err = a.Commit(bg)
require.NoError(t, err)

// The param was committed in block 1, but BeginBlock(1) ran before it
// existed, so the rate is still paused at this point.
got, ok := a.rootStore.GetMigrationBatchSize()
require.True(t, ok)
require.Equal(t, 0, got, "param committed in block 1 must not take effect within block 1")

// Block 2: BeginBlock reads the now-committed param and applies it.
_, err = a.FinalizeBlock(bg, &abci.RequestFinalizeBlock{
Header: &tmproto.Header{ChainID: "sei-test", Height: 2, Time: time.Now().Add(time.Second)},
})
require.NoError(t, err)

got, _ = a.rootStore.GetMigrationBatchSize()
require.Equal(t, 640, got, "migration rate must take effect on the block after the param is committed")
}
19 changes: 16 additions & 3 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import (
"github.com/sei-protocol/sei-chain/sei-cosmos/server/config"
servertypes "github.com/sei-protocol/sei-chain/sei-cosmos/server/types"
storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types"
storev2_rootmulti "github.com/sei-protocol/sei-chain/sei-cosmos/storev2/rootmulti"
sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types"
sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors"
genesistypes "github.com/sei-protocol/sei-chain/sei-cosmos/types/genesis"
Expand Down Expand Up @@ -110,6 +109,7 @@ import (
"github.com/sei-protocol/sei-chain/app/antedecorators"
"github.com/sei-protocol/sei-chain/app/benchmark"
"github.com/sei-protocol/sei-chain/app/legacyabci"
"github.com/sei-protocol/sei-chain/app/migration"
appparams "github.com/sei-protocol/sei-chain/app/params"
"github.com/sei-protocol/sei-chain/app/upgrades"
v0upgrade "github.com/sei-protocol/sei-chain/app/upgrades/v0"
Expand Down Expand Up @@ -140,6 +140,7 @@ import (
tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types"
wasmkeeper "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/keeper"

"github.com/sei-protocol/sei-chain/sei-cosmos/storev2/rootmulti"
"github.com/sei-protocol/sei-chain/utils"
utilmetrics "github.com/sei-protocol/sei-chain/utils/metrics"
"github.com/sei-protocol/sei-chain/wasmbinding"
Expand Down Expand Up @@ -469,6 +470,7 @@ type App struct {
genesisImportConfig genesistypes.GenesisImportConfig

stateStore seidb.StateStore
rootStore *rootmulti.Store
receiptStore receipt.ReceiptStore

forkInitializer func(sdk.Context)
Expand Down Expand Up @@ -549,6 +551,16 @@ func New(
option(app)
}

// The storev2 rootmulti store is the only supported commit multistore; its
// composite SC backend drives the in-flight memiavl->flatkv migration that
// BeginBlock paces via the migration gov param. Fail fast if the legacy
// root multistore is somehow in use.
rootStore, ok := app.CommitMultiStore().(*rootmulti.Store)
if !ok {
panic(fmt.Sprintf("unsupported commit multistore %T: expected *rootmulti.Store", app.CommitMultiStore()))
}
app.rootStore = rootStore

app.ParamsKeeper = initParamsKeeper(appCodec, cdc, keys[paramstypes.StoreKey], tkeys[paramstypes.TStoreKey])

// set the BaseApp's parameter store
Expand Down Expand Up @@ -746,7 +758,7 @@ func New(
app.EvmKeeper.SetTraceDB(traceDB)

if app.evmRPCConfig.TraceBakeUseSnapshot {
if rs, ok := app.CommitMultiStore().(*storev2_rootmulti.Store); ok {
if rs, ok := app.CommitMultiStore().(*rootmulti.Store); ok {
app.EvmKeeper.SetTraceSnapshotStore(evmkeeper.NewTraceSnapshotStore(app.evmRPCConfig.TraceBakeSnapshotWindow))
app.EvmKeeper.SetTraceSnapshotCapture(rs.SnapshotSCStore)
} else {
Expand Down Expand Up @@ -2670,7 +2682,7 @@ func (app *App) SnapshotAwareRPCContextProvider() evmrpc.TraceContextProvider {
return app.RPCContextProvider(i), func() {}
})
}
rs, ok := app.CommitMultiStore().(*storev2_rootmulti.Store)
rs, ok := app.CommitMultiStore().(*rootmulti.Store)
if !ok {
return evmrpc.TraceContextProvider(func(i int64) (sdk.Context, func()) {
return app.RPCContextProvider(i), func() {}
Expand Down Expand Up @@ -2933,6 +2945,7 @@ func initParamsKeeper(appCodec codec.BinaryCodec, legacyAmino *codec.LegacyAmino
paramsKeeper.Subspace(evmtypes.ModuleName)
paramsKeeper.Subspace(epochmoduletypes.ModuleName)
paramsKeeper.Subspace(tokenfactorytypes.ModuleName)
paramsKeeper.Subspace(migration.SubspaceName).WithKeyTable(migration.ParamKeyTable())
// this line is used by starport scaffolding # stargate/app/paramSubspace

return paramsKeeper
Expand Down
72 changes: 72 additions & 0 deletions app/migration/params.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Package migration defines the module-agnostic governance parameters
// that control the state-commitment store's background data migration
// (currently memiavl->flatkv).
//
// These live outside any business module on purpose: the migration rate
// applies to whichever stores the SC router is migrating, so it is an
// app/storage-level concern rather than EVM-specific. The value is held in a
// dedicated x/params subspace and is editable via the standard
// ParameterChangeProposal gov flow. The app reads it once per block in
// BeginBlock and pushes it into the SC commit store.
//
// Caveat: this subspace has no owning AppModule, so the x/params module's
// ExportGenesis (which only emits Fees/CosmosGas params) does not serialize
// NumKeysToMigratePerBlock. A `seid export` taken mid-migration therefore
// omits the rate, and a chain bootstrapped from that genesis re-seeds the
// default (0, paused) on the first BeginBlock. This is not consensus-fatal —
// re-issue the ParameterChangeProposal on the new chain to resume the drain —
// but operators forking/recovering via export must be aware of it.
package migration
Comment thread
claude[bot] marked this conversation as resolved.

import (
"fmt"

paramtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/params/types"
)

// SubspaceName is the x/params subspace that holds storage-migration controls.
const SubspaceName = "migration"

// KeyNumKeysToMigratePerBlock is the param key for the number of keys the
// in-flight SC migration advances per block.
var KeyNumKeysToMigratePerBlock = []byte("NumKeysToMigratePerBlock")

// DefaultNumKeysToMigratePerBlock leaves the migration paused. While it is 0
// (the default until a gov proposal raises it) the SC store does no migration
// work; this param is the sole source of the per-block rate.
const DefaultNumKeysToMigratePerBlock uint64 = 0

// MaxNumKeysToMigratePerBlock bounds the governance-controlled rate. The value
// flows into MemiavlMigrationIterator.NextBatch, which preallocates a slice of
// that capacity; an unbounded uint64 (e.g. a fat-fingered or malicious
// proposal) would deterministically panic (makeslice: cap out of range) or OOM
// every validator at the same height — an unrecoverable chain halt. 1,000,000
// keys/block is far above any realistic drain rate yet caps the preallocation
// at a safe size, so we reject anything larger at proposal-validation time.
const MaxNumKeysToMigratePerBlock uint64 = 1_000_000

// ParamKeyTable returns the key table for the migration subspace.
func ParamKeyTable() paramtypes.KeyTable {
return paramtypes.NewKeyTable(
paramtypes.NewParamSetPair(KeyNumKeysToMigratePerBlock, new(uint64), validateNumKeysToMigratePerBlock),
)
}

// validateNumKeysToMigratePerBlock type-checks the value and bounds it to
// MaxNumKeysToMigratePerBlock. 0 means "paused"; any rate in [0, max] is a
// valid (consensus-deterministic) value. Rejecting oversized values here, at
// proposal-submission time, keeps them out of chain state where they would
// otherwise OOM/panic every validator (see MaxNumKeysToMigratePerBlock).
func validateNumKeysToMigratePerBlock(i interface{}) error {
v, ok := i.(uint64)
if !ok {
return fmt.Errorf("invalid parameter type: %T", i)
}
if v > MaxNumKeysToMigratePerBlock {
return fmt.Errorf(
"NumKeysToMigratePerBlock must be <= %d, got %d",
MaxNumKeysToMigratePerBlock, v,
)
}
return nil
}
Loading
Loading