fix: F-2026-18825 | [Dual Defense] One Invalid Gateway Sibling Erases Valid Outbounds After Committed UEA Burn - #323
Open
0xNilesh wants to merge 2 commits into
Open
fix: F-2026-18825 | [Dual Defense] One Invalid Gateway Sibling Erases Valid Outbounds After Committed UEA Burn#3230xNilesh wants to merge 2 commits into
0xNilesh wants to merge 2 commits into
Conversation
…-18825) The gateway burn was committed before the handlers attached the outbounds, so one invalid leg of a multicall erased the valid ones with their burns already final and the failure swallowed into RevertError.
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.
Root cause — the burn commits before the attach runs
Outbound creation reached through inbound handling is the one of three outbound-creation paths that is
broken; the direct
MsgExecutePayloadpath andEVMHooks.PostTxProcessingboth propagate errors and are safe.ExecutePayloadV2(x/uexecutor/keeper/execute_payload.go) wrapped EVM execution + fee deduction in aCacheContextand committed viawriteCache()before returning. Both callers then attached theoutbounds afterwards, outside any cache — so a payload that called
UniversalGatewayPChad its PRC20burn already permanent by the time the attach ran.
BuildOutboundsFromReceipt(create_outbound.go) is all-or-nothing: fourreturn nil, errpoints (logdecode, chain lookup, chain disabled, unregistered PRC20) discard every valid outbound already
accumulated. So a UEA multicall carrying one valid gateway leg and one invalid sibling ended with:
OutboundTxand noPendingOutboundsrow,UniversalTx.RevertError— a field with 9 writes / 0 reads chain-wide,nil.Nothing on chain recorded that anything had gone wrong, so neither rescue nor remint was eligible.
Why the fix is atomicity, not error propagation
The inbound handler runs inside
MsgVoteInbound. Returning an error there would fail the vote tx and losethe validator's vote, so the handler must keep returning
nil. That consensus constraint is exactly thedesign pressure that produced the swallow — which is why the fix has to make the burn and the attach commit
together, rather than bubble the error up. The handlers still
return nil; the failure now surfaces as aFAILED PcTx instead of a silent
RevertError.The change
x/uexecutor/keeper/execute_payload.go—ExecutePayloadV2takes thetypes.UniversalTxit isexecuting for and performs the attach inside its existing
cacheCtx, beforewriteCache(). On attachfailure it returns
outbound attach failed: …without committing, so the EVM state (including the gatewayburn), the fee deduction and the outbound rows all roll back together.
ExecutePayloadV2has exactly twocallers, both inbound handlers, so the signature change is contained.
execute_inbound_funds_and_payload.go/execute_inbound_gas_and_payload.go— the post-hocAttachOutboundsToExistingUniversalTxblock is gone from the UEA branch of both handlers. A payload errornow records a FAILED PcTx carrying the real reason; nothing writes
RevertErrorand nothing marksSUCCESS on this path any more.
create_outbound.go— the barecollections.ErrNotFoundfrom the PRC20 lookup is wrapped with the tokenand chain, so the FAILED PcTx names the leg that could not be resolved instead of just saying
not found.Behaviour is unchanged.
BuildOutboundsFromReceiptdeliberately left all-or-nothing — Hacken #1 declinedOnce burn and attach are atomic, all-or-nothing is the correct semantics: one bad step reverts the tx,
exactly as ordinary EVM execution behaves. Hacken's recommendation #1 (skip the invalid leg and continue)
would commit the bad leg's burn with nothing to show for it, converting "lose everything" into "silently
lose one thing" — strictly worse, and it would keep the failure invisible. Hacken's #4 (wire
RevertErrorinto a recovery path) is likewise skipped: the goal is to eliminate the swallow, not to build machinery
around it.
Resulting user outcome
The deposit happens before the payload cache, so the rollback undoes only the payload. Bridged funds stay
credited to the UEA, nothing is burned, no partial outbound row is left behind, the PcTx says FAILED with an
actionable reason, the ballot finalises normally, and the user retries without the bad leg.
Tests —
test/integration/uexecutor/inbound_multicall_outbound_atomicity_test.goThe payloads are real UEA multicalls (
bytes4(keccak256("UEA_MULTICALL"))+ ABI-encodedMulticall[]), eachleg burning a PRC20 through
UniversalGatewayPC. The mock gateway's outbound nonce (storage slot 2) is usedas the commit/rollback witness.
OutboundTx, noPendingOutbounds, no gas fee collected, PcTx FAILED naming the bad PRC20,RevertErrorempty, vote tx still commits, deposit still credited to the UEAOutboundTxwith distinct ids + 2PendingOutboundsrows, PcTx SUCCESS, both burns committedEach headline assertion was verified as a genuine regression detector: with the fix reverted, all five fail
in exactly the shape of the finding — burn committed, fee collected, PcTx SUCCESS, empty
ErrorMsg, andRevertErrorholding the swallowed error../x/uexecutor/...,./test/integration/...— all green.F-2026-18195)#319 is open against the same base and modifies the same two handler files — but the CEA
smart-contract branch (
CallExecuteUniversalTx), while this PR changes the UEA branch(
ExecutePayloadV2) plusexecute_payload.go. Same atomicity fix, opposite branch of the same if/else, andthe naming (
outbound attach failed: …, attach insidecacheCtxbeforewriteCache()) is deliberatelymirrored so the two land consistently.
I trial-merged the two branches locally: the merge is clean, both changes survive intact, and the merged
tree builds and passes the full
./test/integration/uexecutor/...suite including both PRs' new tests.Merge order does not matter for correctness, but whichever lands second should re-run that suite.
Ref:
audits/TO_BE_FIXED.md→## F-2026-18825.