fix: F-2026-18823 | [Dual Defense] buildRevertOutbound Fail-Open Leaves Unsignable INBOUND_REVERT and Blocks Rescue - #330
Merged
Merged
Conversation
…them buildRevertOutbound failed open: when the gas metadata lookup failed it returned a PENDING outbound with empty gas fields, which attachOutboundsToUtx indexed into PendingOutbounds unconditionally. UVs refuse to sign it, so the row sat there forever, and non-CEA rescue was gated on a REVERTED inbound-revert so the user had no way out either. - buildRevertOutbound returns (outbound, error) - on gas-metadata failure the revert is marked ABORTED with an AbortReason - attachOutboundsToUtx indexes only PENDING outbounds, and emits outbound_aborted for the rest - the non-CEA rescue gate accepts REVERTED or ABORTED
- keeper unit tests drive buildRevertOutbound with the gas lookup mocked both ways: resolvable stays PENDING with exact gas fields and is indexed, unresolvable aborts with a reason and is not - integration tests assert the revert is ABORTED, absent from PendingOutbounds, and that a non-CEA RESCUE_FUNDS is then accepted - fix the unit fixture's auth store key (authtypes.StoreKey != ModuleName) and wire the real account keeper so UniversalCore calls work
0xNilesh
added a commit
that referenced
this pull request
Aug 26, 2026
…026-18823 The two F-2026-18147 tests were written before #330 landed and asserted the revert outbound is PENDING and queued. #330 aborts a revert whose gas metadata is unresolvable, which is what the harness's UniversalCore stub produces, so they now assert ABORTED and not-queued, matching the sibling happy-path test.
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.
Fixes F-2026-18823 —
buildRevertOutboundfail-open leaves an unsignableINBOUND_REVERTand blocks rescue.Root cause — fail-open
buildRevertOutboundbuilt theINBOUND_REVERTasPENDINGfirst, then looked up gas metadata. On failure of either lookup —GetTokenConfig, orGetGasFeeInfoForRevertOutbound(UniversalCore.getOutboundTxGasAndFees) — it logged "proceeding without gas fields" and returned the outbound anyway, withGasToken/GasFee/GasPrice/GasLimitempty.attachOutboundsToUtxthen wrote it intoPendingOutboundsunconditionally (no status check). The universal validators' outbound builder rejects it (gas price is zero or missing), so it is never signed, never broadcast, and sitsPENDINGforever. Re-resolving the metadata later does not rewrite the fields already stored on the outbound, so the row is dead on arrival.Why fail-closed alone is not enough
Non-CEA rescue was gated on an
INBOUND_REVERThaving reachedREVERTED. Simply not attaching the unsignable revert would clean the queue and still leave the user stuck: rescue would keep refusing with "has no reverted inbound-revert outbound". Recovery has to be restored alongside the queue hygiene.The fix (four parts)
buildRevertOutboundreturns(*OutboundTx, error)— failure is explicit instead of a silently half-built struct. All six callers updated;admin_revert.go's previously-unreachablenilcheck is now real.Status_ABORTEDwith anAbortReasoninstead ofPENDING, and the gas fields are left empty rather than half-written. This reuses the existing shape fromAbortOutbound.attachOutboundsToUtxindexes onlyPENDINGoutbounds intoPendingOutbounds, and emits the sameoutbound_abortedeventAbortOutbounddoes for the rest, so monitoring sees it. Correct generally, not just for this case.REVERTEDorABORTED. Rescue exists for "funds never reached Push and are still locked at source" — a non-CEA inbound that failed and whose revert could not even be built is exactly that. This avoids needing a new admin message to patch gas fields or force a status (Hacken rec 2, deliberately out of scope).The outbound is still attached in the abort case: the attempt stays in the audit trail, and it is what makes the UTX rescue-eligible. The healthy path — metadata resolves — is untouched: still
PENDING, still indexed, same gas values.Cross-cutting note
This removes one of the three confirmed ways to create an outbound that can never leave
PendingOutbounds— no ballot can form for an unsignable row, and there is no admin abort for outbounds — alongside F-2026-18184 and F-2026-18146. It also removes one input to the F-2026-18827 keygen-guard deadlock.Tests
x/uexecutor/keeper/build_revert_outbound_test.go(new) drivesbuildRevertOutboundwith theUniversalCorecall mocked both ways:PENDING, no abort reason, gas fields exactly as the contract returned them, and the outbound is indexed inPendingOutbounds.ABORTED, reason names the failure, gas fields empty, not indexed,outbound_abortedemitted.ABORTEDwith the right reason.(nil, error), the contractadmin_revert.gorelies on.test/integration/uexecutor/inbound_revert_abort_test.go(new) covers it end-to-end through the vote path:ABORTEDrevert with a reason, empty gas fields, and leavesPendingOutboundscompletely empty.ABORTEDrevert present, a non-CEARESCUE_FUNDSis accepted (refused before this change) and the rescue itself is queued while the aborted revert stays out.Existing tests updated where they asserted the old fail-open behaviour (
vote_inbound_validation_test.go,execute_inbound_gas_test.go,revert_stuck_inbound_test.go, and the rescue-gate error string). Each now also asserts the revert is not queued.Regression-detector check: with the three behavioural changes reverted (fail-open restored, unconditional indexing, narrow rescue gate) every new/updated assertion fails, and the two unchanged-behaviour tests still pass. Reverting only the
PendingOutboundsguard independently fails every "not queued" assertion.Results:
./x/uexecutor/...and./test/integration/...pass.TestGaslessExecutePayloadWithModuleSenderfails identically onaudit-fixeswithout this change (pre-existing, unrelated).Notes for review
UniversalCorestub cannot servegetOutboundTxGasAndFees(its PRC20 stub has noSOURCE_CHAIN_NAMESPACE), so everyINBOUND_REVERTbuilt at that level now takes the abort path. That is why several existing integration assertions flip fromPENDINGtoABORTED— it is the harness telling the truth, not a semantic change. The resolvable path is covered by the keeper unit test, which mocks the lookup.keys[authtypes.StoreKey]("acc") from a map keyed by module names ("auth"), yielding a nil store key that panicked the first time the account keeper was touched. Fixed, and the real account keeper is now wired into the keeper under test soUniversalCorecalls resolve the module address.ZeroGasPrice) now aborts the revert rather than parking it. That is not a regression — the parked row was permanently unsignable anyway, since the empty fields are never rewritten — and the abort is observable and rescue-eligible. A retry path for transient failures could be a follow-up.