Skip to content

Commit 861fca0

Browse files
authored
fix: F-2026-18133 | [Dual Defense] Same-Block Validator Jailing Can Snapshot an Oversized Ballot Quorum (#347)
Exclude jailed validators from GetEligibleVoters so a validator jailed in BeginBlock is not snapshotted into ballots created later in the same block.
1 parent c4d2b02 commit 861fca0

2 files changed

Lines changed: 215 additions & 1 deletion

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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+
uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types"
12+
uvalidatortypes "github.com/pushchain/push-chain-node/x/uvalidator/types"
13+
)
14+
15+
// jailLikeSlashingBeginBlock reproduces exactly what x/slashing does to a
16+
// validator during BeginBlock: it calls staking's Keeper.Jail, which runs
17+
// jailValidator -> sets Validator.Jailed and deletes the power index, and
18+
// never touches Validator.Status.
19+
//
20+
// Crucially it does NOT run staking's EndBlocker, so the bonded -> unbonding
21+
// transition has not happened yet. That is the exact window every transaction
22+
// in the block is processed in.
23+
func jailLikeSlashingBeginBlock(t *testing.T, chainApp *app.ChainApp, ctx sdk.Context, val stakingtypes.Validator) stakingtypes.Validator {
24+
t.Helper()
25+
26+
consAddr, err := val.GetConsAddr()
27+
require.NoError(t, err)
28+
require.NoError(t, chainApp.StakingKeeper.Jail(ctx, consAddr))
29+
30+
valAddr, err := sdk.ValAddressFromBech32(val.OperatorAddress)
31+
require.NoError(t, err)
32+
jailed, err := chainApp.StakingKeeper.GetValidator(ctx, valAddr)
33+
require.NoError(t, err)
34+
return jailed
35+
}
36+
37+
// TestGetEligibleVoters_ExcludesSameBlockJailedValidator is the F-2026-18133
38+
// regression suite.
39+
//
40+
// Slashing jails in BeginBlock; staking moves the validator bonded ->
41+
// unbonding only in EndBlocker. For the entire tx-processing phase in between,
42+
// a jailed validator is both Jailed and IsBonded(). Before the fix that
43+
// validator was snapshotted into a new ballot's EligibleVoters, so the frozen
44+
// VotingThreshold ((2*N)/3 + 1) was computed on an inflated N while only N-1
45+
// signers could actually vote -- stranding the ballot at N <= 3.
46+
func TestGetEligibleVoters_ExcludesSameBlockJailedValidator(t *testing.T) {
47+
t.Run("precondition: a same-block jailed validator still reports IsBonded", func(t *testing.T) {
48+
// This subtest asserts the SDK behaviour the finding depends on. If it
49+
// ever stops holding, the fix below is redundant and this will say so.
50+
chainApp, ctx, validators := setupQueryTest(t, 3)
51+
for _, v := range validators {
52+
setUVStatus(t, chainApp, ctx, v, uvalidatortypes.UVStatus_UV_STATUS_ACTIVE)
53+
}
54+
55+
jailed := jailLikeSlashingBeginBlock(t, chainApp, ctx, validators[0])
56+
57+
require.True(t, jailed.IsJailed(), "staking.Jail must set Validator.Jailed")
58+
require.Equal(t, stakingtypes.Bonded, jailed.Status,
59+
"staking.Jail must NOT touch Validator.Status before EndBlocker")
60+
require.True(t, jailed.IsBonded(),
61+
"IsBonded() is GetStatus()==Bonded, so a jailed validator still passes it -- "+
62+
"this is precisely why an explicit Jailed gate is required")
63+
})
64+
65+
t.Run("jailed validator is excluded from the eligible-voter set", func(t *testing.T) {
66+
chainApp, ctx, validators := setupQueryTest(t, 3)
67+
for _, v := range validators {
68+
setUVStatus(t, chainApp, ctx, v, uvalidatortypes.UVStatus_UV_STATUS_ACTIVE)
69+
}
70+
71+
before, err := chainApp.UvalidatorKeeper.GetEligibleVoters(ctx)
72+
require.NoError(t, err)
73+
require.Len(t, before, 3, "all three are eligible before the jail")
74+
75+
jailLikeSlashingBeginBlock(t, chainApp, ctx, validators[0])
76+
77+
after, err := chainApp.UvalidatorKeeper.GetEligibleVoters(ctx)
78+
require.NoError(t, err)
79+
require.Len(t, after, 2, "the jailed validator must drop out of the eligible set")
80+
for _, v := range after {
81+
require.NotEqual(t, validators[0].OperatorAddress, v.IdentifyInfo.CoreValidatorAddress,
82+
"jailed validator must not appear among eligible voters")
83+
}
84+
})
85+
86+
t.Run("PENDING_JOIN validator jailed in the same block is also excluded", func(t *testing.T) {
87+
// setupQueryTest leaves every UV in PENDING_JOIN, which is an eligible
88+
// lifecycle state. The Jailed gate must apply there too.
89+
chainApp, ctx, validators := setupQueryTest(t, 3)
90+
91+
jailLikeSlashingBeginBlock(t, chainApp, ctx, validators[2])
92+
93+
voters, err := chainApp.UvalidatorKeeper.GetEligibleVoters(ctx)
94+
require.NoError(t, err)
95+
require.Len(t, voters, 2)
96+
for _, v := range voters {
97+
require.NotEqual(t, validators[2].OperatorAddress, v.IdentifyInfo.CoreValidatorAddress)
98+
}
99+
})
100+
101+
t.Run("ballot created in the same block freezes a threshold computed on N-1", func(t *testing.T) {
102+
// N = 3 is the worst reachable row from the finding: with the jailed
103+
// validator counted the threshold is (2*3)/3+1 = 3 against only 2
104+
// possible signers -> permanently stranded. With it excluded the
105+
// threshold is (2*2)/3+1 = 2 -> reachable.
106+
chainApp, ctx, validators := setupQueryTest(t, 3)
107+
for _, v := range validators {
108+
setUVStatus(t, chainApp, ctx, v, uvalidatortypes.UVStatus_UV_STATUS_ACTIVE)
109+
}
110+
111+
// BeginBlock: slashing jails validators[0].
112+
jailLikeSlashingBeginBlock(t, chainApp, ctx, validators[0])
113+
114+
// Same block, tx-processing phase: a surviving UV observes an inbound,
115+
// which creates the ballot and freezes EligibleVoters + VotingThreshold.
116+
ballot := voteInboundAndLoadBallot(t, chainApp, ctx, validators[1], sameBlockJailInbound)
117+
118+
// Headline assertion first: the frozen threshold must be computed on
119+
// N-1. Everything else in this subtest is corroboration.
120+
require.Equal(t, int64(2), ballot.VotingThreshold,
121+
"threshold must be (2*2)/3+1 = 2 on the N-1 survivors, not (2*3)/3+1 = 3 on the inflated N")
122+
123+
require.Len(t, ballot.EligibleVoters, 2,
124+
"the jailed validator must not be snapshotted into the ballot")
125+
require.NotContains(t, ballot.EligibleVoters, validators[0].OperatorAddress,
126+
"jailed validator address must be absent from the frozen voter snapshot")
127+
require.Contains(t, ballot.EligibleVoters, validators[1].OperatorAddress)
128+
require.Contains(t, ballot.EligibleVoters, validators[2].OperatorAddress)
129+
})
130+
131+
t.Run("the surviving validators can still finalize that ballot", func(t *testing.T) {
132+
// The liveness half of the finding: with the jailed validator counted,
133+
// the frozen threshold of 3 is unreachable by the 2 survivors and the
134+
// ballot is stranded (only an admin MsgRecomputeBallotQuorum recovers
135+
// it, and DefaultExpiryAfterBlocks = 100_000_000 means it never ages
136+
// out on its own).
137+
chainApp, ctx, validators := setupQueryTest(t, 3)
138+
for _, v := range validators {
139+
setUVStatus(t, chainApp, ctx, v, uvalidatortypes.UVStatus_UV_STATUS_ACTIVE)
140+
}
141+
142+
jailLikeSlashingBeginBlock(t, chainApp, ctx, validators[0])
143+
144+
// First survivor votes: creates the ballot, does not finalize it.
145+
firstVoter, err := sdk.ValAddressFromBech32(validators[1].OperatorAddress)
146+
require.NoError(t, err)
147+
isFinalized, isNew, err := chainApp.UexecutorKeeper.VoteOnInboundBallot(ctx, firstVoter, sameBlockJailInbound)
148+
require.NoError(t, err)
149+
require.True(t, isNew, "the first vote must have created the ballot")
150+
require.False(t, isFinalized, "one vote out of a threshold of two must not finalize")
151+
152+
// Second (and last) survivor votes: this must be the finalizing vote.
153+
secondVoter, err := sdk.ValAddressFromBech32(validators[2].OperatorAddress)
154+
require.NoError(t, err)
155+
isFinalized, isNew, err = chainApp.UexecutorKeeper.VoteOnInboundBallot(ctx, secondVoter, sameBlockJailInbound)
156+
require.NoError(t, err)
157+
require.False(t, isNew, "second vote must land on the existing ballot")
158+
require.True(t, isFinalized,
159+
"every non-jailed validator has now voted; if this is false the ballot is stranded "+
160+
"behind a threshold no reachable signer set can meet")
161+
162+
ballotKey, err := uexecutortypes.GetInboundBallotKey(sameBlockJailInbound)
163+
require.NoError(t, err)
164+
ballot, err := chainApp.UvalidatorKeeper.Ballots.Get(ctx, ballotKey)
165+
require.NoError(t, err)
166+
require.Equal(t, uvalidatortypes.BallotStatus_BALLOT_STATUS_PASSED, ballot.Status,
167+
"the ballot must have reached a terminal PASSED status")
168+
})
169+
}
170+
171+
// sameBlockJailInbound is the observation used by the ballot subtests above.
172+
var sameBlockJailInbound = uexecutortypes.Inbound{
173+
SourceChain: "eip155:11155111",
174+
TxHash: "0xf18133jailedquorum",
175+
LogIndex: "0",
176+
}
177+
178+
// voteInboundAndLoadBallot casts voter's inbound vote through the real
179+
// uexecutor path (x/uexecutor/keeper/voting.go, the first of the seven
180+
// GetEligibleVoters call sites) and returns the ballot it created.
181+
func voteInboundAndLoadBallot(
182+
t *testing.T,
183+
chainApp *app.ChainApp,
184+
ctx sdk.Context,
185+
voter stakingtypes.Validator,
186+
inbound uexecutortypes.Inbound,
187+
) uvalidatortypes.Ballot {
188+
t.Helper()
189+
190+
voterAddr, err := sdk.ValAddressFromBech32(voter.OperatorAddress)
191+
require.NoError(t, err)
192+
193+
_, isNew, err := chainApp.UexecutorKeeper.VoteOnInboundBallot(ctx, voterAddr, inbound)
194+
require.NoError(t, err)
195+
require.True(t, isNew, "the vote must have created the ballot")
196+
197+
ballotKey, err := uexecutortypes.GetInboundBallotKey(inbound)
198+
require.NoError(t, err)
199+
ballot, err := chainApp.UvalidatorKeeper.Ballots.Get(ctx, ballotKey)
200+
require.NoError(t, err)
201+
return ballot
202+
}

x/uvalidator/keeper/validator.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func (k Keeper) GetValidatorsByStatus(ctx context.Context, status types.UVStatus
5050
//
5151
// Eligibility requires BOTH:
5252
// - UV lifecycle status is ACTIVE or PENDING_JOIN; AND
53-
// - the underlying Cosmos staking validator is bonded and not tombstoned.
53+
// - the underlying Cosmos staking validator is bonded, not jailed and not tombstoned.
5454
//
5555
// The staking-state filter prevents stranded UVs (still ACTIVE on paper but
5656
// unbonded/jailed/tombstoned on the base chain) from inflating the ballot
@@ -80,6 +80,18 @@ func (k Keeper) GetEligibleVoters(ctx context.Context) ([]types.UniversalValidat
8080
if !sv.IsBonded() {
8181
return false, nil
8282
}
83+
// A jailed validator is NOT covered by the IsBonded() check above.
84+
// Cosmos SDK's jailValidator sets Validator.Jailed and deletes the
85+
// power index but never touches Validator.Status, and IsBonded() is
86+
// only `GetStatus() == Bonded`. Slashing jails during BeginBlock while
87+
// the bonded -> unbonding transition happens in staking's EndBlocker,
88+
// so for the whole tx-processing phase in between a jailed validator
89+
// still reports IsBonded() == true. Without this gate it is snapshotted
90+
// into a ballot's EligibleVoters and inflates the threshold
91+
// denominator ((2*N)/3 + 1), which strands the ballot at N <= 3.
92+
if sv.IsJailed() {
93+
return false, nil
94+
}
8395
consAddr, caErr := sv.GetConsAddr()
8496
if caErr != nil {
8597
k.Logger().Debug("eligible voter filter: GetConsAddr failed", "validator", addr.String(), "err", caErr)

0 commit comments

Comments
 (0)