docs: F-2026-18801 | [Dual Defense] REJECTED Inbounds Land in ExpiredInbounds With No Shipped Refund Hatch - #334
Open
0xNilesh wants to merge 1 commit into
Open
docs: F-2026-18801 | [Dual Defense] REJECTED Inbounds Land in ExpiredInbounds With No Shipped Refund Hatch#3340xNilesh wants to merge 1 commit into
0xNilesh wants to merge 1 commit into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finding
BallotHooks.afterInboundBallotTerminalfiles both terminal-failure statuses intoExpiredInbounds:No
UniversalTxis created. But the admin hatch accepts only one of them (admin_revert.go:47):and
AttachRescueOutboundFromReceiptneeds a UTX that doesn't exist. So aREJECTED-terminal inbound would have no shipped refund path, admin or user.Why this is documentation rather than a code fix
The state is unreachable for inbounds.
REJECTEDis produced solely byBallot.IsFinalizingVote's threshold-FAILUREbranch, andVoteOnInboundBallothardcodesVOTE_RESULT_SUCCESS.RecomputeBallotQuorumonly yieldsEXPIRED. An inbound ballot terminatesPASSEDorEXPIRED, neverREJECTED.That isn't an accident of implementation — it's the shape of the protocol. An inbound vote is a claim about what you saw. Disagreement forks the ballot key into a separate variant rather than voting against one, and a validator that believes an event is fabricated simply doesn't vote. There is no "I assert this did not happen" for inbounds.
REJECTEDis vocabulary inherited from the genericx/uvalidatorballot layer, where only fund migration actually castsFAILURE(x/utss/keeper/voting.go:126-129, derived from an observed success flag).And the refusal is correct, not an oversight. The two statuses mean opposite things.
EXPIREDis uncertainty — quorum never formed, the deposit may be real, funds may be stuck in the source gateway; refunding is right.REJECTEDis a supermajority affirmatively rejecting the observation; building a revert outbound there would pay out of the TSS-controlled vault against a deposit the validator set concluded never happened. With F-2026-18198 (Solana inbound event forgery) open on exactly this surface, that is the wrong direction.So Hacken's recommendation 1 — extend the hatch to accept
REJECTED— is declined as harmful, not deferred.What this PR changes
Nothing behavioural. It makes an undocumented deliberate decision legible, so it can't be silently reversed:
admin_revert.go— records whyREJECTEDis refused, why it is currently unreachable, and that a future negative-vote path must re-make this decision rather than inherit it.ballot_hooks.go— anError-level log if aREJECTEDvariant ever reaches terminal routing, naming the consequence (no shipped refund path) and this finding. Today that branch cannot fire; if someone adds inbound negative voting, it becomes an alarm instead of a silent trap.revert_stuck_inbound_test.go—TestRevertStuckInbound_RejectedBallot_RefusedDeliberately, seeding aREJECTEDballot directly (no vote path can produce one) and asserting the hatch refuses it, names the required status, and leaves noUniversalTxbehind.Mutation check
The mutation is Hacken's own recommendation — relaxing the hatch to also accept
REJECTED:So the test actively defends the decision rather than merely recording it.
Tests
go test -mod=readonly -tags="ledger test_ledger_mock test" ./x/... ./test/integration/...→ 15 packages ok, 0 failures.gofmtflagsballot_hooks.go, a doc-comment reflow that is pre-existing onaudit-fixes; left alone to keep the diff focused.Other recommendations
INBOUND_REVERTwhen routing toExpiredInbounds) — not taken; same objection as rec 1 for theREJECTEDhalf, and theEXPIREDhalf already has the admin hatch.AllExpiredInboundsalready exists as a query. Not code, so not in this PR.