Skip to content

Commit 301cb40

Browse files
committed
fix: F-2026-18148 | [Dual Defense] Removed Universal Validator Retains ChainMeta Vote Authority
Gate MsgVoteChainMeta on GetEligibleVoters (ACTIVE/PENDING_JOIN + bonded + not tombstoned) so a removed, still-bonded validator cannot reinsert votes.
1 parent 51c2dd7 commit 301cb40

2 files changed

Lines changed: 296 additions & 8 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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+
}

x/uexecutor/keeper/msg_server.go

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,6 @@ func (ms msgServer) VoteChainMeta(ctx context.Context, msg *types.MsgVoteChainMe
153153

154154
signerValAddr := sdk.ValAddress(signerAccAddr)
155155

156-
isBonded, err := ms.k.uvalidatorKeeper.IsBondedUniversalValidator(ctx, msg.Signer)
157-
if err != nil {
158-
return nil, errors.Wrapf(err, "failed to check bonded status for signer %s", msg.Signer)
159-
}
160-
if !isBonded {
161-
return nil, fmt.Errorf("universal validator for signer %s is not bonded", msg.Signer)
162-
}
163-
164156
isTombstoned, err := ms.k.uvalidatorKeeper.IsTombstonedUniversalValidator(ctx, msg.Signer)
165157
if err != nil {
166158
return nil, errors.Wrapf(err, "failed to check tombstoned status for signer %s", msg.Signer)
@@ -169,13 +161,57 @@ func (ms msgServer) VoteChainMeta(ctx context.Context, msg *types.MsgVoteChainMe
169161
return nil, fmt.Errorf("universal validator for signer %s is tombstoned", msg.Signer)
170162
}
171163

164+
// Admission is gated on the same eligibility predicate ballot creation uses
165+
// (lifecycle ACTIVE/PENDING_JOIN AND bonded AND not tombstoned) rather than
166+
// the lifecycle-blind IsBondedUniversalValidator. Admin removal moves a
167+
// universal validator to PENDING_LEAVE while its stake can remain bonded,
168+
// and AfterValidatorRemoved prunes its ChainMeta rows but revokes neither
169+
// its AuthZ grant nor its membership in the universal validator set -- so
170+
// under the bonded-only gate the removed hotkey could reinsert votes right
171+
// after the prune.
172+
//
173+
// Tightening admission is safe here, and only here, because ChainMeta is
174+
// median-based rather than ballot-based: there is no CreateBallot, no
175+
// snapshotted EligibleVoters and no frozen VotingThreshold. Every vote
176+
// recomputes the median over whichever votes are currently fresh, so a
177+
// narrower voter set cannot strand anything in flight. The ballot-based
178+
// vote paths (VoteInbound/VoteOutbound above) deliberately keep the looser
179+
// gate: tightening them would make already-frozen thresholds unreachable.
180+
if err := ms.requireEligibleChainMetaVoter(ctx, signerValAddr); err != nil {
181+
return nil, err
182+
}
183+
172184
err = ms.k.VoteChainMeta(ctx, signerValAddr, msg.ObservedChainId, msg.Price, msg.ChainHeight)
173185
if err != nil {
174186
return nil, err
175187
}
176188
return &types.MsgVoteChainMetaResponse{}, nil
177189
}
178190

191+
// requireEligibleChainMetaVoter returns nil only when signerValAddr is present
192+
// in the current eligible-voter set, i.e. it satisfies exactly the same
193+
// predicate uvalidator applies when it snapshots a ballot's voters. Reusing
194+
// GetEligibleVoters rather than re-deriving the checks keeps ChainMeta vote
195+
// admission from drifting away from that definition.
196+
func (ms msgServer) requireEligibleChainMetaVoter(ctx context.Context, signerValAddr sdk.ValAddress) error {
197+
eligible, err := ms.k.uvalidatorKeeper.GetEligibleVoters(ctx)
198+
if err != nil {
199+
return errors.Wrapf(err, "failed to fetch eligible voters for signer %s", signerValAddr.String())
200+
}
201+
202+
want := signerValAddr.String()
203+
for _, uv := range eligible {
204+
if uv.IdentifyInfo != nil && uv.IdentifyInfo.CoreValidatorAddress == want {
205+
return nil
206+
}
207+
}
208+
209+
return fmt.Errorf(
210+
"universal validator %s is not an eligible voter; only ACTIVE or PENDING_JOIN universal validators with bonded, non-tombstoned staking state may vote on chain meta",
211+
want,
212+
)
213+
}
214+
179215
// RevertStuckInbound is the admin escape hatch — see Keeper.RevertStuckInbound.
180216
func (ms msgServer) RevertStuckInbound(ctx context.Context, msg *types.MsgRevertStuckInbound) (*types.MsgRevertStuckInboundResponse, error) {
181217
ms.k.Logger().Info("msg: RevertStuckInbound", "signer", msg.Signer)

0 commit comments

Comments
 (0)