Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 151 additions & 4 deletions test/integration/utss/fund_migration_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package integrationtest

import (
"bytes"
"fmt"
"math/big"
"strconv"
"strings"
"testing"

"cosmossdk.io/collections"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
Expand All @@ -17,6 +19,7 @@ import (
utils "github.com/pushchain/push-chain-node/test/utils"

uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types"
utsskeeper "github.com/pushchain/push-chain-node/x/utss/keeper"
utsstypes "github.com/pushchain/push-chain-node/x/utss/types"
uvalidatortypes "github.com/pushchain/push-chain-node/x/uvalidator/types"
)
Expand Down Expand Up @@ -203,7 +206,9 @@ func TestInitiateFundMigration(t *testing.T) {

migrationId, err := app.UtssKeeper.InitiateFundMigration(ctx, oldKeyId, testChain, testBalance)
require.NoError(t, err)
require.Equal(t, uint64(0), migrationId)
// Ids start at 1, not 0 — 0 is reserved for "unset" and is rejected by
// MsgVoteFundMigration.ValidateBasic (F-2026-18789).
require.Equal(t, uint64(1), migrationId)

// Verify migration is stored
migration, err := app.UtssKeeper.FundMigrations.Get(ctx, migrationId)
Expand Down Expand Up @@ -286,9 +291,13 @@ func TestInitiateFundMigration(t *testing.T) {
_, err := app.UtssKeeper.InitiateFundMigration(ctx, oldKeyId, testChain, "1")
require.ErrorContains(t, err, "does not cover the migration fee")

// Nothing may be left behind.
_, err = app.UtssKeeper.FundMigrations.Get(ctx, 0)
require.Error(t, err, "a rejected migration must not be stored")
// Nothing may be left behind under any id.
var stored int
require.NoError(t, app.UtssKeeper.FundMigrations.Walk(ctx, nil, func(uint64, utsstypes.FundMigration) (bool, error) {
stored++
return false, nil
}))
require.Zero(t, stored, "a rejected migration must not be stored")
})

t.Run("Fails if old key not found", func(t *testing.T) {
Expand Down Expand Up @@ -516,3 +525,141 @@ func TestVoteFundMigration_MalformedHashRejected(t *testing.T) {
err = app.UtssKeeper.VoteFundMigration(ctx, valAddr, migrationId, "0xnot-a-real-hash", true)
require.ErrorContains(t, err, "invalid tx hash")
}

// TestInitiateFundMigration_FirstMigrationIsVotable is the F-2026-18789
// regression.
//
// Migration ids used to come straight off collections.Sequence, whose first
// value is 0, while MsgVoteFundMigration.ValidateBasic rejects
// migration_id == 0 as "unset". The very first migration on a fresh chain was
// therefore unvotable: every vote died in ValidateBasic before it ever reached
// the keeper, the migration never left PendingMigrations, and
// InitiateFundMigration then refused every later migration for that chain —
// the lane was bricked with no way out short of an upgrade. audit-fixes is a
// fresh-genesis branch, so this fires on first use.
//
// Ids are now allocated as sequence + 1: the first id is 1 and 0 stays
// reserved for "unset". The three cases are separate subtests on purpose, so
// that each one reports independently instead of the first failure masking
// the rest.
func TestInitiateFundMigration_FirstMigrationIsVotable(t *testing.T) {
// firstMigration runs the very first migration a fresh chain ever
// allocates — the case that used to be unreachable — and returns its id.
firstMigration := func(t *testing.T) (*app.ChainApp, sdk.Context, []string, string, uint64) {
t.Helper()
chainApp, ctx, universalVals, oldKeyId := setupFundMigrationTest(t, 3, false)

seq, err := chainApp.UtssKeeper.NextMigrationId.Peek(ctx)
require.NoError(t, err)
require.Zero(t, seq, "fixture must start from a virgin sequence or this proves nothing")

migrationId, err := chainApp.UtssKeeper.InitiateFundMigration(ctx, oldKeyId, testChain, testBalance)
require.NoError(t, err)
return chainApp, ctx, universalVals, oldKeyId, migrationId
}

const txHash = "0xdeadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678"

voteMsg := func(t *testing.T, val string, migrationId uint64) *utsstypes.MsgVoteFundMigration {
t.Helper()
valAddr, err := sdk.ValAddressFromBech32(val)
require.NoError(t, err)
return &utsstypes.MsgVoteFundMigration{
Signer: sdk.AccAddress(valAddr).String(),
MigrationId: migrationId,
TxHash: txHash,
Success: true,
}
}

t.Run("the first id is never the reserved 0", func(t *testing.T) {
chainApp, ctx, _, _, migrationId := firstMigration(t)

require.NotZero(t, migrationId,
"the first migration id must never be 0: MsgVoteFundMigration.ValidateBasic rejects 0 as unset")
require.Equal(t, uint64(1), migrationId)

// The record really is stored under that votable id.
migration, err := chainApp.UtssKeeper.FundMigrations.Get(ctx, migrationId)
require.NoError(t, err)
require.Equal(t, utsstypes.FundMigrationStatus_FUND_MIGRATION_STATUS_PENDING, migration.Status)
require.Equal(t, testChain, migration.Chain)
})

t.Run("a vote on the first migration passes ValidateBasic", func(t *testing.T) {
_, _, universalVals, _, migrationId := firstMigration(t)

// This is the exact gate that bricked the lane: the message a universal
// validator broadcasts is rejected here, before the keeper is reached.
msg := voteMsg(t, universalVals[0], migrationId)
require.NoError(t, msg.ValidateBasic(),
"a vote on the first migration must survive ValidateBasic")
})

t.Run("the first migration finalizes and unblocks the chain", func(t *testing.T) {
chainApp, ctx, universalVals, oldKeyId, migrationId := firstMigration(t)
msgServer := utsskeeper.NewMsgServerImpl(chainApp.UtssKeeper)

for _, val := range universalVals {
msg := voteMsg(t, val, migrationId)
require.NoError(t, msg.ValidateBasic())
_, err := msgServer.VoteFundMigration(ctx, msg)
require.NoError(t, err, "a vote on the first migration must reach the ballot")
}

migration, err := chainApp.UtssKeeper.FundMigrations.Get(ctx, migrationId)
require.NoError(t, err)
require.Equal(t, utsstypes.FundMigrationStatus_FUND_MIGRATION_STATUS_COMPLETED, migration.Status,
"votes on the first migration must be able to finalize it")
require.Equal(t, txHash, migration.TxHash)

_, err = chainApp.UtssKeeper.PendingMigrations.Get(ctx, migrationId)
require.ErrorIs(t, err, collections.ErrNotFound,
"a finalized migration must leave PendingMigrations, or the chain stays blocked")

// The outcome the bug denied: the chain is migratable again, under the
// next votable id.
secondId, err := chainApp.UtssKeeper.InitiateFundMigration(ctx, oldKeyId, testChain, testBalance)
require.NoError(t, err, "a second migration must be possible once the first finalized")
require.Equal(t, uint64(2), secondId)
})
}

// TestVoteFundMigration_ZeroMigrationIdStaysRejected pins the other half of
// the F-2026-18789 contract: 0 keeps meaning "unset". The fix moves ids off 0
// rather than allowing 0, so this guard must stay in place.
func TestVoteFundMigration_ZeroMigrationIdStaysRejected(t *testing.T) {
msg := &utsstypes.MsgVoteFundMigration{
Signer: sdk.AccAddress(bytes.Repeat([]byte{1}, 20)).String(),
MigrationId: 0,
TxHash: "0xdeadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678",
Success: true,
}
require.ErrorContains(t, msg.ValidateBasic(), "migration_id is required")
}

// TestFundMigrationIdsSurviveGenesisRoundTrip guards the interaction between
// the sequence + 1 allocation and genesis: the exported counter must not hand
// an already-used id back after an export/import cycle.
func TestFundMigrationIdsSurviveGenesisRoundTrip(t *testing.T) {
app, ctx, _, oldKeyId := setupFundMigrationTest(t, 3, false)

firstId, err := app.UtssKeeper.InitiateFundMigration(ctx, oldKeyId, testChain, testBalance)
require.NoError(t, err)
require.Equal(t, uint64(1), firstId)

// ExportGenesis reads Params, which this fixture never seeds.
require.NoError(t, app.UtssKeeper.Params.Set(ctx, utsstypes.Params{
Admin: "push1negskcfqu09j5zvpk7nhvacnwyy2mafffy7r6a",
}))

exported := app.UtssKeeper.ExportGenesis(ctx)
require.Equal(t, uint64(1), exported.NextMigrationId)
require.NoError(t, app.UtssKeeper.InitGenesis(ctx, exported))

// The next allocation must not collide with the id already in state.
seq, err := app.UtssKeeper.NextMigrationId.Next(ctx)
require.NoError(t, err)
require.Greater(t, seq+1, firstId,
"an export/import cycle must not re-issue an id that is already taken")
}
12 changes: 10 additions & 2 deletions x/utss/keeper/msg_initiate_fund_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,19 @@ func (k Keeper) InitiateFundMigration(ctx context.Context, oldKeyId, chain, bala
return 0, fmt.Errorf("failed to get l1 gas fee for chain %s: %w", chain, err)
}

// 8. Create migration record
migrationId, err := k.NextMigrationId.Next(ctx)
// 8. Create migration record.
//
// Ids are allocated as sequence + 1, so the first migration on a chain is 1
// and never 0. MsgVoteFundMigration.ValidateBasic treats migration_id == 0
// as "unset" and rejects the message, so a migration stored under id 0
// could never be voted on: it would stay in PendingMigrations forever and
// block every later migration for that chain (F-2026-18789). Keeping 0
// reserved for "unset" also keeps that ValidateBasic guard meaningful.
seq, err := k.NextMigrationId.Next(ctx)
if err != nil {
return 0, fmt.Errorf("failed to get next migration id: %w", err)
}
migrationId := seq + 1

// Derive the sweep amount from the observed balance and the fees just
// fetched. Rejecting here turns a balance that cannot cover its own transfer
Expand Down
Loading