|
| 1 | +package integrationtest |
| 2 | + |
| 3 | +import ( |
| 4 | + "testing" |
| 5 | + |
| 6 | + sdk "github.com/cosmos/cosmos-sdk/types" |
| 7 | + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" |
| 8 | + "github.com/stretchr/testify/require" |
| 9 | + |
| 10 | + "github.com/pushchain/push-chain-node/app" |
| 11 | + utils "github.com/pushchain/push-chain-node/test/utils" |
| 12 | + uvalidatortypes "github.com/pushchain/push-chain-node/x/uvalidator/types" |
| 13 | +) |
| 14 | + |
| 15 | +// coreAccOf returns the account bech32 that signs MsgVoteChainMeta on behalf of |
| 16 | +// the given staking validator (the hotkey's grantee target). |
| 17 | +func coreAccOf(t *testing.T, val stakingtypes.Validator) string { |
| 18 | + t.Helper() |
| 19 | + valAddr, err := sdk.ValAddressFromBech32(val.OperatorAddress) |
| 20 | + require.NoError(t, err) |
| 21 | + return sdk.AccAddress(valAddr).String() |
| 22 | +} |
| 23 | + |
| 24 | +// forceUVLifecycleStatus overwrites only the lifecycle status of an already |
| 25 | +// registered universal validator, leaving identity/network info intact and |
| 26 | +// leaving the underlying staking validator bonded. It bypasses transition |
| 27 | +// validation so INACTIVE can be reached directly. |
| 28 | +func forceUVLifecycleStatus( |
| 29 | + t *testing.T, |
| 30 | + testApp *app.ChainApp, |
| 31 | + ctx sdk.Context, |
| 32 | + val stakingtypes.Validator, |
| 33 | + status uvalidatortypes.UVStatus, |
| 34 | +) { |
| 35 | + t.Helper() |
| 36 | + valAddr, err := sdk.ValAddressFromBech32(val.OperatorAddress) |
| 37 | + require.NoError(t, err) |
| 38 | + |
| 39 | + uv, err := testApp.UvalidatorKeeper.UniversalValidatorSet.Get(ctx, valAddr) |
| 40 | + require.NoError(t, err) |
| 41 | + uv.LifecycleInfo.CurrentStatus = status |
| 42 | + require.NoError(t, testApp.UvalidatorKeeper.UniversalValidatorSet.Set(ctx, valAddr, uv)) |
| 43 | +} |
| 44 | + |
| 45 | +// requireStillBonded asserts the staking validator behind a universal validator |
| 46 | +// is still bonded. This is the precondition the finding rests on: lifecycle |
| 47 | +// removal does not unbond stake, so a bonded-only admission gate keeps letting |
| 48 | +// the removed hotkey in. |
| 49 | +func requireStillBonded(t *testing.T, testApp *app.ChainApp, ctx sdk.Context, val stakingtypes.Validator) { |
| 50 | + t.Helper() |
| 51 | + valAddr, err := sdk.ValAddressFromBech32(val.OperatorAddress) |
| 52 | + require.NoError(t, err) |
| 53 | + sv, err := testApp.StakingKeeper.GetValidator(ctx, valAddr) |
| 54 | + require.NoError(t, err) |
| 55 | + require.True(t, sv.IsBonded(), |
| 56 | + "removal must leave the validator bonded -- otherwise the finding's vector would not exist") |
| 57 | +} |
| 58 | + |
| 59 | +// TestVoteChainMeta_EligibilityGate is the F-2026-18148 regression suite. |
| 60 | +// |
| 61 | +// MsgVoteChainMeta used to admit any bonded, registered universal validator. |
| 62 | +// Admin removal moves a universal validator to PENDING_LEAVE while its stake |
| 63 | +// stays bonded, and AfterValidatorRemoved prunes its ChainMeta rows but revokes |
| 64 | +// neither its AuthZ grant nor its membership in the set -- so the removed |
| 65 | +// hotkey could reinsert votes straight after the prune. Admission is now gated |
| 66 | +// on the same eligibility predicate (ACTIVE / PENDING_JOIN + bonded + not |
| 67 | +// tombstoned) that uvalidator uses to snapshot ballot voters. |
| 68 | +func TestVoteChainMeta_EligibilityGate(t *testing.T) { |
| 69 | + chainId := "eip155:11155111" |
| 70 | + |
| 71 | + t.Run("removed PENDING_LEAVE validator cannot reinsert a vote after the prune", func(t *testing.T) { |
| 72 | + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 5) |
| 73 | + |
| 74 | + // Removal of an ACTIVE universal validator requires no ongoing TSS. |
| 75 | + _ = testApp.UtssKeeper.CurrentTssProcess.Remove(ctx) |
| 76 | + for _, v := range vals { |
| 77 | + valAddr, err := sdk.ValAddressFromBech32(v.OperatorAddress) |
| 78 | + require.NoError(t, err) |
| 79 | + require.NoError(t, testApp.UvalidatorKeeper.UpdateValidatorStatus( |
| 80 | + ctx, valAddr, |
| 81 | + uvalidatortypes.UVStatus_UV_STATUS_ACTIVE, |
| 82 | + uvalidatortypes.TransitionReason_TRANSITION_REASON_UNSPECIFIED, |
| 83 | + )) |
| 84 | + } |
| 85 | + |
| 86 | + coreAccs := make([]string, len(vals)) |
| 87 | + for i := range vals { |
| 88 | + coreAccs[i] = coreAccOf(t, vals[i]) |
| 89 | + } |
| 90 | + |
| 91 | + // Five ACTIVE validators vote. Prices 100..500, heights 10..50. |
| 92 | + // After the 3rd vote the oracle bootstraps; by the 5th the recorded |
| 93 | + // upper median price is 300 and LastAppliedChainHeight is 30. |
| 94 | + prices := []uint64{100, 200, 300, 400, 500} |
| 95 | + heights := []uint64{10, 20, 30, 40, 50} |
| 96 | + for i := range vals { |
| 97 | + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[i], coreAccs[i], chainId, prices[i], heights[i])) |
| 98 | + } |
| 99 | + |
| 100 | + stored, found, err := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) |
| 101 | + require.NoError(t, err) |
| 102 | + require.True(t, found) |
| 103 | + require.Len(t, stored.Signers, 5) |
| 104 | + require.Equal(t, uint64(300), stored.Prices[stored.MedianIndex], "baseline recorded median price") |
| 105 | + require.Equal(t, uint64(30), stored.LastAppliedChainHeight, "baseline applied chain height") |
| 106 | + |
| 107 | + // Admin removes validator 4: ACTIVE -> PENDING_LEAVE, ChainMeta pruned. |
| 108 | + require.NoError(t, testApp.UvalidatorKeeper.RemoveUniversalValidator(ctx, vals[4].OperatorAddress)) |
| 109 | + |
| 110 | + removedValAddr, err := sdk.ValAddressFromBech32(vals[4].OperatorAddress) |
| 111 | + require.NoError(t, err) |
| 112 | + uv, uvFound, err := testApp.UvalidatorKeeper.GetUniversalValidator(ctx, removedValAddr) |
| 113 | + require.NoError(t, err) |
| 114 | + require.True(t, uvFound, "removal keeps the row in the set -- only the lifecycle status changes") |
| 115 | + require.Equal(t, uvalidatortypes.UVStatus_UV_STATUS_PENDING_LEAVE, uv.LifecycleInfo.CurrentStatus) |
| 116 | + |
| 117 | + // The two halves of the vector: stake is still bonded, and the AuthZ |
| 118 | + // grant was never revoked, so the hotkey can still build the tx. |
| 119 | + requireStillBonded(t, testApp, ctx, vals[4]) |
| 120 | + |
| 121 | + pruned, _, err := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) |
| 122 | + require.NoError(t, err) |
| 123 | + require.Len(t, pruned.Signers, 4, "the removed validator's ChainMeta row must have been pruned") |
| 124 | + |
| 125 | + // The removed hotkey now tries to reinsert a vote. A price of 250 sits |
| 126 | + // between the surviving 200 and 300, so if it landed it would drag the |
| 127 | + // upper median down from 300 to 250. Height 35 clears the stale-height |
| 128 | + // gate (LastAppliedChainHeight = 30). |
| 129 | + reinsertErr := utils.ExecVoteChainMeta(t, ctx, testApp, uvals[4], coreAccs[4], chainId, 250, 35) |
| 130 | + |
| 131 | + after, _, err := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) |
| 132 | + require.NoError(t, err) |
| 133 | + |
| 134 | + // State assertions first: an aborting error assertion must not be able |
| 135 | + // to hide a vote that actually landed. |
| 136 | + require.Equal(t, uint64(300), after.Prices[after.MedianIndex], |
| 137 | + "the median must still be computed over the four surviving votes only") |
| 138 | + require.NotContains(t, after.Signers, removedValAddr.String(), |
| 139 | + "the removed validator must not reappear among the ChainMeta signers") |
| 140 | + require.NotContains(t, after.Prices, uint64(250), "the rejected price must not be recorded") |
| 141 | + require.Len(t, after.Signers, 4, "no new signer row may be inserted") |
| 142 | + require.Equal(t, uint64(30), after.LastAppliedChainHeight, |
| 143 | + "a rejected vote must not advance the applied chain height") |
| 144 | + |
| 145 | + require.Error(t, reinsertErr, "a PENDING_LEAVE universal validator must not be able to vote on chain meta") |
| 146 | + require.Contains(t, reinsertErr.Error(), "is not an eligible voter") |
| 147 | + }) |
| 148 | + |
| 149 | + t.Run("INACTIVE but still-bonded validator is rejected", func(t *testing.T) { |
| 150 | + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 3) |
| 151 | + |
| 152 | + for _, v := range vals { |
| 153 | + valAddr, err := sdk.ValAddressFromBech32(v.OperatorAddress) |
| 154 | + require.NoError(t, err) |
| 155 | + require.NoError(t, testApp.UvalidatorKeeper.UpdateValidatorStatus( |
| 156 | + ctx, valAddr, |
| 157 | + uvalidatortypes.UVStatus_UV_STATUS_ACTIVE, |
| 158 | + uvalidatortypes.TransitionReason_TRANSITION_REASON_UNSPECIFIED, |
| 159 | + )) |
| 160 | + } |
| 161 | + forceUVLifecycleStatus(t, testApp, ctx, vals[2], uvalidatortypes.UVStatus_UV_STATUS_INACTIVE) |
| 162 | + requireStillBonded(t, testApp, ctx, vals[2]) |
| 163 | + |
| 164 | + voteErr := utils.ExecVoteChainMeta(t, ctx, testApp, uvals[2], coreAccOf(t, vals[2]), chainId, 777, 7) |
| 165 | + |
| 166 | + _, found, err := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) |
| 167 | + require.NoError(t, err) |
| 168 | + require.False(t, found, "an INACTIVE validator's vote must not create a ChainMeta entry") |
| 169 | + |
| 170 | + require.Error(t, voteErr, "an INACTIVE universal validator must not be able to vote on chain meta") |
| 171 | + require.Contains(t, voteErr.Error(), "is not an eligible voter") |
| 172 | + }) |
| 173 | + |
| 174 | + t.Run("ACTIVE validator is still accepted", func(t *testing.T) { |
| 175 | + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 3) |
| 176 | + |
| 177 | + for _, v := range vals { |
| 178 | + valAddr, err := sdk.ValAddressFromBech32(v.OperatorAddress) |
| 179 | + require.NoError(t, err) |
| 180 | + require.NoError(t, testApp.UvalidatorKeeper.UpdateValidatorStatus( |
| 181 | + ctx, valAddr, |
| 182 | + uvalidatortypes.UVStatus_UV_STATUS_ACTIVE, |
| 183 | + uvalidatortypes.TransitionReason_TRANSITION_REASON_UNSPECIFIED, |
| 184 | + )) |
| 185 | + } |
| 186 | + |
| 187 | + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccOf(t, vals[0]), chainId, 100, 1)) |
| 188 | + |
| 189 | + stored, found, err := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) |
| 190 | + require.NoError(t, err) |
| 191 | + require.True(t, found) |
| 192 | + require.Len(t, stored.Signers, 1, "the ACTIVE validator's vote must be recorded") |
| 193 | + }) |
| 194 | + |
| 195 | + t.Run("PENDING_JOIN validator is still accepted", func(t *testing.T) { |
| 196 | + // setupVoteChainMetaTest registers every universal validator through |
| 197 | + // AddUniversalValidator, which leaves them in PENDING_JOIN. |
| 198 | + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 3) |
| 199 | + |
| 200 | + valAddr, err := sdk.ValAddressFromBech32(vals[1].OperatorAddress) |
| 201 | + require.NoError(t, err) |
| 202 | + uv, found, err := testApp.UvalidatorKeeper.GetUniversalValidator(ctx, valAddr) |
| 203 | + require.NoError(t, err) |
| 204 | + require.True(t, found) |
| 205 | + require.Equal(t, uvalidatortypes.UVStatus_UV_STATUS_PENDING_JOIN, uv.LifecycleInfo.CurrentStatus) |
| 206 | + |
| 207 | + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccOf(t, vals[1]), chainId, 100, 1)) |
| 208 | + |
| 209 | + stored, found, err := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) |
| 210 | + require.NoError(t, err) |
| 211 | + require.True(t, found) |
| 212 | + require.Len(t, stored.Signers, 1, "the PENDING_JOIN validator's vote must be recorded") |
| 213 | + }) |
| 214 | + |
| 215 | + t.Run("fewer than three eligible validators cannot reach the bootstrap minimum", func(t *testing.T) { |
| 216 | + // Documents the bootstrap interaction, it does not assert a defect: |
| 217 | + // chainMetaMinVotesForFirstWrite = 3 counts fresh vote ROWS, and there |
| 218 | + // is at most one row per validator. Tightening admission can only |
| 219 | + // shrink the pool of validators able to produce a row, so a set with |
| 220 | + // fewer than three ELIGIBLE universal validators can never bootstrap |
| 221 | + // the oracle. That was already true of any topology with fewer than |
| 222 | + // three bonded universal validators; this gate makes lifecycle state |
| 223 | + // count towards it too. |
| 224 | + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 3) |
| 225 | + |
| 226 | + for _, v := range vals { |
| 227 | + valAddr, err := sdk.ValAddressFromBech32(v.OperatorAddress) |
| 228 | + require.NoError(t, err) |
| 229 | + require.NoError(t, testApp.UvalidatorKeeper.UpdateValidatorStatus( |
| 230 | + ctx, valAddr, |
| 231 | + uvalidatortypes.UVStatus_UV_STATUS_ACTIVE, |
| 232 | + uvalidatortypes.TransitionReason_TRANSITION_REASON_UNSPECIFIED, |
| 233 | + )) |
| 234 | + } |
| 235 | + forceUVLifecycleStatus(t, testApp, ctx, vals[2], uvalidatortypes.UVStatus_UV_STATUS_PENDING_LEAVE) |
| 236 | + requireStillBonded(t, testApp, ctx, vals[2]) |
| 237 | + |
| 238 | + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccOf(t, vals[0]), chainId, 100, 1)) |
| 239 | + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccOf(t, vals[1]), chainId, 200, 2)) |
| 240 | + thirdErr := utils.ExecVoteChainMeta(t, ctx, testApp, uvals[2], coreAccOf(t, vals[2]), chainId, 300, 3) |
| 241 | + |
| 242 | + stored, found, err := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) |
| 243 | + require.NoError(t, err) |
| 244 | + require.True(t, found) |
| 245 | + require.Len(t, stored.Signers, 2, "only the two eligible validators may hold a vote row") |
| 246 | + require.Equal(t, uint64(0), stored.LastAppliedChainHeight, |
| 247 | + "two fresh votes are below chainMetaMinVotesForFirstWrite=3, so the oracle stays un-bootstrapped") |
| 248 | + |
| 249 | + require.Error(t, thirdErr) |
| 250 | + require.Contains(t, thirdErr.Error(), "is not an eligible voter") |
| 251 | + }) |
| 252 | +} |
0 commit comments