From 3e50750eb0286f19bbebcd3924307d6350533237 Mon Sep 17 00:00:00 2001 From: chatton Date: Tue, 25 Nov 2025 11:55:43 +0000 Subject: [PATCH 01/11] wip: undelegate from sequecner --- Dockerfile.cosmos-sdk | 4 + modules/migrationmngr/keeper/migration.go | 100 +++++++++++++++--- .../single_validator_comet_migration_test.go | 65 ++++++++---- 3 files changed, 137 insertions(+), 32 deletions(-) diff --git a/Dockerfile.cosmos-sdk b/Dockerfile.cosmos-sdk index 3a22b34d..7036803b 100644 --- a/Dockerfile.cosmos-sdk +++ b/Dockerfile.cosmos-sdk @@ -32,6 +32,10 @@ RUN ignite evolve add-migrate # Replace ev-abci with local version to get updated proto files RUN go mod edit -replace github.com/evstack/ev-node=github.com/evstack/ev-node@${EVNODE_VERSION} && \ go mod edit -replace github.com/evstack/ev-abci=/workspace/ev-abci && \ + go mod edit -replace github.com/libp2p/go-libp2p-quic-transport=github.com/libp2p/go-libp2p-quic-transport@v0.33.1 && \ + go mod edit -replace github.com/libp2p/go-libp2p=github.com/libp2p/go-libp2p@v0.43.0 && \ + go mod edit -replace github.com/quic-go/quic-go=github.com/quic-go/quic-go@v0.54.1 && \ + go mod edit -replace github.com/quic-go/webtransport-go=github.com/quic-go/webtransport-go@v0.9.0 && \ go mod tidy # TODO: replace this with proper ignite flag to skip IBC registration when available diff --git a/modules/migrationmngr/keeper/migration.go b/modules/migrationmngr/keeper/migration.go index a4a3f308..86fc3355 100644 --- a/modules/migrationmngr/keeper/migration.go +++ b/modules/migrationmngr/keeper/migration.go @@ -30,18 +30,49 @@ func (k Keeper) migrateNow( return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to unpack sequencer pubkey: %v", err) } - if migrationData.StayOnComet { - // unbond delegations, let staking module handle validator updates - k.Logger(ctx).Info("Unbonding all validators immediately (StayOnComet, IBC not enabled)") - validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) - for _, val := range validatorsToRemove { - if err := k.unbondValidatorDelegations(ctx, val); err != nil { - return nil, err + if migrationData.StayOnComet { + // StayOnComet (IBC disabled): fully undelegate all validators' tokens and + // explicitly set the final CometBFT validator set to a single validator + // (the sequencer) with power=1. + k.Logger(ctx).Info("StayOnComet: immediate undelegation and explicit valset update (IBC disabled)") + + // unbond non-sequencer validators + validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) + for _, val := range validatorsToRemove { + if err := k.unbondValidatorDelegations(ctx, val); err != nil { + return nil, err + } + } + + // unbond sequencer delegations + var seqVal stakingtypes.Validator + foundSeq := false + for _, val := range lastValidatorSet { + if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { + seqVal = val + foundSeq = true + break + } + } + if foundSeq { + if err := k.unbondValidatorDelegations(ctx, seqVal); err != nil { + return nil, err + } } + + // Build ABCI updates: zeros for all non-sequencers; sequencer power 1 + var updates []abci.ValidatorUpdate + for _, val := range validatorsToRemove { + updates = append(updates, val.ABCIValidatorUpdateZero()) + } + pk, err := migrationData.Sequencer.TmConsPublicKey() + if err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get sequencer pubkey: %v", err) + } + updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: 1}) + + return updates, nil } - // return empty updates - staking module will update CometBFT - return []abci.ValidatorUpdate{}, nil - } // rollup migration: build and return ABCI updates directly switch len(migrationData.Attesters) { @@ -165,10 +196,51 @@ func (k Keeper) migrateOver( return k.migrateNow(ctx, migrationData, lastValidatorSet) } - if migrationData.StayOnComet { - // unbond delegations gradually, let staking module handle validator updates - return k.migrateOverWithUnbonding(ctx, migrationData, lastValidatorSet, step) - } + if migrationData.StayOnComet { + // StayOnComet with IBC enabled: perform gradual undelegations for non-sequencers + // and on the final smoothing step set sequencer power to 1 and undelegate it. + if step+1 == IBCSmoothingFactor { + k.Logger(ctx).Info("StayOnComet: finalization step, setting sequencer power=1 and undelegating sequencer") + + // undelegate sequencer delegations + var seqVal stakingtypes.Validator + foundSeq := false + for _, val := range lastValidatorSet { + if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { + seqVal = val + foundSeq = true + break + } + } + if foundSeq { + if err := k.unbondValidatorDelegations(ctx, seqVal); err != nil { + return nil, err + } + } + + // ABCI updates: zero all non-sequencers; set sequencer to 1 + var updates []abci.ValidatorUpdate + validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) + for _, val := range validatorsToRemove { + updates = append(updates, val.ABCIValidatorUpdateZero()) + } + pk, err := migrationData.Sequencer.TmConsPublicKey() + if err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get sequencer pubkey: %v", err) + } + updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: 1}) + + // increment step to mark completion next block + if err := k.MigrationStep.Set(ctx, step+1); err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) + } + + return updates, nil + } + + // not final step: continue gradual unbonding of non-sequencers + return k.migrateOverWithUnbonding(ctx, migrationData, lastValidatorSet, step) + } // rollup migration: build and return ABCI updates directly switch len(migrationData.Attesters) { diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index 4f2a6d37..8a48d9a4 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -51,7 +51,9 @@ type SingleValidatorSuite struct { ibcDenom string preMigrationIBCBal sdk.Coin - migrationHeight uint64 + migrationHeight uint64 + // number of validators on the primary chain at test start + initialValidators int } func TestSingleValSuite(t *testing.T) { @@ -90,10 +92,12 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { t.Run("create_chains", func(t *testing.T) { var wg sync.WaitGroup wg.Add(2) - go func() { - defer wg.Done() - s.chain = s.createAndStartChain(ctx, 5, "gm-1") - }() + go func() { + defer wg.Done() + // start with 5 validators on the primary chain + s.initialValidators = 5 + s.chain = s.createAndStartChain(ctx, s.initialValidators, "gm-1") + }() go func() { defer wg.Done() @@ -400,19 +404,44 @@ func (s *SingleValidatorSuite) validateSingleValidatorSet(ctx context.Context) { s.T().Logf("Bonded validators: %d", len(bondedResp.Validators)) s.Require().Len(bondedResp.Validators, 1, "should have exactly 1 bonded validator") - // check unbonding validators - unbondingResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ - Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonding)], - }) - s.Require().NoError(err) - s.T().Logf("Unbonding validators: %d", len(unbondingResp.Validators)) - - // check unbonded validators - unbondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ - Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonded)], - }) - s.Require().NoError(err) - s.T().Logf("Unbonded validators: %d", len(unbondedResp.Validators)) + // check unbonding validators + unbondingResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ + Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonding)], + }) + s.Require().NoError(err) + s.T().Logf("Unbonding validators: %d", len(unbondingResp.Validators)) + s.Require().Len(unbondingResp.Validators, 0, "no validators should be in unbonding state at completion") + + // check unbonded validators + unbondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ + Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonded)], + }) + s.Require().NoError(err) + s.T().Logf("Unbonded validators: %d", len(unbondedResp.Validators)) + if s.initialValidators > 0 { + s.Require().Equal(s.initialValidators-1, len(unbondedResp.Validators), "all non-sequencer validators should be unbonded") + } + + // additionally assert that the remaining bonded validator (sequencer) has no delegations left + // find the operator address for validator 0 + val0 := s.chain.GetNode() + stdout, stderr, err := val0.Exec(ctx, []string{ + "gmd", "keys", "show", "--address", "validator", + "--home", val0.HomeDir(), + "--keyring-backend", "test", + "--bech", "val", + }, nil) + s.Require().NoError(err, "failed to get valoper address from node 0: %s", stderr) + val0Oper := string(bytes.TrimSpace(stdout)) + + // query delegations to the remaining validator; expect zero after finalization step + delResp, err := stakeQC.ValidatorDelegations(ctx, &stakingtypes.QueryValidatorDelegationsRequest{ + ValidatorAddr: val0Oper, + Pagination: nil, + }) + s.Require().NoError(err) + s.T().Logf("Delegations to remaining validator: %d", len(delResp.DelegationResponses)) + s.Require().Len(delResp.DelegationResponses, 0, "remaining validator should have zero delegations after final step") s.T().Log("Validator set validated: 1 bonded validator") } From 80d810b49b998e2e55718ff1110313252bf6df68 Mon Sep 17 00:00:00 2001 From: chatton Date: Tue, 25 Nov 2025 14:10:03 +0000 Subject: [PATCH 02/11] chore: gradtually shift power to single sequencer --- modules/migrationmngr/keeper/migration.go | 53 +++++++++++++++++-- .../single_validator_comet_migration_test.go | 36 ++++++++----- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/modules/migrationmngr/keeper/migration.go b/modules/migrationmngr/keeper/migration.go index 86fc3355..c195a7bf 100644 --- a/modules/migrationmngr/keeper/migration.go +++ b/modules/migrationmngr/keeper/migration.go @@ -197,8 +197,13 @@ func (k Keeper) migrateOver( } if migrationData.StayOnComet { - // StayOnComet with IBC enabled: perform gradual undelegations for non-sequencers - // and on the final smoothing step set sequencer power to 1 and undelegate it. + // StayOnComet with IBC enabled: from the very first smoothing step, keep + // membership constant and reweight CometBFT powers so that the sequencer + // alone has >1/3 voting power. This removes timing sensitivity for IBC + // client updates. In parallel, undelegate non-sequencers gradually at the + // staking layer. + + // Final step: set sequencer power=1 and undelegate sequencer if step+1 == IBCSmoothingFactor { k.Logger(ctx).Info("StayOnComet: finalization step, setting sequencer power=1 and undelegating sequencer") @@ -238,8 +243,48 @@ func (k Keeper) migrateOver( return updates, nil } - // not final step: continue gradual unbonding of non-sequencers - return k.migrateOverWithUnbonding(ctx, migrationData, lastValidatorSet, step) + // Non-final steps: perform undelegation for a slice of non-sequencers, + // then emit reweighting updates for the full membership. + // 1) Unbond a chunk of non-sequencer validators on staking side + validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) + if len(validatorsToRemove) > 0 { + removePerStep := (len(validatorsToRemove) + int(IBCSmoothingFactor) - 1) / int(IBCSmoothingFactor) + startRemove := int(step) * removePerStep + endRemove := min(startRemove+removePerStep, len(validatorsToRemove)) + k.Logger(ctx).Info("StayOnComet: undelegating non-sequencers for step", + "step", step, "start_index", startRemove, "end_index", endRemove, "total_to_remove", len(validatorsToRemove)) + for _, val := range validatorsToRemove[startRemove:endRemove] { + if err := k.unbondValidatorDelegations(ctx, val); err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to undelegate: %v", err) + } + } + } + + // 2) Emit reweighting updates: sequencer gets large power, others get 1 + n := len(lastValidatorSet) + if n == 0 { + return []abci.ValidatorUpdate{}, nil + } + seqPower := int64(2 * n) + var updates []abci.ValidatorUpdate + for _, val := range lastValidatorSet { + pk, err := val.CmtConsPublicKey() + if err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get validator pubkey: %v", err) + } + power := int64(1) + if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { + power = seqPower + } + updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: power}) + } + + // advance smoothing step + if err := k.MigrationStep.Set(ctx, step+1); err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) + } + + return updates, nil } // rollup migration: build and return ABCI updates directly diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index 8a48d9a4..115939ed 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -396,31 +396,31 @@ func (s *SingleValidatorSuite) validateSingleValidatorSet(ctx context.Context) { stakeQC := stakingtypes.NewQueryClient(conn) - // check bonded validators - bondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ - Status: stakingtypes.BondStatus_name[int32(stakingtypes.Bonded)], - }) - s.Require().NoError(err) - s.T().Logf("Bonded validators: %d", len(bondedResp.Validators)) - s.Require().Len(bondedResp.Validators, 1, "should have exactly 1 bonded validator") + // staking bonded validators should be zero because all tokens are undelegated + bondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ + Status: stakingtypes.BondStatus_name[int32(stakingtypes.Bonded)], + }) + s.Require().NoError(err) + s.T().Logf("Bonded validators: %d", len(bondedResp.Validators)) + s.Require().Len(bondedResp.Validators, 0, "staking should report zero bonded validators after finalization") - // check unbonding validators + // check unbonding validators: after undelegation, validators enter unbonding state unbondingResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonding)], }) s.Require().NoError(err) s.T().Logf("Unbonding validators: %d", len(unbondingResp.Validators)) - s.Require().Len(unbondingResp.Validators, 0, "no validators should be in unbonding state at completion") + if s.initialValidators > 0 { + s.Require().Equal(s.initialValidators, len(unbondingResp.Validators), "all validators should be in unbonding state after finalization") + } - // check unbonded validators + // check unbonded validators: expect 0 since unbonding period has not elapsed unbondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonded)], }) s.Require().NoError(err) s.T().Logf("Unbonded validators: %d", len(unbondedResp.Validators)) - if s.initialValidators > 0 { - s.Require().Equal(s.initialValidators-1, len(unbondedResp.Validators), "all non-sequencer validators should be unbonded") - } + s.Require().Len(unbondedResp.Validators, 0, "no validators should be fully unbonded yet") // additionally assert that the remaining bonded validator (sequencer) has no delegations left // find the operator address for validator 0 @@ -443,7 +443,15 @@ func (s *SingleValidatorSuite) validateSingleValidatorSet(ctx context.Context) { s.T().Logf("Delegations to remaining validator: %d", len(delResp.DelegationResponses)) s.Require().Len(delResp.DelegationResponses, 0, "remaining validator should have zero delegations after final step") - s.T().Log("Validator set validated: 1 bonded validator") + // Also verify CometBFT validator set has exactly one validator with power=1 + rpcClient, err := s.chain.GetNode().GetRPCClient() + s.Require().NoError(err) + vals, err := rpcClient.Validators(ctx, nil, nil, nil) + s.Require().NoError(err) + s.Require().Equal(1, len(vals.Validators), "CometBFT should have exactly 1 validator in the set") + s.Require().Equal(int64(1), vals.Validators[0].VotingPower, "CometBFT validator should have voting power 1") + + s.T().Log("Validator set validated: staking has 0 bonded; CometBFT has 1 validator with power=1") } // validateChainProducesBlocks validates the chain continues to produce blocks From e6adc19177cadd6b8792c4a6dd11fc6ea8db6855 Mon Sep 17 00:00:00 2001 From: chatton Date: Tue, 25 Nov 2025 15:21:33 +0000 Subject: [PATCH 03/11] chore: test passing locally --- modules/migrationmngr/keeper/migration.go | 295 ++++++++---------- .../single_validator_comet_migration_test.go | 215 ++++++++----- 2 files changed, 272 insertions(+), 238 deletions(-) diff --git a/modules/migrationmngr/keeper/migration.go b/modules/migrationmngr/keeper/migration.go index c195a7bf..d1693170 100644 --- a/modules/migrationmngr/keeper/migration.go +++ b/modules/migrationmngr/keeper/migration.go @@ -30,49 +30,49 @@ func (k Keeper) migrateNow( return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to unpack sequencer pubkey: %v", err) } - if migrationData.StayOnComet { - // StayOnComet (IBC disabled): fully undelegate all validators' tokens and - // explicitly set the final CometBFT validator set to a single validator - // (the sequencer) with power=1. - k.Logger(ctx).Info("StayOnComet: immediate undelegation and explicit valset update (IBC disabled)") - - // unbond non-sequencer validators - validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) - for _, val := range validatorsToRemove { - if err := k.unbondValidatorDelegations(ctx, val); err != nil { - return nil, err - } - } - - // unbond sequencer delegations - var seqVal stakingtypes.Validator - foundSeq := false - for _, val := range lastValidatorSet { - if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { - seqVal = val - foundSeq = true - break - } - } - if foundSeq { - if err := k.unbondValidatorDelegations(ctx, seqVal); err != nil { - return nil, err - } + if migrationData.StayOnComet { + // StayOnComet (IBC disabled): fully undelegate all validators' tokens and + // explicitly set the final CometBFT validator set to a single validator + // (the sequencer) with power=1. + k.Logger(ctx).Info("StayOnComet: immediate undelegation and explicit valset update (IBC disabled)") + + // unbond non-sequencer validators + validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) + for _, val := range validatorsToRemove { + if err := k.unbondValidatorDelegations(ctx, val); err != nil { + return nil, err } + } - // Build ABCI updates: zeros for all non-sequencers; sequencer power 1 - var updates []abci.ValidatorUpdate - for _, val := range validatorsToRemove { - updates = append(updates, val.ABCIValidatorUpdateZero()) + // unbond sequencer delegations + var seqVal stakingtypes.Validator + foundSeq := false + for _, val := range lastValidatorSet { + if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { + seqVal = val + foundSeq = true + break } - pk, err := migrationData.Sequencer.TmConsPublicKey() - if err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get sequencer pubkey: %v", err) + } + if foundSeq { + if err := k.unbondValidatorDelegations(ctx, seqVal); err != nil { + return nil, err } - updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: 1}) + } - return updates, nil + // Build ABCI updates: zeros for all non-sequencers; sequencer power 1 + var updates []abci.ValidatorUpdate + for _, val := range validatorsToRemove { + updates = append(updates, val.ABCIValidatorUpdateZero()) } + pk, err := migrationData.Sequencer.TmConsPublicKey() + if err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get sequencer pubkey: %v", err) + } + updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: 1}) + + return updates, nil + } // rollup migration: build and return ABCI updates directly switch len(migrationData.Attesters) { @@ -196,96 +196,96 @@ func (k Keeper) migrateOver( return k.migrateNow(ctx, migrationData, lastValidatorSet) } - if migrationData.StayOnComet { - // StayOnComet with IBC enabled: from the very first smoothing step, keep - // membership constant and reweight CometBFT powers so that the sequencer - // alone has >1/3 voting power. This removes timing sensitivity for IBC - // client updates. In parallel, undelegate non-sequencers gradually at the - // staking layer. - - // Final step: set sequencer power=1 and undelegate sequencer - if step+1 == IBCSmoothingFactor { - k.Logger(ctx).Info("StayOnComet: finalization step, setting sequencer power=1 and undelegating sequencer") - - // undelegate sequencer delegations - var seqVal stakingtypes.Validator - foundSeq := false - for _, val := range lastValidatorSet { - if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { - seqVal = val - foundSeq = true - break - } - } - if foundSeq { - if err := k.unbondValidatorDelegations(ctx, seqVal); err != nil { - return nil, err - } - } - - // ABCI updates: zero all non-sequencers; set sequencer to 1 - var updates []abci.ValidatorUpdate - validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) - for _, val := range validatorsToRemove { - updates = append(updates, val.ABCIValidatorUpdateZero()) - } - pk, err := migrationData.Sequencer.TmConsPublicKey() - if err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get sequencer pubkey: %v", err) - } - updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: 1}) - - // increment step to mark completion next block - if err := k.MigrationStep.Set(ctx, step+1); err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) - } - - return updates, nil - } - - // Non-final steps: perform undelegation for a slice of non-sequencers, - // then emit reweighting updates for the full membership. - // 1) Unbond a chunk of non-sequencer validators on staking side - validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) - if len(validatorsToRemove) > 0 { - removePerStep := (len(validatorsToRemove) + int(IBCSmoothingFactor) - 1) / int(IBCSmoothingFactor) - startRemove := int(step) * removePerStep - endRemove := min(startRemove+removePerStep, len(validatorsToRemove)) - k.Logger(ctx).Info("StayOnComet: undelegating non-sequencers for step", - "step", step, "start_index", startRemove, "end_index", endRemove, "total_to_remove", len(validatorsToRemove)) - for _, val := range validatorsToRemove[startRemove:endRemove] { - if err := k.unbondValidatorDelegations(ctx, val); err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to undelegate: %v", err) - } - } - } - - // 2) Emit reweighting updates: sequencer gets large power, others get 1 - n := len(lastValidatorSet) - if n == 0 { - return []abci.ValidatorUpdate{}, nil - } - seqPower := int64(2 * n) - var updates []abci.ValidatorUpdate - for _, val := range lastValidatorSet { - pk, err := val.CmtConsPublicKey() - if err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get validator pubkey: %v", err) - } - power := int64(1) - if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { - power = seqPower - } - updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: power}) - } - - // advance smoothing step - if err := k.MigrationStep.Set(ctx, step+1); err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) - } - - return updates, nil - } + if migrationData.StayOnComet { + // StayOnComet with IBC enabled: from the very first smoothing step, keep + // membership constant and reweight CometBFT powers so that the sequencer + // alone has >1/3 voting power. This removes timing sensitivity for IBC + // client updates. In parallel, undelegate non-sequencers gradually at the + // staking layer. + + // Final step: set sequencer power=1 and undelegate sequencer + if step+1 == IBCSmoothingFactor { + k.Logger(ctx).Info("StayOnComet: finalization step, setting sequencer power=1 and undelegating sequencer") + + // undelegate sequencer delegations + var seqVal stakingtypes.Validator + foundSeq := false + for _, val := range lastValidatorSet { + if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { + seqVal = val + foundSeq = true + break + } + } + if foundSeq { + if err := k.unbondValidatorDelegations(ctx, seqVal); err != nil { + return nil, err + } + } + + // ABCI updates: zero all non-sequencers; set sequencer to 1 + var updates []abci.ValidatorUpdate + validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) + for _, val := range validatorsToRemove { + updates = append(updates, val.ABCIValidatorUpdateZero()) + } + pk, err := migrationData.Sequencer.TmConsPublicKey() + if err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get sequencer pubkey: %v", err) + } + updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: 1}) + + // increment step to mark completion next block + if err := k.MigrationStep.Set(ctx, step+1); err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) + } + + return updates, nil + } + + // Non-final steps: perform undelegation for a slice of non-sequencers, + // then emit reweighting updates for the full membership. + // 1) Unbond a chunk of non-sequencer validators on staking side + validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) + if len(validatorsToRemove) > 0 { + removePerStep := (len(validatorsToRemove) + int(IBCSmoothingFactor) - 1) / int(IBCSmoothingFactor) + startRemove := int(step) * removePerStep + endRemove := min(startRemove+removePerStep, len(validatorsToRemove)) + k.Logger(ctx).Info("StayOnComet: undelegating non-sequencers for step", + "step", step, "start_index", startRemove, "end_index", endRemove, "total_to_remove", len(validatorsToRemove)) + for _, val := range validatorsToRemove[startRemove:endRemove] { + if err := k.unbondValidatorDelegations(ctx, val); err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to undelegate: %v", err) + } + } + } + + // 2) Emit reweighting updates: sequencer gets large power, others get 1 + n := len(lastValidatorSet) + if n == 0 { + return []abci.ValidatorUpdate{}, nil + } + seqPower := int64(2 * n) + var updates []abci.ValidatorUpdate + for _, val := range lastValidatorSet { + pk, err := val.CmtConsPublicKey() + if err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get validator pubkey: %v", err) + } + power := int64(1) + if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { + power = seqPower + } + updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: power}) + } + + // advance smoothing step + if err := k.MigrationStep.Set(ctx, step+1); err != nil { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) + } + + return updates, nil + } // rollup migration: build and return ABCI updates directly switch len(migrationData.Attesters) { @@ -446,44 +446,3 @@ func getValidatorsToRemove(migrationData types.EvolveMigration, lastValidatorSet } return validatorsToRemove } - -// migrateOverWithUnbonding unbonds validators gradually over the smoothing period. -// This is used when StayOnComet is true with IBC enabled. -func (k Keeper) migrateOverWithUnbonding( - ctx context.Context, - migrationData types.EvolveMigration, - lastValidatorSet []stakingtypes.Validator, - step uint64, -) ([]abci.ValidatorUpdate, error) { - validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) - - if len(validatorsToRemove) == 0 { - k.Logger(ctx).Info("No validators to remove, migration complete") - return []abci.ValidatorUpdate{}, nil - } - - // unbond validators gradually - removePerStep := (len(validatorsToRemove) + int(IBCSmoothingFactor) - 1) / int(IBCSmoothingFactor) - startRemove := int(step) * removePerStep - endRemove := min(startRemove+removePerStep, len(validatorsToRemove)) - - k.Logger(ctx).Info("Unbonding validators gradually", - "step", step, - "start_index", startRemove, - "end_index", endRemove, - "total_to_remove", len(validatorsToRemove)) - - for _, val := range validatorsToRemove[startRemove:endRemove] { - if err := k.unbondValidatorDelegations(ctx, val); err != nil { - return nil, err - } - } - - // increment step - if err := k.MigrationStep.Set(ctx, step+1); err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) - } - - // return empty updates - let staking module handle validator set changes - return []abci.ValidatorUpdate{}, nil -} diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index 115939ed..fbc01bfa 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -51,9 +51,12 @@ type SingleValidatorSuite struct { ibcDenom string preMigrationIBCBal sdk.Coin - migrationHeight uint64 - // number of validators on the primary chain at test start - initialValidators int + migrationHeight uint64 + // number of validators on the primary chain at test start + initialValidators int + + // cancel function for background client update loop + relayerUpdateCancel context.CancelFunc } func TestSingleValSuite(t *testing.T) { @@ -92,12 +95,12 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { t.Run("create_chains", func(t *testing.T) { var wg sync.WaitGroup wg.Add(2) - go func() { - defer wg.Done() - // start with 5 validators on the primary chain - s.initialValidators = 5 - s.chain = s.createAndStartChain(ctx, s.initialValidators, "gm-1") - }() + go func() { + defer wg.Done() + // start with 5 validators on the primary chain + s.initialValidators = 5 + s.chain = s.createAndStartChain(ctx, s.initialValidators, "gm-1") + }() go func() { defer wg.Done() @@ -111,10 +114,19 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.setupIBCConnection(ctx) }) + // Establish initial IBC state and set s.ibcDenom before starting the + // background relayer update loop. t.Run("perform_ibc_transfers", func(t *testing.T) { s.performIBCTransfers(ctx) }) + // Continuously provoke Hermes client updates during migration by sending + // tiny IBC transfers on a short interval in the background. This greatly + // reduces timing sensitivity for light client anchoring. + t.Run("start_relayer_update_loop", func(t *testing.T) { + s.startRelayerUpdateLoop(ctx) + }) + t.Run("submit_migration_proposal", func(t *testing.T) { s.submitSingleValidatorMigrationProposal(ctx) }) @@ -131,6 +143,13 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.validateChainProducesBlocks(ctx) }) + // Stop the background relayer update loop + t.Run("stop_relayer_update_loop", func(t *testing.T) { + if s.relayerUpdateCancel != nil { + s.relayerUpdateCancel() + } + }) + t.Run("validate_ibc_preserved", func(t *testing.T) { s.validateIBCStatePreserved(ctx) }) @@ -279,6 +298,54 @@ func (s *SingleValidatorSuite) performIBCTransfers(ctx context.Context) { s.T().Logf("IBC transfer complete: %s %s", ibcBalance.Amount, s.ibcDenom) } +// startRelayerUpdateLoop starts a background goroutine that periodically sends a +// tiny IBC transfer to provoke Hermes to relay packets and submit client updates +// during the migration window. This reduces timing sensitivity for anchoring. +func (s *SingleValidatorSuite) startRelayerUpdateLoop(parentCtx context.Context) { + // create cancellable context and remember cancel + ctx, cancel := context.WithCancel(parentCtx) + s.relayerUpdateCancel = cancel + + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // send a very small IBC transfer from counterparty -> primary chain + if s.counterpartyChain == nil || s.chain == nil { + continue + } + + // wallets + ibcChainWallet := s.counterpartyChain.GetFaucetWallet() + gmWallet := s.chain.GetFaucetWallet() + + // tiny amount to minimize noise + amount := math.NewInt(1) + msg := transfertypes.NewMsgTransfer( + s.ibcChannel.PortID, + s.ibcChannel.ChannelID, + sdk.NewCoin("stake", amount), + ibcChainWallet.GetFormattedAddress(), + gmWallet.GetFormattedAddress(), + clienttypes.ZeroHeight(), + uint64(time.Now().Add(30*time.Second).UnixNano()), + "", + ) + + // short timeout context per tx; ignore errors to keep loop resilient + txCtx, cancelTx := context.WithTimeout(ctx, 20*time.Second) + _, _ = s.counterpartyChain.BroadcastMessages(txCtx, ibcChainWallet, msg) + cancelTx() + } + } + }() +} + // submitSingleValidatorMigrationProposal submits a proposal to migrate to single validator func (s *SingleValidatorSuite) submitSingleValidatorMigrationProposal(ctx context.Context) { s.T().Log("Submitting single validator migration proposal...") @@ -396,62 +463,62 @@ func (s *SingleValidatorSuite) validateSingleValidatorSet(ctx context.Context) { stakeQC := stakingtypes.NewQueryClient(conn) - // staking bonded validators should be zero because all tokens are undelegated - bondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ - Status: stakingtypes.BondStatus_name[int32(stakingtypes.Bonded)], - }) - s.Require().NoError(err) - s.T().Logf("Bonded validators: %d", len(bondedResp.Validators)) - s.Require().Len(bondedResp.Validators, 0, "staking should report zero bonded validators after finalization") - - // check unbonding validators: after undelegation, validators enter unbonding state - unbondingResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ - Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonding)], - }) - s.Require().NoError(err) - s.T().Logf("Unbonding validators: %d", len(unbondingResp.Validators)) - if s.initialValidators > 0 { - s.Require().Equal(s.initialValidators, len(unbondingResp.Validators), "all validators should be in unbonding state after finalization") - } - - // check unbonded validators: expect 0 since unbonding period has not elapsed - unbondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ - Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonded)], - }) - s.Require().NoError(err) - s.T().Logf("Unbonded validators: %d", len(unbondedResp.Validators)) - s.Require().Len(unbondedResp.Validators, 0, "no validators should be fully unbonded yet") - - // additionally assert that the remaining bonded validator (sequencer) has no delegations left - // find the operator address for validator 0 - val0 := s.chain.GetNode() - stdout, stderr, err := val0.Exec(ctx, []string{ - "gmd", "keys", "show", "--address", "validator", - "--home", val0.HomeDir(), - "--keyring-backend", "test", - "--bech", "val", - }, nil) - s.Require().NoError(err, "failed to get valoper address from node 0: %s", stderr) - val0Oper := string(bytes.TrimSpace(stdout)) - - // query delegations to the remaining validator; expect zero after finalization step - delResp, err := stakeQC.ValidatorDelegations(ctx, &stakingtypes.QueryValidatorDelegationsRequest{ - ValidatorAddr: val0Oper, - Pagination: nil, - }) - s.Require().NoError(err) - s.T().Logf("Delegations to remaining validator: %d", len(delResp.DelegationResponses)) - s.Require().Len(delResp.DelegationResponses, 0, "remaining validator should have zero delegations after final step") - - // Also verify CometBFT validator set has exactly one validator with power=1 - rpcClient, err := s.chain.GetNode().GetRPCClient() - s.Require().NoError(err) - vals, err := rpcClient.Validators(ctx, nil, nil, nil) - s.Require().NoError(err) - s.Require().Equal(1, len(vals.Validators), "CometBFT should have exactly 1 validator in the set") - s.Require().Equal(int64(1), vals.Validators[0].VotingPower, "CometBFT validator should have voting power 1") - - s.T().Log("Validator set validated: staking has 0 bonded; CometBFT has 1 validator with power=1") + // staking bonded validators should be zero because all tokens are undelegated + bondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ + Status: stakingtypes.BondStatus_name[int32(stakingtypes.Bonded)], + }) + s.Require().NoError(err) + s.T().Logf("Bonded validators: %d", len(bondedResp.Validators)) + s.Require().Len(bondedResp.Validators, 0, "staking should report zero bonded validators after finalization") + + // check unbonding validators: after undelegation, validators enter unbonding state + unbondingResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ + Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonding)], + }) + s.Require().NoError(err) + s.T().Logf("Unbonding validators: %d", len(unbondingResp.Validators)) + if s.initialValidators > 0 { + s.Require().Equal(s.initialValidators, len(unbondingResp.Validators), "all validators should be in unbonding state after finalization") + } + + // check unbonded validators: expect 0 since unbonding period has not elapsed + unbondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ + Status: stakingtypes.BondStatus_name[int32(stakingtypes.Unbonded)], + }) + s.Require().NoError(err) + s.T().Logf("Unbonded validators: %d", len(unbondedResp.Validators)) + s.Require().Len(unbondedResp.Validators, 0, "no validators should be fully unbonded yet") + + // additionally assert that the remaining bonded validator (sequencer) has no delegations left + // find the operator address for validator 0 + val0 := s.chain.GetNode() + stdout, stderr, err := val0.Exec(ctx, []string{ + "gmd", "keys", "show", "--address", "validator", + "--home", val0.HomeDir(), + "--keyring-backend", "test", + "--bech", "val", + }, nil) + s.Require().NoError(err, "failed to get valoper address from node 0: %s", stderr) + val0Oper := string(bytes.TrimSpace(stdout)) + + // query delegations to the remaining validator; expect zero after finalization step + delResp, err := stakeQC.ValidatorDelegations(ctx, &stakingtypes.QueryValidatorDelegationsRequest{ + ValidatorAddr: val0Oper, + Pagination: nil, + }) + s.Require().NoError(err) + s.T().Logf("Delegations to remaining validator: %d", len(delResp.DelegationResponses)) + s.Require().Len(delResp.DelegationResponses, 0, "remaining validator should have zero delegations after final step") + + // Also verify CometBFT validator set has exactly one validator with power=1 + rpcClient, err := s.chain.GetNode().GetRPCClient() + s.Require().NoError(err) + vals, err := rpcClient.Validators(ctx, nil, nil, nil) + s.Require().NoError(err) + s.Require().Equal(1, len(vals.Validators), "CometBFT should have exactly 1 validator in the set") + s.Require().Equal(int64(1), vals.Validators[0].VotingPower, "CometBFT validator should have voting power 1") + + s.T().Log("Validator set validated: staking has 0 bonded; CometBFT has 1 validator with power=1") } // validateChainProducesBlocks validates the chain continues to produce blocks @@ -484,9 +551,12 @@ func (s *SingleValidatorSuite) validateIBCStatePreserved(ctx context.Context) { gmWallet.GetFormattedAddress(), s.ibcDenom) s.Require().NoError(err) - s.Require().Equal(s.preMigrationIBCBal.Amount, currentIBCBalance.Amount) - - s.T().Logf("IBC balance preserved: %s %s", currentIBCBalance.Amount, s.ibcDenom) + // Background relayer update loop may have sent tiny transfers that adjust + // this balance slightly. Instead of strict equality, assert that balance + // has not decreased, and proceed to verify a round-trip transfer works. + s.Require().True(currentIBCBalance.Amount.GTE(s.preMigrationIBCBal.Amount), + "IBC balance should not be less than pre-migration balance") + s.T().Logf("IBC balance (>= pre-migration): %s %s (pre=%s)", currentIBCBalance.Amount, s.ibcDenom, s.preMigrationIBCBal.Amount) // perform IBC transfer back to verify IBC still works after migration s.T().Log("Performing IBC transfer back to verify IBC functionality...") @@ -494,6 +564,10 @@ func (s *SingleValidatorSuite) validateIBCStatePreserved(ctx context.Context) { transferAmount := math.NewInt(100_000) ibcChainWallet := s.counterpartyChain.GetFaucetWallet() + // give relayer a moment to drain any backlog from the background loop + err = wait.ForBlocks(ctx, 3, s.counterpartyChain, s.chain) + s.Require().NoError(err) + // get counterparty network info to query balance counterpartyNetworkInfo, err := s.counterpartyChain.GetNode().GetNetworkInfo(ctx) s.Require().NoError(err) @@ -517,7 +591,7 @@ func (s *SingleValidatorSuite) validateIBCStatePreserved(ctx context.Context) { "", ) - ctxTx, cancelTx := context.WithTimeout(ctx, 2*time.Minute) + ctxTx, cancelTx := context.WithTimeout(ctx, 3*time.Minute) defer cancelTx() resp, err := s.chain.BroadcastMessages(ctxTx, gmWallet, transferMsg) s.Require().NoError(err) @@ -526,7 +600,7 @@ func (s *SingleValidatorSuite) validateIBCStatePreserved(ctx context.Context) { s.T().Log("Waiting for IBC transfer to complete...") // wait for transfer to complete on counterparty chain - err = wait.ForCondition(ctx, 2*time.Minute, 2*time.Second, func() (bool, error) { + err = wait.ForCondition(ctx, 3*time.Minute, 2*time.Second, func() (bool, error) { balance, err := queryBankBalance(ctx, counterpartyNetworkInfo.External.GRPCAddress(), ibcChainWallet.GetFormattedAddress(), @@ -535,6 +609,7 @@ func (s *SingleValidatorSuite) validateIBCStatePreserved(ctx context.Context) { return false, nil } expectedBalance := initialCounterpartyBalance.Amount.Add(transferAmount) + s.T().Logf("Waiting for IBC transfer: current=%s expected>=%s denom=stake", balance.Amount.String(), expectedBalance.String()) return balance.Amount.GTE(expectedBalance), nil }) s.Require().NoError(err) From c49de7c73c21dbffad8444f7f8b8b714e616dcbb Mon Sep 17 00:00:00 2001 From: chatton Date: Wed, 26 Nov 2025 08:19:16 +0000 Subject: [PATCH 04/11] chore: make migration take a lot longer when ibc is enabled --- modules/migrationmngr/keeper/migration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/migrationmngr/keeper/migration.go b/modules/migrationmngr/keeper/migration.go index d1693170..b19136ed 100644 --- a/modules/migrationmngr/keeper/migration.go +++ b/modules/migrationmngr/keeper/migration.go @@ -14,7 +14,7 @@ import ( ) // IBCSmoothingFactor is the factor used to smooth the migration process when IBC is enabled. It determines how many blocks the migration will take. -var IBCSmoothingFactor uint64 = 30 +var IBCSmoothingFactor uint64 = 300 // migrateNow migrates the chain to evolve immediately. // this method is used when ibc is not enabled, so no migration smoothing is needed. From 130996cd7ae9861fdb3a8f7dc7e62aec72429bd3 Mon Sep 17 00:00:00 2001 From: chatton Date: Wed, 26 Nov 2025 10:00:36 +0000 Subject: [PATCH 05/11] chore: bump test migration height --- tests/integration/single_validator_comet_migration_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index fbc01bfa..23b8764a 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -430,8 +430,8 @@ func (s *SingleValidatorSuite) getValidatorPubKey(ctx context.Context, conn *grp func (s *SingleValidatorSuite) waitForMigrationCompletion(ctx context.Context) { s.T().Log("Waiting for migration to complete...") - // migration should complete at migrationHeight + IBCSmoothingFactor (30 blocks) - targetHeight := int64(s.migrationHeight + 30) + // migration should complete at migrationHeight + IBCSmoothingFactor (300 blocks) + targetHeight := int64(s.migrationHeight + 300) err := wait.ForCondition(ctx, 5*time.Minute, 5*time.Second, func() (bool, error) { h, err := s.chain.Height(ctx) From 57c639975dc84234ea8cb9270903c37ccc7b5ca0 Mon Sep 17 00:00:00 2001 From: chatton Date: Wed, 26 Nov 2025 10:31:35 +0000 Subject: [PATCH 06/11] chore: bump timeout of test --- .github/workflows/migration_test.yml | 2 +- tests/integration/single_validator_comet_migration_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/migration_test.yml b/.github/workflows/migration_test.yml index f06078ce..27907158 100644 --- a/.github/workflows/migration_test.yml +++ b/.github/workflows/migration_test.yml @@ -255,7 +255,7 @@ jobs: - name: Run Migration Test (Stay on Comet) run: | cd tests/integration - go test -v -run TestSingleValSuite/TestNTo1StayOnCometMigration -timeout 30m + go test -v -run TestSingleValSuite/TestNTo1StayOnCometMigration -timeout 90m env: COSMOS_SDK_IMAGE_REPO: ${{ env.COSMOS_SDK_IMAGE_REPO }} COSMOS_SDK_IMAGE_TAG: ${{ needs.determine-tag.outputs.image_tag }} diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index 23b8764a..5f7202ec 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -433,7 +433,7 @@ func (s *SingleValidatorSuite) waitForMigrationCompletion(ctx context.Context) { // migration should complete at migrationHeight + IBCSmoothingFactor (300 blocks) targetHeight := int64(s.migrationHeight + 300) - err := wait.ForCondition(ctx, 5*time.Minute, 5*time.Second, func() (bool, error) { + err := wait.ForCondition(ctx, time.Hour, 10*time.Second, func() (bool, error) { h, err := s.chain.Height(ctx) if err != nil { return false, err From 639c2a596d6282bfc006e6733d186e316fb583dd Mon Sep 17 00:00:00 2001 From: chatton Date: Wed, 26 Nov 2025 14:24:23 +0000 Subject: [PATCH 07/11] chore: test passing with manual client updates --- .../single_validator_comet_migration_test.go | 423 +++++++++++++++++- 1 file changed, 414 insertions(+), 9 deletions(-) diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index 5f7202ec..1148f1d4 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -3,7 +3,10 @@ package integration_test import ( "bytes" "context" + "encoding/json" "fmt" + "regexp" + "strconv" "sync" "testing" "time" @@ -36,6 +39,8 @@ import ( "google.golang.org/grpc/credentials/insecure" ) +const FirstClientID = "07-tendermint-1" + // SingleValidatorSuite tests migration from N validators to 1 validator on CometBFT type SingleValidatorSuite struct { DockerIntegrationTestSuite @@ -57,6 +62,15 @@ type SingleValidatorSuite struct { // cancel function for background client update loop relayerUpdateCancel context.CancelFunc + + // last height on the primary chain (subject) for which we've + // successfully attempted a client update on the counterparty (host). + // Used to step updates height-by-height during migration. + lastUpdatedChainOnCounterparty int64 + + // Resolved client IDs tied to the IBC connection/channel + hostClientIDOnCounterparty string // client for gm-1 that lives on gm-2 + hostClientIDOnChain string // client for gm-2 that lives on gm-1 } func TestSingleValSuite(t *testing.T) { @@ -120,12 +134,8 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.performIBCTransfers(ctx) }) - // Continuously provoke Hermes client updates during migration by sending - // tiny IBC transfers on a short interval in the background. This greatly - // reduces timing sensitivity for light client anchoring. - t.Run("start_relayer_update_loop", func(t *testing.T) { - s.startRelayerUpdateLoop(ctx) - }) + // We no longer run a background relayer update loop; instead we backfill + // client updates after the migration completes. t.Run("submit_migration_proposal", func(t *testing.T) { s.submitSingleValidatorMigrationProposal(ctx) @@ -135,6 +145,16 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.waitForMigrationCompletion(ctx) }) + // Optional: Backfill client updates after the upgrade window, stepping + // from the client's current trusted height up to the final migration + // height. This keeps gm-1's client on gm-2 up to date across the + // migration window without background updates. + t.Run("backfill_client_updates", func(t *testing.T) { + end := int64(s.migrationHeight + 30) + err := s.BackfillChainClientOnCounterpartyUntil(ctx, end) + s.Require().NoError(err) + }) + t.Run("validate_single_validator", func(t *testing.T) { s.validateSingleValidatorSet(ctx) }) @@ -143,6 +163,13 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.validateChainProducesBlocks(ctx) }) + // Emit detailed IBC debug information before final IBC preservation checks + t.Run("debug_ibc_state", func(t *testing.T) { + if err := s.dumpIBCDebug(ctx); err != nil { + s.T().Logf("dump IBC debug failed: %v", err) + } + }) + // Stop the background relayer update loop t.Run("stop_relayer_update_loop", func(t *testing.T) { if s.relayerUpdateCancel != nil { @@ -237,6 +264,15 @@ func (s *SingleValidatorSuite) setupIBCConnection(ctx context.Context) { s.Require().NoError(err) s.T().Logf("IBC connection established: %s <-> %s", s.ibcConnection.ConnectionID, s.ibcConnection.CounterpartyID) + + // Resolve and log the client IDs bound to the connection on both chains + hostClient, err := queryConnectionClientID(ctx, s.counterpartyChain, s.ibcConnection.ConnectionID) + s.Require().NoError(err) + counterpartyClient, err := queryConnectionClientID(ctx, s.chain, s.ibcConnection.CounterpartyID) + s.Require().NoError(err) + s.hostClientIDOnCounterparty = hostClient + s.hostClientIDOnChain = counterpartyClient + s.T().Logf("Resolved client IDs: gm-2 has client %s (for gm-1), gm-1 has client %s (for gm-2)", hostClient, counterpartyClient) } // performIBCTransfers performs IBC transfers to establish IBC state @@ -298,6 +334,39 @@ func (s *SingleValidatorSuite) performIBCTransfers(ctx context.Context) { s.T().Logf("IBC transfer complete: %s %s", ibcBalance.Amount, s.ibcDenom) } +// startRelayerUpdateLoop starts a background goroutine that periodically +// updates clients. +func (s *SingleValidatorSuite) startRelayerUpdateLoop(parentCtx context.Context) { + // create cancellable context and remember cancel + ctx, cancel := context.WithCancel(parentCtx) + s.relayerUpdateCancel = cancel + + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // send a very small IBC transfer from counterparty -> primary chain + if s.counterpartyChain == nil || s.chain == nil { + continue + } + + // Keep the gm-1 client on the counterparty (gm-2) up to date by + // stepping through each new height on the primary chain. This avoids + // large single-hop updates across validator set transitions. + if err := s.StepwiseUpdateChainClientOnCounterparty(ctx); err != nil { + s.T().Logf("Stepwise client update error: %v", err) + } + } + } + }() +} + +/* // startRelayerUpdateLoop starts a background goroutine that periodically sends a // tiny IBC transfer to provoke Hermes to relay packets and submit client updates // during the migration window. This reduces timing sensitivity for anchoring. @@ -345,7 +414,7 @@ func (s *SingleValidatorSuite) startRelayerUpdateLoop(parentCtx context.Context) } }() } - +*/ // submitSingleValidatorMigrationProposal submits a proposal to migrate to single validator func (s *SingleValidatorSuite) submitSingleValidatorMigrationProposal(ctx context.Context) { s.T().Log("Submitting single validator migration proposal...") @@ -431,12 +500,13 @@ func (s *SingleValidatorSuite) waitForMigrationCompletion(ctx context.Context) { s.T().Log("Waiting for migration to complete...") // migration should complete at migrationHeight + IBCSmoothingFactor (300 blocks) - targetHeight := int64(s.migrationHeight + 300) + targetHeight := int64(s.migrationHeight + 30) err := wait.ForCondition(ctx, time.Hour, 10*time.Second, func() (bool, error) { h, err := s.chain.Height(ctx) if err != nil { - return false, err + s.T().Logf("Error getting height: %v", err) + return false, nil } s.T().Logf("Current height: %d, Target: %d", h, targetHeight) return h >= targetHeight, nil @@ -631,3 +701,338 @@ func (s *SingleValidatorSuite) calculateIBCDenom(portID, channelID, baseDenom st prefixedDenom := transfertypes.GetPrefixedDenom(portID, channelID, baseDenom) return transfertypes.ParseDenomTrace(prefixedDenom).IBCDenom() } + +// UpdateClients updates clients on both chains. +// It is assumed there is only one client and uses the hard coded client ID that both will have. +func (s *SingleValidatorSuite) UpdateClients(ctx context.Context, hermes *relayer.Hermes, chainA, chainB *cosmos.Chain) error { + if err := updateClient(ctx, hermes, chainA.GetChainID(), FirstClientID); err != nil { + return fmt.Errorf("failed to update client on chain %s: %w", chainA.GetChainID(), err) + } + + if err := updateClient(ctx, hermes, chainB.GetChainID(), FirstClientID); err != nil { + return fmt.Errorf("failed to update client on chain %s: %w", chainB.GetChainID(), err) + } + + return nil +} + +// updateClient updates the specified client with the hostChainID and clientID. +func updateClient(ctx context.Context, hermes *relayer.Hermes, hostChainID, clientID string) error { + cmd := []string{ + "hermes", "--json", "update", "client", + "--host-chain", hostChainID, + "--client", clientID, + } + _, _, err := hermes.Exec(ctx, hermes.Logger, cmd, nil) + return err +} + +// revisionNumberFromChainIDOrClient tries to extract the revision number from +// the subject chain-id (e.g., "gm-1" -> 1). If parsing fails, it queries the +// client state on the host chain and extracts latest_height.revision_number. +func revisionNumberFromChainIDOrClient(ctx context.Context, subjectChainID string, host *cosmos.Chain, clientID string) (uint64, error) { + // Parse suffix from chain-id + re := regexp.MustCompile(`-(\d+)$`) + if m := re.FindStringSubmatch(subjectChainID); len(m) == 2 { + if n, err := strconv.ParseUint(m[1], 10, 64); err == nil { + return n, nil + } + } + + networkInfo, err := host.GetNode().GetNetworkInfo(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get host node network info: %w", err) + } + + // Fallback: query client state JSON on host chain + nodes := host.GetNodes() + if len(nodes) == 0 { + return 0, fmt.Errorf("no nodes for host chain") + } + node := nodes[0].(*cosmos.ChainNode) + stdout, stderr, err := node.Exec(ctx, []string{ + "gmd", "q", "ibc", "client", "state", clientID, "-o", "json", + "--grpc-addr", networkInfo.External.GRPCAddress(), "--grpc-insecure", "--prove=false", + }, nil) + if err != nil { + return 0, fmt.Errorf("query client state failed: %s", stderr) + } + var resp struct { + ClientState struct { + LatestHeight struct { + RevisionNumber json.Number `json:"revision_number"` + RevisionHeight json.Number `json:"revision_height"` + } `json:"latest_height"` + } `json:"client_state"` + } + if err := json.Unmarshal(stdout, &resp); err == nil { + if rn, err := resp.ClientState.LatestHeight.RevisionNumber.Int64(); err == nil && rn >= 0 { + return uint64(rn), nil + } + } + return 0, fmt.Errorf("could not determine revision_number from client state JSON") +} + +// queryClientRevisionHeight returns latest_height.revision_height for the client on the host chain. +func queryClientRevisionHeight(ctx context.Context, host *cosmos.Chain, clientID string) (int64, error) { + nodes := host.GetNodes() + if len(nodes) == 0 { + return 0, fmt.Errorf("no nodes for host chain") + } + node := nodes[0].(*cosmos.ChainNode) + + networkInfo, err := node.GetNetworkInfo(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get host node network info: %w", err) + } + + stdout, stderr, err := node.Exec(ctx, []string{ + "gmd", "q", "ibc", "client", "state", clientID, "-o", "json", + "--grpc-addr", networkInfo.Internal.GRPCAddress(), "--grpc-insecure", "--prove=false", + }, nil) + if err != nil { + return 0, fmt.Errorf("query client state failed: %s", stderr) + } + var resp struct { + ClientState struct { + LatestHeight struct { + RevisionHeight json.Number `json:"revision_height"` + } `json:"latest_height"` + } `json:"client_state"` + } + if err := json.Unmarshal(stdout, &resp); err != nil { + return 0, fmt.Errorf("failed to decode client state JSON: %w", err) + } + if rh, err := resp.ClientState.LatestHeight.RevisionHeight.Int64(); err == nil { + return rh, nil + } + return 0, fmt.Errorf("could not parse client revision_height from host state JSON") +} + +// queryConnectionClientID queries the IBC connection end and returns its client_id. +func queryConnectionClientID(ctx context.Context, chain *cosmos.Chain, connectionID string) (string, error) { + node := chain.GetNode() + networkInfo, err := node.GetNetworkInfo(ctx) + if err != nil { + return "", fmt.Errorf("failed to get network info: %w", err) + } + // Use internal gRPC address when querying from inside the node container. + var stdout, stderr []byte + // Simple retry in case the service or state is not immediately available. + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + stdout, stderr, err = node.Exec(ctx, []string{ + "gmd", "q", "ibc", "connection", "end", connectionID, "-o", "json", + "--grpc-addr", networkInfo.Internal.GRPCAddress(), "--grpc-insecure", "--prove=false", + }, nil) + if err == nil { + lastErr = nil + break + } + lastErr = fmt.Errorf("query connection end failed: %s", stderr) + // small delay before retrying + time.Sleep(300 * time.Millisecond) + } + if lastErr != nil { + return "", lastErr + } + var resp struct { + Connection struct { + ClientID string `json:"client_id"` + } `json:"connection"` + } + if err := json.Unmarshal(stdout, &resp); err != nil { + return "", fmt.Errorf("failed to decode connection end JSON: %w", err) + } + if resp.Connection.ClientID == "" { + return "", fmt.Errorf("empty client_id in connection end for %s", connectionID) + } + return resp.Connection.ClientID, nil +} + +// dumpIBCDebug logs useful IBC-related state: chain heights, connection/channel IDs, +// resolved client IDs and their latest heights/chain-ids on both chains. +func (s *SingleValidatorSuite) dumpIBCDebug(ctx context.Context) error { + // Current chain heights + hA, err := s.chain.Height(ctx) + if err != nil { + return err + } + hB, err := s.counterpartyChain.Height(ctx) + if err != nil { + return err + } + s.T().Logf("Heights: %s=%d, %s=%d", s.chain.GetChainID(), hA, s.counterpartyChain.GetChainID(), hB) + + // Connection and channel IDs + s.T().Logf("Connection IDs: A=%s, B=%s", s.ibcConnection.CounterpartyID, s.ibcConnection.ConnectionID) + s.T().Logf("Channel IDs: A=%s/%s, B=%s/%s", s.ibcChannel.CounterpartyPort, s.ibcChannel.CounterpartyID, s.ibcChannel.PortID, s.ibcChannel.ChannelID) + + // Resolve client IDs from connections (reconfirm) + hostClientB, err := queryConnectionClientID(ctx, s.counterpartyChain, s.ibcConnection.ConnectionID) + if err != nil { + return err + } + hostClientA, err := queryConnectionClientID(ctx, s.chain, s.ibcConnection.CounterpartyID) + if err != nil { + return err + } + s.T().Logf("Client IDs: on %s (for %s) = %s; on %s (for %s) = %s", + s.counterpartyChain.GetChainID(), s.chain.GetChainID(), hostClientB, + s.chain.GetChainID(), s.counterpartyChain.GetChainID(), hostClientA) + + // Query and log client states + ciB, err := queryClientInfo(ctx, s.counterpartyChain, hostClientB) + if err != nil { + return err + } + s.T().Logf("Client on %s tracking %s: latest_height=%d (rev=%d)", s.counterpartyChain.GetChainID(), ciB.TrackedChainID, ciB.RevisionHeight, ciB.RevisionNumber) + + ciA, err := queryClientInfo(ctx, s.chain, hostClientA) + if err != nil { + return err + } + s.T().Logf("Client on %s tracking %s: latest_height=%d (rev=%d)", s.chain.GetChainID(), ciA.TrackedChainID, ciA.RevisionHeight, ciA.RevisionNumber) + + return nil +} + +type clientInfo struct { + TrackedChainID string + RevisionNumber uint64 + RevisionHeight int64 +} + +// queryClientInfo returns chain-id and latest height for a client on a chain. +func queryClientInfo(ctx context.Context, chain *cosmos.Chain, clientID string) (clientInfo, error) { + node := chain.GetNode() + networkInfo, err := node.GetNetworkInfo(ctx) + if err != nil { + return clientInfo{}, fmt.Errorf("failed to get network info: %w", err) + } + stdout, stderr, err := node.Exec(ctx, []string{ + "gmd", "q", "ibc", "client", "state", clientID, "-o", "json", + "--grpc-addr", networkInfo.External.GRPCAddress(), "--grpc-insecure", "--prove=false", + }, nil) + if err != nil { + return clientInfo{}, fmt.Errorf("query client state failed: %s", stderr) + } + var resp struct { + ClientState struct { + ChainID string `json:"chain_id"` + LatestHeight struct { + RevisionNumber json.Number `json:"revision_number"` + RevisionHeight json.Number `json:"revision_height"` + } `json:"latest_height"` + } `json:"client_state"` + } + if err := json.Unmarshal(stdout, &resp); err != nil { + return clientInfo{}, fmt.Errorf("failed to decode client state JSON: %w", err) + } + rn, _ := resp.ClientState.LatestHeight.RevisionNumber.Int64() + rh, _ := resp.ClientState.LatestHeight.RevisionHeight.Int64() + return clientInfo{ + TrackedChainID: resp.ClientState.ChainID, + RevisionNumber: uint64(rn), + RevisionHeight: rh, + }, nil +} + +// StepwiseUpdateChainClientOnCounterparty advances the gm-1 client that lives on +// the counterparty chain (gm-2) one height at a time up to the current height +// of gm-1. This helps cross validator-set transitions that would otherwise fail +// a single-hop update due to insufficient overlap. +func (s *SingleValidatorSuite) StepwiseUpdateChainClientOnCounterparty(ctx context.Context) error { + if s.chain == nil || s.counterpartyChain == nil || s.hermes == nil { + return nil + } + + // Subject (client updates prove headers from this chain) + latest, err := s.chain.Height(ctx) + if err != nil { + return err + } + + // Start stepping from the next height after the last attempt + start := s.lastUpdatedChainOnCounterparty + 1 + if start < 1 { + start = 1 + } + if start > latest { + return nil + } + + clientID := s.hostClientIDOnCounterparty + if clientID == "" { + return fmt.Errorf("host client ID on counterparty not resolved") + } + for h := start; h <= latest; h++ { + if err := updateClientAtHeight(s.T(), ctx, s.hermes, s.counterpartyChain, s.chain.GetChainID(), clientID, h); err != nil { + // Log and continue; the next iteration may still succeed if overlap permits + s.T().Logf("update client at height %d failed: %v", h, err) + // Do not advance the last updated marker on failure + continue + } + s.lastUpdatedChainOnCounterparty = h + } + + return nil +} + +// updateClientAtHeight updates the client by submitting a header for a specific +// subject-chain height. Hermes expects a numeric height; for single-revision +// test chains this is sufficient. +func updateClientAtHeight(t *testing.T, ctx context.Context, hermes *relayer.Hermes, host *cosmos.Chain, subjectChainID, clientID string, height int64) error { + // Hermes v1.13.1 expects a plain numeric revision_height for --height. + // It derives the revision_number from chain context. Use bare height here. + hArg := fmt.Sprintf("%d", height) + cmd := []string{ + "hermes", "--json", "update", "client", + "--host-chain", host.GetChainID(), + "--client", clientID, + "--height", hArg, + } + stdout, stderr, err := hermes.Exec(ctx, hermes.Logger, cmd, nil) + t.Logf("update client at height %s stdout: %s", hArg, stdout) + t.Logf("update client at height %s: stderr %s", hArg, stderr) + return err +} + +// BackfillChainClientOnCounterpartyFrom steps the client on the counterparty +// from a specific starting height on the primary chain up to the current height. +// BackfillChainClientOnCounterpartyUntil steps from the host client's current +// trusted height + 1 up to and including endHeight on the subject chain. +func (s *SingleValidatorSuite) BackfillChainClientOnCounterpartyUntil(ctx context.Context, endHeight int64) error { + if s.chain == nil || s.counterpartyChain == nil || s.hermes == nil { + return fmt.Errorf("missing chain(s) or hermes") + } + + // Start from host client's current trusted height + 1 to ensure continuity. + clientID := s.hostClientIDOnCounterparty + if clientID == "" { + return fmt.Errorf("host client ID on counterparty not resolved") + } + trusted, err := queryClientRevisionHeight(ctx, s.counterpartyChain, clientID) + if err != nil { + return err + } + // Always start from the client's current trusted height + 1 on the host chain + startHeight := trusted + 1 + if startHeight < 1 { + startHeight = 1 + } + + // Do not go past the requested endHeight + if endHeight < startHeight { + return nil + } + + for h := startHeight; h <= endHeight; h++ { + if err := updateClientAtHeight(s.T(), ctx, s.hermes, s.counterpartyChain, s.chain.GetChainID(), clientID, h); err != nil { + s.T().Logf("backfill update at height %d failed: %v", h, err) + return err + } + s.lastUpdatedChainOnCounterparty = h + } + return nil +} From 0e34b3c6be02c5fe19398b609c331144ddf4644d Mon Sep 17 00:00:00 2001 From: chatton Date: Wed, 26 Nov 2025 14:48:33 +0000 Subject: [PATCH 08/11] chore: clean up debug code --- .../single_validator_comet_migration_test.go | 403 +----------------- 1 file changed, 21 insertions(+), 382 deletions(-) diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index 1148f1d4..97fa1d8d 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -5,8 +5,6 @@ import ( "context" "encoding/json" "fmt" - "regexp" - "strconv" "sync" "testing" "time" @@ -39,7 +37,13 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -const FirstClientID = "07-tendermint-1" +const ( + // matches the variable of the same name in ev-abci. + IBCSmoothingWindow = 30 + // firstClientID is the name of the first client that is generated. NOTE: for this test it is always the same + // as only a single client is being created on each chain. + firstClientID = "07-tendermint-1" +) // SingleValidatorSuite tests migration from N validators to 1 validator on CometBFT type SingleValidatorSuite struct { @@ -60,17 +64,10 @@ type SingleValidatorSuite struct { // number of validators on the primary chain at test start initialValidators int - // cancel function for background client update loop - relayerUpdateCancel context.CancelFunc - // last height on the primary chain (subject) for which we've // successfully attempted a client update on the counterparty (host). // Used to step updates height-by-height during migration. lastUpdatedChainOnCounterparty int64 - - // Resolved client IDs tied to the IBC connection/channel - hostClientIDOnCounterparty string // client for gm-1 that lives on gm-2 - hostClientIDOnChain string // client for gm-2 that lives on gm-1 } func TestSingleValSuite(t *testing.T) { @@ -134,9 +131,6 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.performIBCTransfers(ctx) }) - // We no longer run a background relayer update loop; instead we backfill - // client updates after the migration completes. - t.Run("submit_migration_proposal", func(t *testing.T) { s.submitSingleValidatorMigrationProposal(ctx) }) @@ -145,13 +139,12 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.waitForMigrationCompletion(ctx) }) - // Optional: Backfill client updates after the upgrade window, stepping - // from the client's current trusted height up to the final migration - // height. This keeps gm-1's client on gm-2 up to date across the - // migration window without background updates. + // in order for IBC to function, we need to ensure that the light client on the counterparty + // has a client state for each height across the migration window. + // NOTE: this can be done AFTER the migration. We just need to make sure that we fill in every block. t.Run("backfill_client_updates", func(t *testing.T) { end := int64(s.migrationHeight + 30) - err := s.BackfillChainClientOnCounterpartyUntil(ctx, end) + err := s.backfillChainClientOnCounterpartyUntil(ctx, end) s.Require().NoError(err) }) @@ -163,20 +156,6 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.validateChainProducesBlocks(ctx) }) - // Emit detailed IBC debug information before final IBC preservation checks - t.Run("debug_ibc_state", func(t *testing.T) { - if err := s.dumpIBCDebug(ctx); err != nil { - s.T().Logf("dump IBC debug failed: %v", err) - } - }) - - // Stop the background relayer update loop - t.Run("stop_relayer_update_loop", func(t *testing.T) { - if s.relayerUpdateCancel != nil { - s.relayerUpdateCancel() - } - }) - t.Run("validate_ibc_preserved", func(t *testing.T) { s.validateIBCStatePreserved(ctx) }) @@ -264,15 +243,6 @@ func (s *SingleValidatorSuite) setupIBCConnection(ctx context.Context) { s.Require().NoError(err) s.T().Logf("IBC connection established: %s <-> %s", s.ibcConnection.ConnectionID, s.ibcConnection.CounterpartyID) - - // Resolve and log the client IDs bound to the connection on both chains - hostClient, err := queryConnectionClientID(ctx, s.counterpartyChain, s.ibcConnection.ConnectionID) - s.Require().NoError(err) - counterpartyClient, err := queryConnectionClientID(ctx, s.chain, s.ibcConnection.CounterpartyID) - s.Require().NoError(err) - s.hostClientIDOnCounterparty = hostClient - s.hostClientIDOnChain = counterpartyClient - s.T().Logf("Resolved client IDs: gm-2 has client %s (for gm-1), gm-1 has client %s (for gm-2)", hostClient, counterpartyClient) } // performIBCTransfers performs IBC transfers to establish IBC state @@ -334,87 +304,6 @@ func (s *SingleValidatorSuite) performIBCTransfers(ctx context.Context) { s.T().Logf("IBC transfer complete: %s %s", ibcBalance.Amount, s.ibcDenom) } -// startRelayerUpdateLoop starts a background goroutine that periodically -// updates clients. -func (s *SingleValidatorSuite) startRelayerUpdateLoop(parentCtx context.Context) { - // create cancellable context and remember cancel - ctx, cancel := context.WithCancel(parentCtx) - s.relayerUpdateCancel = cancel - - go func() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - // send a very small IBC transfer from counterparty -> primary chain - if s.counterpartyChain == nil || s.chain == nil { - continue - } - - // Keep the gm-1 client on the counterparty (gm-2) up to date by - // stepping through each new height on the primary chain. This avoids - // large single-hop updates across validator set transitions. - if err := s.StepwiseUpdateChainClientOnCounterparty(ctx); err != nil { - s.T().Logf("Stepwise client update error: %v", err) - } - } - } - }() -} - -/* -// startRelayerUpdateLoop starts a background goroutine that periodically sends a -// tiny IBC transfer to provoke Hermes to relay packets and submit client updates -// during the migration window. This reduces timing sensitivity for anchoring. -func (s *SingleValidatorSuite) startRelayerUpdateLoop(parentCtx context.Context) { - // create cancellable context and remember cancel - ctx, cancel := context.WithCancel(parentCtx) - s.relayerUpdateCancel = cancel - - go func() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - // send a very small IBC transfer from counterparty -> primary chain - if s.counterpartyChain == nil || s.chain == nil { - continue - } - - // wallets - ibcChainWallet := s.counterpartyChain.GetFaucetWallet() - gmWallet := s.chain.GetFaucetWallet() - - // tiny amount to minimize noise - amount := math.NewInt(1) - msg := transfertypes.NewMsgTransfer( - s.ibcChannel.PortID, - s.ibcChannel.ChannelID, - sdk.NewCoin("stake", amount), - ibcChainWallet.GetFormattedAddress(), - gmWallet.GetFormattedAddress(), - clienttypes.ZeroHeight(), - uint64(time.Now().Add(30*time.Second).UnixNano()), - "", - ) - - // short timeout context per tx; ignore errors to keep loop resilient - txCtx, cancelTx := context.WithTimeout(ctx, 20*time.Second) - _, _ = s.counterpartyChain.BroadcastMessages(txCtx, ibcChainWallet, msg) - cancelTx() - } - } - }() -} -*/ // submitSingleValidatorMigrationProposal submits a proposal to migrate to single validator func (s *SingleValidatorSuite) submitSingleValidatorMigrationProposal(ctx context.Context) { s.T().Log("Submitting single validator migration proposal...") @@ -430,7 +319,7 @@ func (s *SingleValidatorSuite) submitSingleValidatorMigrationProposal(ctx contex s.Require().NoError(err) // schedule migration 30 blocks in the future to allow governance - migrateAt := uint64(curHeight + 30) + migrateAt := uint64(curHeight + IBCSmoothingWindow) s.migrationHeight = migrateAt s.T().Logf("Current height: %d, Migration at: %d", curHeight, migrateAt) @@ -499,8 +388,8 @@ func (s *SingleValidatorSuite) getValidatorPubKey(ctx context.Context, conn *grp func (s *SingleValidatorSuite) waitForMigrationCompletion(ctx context.Context) { s.T().Log("Waiting for migration to complete...") - // migration should complete at migrationHeight + IBCSmoothingFactor (300 blocks) - targetHeight := int64(s.migrationHeight + 30) + // migration should complete at migrationHeight + IBCSmoothingFactor (30 blocks) + targetHeight := int64(s.migrationHeight + IBCSmoothingWindow) err := wait.ForCondition(ctx, time.Hour, 10*time.Second, func() (bool, error) { h, err := s.chain.Height(ctx) @@ -702,77 +591,6 @@ func (s *SingleValidatorSuite) calculateIBCDenom(portID, channelID, baseDenom st return transfertypes.ParseDenomTrace(prefixedDenom).IBCDenom() } -// UpdateClients updates clients on both chains. -// It is assumed there is only one client and uses the hard coded client ID that both will have. -func (s *SingleValidatorSuite) UpdateClients(ctx context.Context, hermes *relayer.Hermes, chainA, chainB *cosmos.Chain) error { - if err := updateClient(ctx, hermes, chainA.GetChainID(), FirstClientID); err != nil { - return fmt.Errorf("failed to update client on chain %s: %w", chainA.GetChainID(), err) - } - - if err := updateClient(ctx, hermes, chainB.GetChainID(), FirstClientID); err != nil { - return fmt.Errorf("failed to update client on chain %s: %w", chainB.GetChainID(), err) - } - - return nil -} - -// updateClient updates the specified client with the hostChainID and clientID. -func updateClient(ctx context.Context, hermes *relayer.Hermes, hostChainID, clientID string) error { - cmd := []string{ - "hermes", "--json", "update", "client", - "--host-chain", hostChainID, - "--client", clientID, - } - _, _, err := hermes.Exec(ctx, hermes.Logger, cmd, nil) - return err -} - -// revisionNumberFromChainIDOrClient tries to extract the revision number from -// the subject chain-id (e.g., "gm-1" -> 1). If parsing fails, it queries the -// client state on the host chain and extracts latest_height.revision_number. -func revisionNumberFromChainIDOrClient(ctx context.Context, subjectChainID string, host *cosmos.Chain, clientID string) (uint64, error) { - // Parse suffix from chain-id - re := regexp.MustCompile(`-(\d+)$`) - if m := re.FindStringSubmatch(subjectChainID); len(m) == 2 { - if n, err := strconv.ParseUint(m[1], 10, 64); err == nil { - return n, nil - } - } - - networkInfo, err := host.GetNode().GetNetworkInfo(ctx) - if err != nil { - return 0, fmt.Errorf("failed to get host node network info: %w", err) - } - - // Fallback: query client state JSON on host chain - nodes := host.GetNodes() - if len(nodes) == 0 { - return 0, fmt.Errorf("no nodes for host chain") - } - node := nodes[0].(*cosmos.ChainNode) - stdout, stderr, err := node.Exec(ctx, []string{ - "gmd", "q", "ibc", "client", "state", clientID, "-o", "json", - "--grpc-addr", networkInfo.External.GRPCAddress(), "--grpc-insecure", "--prove=false", - }, nil) - if err != nil { - return 0, fmt.Errorf("query client state failed: %s", stderr) - } - var resp struct { - ClientState struct { - LatestHeight struct { - RevisionNumber json.Number `json:"revision_number"` - RevisionHeight json.Number `json:"revision_height"` - } `json:"latest_height"` - } `json:"client_state"` - } - if err := json.Unmarshal(stdout, &resp); err == nil { - if rn, err := resp.ClientState.LatestHeight.RevisionNumber.Int64(); err == nil && rn >= 0 { - return uint64(rn), nil - } - } - return 0, fmt.Errorf("could not determine revision_number from client state JSON") -} - // queryClientRevisionHeight returns latest_height.revision_height for the client on the host chain. func queryClientRevisionHeight(ctx context.Context, host *cosmos.Chain, clientID string) (int64, error) { nodes := host.GetNodes() @@ -809,182 +627,10 @@ func queryClientRevisionHeight(ctx context.Context, host *cosmos.Chain, clientID return 0, fmt.Errorf("could not parse client revision_height from host state JSON") } -// queryConnectionClientID queries the IBC connection end and returns its client_id. -func queryConnectionClientID(ctx context.Context, chain *cosmos.Chain, connectionID string) (string, error) { - node := chain.GetNode() - networkInfo, err := node.GetNetworkInfo(ctx) - if err != nil { - return "", fmt.Errorf("failed to get network info: %w", err) - } - // Use internal gRPC address when querying from inside the node container. - var stdout, stderr []byte - // Simple retry in case the service or state is not immediately available. - var lastErr error - for attempt := 0; attempt < 3; attempt++ { - stdout, stderr, err = node.Exec(ctx, []string{ - "gmd", "q", "ibc", "connection", "end", connectionID, "-o", "json", - "--grpc-addr", networkInfo.Internal.GRPCAddress(), "--grpc-insecure", "--prove=false", - }, nil) - if err == nil { - lastErr = nil - break - } - lastErr = fmt.Errorf("query connection end failed: %s", stderr) - // small delay before retrying - time.Sleep(300 * time.Millisecond) - } - if lastErr != nil { - return "", lastErr - } - var resp struct { - Connection struct { - ClientID string `json:"client_id"` - } `json:"connection"` - } - if err := json.Unmarshal(stdout, &resp); err != nil { - return "", fmt.Errorf("failed to decode connection end JSON: %w", err) - } - if resp.Connection.ClientID == "" { - return "", fmt.Errorf("empty client_id in connection end for %s", connectionID) - } - return resp.Connection.ClientID, nil -} - -// dumpIBCDebug logs useful IBC-related state: chain heights, connection/channel IDs, -// resolved client IDs and their latest heights/chain-ids on both chains. -func (s *SingleValidatorSuite) dumpIBCDebug(ctx context.Context) error { - // Current chain heights - hA, err := s.chain.Height(ctx) - if err != nil { - return err - } - hB, err := s.counterpartyChain.Height(ctx) - if err != nil { - return err - } - s.T().Logf("Heights: %s=%d, %s=%d", s.chain.GetChainID(), hA, s.counterpartyChain.GetChainID(), hB) - - // Connection and channel IDs - s.T().Logf("Connection IDs: A=%s, B=%s", s.ibcConnection.CounterpartyID, s.ibcConnection.ConnectionID) - s.T().Logf("Channel IDs: A=%s/%s, B=%s/%s", s.ibcChannel.CounterpartyPort, s.ibcChannel.CounterpartyID, s.ibcChannel.PortID, s.ibcChannel.ChannelID) - - // Resolve client IDs from connections (reconfirm) - hostClientB, err := queryConnectionClientID(ctx, s.counterpartyChain, s.ibcConnection.ConnectionID) - if err != nil { - return err - } - hostClientA, err := queryConnectionClientID(ctx, s.chain, s.ibcConnection.CounterpartyID) - if err != nil { - return err - } - s.T().Logf("Client IDs: on %s (for %s) = %s; on %s (for %s) = %s", - s.counterpartyChain.GetChainID(), s.chain.GetChainID(), hostClientB, - s.chain.GetChainID(), s.counterpartyChain.GetChainID(), hostClientA) - - // Query and log client states - ciB, err := queryClientInfo(ctx, s.counterpartyChain, hostClientB) - if err != nil { - return err - } - s.T().Logf("Client on %s tracking %s: latest_height=%d (rev=%d)", s.counterpartyChain.GetChainID(), ciB.TrackedChainID, ciB.RevisionHeight, ciB.RevisionNumber) - - ciA, err := queryClientInfo(ctx, s.chain, hostClientA) - if err != nil { - return err - } - s.T().Logf("Client on %s tracking %s: latest_height=%d (rev=%d)", s.chain.GetChainID(), ciA.TrackedChainID, ciA.RevisionHeight, ciA.RevisionNumber) - - return nil -} - -type clientInfo struct { - TrackedChainID string - RevisionNumber uint64 - RevisionHeight int64 -} - -// queryClientInfo returns chain-id and latest height for a client on a chain. -func queryClientInfo(ctx context.Context, chain *cosmos.Chain, clientID string) (clientInfo, error) { - node := chain.GetNode() - networkInfo, err := node.GetNetworkInfo(ctx) - if err != nil { - return clientInfo{}, fmt.Errorf("failed to get network info: %w", err) - } - stdout, stderr, err := node.Exec(ctx, []string{ - "gmd", "q", "ibc", "client", "state", clientID, "-o", "json", - "--grpc-addr", networkInfo.External.GRPCAddress(), "--grpc-insecure", "--prove=false", - }, nil) - if err != nil { - return clientInfo{}, fmt.Errorf("query client state failed: %s", stderr) - } - var resp struct { - ClientState struct { - ChainID string `json:"chain_id"` - LatestHeight struct { - RevisionNumber json.Number `json:"revision_number"` - RevisionHeight json.Number `json:"revision_height"` - } `json:"latest_height"` - } `json:"client_state"` - } - if err := json.Unmarshal(stdout, &resp); err != nil { - return clientInfo{}, fmt.Errorf("failed to decode client state JSON: %w", err) - } - rn, _ := resp.ClientState.LatestHeight.RevisionNumber.Int64() - rh, _ := resp.ClientState.LatestHeight.RevisionHeight.Int64() - return clientInfo{ - TrackedChainID: resp.ClientState.ChainID, - RevisionNumber: uint64(rn), - RevisionHeight: rh, - }, nil -} - -// StepwiseUpdateChainClientOnCounterparty advances the gm-1 client that lives on -// the counterparty chain (gm-2) one height at a time up to the current height -// of gm-1. This helps cross validator-set transitions that would otherwise fail -// a single-hop update due to insufficient overlap. -func (s *SingleValidatorSuite) StepwiseUpdateChainClientOnCounterparty(ctx context.Context) error { - if s.chain == nil || s.counterpartyChain == nil || s.hermes == nil { - return nil - } - - // Subject (client updates prove headers from this chain) - latest, err := s.chain.Height(ctx) - if err != nil { - return err - } - - // Start stepping from the next height after the last attempt - start := s.lastUpdatedChainOnCounterparty + 1 - if start < 1 { - start = 1 - } - if start > latest { - return nil - } - - clientID := s.hostClientIDOnCounterparty - if clientID == "" { - return fmt.Errorf("host client ID on counterparty not resolved") - } - for h := start; h <= latest; h++ { - if err := updateClientAtHeight(s.T(), ctx, s.hermes, s.counterpartyChain, s.chain.GetChainID(), clientID, h); err != nil { - // Log and continue; the next iteration may still succeed if overlap permits - s.T().Logf("update client at height %d failed: %v", h, err) - // Do not advance the last updated marker on failure - continue - } - s.lastUpdatedChainOnCounterparty = h - } - - return nil -} - // updateClientAtHeight updates the client by submitting a header for a specific // subject-chain height. Hermes expects a numeric height; for single-revision // test chains this is sufficient. -func updateClientAtHeight(t *testing.T, ctx context.Context, hermes *relayer.Hermes, host *cosmos.Chain, subjectChainID, clientID string, height int64) error { - // Hermes v1.13.1 expects a plain numeric revision_height for --height. - // It derives the revision_number from chain context. Use bare height here. +func updateClientAtHeight(ctx context.Context, hermes *relayer.Hermes, host *cosmos.Chain, clientID string, height int64) error { hArg := fmt.Sprintf("%d", height) cmd := []string{ "hermes", "--json", "update", "client", @@ -992,30 +638,23 @@ func updateClientAtHeight(t *testing.T, ctx context.Context, hermes *relayer.Her "--client", clientID, "--height", hArg, } - stdout, stderr, err := hermes.Exec(ctx, hermes.Logger, cmd, nil) - t.Logf("update client at height %s stdout: %s", hArg, stdout) - t.Logf("update client at height %s: stderr %s", hArg, stderr) + _, _, err := hermes.Exec(ctx, hermes.Logger, cmd, nil) return err } -// BackfillChainClientOnCounterpartyFrom steps the client on the counterparty -// from a specific starting height on the primary chain up to the current height. -// BackfillChainClientOnCounterpartyUntil steps from the host client's current +// backfillChainClientOnCounterpartyUntil steps from the host client's current // trusted height + 1 up to and including endHeight on the subject chain. -func (s *SingleValidatorSuite) BackfillChainClientOnCounterpartyUntil(ctx context.Context, endHeight int64) error { +func (s *SingleValidatorSuite) backfillChainClientOnCounterpartyUntil(ctx context.Context, endHeight int64) error { if s.chain == nil || s.counterpartyChain == nil || s.hermes == nil { return fmt.Errorf("missing chain(s) or hermes") } // Start from host client's current trusted height + 1 to ensure continuity. - clientID := s.hostClientIDOnCounterparty - if clientID == "" { - return fmt.Errorf("host client ID on counterparty not resolved") - } - trusted, err := queryClientRevisionHeight(ctx, s.counterpartyChain, clientID) + trusted, err := queryClientRevisionHeight(ctx, s.counterpartyChain, firstClientID) if err != nil { return err } + // Always start from the client's current trusted height + 1 on the host chain startHeight := trusted + 1 if startHeight < 1 { @@ -1028,7 +667,7 @@ func (s *SingleValidatorSuite) BackfillChainClientOnCounterpartyUntil(ctx contex } for h := startHeight; h <= endHeight; h++ { - if err := updateClientAtHeight(s.T(), ctx, s.hermes, s.counterpartyChain, s.chain.GetChainID(), clientID, h); err != nil { + if err := updateClientAtHeight(ctx, s.hermes, s.counterpartyChain, firstClientID, h); err != nil { s.T().Logf("backfill update at height %d failed: %v", h, err) return err } From 39c6e1dbf4acb4b0f26ab1a2485fcbd24c216000 Mon Sep 17 00:00:00 2001 From: chatton Date: Wed, 26 Nov 2025 14:56:09 +0000 Subject: [PATCH 09/11] chore: remove unused workflows --- .github/workflows/migration_test.yml | 137 +----------------- .../single_validator_comet_migration_test.go | 39 +++-- 2 files changed, 18 insertions(+), 158 deletions(-) diff --git a/.github/workflows/migration_test.yml b/.github/workflows/migration_test.yml index 27907158..d607606d 100644 --- a/.github/workflows/migration_test.yml +++ b/.github/workflows/migration_test.yml @@ -89,141 +89,6 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max,ignore-error=true - docker-build-evolve-image: - name: Build Evolve Docker Images - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: determine-tag - - strategy: - fail-fast: false - matrix: - include: - - enable_ibc: "true" - tag_suffix: "" # normal tag - - enable_ibc: "false" - tag_suffix: "-no-ibc" # appended to tag - - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Echo tag - run: | - echo "Base tag: ${{ needs.determine-tag.outputs.image_tag }}" - echo "Full tag: ${{ needs.determine-tag.outputs.image_tag }}${{ matrix.tag_suffix }}" - echo "ENABLE_IBC=${{ matrix.enable_ibc }}" - - - name: Build & push Evolve image - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile - build-args: | - EVNODE_VERSION=${{ env.EVNODE_VERSION }} - IGNITE_VERSION=${{ env.IGNITE_VERSION }} - IGNITE_EVOLVE_APP_VERSION=${{ env.IGNITE_EVOLVE_APP_VERSION }} - ENABLE_IBC=${{ matrix.enable_ibc }} - push: true - tags: ${{ env.EVOLVE_IMAGE_REPO }}:${{ needs.determine-tag.outputs.image_tag }}${{ matrix.tag_suffix }} - cache-from: type=gha - cache-to: type=gha,mode=max,ignore-error=true - - migration-tastora-single-node: - name: Test Migration from Cosmos SDK to Evolve (Single Node) - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: - - determine-tag - - docker-build-evolve-image - - docker-build-sdk-image - - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: tests/integration/go.mod - cache: true - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Pull images - run: | - docker pull ${{ env.EVOLVE_IMAGE_REPO }}:${{ needs.determine-tag.outputs.image_tag }} - docker pull ${{ env.COSMOS_SDK_IMAGE_REPO }}:${{ needs.determine-tag.outputs.image_tag }} - - - name: Run Migration Test - run: | - cd tests/integration - go test -v -run TestMigrationSuite/TestCosmosToEvolveMigration -timeout 30m - env: - EVOLVE_IMAGE_REPO: ${{ env.EVOLVE_IMAGE_REPO }} - EVOLVE_IMAGE_TAG: ${{ needs.determine-tag.outputs.image_tag }} - COSMOS_SDK_IMAGE_REPO: ${{ env.COSMOS_SDK_IMAGE_REPO }} - COSMOS_SDK_IMAGE_TAG: ${{ needs.determine-tag.outputs.image_tag }} - - migration-tastora-multi-node: - name: Test Migration from Cosmos SDK to Evolve - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: - - determine-tag - - docker-build-evolve-image - - docker-build-sdk-image - - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: tests/integration/go.mod - cache: true - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Pull images - run: | - docker pull ${{ env.EVOLVE_IMAGE_REPO }}:${{ needs.determine-tag.outputs.image_tag }}-no-ibc - docker pull ${{ env.COSMOS_SDK_IMAGE_REPO }}:${{ needs.determine-tag.outputs.image_tag }}-no-ibc - - - name: Run Migration Test - run: | - cd tests/integration - go test -v -run TestMigrationSuite/TestCosmosToEvolveMigration_MultiValidator_GovSuccess -timeout 30m - env: - EVOLVE_IMAGE_REPO: ${{ env.EVOLVE_IMAGE_REPO }} - EVOLVE_IMAGE_TAG: ${{ needs.determine-tag.outputs.image_tag }}-no-ibc - COSMOS_SDK_IMAGE_REPO: ${{ env.COSMOS_SDK_IMAGE_REPO }} - COSMOS_SDK_IMAGE_TAG: ${{ needs.determine-tag.outputs.image_tag }}-no-ibc - migration-stay-on-comet: name: Test Migration To 1 Validator Staying on Comet runs-on: ubuntu-latest @@ -255,7 +120,7 @@ jobs: - name: Run Migration Test (Stay on Comet) run: | cd tests/integration - go test -v -run TestSingleValSuite/TestNTo1StayOnCometMigration -timeout 90m + go test -v -run TestSingleValSuite/TestNTo1StayOnCometMigration -timeout 30m env: COSMOS_SDK_IMAGE_REPO: ${{ env.COSMOS_SDK_IMAGE_REPO }} COSMOS_SDK_IMAGE_TAG: ${{ needs.determine-tag.outputs.image_tag }} diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index 97fa1d8d..99c9cf9f 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -39,7 +39,7 @@ import ( const ( // matches the variable of the same name in ev-abci. - IBCSmoothingWindow = 30 + IBCSmoothingFactor = 30 // firstClientID is the name of the first client that is generated. NOTE: for this test it is always the same // as only a single client is being created on each chain. firstClientID = "07-tendermint-1" @@ -109,8 +109,7 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { go func() { defer wg.Done() // start with 5 validators on the primary chain - s.initialValidators = 5 - s.chain = s.createAndStartChain(ctx, s.initialValidators, "gm-1") + s.chain = s.createAndStartChain(ctx, 5, "gm-1") }() go func() { @@ -125,8 +124,7 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.setupIBCConnection(ctx) }) - // Establish initial IBC state and set s.ibcDenom before starting the - // background relayer update loop. + // Establish initial IBC state and capture s.ibcDenom and pre-migration balance. t.Run("perform_ibc_transfers", func(t *testing.T) { s.performIBCTransfers(ctx) }) @@ -139,11 +137,12 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.waitForMigrationCompletion(ctx) }) - // in order for IBC to function, we need to ensure that the light client on the counterparty - // has a client state for each height across the migration window. - // NOTE: this can be done AFTER the migration. We just need to make sure that we fill in every block. + // Ensure the light client on the counterparty has consensus states for + // every height across the migration window. This can be done AFTER the + // migration by backfilling one height at a time. + // The equivalent of this needs to be done for each counterparty. t.Run("backfill_client_updates", func(t *testing.T) { - end := int64(s.migrationHeight + 30) + end := int64(s.migrationHeight + IBCSmoothingFactor) err := s.backfillChainClientOnCounterpartyUntil(ctx, end) s.Require().NoError(err) }) @@ -319,7 +318,7 @@ func (s *SingleValidatorSuite) submitSingleValidatorMigrationProposal(ctx contex s.Require().NoError(err) // schedule migration 30 blocks in the future to allow governance - migrateAt := uint64(curHeight + IBCSmoothingWindow) + migrateAt := uint64(curHeight + IBCSmoothingFactor) s.migrationHeight = migrateAt s.T().Logf("Current height: %d, Migration at: %d", curHeight, migrateAt) @@ -389,7 +388,7 @@ func (s *SingleValidatorSuite) waitForMigrationCompletion(ctx context.Context) { s.T().Log("Waiting for migration to complete...") // migration should complete at migrationHeight + IBCSmoothingFactor (30 blocks) - targetHeight := int64(s.migrationHeight + IBCSmoothingWindow) + targetHeight := int64(s.migrationHeight + IBCSmoothingFactor) err := wait.ForCondition(ctx, time.Hour, 10*time.Second, func() (bool, error) { h, err := s.chain.Height(ctx) @@ -436,9 +435,7 @@ func (s *SingleValidatorSuite) validateSingleValidatorSet(ctx context.Context) { }) s.Require().NoError(err) s.T().Logf("Unbonding validators: %d", len(unbondingResp.Validators)) - if s.initialValidators > 0 { - s.Require().Equal(s.initialValidators, len(unbondingResp.Validators), "all validators should be in unbonding state after finalization") - } + s.Require().Equal(len(s.chain.GetNodes()), len(unbondingResp.Validators), "all validators should be in unbonding state after finalization") // check unbonded validators: expect 0 since unbonding period has not elapsed unbondedResp, err := stakeQC.Validators(ctx, &stakingtypes.QueryValidatorsRequest{ @@ -477,7 +474,7 @@ func (s *SingleValidatorSuite) validateSingleValidatorSet(ctx context.Context) { s.Require().Equal(1, len(vals.Validators), "CometBFT should have exactly 1 validator in the set") s.Require().Equal(int64(1), vals.Validators[0].VotingPower, "CometBFT validator should have voting power 1") - s.T().Log("Validator set validated: staking has 0 bonded; CometBFT has 1 validator with power=1") + s.T().Log("Validator set validated: staking has 0 bonded, CometBFT has 1 validator with power=1") } // validateChainProducesBlocks validates the chain continues to produce blocks @@ -510,12 +507,10 @@ func (s *SingleValidatorSuite) validateIBCStatePreserved(ctx context.Context) { gmWallet.GetFormattedAddress(), s.ibcDenom) s.Require().NoError(err) - // Background relayer update loop may have sent tiny transfers that adjust - // this balance slightly. Instead of strict equality, assert that balance - // has not decreased, and proceed to verify a round-trip transfer works. - s.Require().True(currentIBCBalance.Amount.GTE(s.preMigrationIBCBal.Amount), - "IBC balance should not be less than pre-migration balance") - s.T().Logf("IBC balance (>= pre-migration): %s %s (pre=%s)", currentIBCBalance.Amount, s.ibcDenom, s.preMigrationIBCBal.Amount) + // With no background transfers during migration, expect exact equality. + s.Require().Equal(s.preMigrationIBCBal.Amount, currentIBCBalance.Amount, + "IBC balance should equal pre-migration balance") + s.T().Logf("IBC balance (pre-migration): %s %s", currentIBCBalance.Amount, s.ibcDenom) // perform IBC transfer back to verify IBC still works after migration s.T().Log("Performing IBC transfer back to verify IBC functionality...") @@ -523,7 +518,7 @@ func (s *SingleValidatorSuite) validateIBCStatePreserved(ctx context.Context) { transferAmount := math.NewInt(100_000) ibcChainWallet := s.counterpartyChain.GetFaucetWallet() - // give relayer a moment to drain any backlog from the background loop + // wait a few blocks to ensure relayer has synced recent heights err = wait.ForBlocks(ctx, 3, s.counterpartyChain, s.chain) s.Require().NoError(err) From 3b1f5e7053b9cef020907ba7b75196af3897544f Mon Sep 17 00:00:00 2001 From: chatton Date: Wed, 26 Nov 2025 14:58:40 +0000 Subject: [PATCH 10/11] chore: change migration smoothing factor --- modules/migrationmngr/keeper/migration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/migrationmngr/keeper/migration.go b/modules/migrationmngr/keeper/migration.go index b19136ed..d1693170 100644 --- a/modules/migrationmngr/keeper/migration.go +++ b/modules/migrationmngr/keeper/migration.go @@ -14,7 +14,7 @@ import ( ) // IBCSmoothingFactor is the factor used to smooth the migration process when IBC is enabled. It determines how many blocks the migration will take. -var IBCSmoothingFactor uint64 = 300 +var IBCSmoothingFactor uint64 = 30 // migrateNow migrates the chain to evolve immediately. // this method is used when ibc is not enabled, so no migration smoothing is needed. From ff2e3357d675dbfdba56e2a7f4482ccab937f3d2 Mon Sep 17 00:00:00 2001 From: chatton Date: Wed, 26 Nov 2025 15:51:38 +0000 Subject: [PATCH 11/11] chore: simplified migration logic --- .github/workflows/integration_test.yml | 578 ---------------------- modules/migrationmngr/keeper/migration.go | 65 +-- 2 files changed, 11 insertions(+), 632 deletions(-) delete mode 100644 .github/workflows/integration_test.yml diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml deleted file mode 100644 index 9a7e45dc..00000000 --- a/.github/workflows/integration_test.yml +++ /dev/null @@ -1,578 +0,0 @@ -name: Ev-ABCI Integration & IBC Tests - -on: - push: - branches: ["main"] - pull_request: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-gm-image: - name: Build GM Image - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - packages: write - outputs: - image_tag: ${{ steps.tag.outputs.tag }} - env: - IGNITE_VERSION: v29.3.0 # the gm build script depends on some annotations - IGNITE_EVOLVE_APP_VERSION: main - EVNODE_VERSION: v1.0.0-beta.9 - steps: - - uses: actions/checkout@v5 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Determine image tag - id: tag - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "tag=ghcr.io/01builders/evolve-gm:pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" - else - echo "tag=ghcr.io/01builders/evolve-gm:${{ github.sha }}" >> "$GITHUB_OUTPUT" - fi - - - name: Build and push GM image - uses: docker/build-push-action@v6 - with: - context: . - file: tests/integration/docker/Dockerfile.gm - push: true - tags: ${{ steps.tag.outputs.tag }} - build-args: | - IGNITE_VERSION=${{ env.IGNITE_VERSION }} - IGNITE_EVOLVE_APP_VERSION=${{ env.IGNITE_EVOLVE_APP_VERSION }} - EVNODE_VERSION=${{ env.EVNODE_VERSION }} - - liveness-tastora: - name: Test with EV-ABCI Chain (Tastora) - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - EVNODE_VERSION: "v1.0.0-beta.9" - IGNITE_VERSION: "v29.6.1" - IGNITE_EVOLVE_APP_VERSION: "main" # use tagged when apps has tagged (blocked on things) - EVOLVE_IMAGE_REPO: "evolve-gm" - EVOLVE_IMAGE_TAG: "latest" - - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "stable" - cache: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build Evolve Docker Image - run: | - docker build \ - --build-arg EVNODE_VERSION=${{ env.EVNODE_VERSION }} \ - --build-arg IGNITE_VERSION=${{ env.IGNITE_VERSION }} \ - --build-arg IGNITE_EVOLVE_APP_VERSION=${{ env.IGNITE_EVOLVE_APP_VERSION }} \ - -t ${{ env.EVOLVE_IMAGE_REPO }}:${{ env.EVOLVE_IMAGE_TAG }} \ - . - - - name: Run Liveness Test - run: | - cd tests/integration - go test -v -run TestDockerIntegrationTestSuite/TestLivenessWithCelestiaDA -timeout 30m - env: - EVOLVE_IMAGE_REPO: ${{ env.EVOLVE_IMAGE_REPO }} - EVOLVE_IMAGE_TAG: ${{ env.EVOLVE_IMAGE_TAG }} - - liveness: - name: Test with Evolve Chain - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - DO_NOT_TRACK: true - EVNODE_VERSION: "v1.0.0-beta.9" - IGNITE_VERSION: "v29.6.1" - IGNITE_EVOLVE_APP_VERSION: "main" # use tagged when apps has tagged (blocked on things) - outputs: - carol_mnemonic: ${{ steps.save_mnemonic.outputs.carol_mnemonic }} - gmd_home: ${{ steps.paths.outputs.GMD_HOME }} - - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "stable" - cache: true - - - name: Install Ignite CLI - run: | - curl -sSL https://get.ignite.com/cli@$IGNITE_VERSION! | bash - - - name: Scaffold Evolve Chain - run: | - # scaffold a new chain - ignite scaffold chain gm --no-module --skip-git --address-prefix gm - cd gm - - # install evolve app - ignite app install github.com/ignite/apps/evolve@$IGNITE_EVOLVE_APP_VERSION - - # add evolve to the chain - ignite evolve add - - - name: Start Local DA - run: | - cd gm - # start the local da in the background - go tool github.com/evstack/ev-node/da/cmd/local-da & - # capture the background process PID - echo "DA_PID=$!" >> $GITHUB_ENV - # give it a moment to start - sleep 3 - - - name: Replace ABCI Module with Current Branch And Prepare Chain - run: | - # get the path to the current checkout of ev-abci - CURRENT_DIR=$(pwd) - GO_EXECUTION_ABCI_DIR=$CURRENT_DIR - - # enter the gm directory - cd gm - - # replace the github.com/evstack/ev-node module with tagged version - go mod edit -replace github.com/evstack/ev-node=github.com/evstack/ev-node@$EVNODE_VERSION - - # replace the github.com/evstack/ev-abci module with the local version - go mod edit -replace github.com/evstack/ev-abci=$GO_EXECUTION_ABCI_DIR - - # download dependencies and update go.mod/go.sum - go mod tidy - - # build the chain - ignite chain build --skip-proto - - # initialize evolve - ignite evolve init - - - name: Create extra accounts - id: save_mnemonic - run: | - MNEMONIC=$(gmd keys add carol --output json | jq -r .mnemonic) - echo "$MNEMONIC" > carol.mnemonic - echo "CAROL_MNEMONIC=$MNEMONIC" >> $GITHUB_ENV - echo "carol_mnemonic=$MNEMONIC" >> $GITHUB_OUTPUT - - - name: Get gm binary and gmd home paths - id: paths - run: | - GM_BINARY_PATH=$(which gmd) - echo "GM_BINARY_PATH=$GM_BINARY_PATH" - echo "GM_BINARY_PATH=$GM_BINARY_PATH" >> $GITHUB_ENV - echo "GM_BINARY_PATH=$GM_BINARY_PATH" >> $GITHUB_OUTPUT - GMD_HOME=$(gmd config home) - echo "GMD_HOME=$GMD_HOME" - echo "GMD_HOME=$GMD_HOME" >> $GITHUB_ENV - echo "GMD_HOME=$GMD_HOME" >> $GITHUB_OUTPUT - - - name: Upload gm Binary and gmd Home Directory - uses: actions/upload-artifact@v5 - with: - name: gmd - include-hidden-files: true - if-no-files-found: error - path: | - ${{ steps.paths.outputs.GM_BINARY_PATH }} - ${{ steps.paths.outputs.GMD_HOME }} - - - name: Start Chain and Wait for Blocks - run: | - # start the chain and send output to a log file - gmd start --rollkit.node.aggregator --log_format=json > chain.log 2>&1 & - CHAIN_PID=$! - echo "CHAIN_PID=$CHAIN_PID" >> $GITHUB_ENV - - echo "Waiting for chain to produce blocks..." - - # wait for chain to start and check for 5 blocks - BLOCKS_FOUND=0 - MAX_ATTEMPTS=60 - ATTEMPT=0 - - while [ $BLOCKS_FOUND -lt 5 ] && [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do - sleep 2 - ATTEMPT=$((ATTEMPT+1)) - - # check if the chain is still running - if ! ps -p $CHAIN_PID > /dev/null; then - echo "Chain process died unexpectedly" - cat chain.log - exit 1 - fi - - # query the node for the current block height - BLOCKS_FOUND=$(gmd query block --output json | tail -n +2 | jq -r '.header.height') - echo "Found $BLOCKS_FOUND blocks so far (attempt $ATTEMPT/$MAX_ATTEMPTS)" - done - - if [ $BLOCKS_FOUND -lt 5 ]; then - echo "Failed to find 5 blocks within time limit" - cat chain.log - exit 1 - fi - - echo "Success! Chain produced at least 5 blocks." - - - name: Test Transaction Submission and Query - run: | - # get Bob's and Carol's addresses - BOB_ADDRESS=$(gmd keys show bob -a) - CAROL_ADDRESS=$(gmd keys show carol -a) - echo "Bob's address: $BOB_ADDRESS" - echo "Carol's address: $CAROL_ADDRESS" - - # query bob's initial balance - echo "Querying Bob's initial balance..." - INITIAL_BALANCE=$(gmd query bank balances $BOB_ADDRESS --output json | jq '.balances[0].amount' -r) - echo "Bob's initial balance: $INITIAL_BALANCE stake" - - # check that bob has funds - if [ "$INITIAL_BALANCE" == "" ] || [ "$INITIAL_BALANCE" == "null" ] || [ "$INITIAL_BALANCE" -lt 100 ]; then - echo "Error: Bob's account not properly funded" - exit 1 - fi - - # send transaction from bob to carol and get tx hash - echo "Sending 100stake from Bob to Carol..." - TX_HASH=$(gmd tx bank send $BOB_ADDRESS $CAROL_ADDRESS 100stake -y --output json | jq -r .txhash) - - sleep 3 - - # query the transaction - TX_RESULT=$(gmd query tx $TX_HASH --output json) - TX_CODE=$(echo $TX_RESULT | jq -r '.code') - if [ "$TX_CODE" != "0" ]; then - echo "Error: Transaction failed with code $TX_CODE" - echo $TX_RESULT | jq - exit 1 - fi - - # query bob's balance after transaction - FINAL_BALANCE=$(gmd query bank balances $BOB_ADDRESS --output json | jq '.balances[0].amount' -r) - echo "Bob's final balance: $FINAL_BALANCE" - - # calculate and verify the expected balance - EXPECTED_BALANCE=$((INITIAL_BALANCE - 100)) - if [ "$FINAL_BALANCE" != "$EXPECTED_BALANCE" ]; then - echo "Error: Balance mismatch. Expected: $EXPECTED_BALANCE, Actual: $FINAL_BALANCE" - exit 1 - fi - - echo "✅ Transaction test successful! Balance correctly updated." - - - name: Cleanup Processes - if: always() - run: | - # kill chain process if it exists - if [[ -n "${CHAIN_PID}" ]]; then - kill -9 $CHAIN_PID || true - fi - - # kill DA process if it exists - if [[ -n "${DA_PID}" ]]; then - kill -9 $DA_PID || true - fi - - ibc: - name: Test IBC Connection Ev-ABCI <-> Cosmos Hub - runs-on: ubuntu-latest - timeout-minutes: 60 - needs: liveness - env: - DO_NOT_TRACK: true - CAROL_MNEMONIC: ${{ needs.liveness.outputs.carol_mnemonic }} - GMD_HOME: ${{ needs.liveness.outputs.gmd_home }} - HERMES_VERSION: "v1.13.1" - GAIA_VERSION: "v25.1.0" - EVNODE_VERSION: "v1.0.0-beta.9" - steps: - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: stable - - - name: Download gm Binary and gmd Home Directory - uses: actions/download-artifact@v4 # keep v4. - with: - name: gmd - path: gmd - - - name: Download Gaia Binary - run: | - wget https://github.com/cosmos/gaia/releases/download/${GAIA_VERSION}/gaiad-${GAIA_VERSION}-linux-amd64 - chmod +x gaiad-${GAIA_VERSION}-linux-amd64 - sudo mv gaiad-${GAIA_VERSION}-linux-amd64 /usr/local/bin/gaiad - - - name: Download Hermes Binary - run: | - wget https://github.com/informalsystems/hermes/releases/download/${HERMES_VERSION}/hermes-${HERMES_VERSION}-x86_64-unknown-linux-gnu.tar.gz - tar -xzf hermes-${HERMES_VERSION}-x86_64-unknown-linux-gnu.tar.gz - sudo mv hermes /usr/local/bin/hermes - - - name: Start Cosmos Hub Chain - run: | - gaiad init cosmos-local --chain-id cosmos-local - echo "$CAROL_MNEMONIC" | gaiad keys add validator \ - --keyring-backend test \ - --recover > /dev/null 2>&1 - gaiad genesis add-genesis-account $(gaiad keys show validator -a --keyring-backend test) 1000000000stake - gaiad genesis gentx validator 100000000stake --fees 1stake --chain-id cosmos-local --keyring-backend test - gaiad genesis collect-gentxs - gaiad config set app minimum-gas-prices 0.025stake - gaiad config set app grpc.enable true - gaiad start --rpc.laddr tcp://0.0.0.0:26654 --rpc.pprof_laddr localhost:6061 --p2p.laddr tcp://0.0.0.0:26653 --grpc.address 0.0.0.0:9091 --log_format=json > cosmos.log 2>&1 & - echo "COSMOS_PID=$!" >> $GITHUB_ENV - sleep 5 - - - name: Start Local DA - run: | - # Create a temporary go module to use go tool (can't use go install due to replace directives) - mkdir -p /tmp/da-tool - cd /tmp/da-tool - go mod init temp - go mod edit -replace github.com/evstack/ev-node=github.com/evstack/ev-node@$EVNODE_VERSION - go get github.com/evstack/ev-node/da/cmd/local-da - # start the local da in the background - go tool github.com/evstack/ev-node/da/cmd/local-da & - # capture the background process PID - echo "DA_PID=$!" >> $GITHUB_ENV - # give it a moment to start - sleep 3 - - - name: Start Evolve Chain - run: | - chmod +x ./gmd/go/bin/gmd # restoring permissions after download - ./gmd/go/bin/gmd start --rollkit.node.aggregator --rpc.laddr tcp://0.0.0.0:26657 --grpc.address 0.0.0.0:9090 --log_format=json --home ./gmd/.gm > chain.log 2>&1 & - echo "CHAIN_PID=$!" >> $GITHUB_ENV - sleep 10 - CAROL_ADDRESS=$(./gmd/go/bin/gmd keys show carol -a --home ./gmd/.gm) - echo "Fund Carol's account ($CAROL_ADDRESS)" - ./gmd/go/bin/gmd tx bank send bob $CAROL_ADDRESS 200000stake -y --output json --home ./gmd/.gm - sleep 5 - - - name: Configure & Start Hermes Relayer - run: | - mkdir -p ~/.hermes - cat > ~/.hermes/config.toml < $tmp - hermes keys add --chain gm --mnemonic-file $tmp - hermes keys add --chain cosmos-local --mnemonic-file $tmp - hermes start > hermes.log 2>&1 & - echo "HERMES_PID=$!" >> $GITHUB_ENV - - - name: Create IBC Connection and Channel - run: | - hermes create channel --a-chain gm --a-port transfer --b-chain cosmos-local --b-port transfer --order unordered --new-client-connection --yes - - - name: ICS20 Transfer Evolve -> Cosmos Hub - run: | - ROLLKIT_ADDR=$(./gmd/go/bin/gmd keys show carol -a --home ./gmd/.gm) - COSMOS_ADDR=$(gaiad keys show validator -a --keyring-backend test) - ./gmd/go/bin/gmd tx ibc-transfer transfer transfer channel-0 $COSMOS_ADDR 100stake --from carol -y --home ./gmd/.gm - - # Wait for IBC transfer to complete with retry logic - echo "Waiting for IBC transfer to complete..." - MAX_ATTEMPTS=30 - ATTEMPT=0 - IBC_FOUND=false - - while [ $ATTEMPT -lt $MAX_ATTEMPTS ] && [ "$IBC_FOUND" = false ]; do - sleep 3 - ATTEMPT=$((ATTEMPT+1)) - - BALANCE=$(gaiad query bank balances $COSMOS_ADDR --output json --node http://localhost:26654 | jq '.balances') - echo "Attempt $ATTEMPT/$MAX_ATTEMPTS - Gm balance: $BALANCE" - - # Check if any denom starts with ibc/ - if echo "$BALANCE" | jq -e '.[] | select(.denom | startswith("ibc/"))' > /dev/null; then - IBC_FOUND=true - echo "✅ IBC transfer successful! IBC denom found in balance." - else - echo "IBC denom not found yet, retrying in 3 seconds..." - fi - done - - if [ "$IBC_FOUND" = false ]; then - echo "Error: No IBC denom found in balance after transfer within $MAX_ATTEMPTS attempts!" - echo "Final balance: $BALANCE" - exit 1 - fi - - - name: ICS20 Transfer Cosmos Hub -> Evolve - run: | - ROLLKIT_ADDR=$(./gmd/go/bin/gmd keys show carol -a --home ./gmd/.gm) - COSMOS_ADDR=$(gaiad keys show validator -a --keyring-backend test) - gaiad tx ibc-transfer transfer transfer channel-0 $ROLLKIT_ADDR 100stake --from validator --node http://localhost:26654 --fees 200000stake --keyring-backend test -y - - # Wait for IBC transfer to complete with retry logic - echo "Waiting for IBC transfer to complete..." - MAX_ATTEMPTS=30 - ATTEMPT=0 - IBC_FOUND=false - - while [ $ATTEMPT -lt $MAX_ATTEMPTS ] && [ "$IBC_FOUND" = false ]; do - sleep 3 - ATTEMPT=$((ATTEMPT+1)) - - BALANCE=$(./gmd/go/bin/gmd query bank balances $ROLLKIT_ADDR --output json --home ./gmd/.gm | jq '.balances') - echo "Attempt $ATTEMPT/$MAX_ATTEMPTS - Gm balance: $BALANCE" - - # Check if any denom starts with ibc/ - if echo "$BALANCE" | jq -e '.[] | select(.denom | startswith("ibc/"))' > /dev/null; then - IBC_FOUND=true - echo "✅ IBC transfer successful! IBC denom found in balance." - else - echo "IBC denom not found yet, retrying in 3 seconds..." - fi - done - - if [ "$IBC_FOUND" = false ]; then - echo "Error: No IBC denom found in balance after transfer within $MAX_ATTEMPTS attempts!" - echo "Final balance: $BALANCE" - exit 1 - fi - - - name: Print logs on failure - if: failure() - run: | - echo '--- chain.log ---' - cat chain.log || true - echo '--- cosmos.log ---' - cat cosmos.log || true - echo '--- hermes.log ---' - cat hermes.log || true - - - name: Cleanup Processes - if: always() - run: | - if [[ -n "${CHAIN_PID}" ]]; then - kill -9 $CHAIN_PID || true - fi - if [[ -n "${COSMOS_PID}" ]]; then - kill -9 $COSMOS_PID || true - fi - if [[ -n "${DA_PID}" ]]; then - kill -9 $DA_PID || true - fi - if [[ -n "${HERMES_PID}" ]]; then - kill -9 $HERMES_PID || true - fi - - attester-integration: - needs: build-gm-image - name: Attester Mode Integration Test - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - DO_NOT_TRACK: true - EVNODE_VERSION: "v1.0.0-beta.9" - IGNITE_VERSION: "v29.6.1" - IGNITE_EVOLVE_APP_VERSION: "main" - GAIA_VERSION: "v25.1.0" - EVOLVE_IMAGE_REPO: "evabci/gm" - EVOLVE_IMAGE_TAG: "local" - - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Pull GM image from GHCR - run: | - docker pull ${{ needs.build-gm-image.outputs.image_tag }} - docker tag ${{ needs.build-gm-image.outputs.image_tag }} evabci/gm:local - - - name: Run attester integration test - working-directory: tests/integration - env: - GOTOOLCHAIN: auto - VERBOSE: "true" - run: | - go test -v -run 'TestDockerIntegrationTestSuite/TestAttesterSystem' -count=1 diff --git a/modules/migrationmngr/keeper/migration.go b/modules/migrationmngr/keeper/migration.go index d1693170..e06eda88 100644 --- a/modules/migrationmngr/keeper/migration.go +++ b/modules/migrationmngr/keeper/migration.go @@ -32,39 +32,24 @@ func (k Keeper) migrateNow( if migrationData.StayOnComet { // StayOnComet (IBC disabled): fully undelegate all validators' tokens and - // explicitly set the final CometBFT validator set to a single validator - // (the sequencer) with power=1. + // explicitly set the final CometBFT validator set to a single validator with power=1. k.Logger(ctx).Info("StayOnComet: immediate undelegation and explicit valset update (IBC disabled)") - // unbond non-sequencer validators - validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) - for _, val := range validatorsToRemove { + // unbond all validator delegations + for _, val := range lastValidatorSet { if err := k.unbondValidatorDelegations(ctx, val); err != nil { return nil, err } } - // unbond sequencer delegations - var seqVal stakingtypes.Validator - foundSeq := false - for _, val := range lastValidatorSet { - if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { - seqVal = val - foundSeq = true - break - } - } - if foundSeq { - if err := k.unbondValidatorDelegations(ctx, seqVal); err != nil { - return nil, err - } - } + validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) - // Build ABCI updates: zeros for all non-sequencers; sequencer power 1 + // Build ABCI updates: zeros for all non-sequencers. sequencer power 1 var updates []abci.ValidatorUpdate for _, val := range validatorsToRemove { updates = append(updates, val.ABCIValidatorUpdateZero()) } + pk, err := migrationData.Sequencer.TmConsPublicKey() if err != nil { return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get sequencer pubkey: %v", err) @@ -200,30 +185,19 @@ func (k Keeper) migrateOver( // StayOnComet with IBC enabled: from the very first smoothing step, keep // membership constant and reweight CometBFT powers so that the sequencer // alone has >1/3 voting power. This removes timing sensitivity for IBC - // client updates. In parallel, undelegate non-sequencers gradually at the - // staking layer. + // client updates. // Final step: set sequencer power=1 and undelegate sequencer if step+1 == IBCSmoothingFactor { - k.Logger(ctx).Info("StayOnComet: finalization step, setting sequencer power=1 and undelegating sequencer") + k.Logger(ctx).Info("StayOnComet: finalization step, setting sequencer power=1 and undelegating all delegations") - // undelegate sequencer delegations - var seqVal stakingtypes.Validator - foundSeq := false for _, val := range lastValidatorSet { - if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { - seqVal = val - foundSeq = true - break - } - } - if foundSeq { - if err := k.unbondValidatorDelegations(ctx, seqVal); err != nil { + if err := k.unbondValidatorDelegations(ctx, val); err != nil { return nil, err } } - // ABCI updates: zero all non-sequencers; set sequencer to 1 + // ABCI updates: zero all non-sequencers, set sequencer to 1 var updates []abci.ValidatorUpdate validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) for _, val := range validatorsToRemove { @@ -243,24 +217,7 @@ func (k Keeper) migrateOver( return updates, nil } - // Non-final steps: perform undelegation for a slice of non-sequencers, - // then emit reweighting updates for the full membership. - // 1) Unbond a chunk of non-sequencer validators on staking side - validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) - if len(validatorsToRemove) > 0 { - removePerStep := (len(validatorsToRemove) + int(IBCSmoothingFactor) - 1) / int(IBCSmoothingFactor) - startRemove := int(step) * removePerStep - endRemove := min(startRemove+removePerStep, len(validatorsToRemove)) - k.Logger(ctx).Info("StayOnComet: undelegating non-sequencers for step", - "step", step, "start_index", startRemove, "end_index", endRemove, "total_to_remove", len(validatorsToRemove)) - for _, val := range validatorsToRemove[startRemove:endRemove] { - if err := k.unbondValidatorDelegations(ctx, val); err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to undelegate: %v", err) - } - } - } - - // 2) Emit reweighting updates: sequencer gets large power, others get 1 + // emit reweighting updates: ensure sequencer gets large power, others get 1. n := len(lastValidatorSet) if n == 0 { return []abci.ValidatorUpdate{}, nil