Skip to content

Commit 4827e0d

Browse files
committed
docs: record why admin revert refuses REJECTED inbound ballots
REJECTED is a quorum asserting the observation is invalid, not a stuck deposit, so refunding it would pay out against a deposit the validator set said never happened. Adds an alarm if the unreachable state ever occurs.
1 parent 83c145f commit 4827e0d

3 files changed

Lines changed: 84 additions & 0 deletions

File tree

test/integration/uexecutor/revert_stuck_inbound_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,42 @@ func TestRevertStuckInbound_RecomputeThenRevert_E2E(t *testing.T) {
297297
require.Len(t, utx.OutboundTx, 1)
298298
require.Equal(t, uexecutortypes.TxType_INBOUND_REVERT, utx.OutboundTx[0].TxType)
299299
}
300+
301+
// TestRevertStuckInbound_RejectedBallot_RefusedDeliberately pins the refusal
302+
// documented for F-2026-18801.
303+
//
304+
// The terminal-routing hook files BOTH terminal-failure statuses into
305+
// ExpiredInbounds, but the admin hatch accepts only EXPIRED. That asymmetry is
306+
// intentional, and this test exists so a future change cannot quietly relax it:
307+
//
308+
// - EXPIRED is uncertainty. Quorum never formed, the deposit may be real, the
309+
// funds may be stuck in the source gateway. Refunding is correct.
310+
// - REJECTED is a supermajority asserting the observation is invalid. A revert
311+
// outbound there would pay out of the TSS vault against a deposit the
312+
// validator set concluded never happened.
313+
//
314+
// Note this state is unreachable for inbounds today (VoteOnInboundBallot
315+
// hardcodes VOTE_RESULT_SUCCESS, so threshold-FAILURE never fires); the ballot is
316+
// seeded directly here precisely because no vote path can produce it. If inbound
317+
// negative voting is ever added, this test is the place the design decision has
318+
// to be re-made rather than inherited.
319+
func TestRevertStuckInbound_RejectedBallot_RefusedDeliberately(t *testing.T) {
320+
chainApp, ctx, inbound, admin := setupRevertStuckInbound(t)
321+
seedBallot(t, chainApp, ctx, inbound, uvalidatortypes.BallotStatus_BALLOT_STATUS_REJECTED)
322+
323+
ms := uexecutorkeeper.NewMsgServerImpl(chainApp.UexecutorKeeper)
324+
_, err := ms.RevertStuckInbound(sdk.WrapSDKContext(ctx), &uexecutortypes.MsgRevertStuckInbound{
325+
Signer: admin,
326+
Inbound: inbound,
327+
})
328+
require.Error(t, err, "admin revert must refuse a REJECTED ballot")
329+
require.Contains(t, err.Error(), "admin revert requires EXPIRED",
330+
"the refusal must name the required status so an operator knows why")
331+
332+
// The refusal must be total: no UTX, and therefore no revert outbound that
333+
// could later be signed and broadcast.
334+
utxKey := uexecutortypes.GetInboundUniversalTxKey(*inbound)
335+
has, hErr := chainApp.UexecutorKeeper.HasUniversalTx(ctx, utxKey)
336+
require.NoError(t, hErr)
337+
require.False(t, has, "a refused revert must not leave a UniversalTx behind")
338+
}

x/uexecutor/keeper/admin_revert.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ import (
2222
// to EXPIRED if it isn't already (recompute auto-expires when no eligible
2323
// voters remain).
2424
//
25+
// REJECTED is refused deliberately, not by omission (F-2026-18801). The two
26+
// terminal-failure statuses mean opposite things:
27+
//
28+
// - EXPIRED is uncertainty. Quorum never formed, so we do not know whether the
29+
// deposit happened; the funds may genuinely be sitting in the source-chain
30+
// gateway. Refunding is the right instinct.
31+
// - REJECTED is a supermajority of universal validators affirmatively voting
32+
// that the observation is invalid. Building a revert outbound for that would
33+
// pay real funds out of the TSS-controlled vault against a deposit the
34+
// validator set concluded never occurred.
35+
//
36+
// It is also unreachable for inbounds today: REJECTED is only produced by
37+
// Ballot.IsFinalizingVote's threshold-FAILURE branch, and VoteOnInboundBallot
38+
// hardcodes VOTE_RESULT_SUCCESS - an inbound observer either votes for what it
39+
// saw or stays silent, there is no "I assert this did not happen" vote. So an
40+
// inbound ballot terminates PASSED or EXPIRED, never REJECTED.
41+
//
42+
// If a negative-vote path for inbounds is ever added, this refusal must be
43+
// revisited as a design decision rather than silently inherited; see the
44+
// unreachable-status warning in BallotHooks.afterInboundBallotTerminal.
45+
//
2546
// Returns the new UTX ID and revert outbound ID for telemetry.
2647
func (k Keeper) RevertStuckInbound(ctx context.Context, inbound types.Inbound) (utxId, outboundId string, err error) {
2748
sdkCtx := sdk.UnwrapSDKContext(ctx)

x/uexecutor/keeper/ballot_hooks.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,30 @@ func (h BallotHooks) afterInboundBallotTerminal(
134134
// All variants are terminal-failure (EXPIRED or REJECTED). Preserve
135135
// the full audit trail in ExpiredInbounds for the future escape-hatch
136136
// refund flow.
137+
//
138+
// Only EXPIRED is reachable here for inbounds: REJECTED comes solely from
139+
// Ballot.IsFinalizingVote's threshold-FAILURE branch, and VoteOnInboundBallot
140+
// hardcodes VOTE_RESULT_SUCCESS. An inbound observer votes for what it saw or
141+
// stays silent; disagreement forks the ballot key into a separate variant
142+
// rather than voting against one. The admin hatch (RevertStuckInbound)
143+
// therefore accepts EXPIRED only, and refuses REJECTED deliberately - see the
144+
// reasoning there.
145+
//
146+
// Shout if that ever stops being true. A REJECTED inbound reaching this point
147+
// means someone added a negative-vote path and silently reopened a terminal
148+
// state with no refund route (F-2026-18801).
149+
for _, v := range entry.Variants {
150+
if v.TerminalStatus == uvalidatortypes.BallotStatus_BALLOT_STATUS_REJECTED {
151+
h.k.Logger().Error(
152+
"REJECTED inbound ballot variant reached terminal routing - this should be unreachable; "+
153+
"inbound votes are SUCCESS-only. RevertStuckInbound will refuse this entry, leaving it "+
154+
"with no shipped refund path. Revisit F-2026-18801 before shipping inbound negative voting.",
155+
"utx_key", utxKey,
156+
"ballot_id", v.BallotId,
157+
)
158+
}
159+
}
160+
137161
sdkCtx := sdk.UnwrapSDKContext(ctx)
138162
return h.k.ExpiredInbounds.Set(ctx, utxKey, types.ExpiredInboundEntry{
139163
UtxKey: utxKey,

0 commit comments

Comments
 (0)