Skip to content

fix: F-2026-18195 | [Dual Defense] isCEA Smart-Contract Inbound Lifecycle Gaps Strand Bridged Principal - #319

Open
0xNilesh wants to merge 2 commits into
audit-fixesfrom
F-2026-18195
Open

fix: F-2026-18195 | [Dual Defense] isCEA Smart-Contract Inbound Lifecycle Gaps Strand Bridged Principal#319
0xNilesh wants to merge 2 commits into
audit-fixesfrom
F-2026-18195

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

Scope

Hacken's F-2026-18195 bundles two defects. This PR fixes only the second one.

Fixed here — the missing outbound attach on a successful smart-contract callback.
On the isCEA smart-contract recipient branch, when the callback succeeded the handler recorded the
PcTx and returned without calling AttachOutboundsToExistingUniversalTx. If the recipient
contract called UniversalGatewayPC during its callback, the PRC20 was burned and a
UniversalTxOutbound log emitted — but no OutboundTx and no PendingOutbounds row was ever
created, so nothing was signed or delivered. DerivedEVMCall skips PostTxProcessing, so the EVM
hook does not compensate. Every step reported SUCCESS, so rescue and remint were both ineligible:
burned supply with nothing to deliver.

The UEA branch in the same function already attached. There was no comment or documented rationale
for the difference — this was an asymmetry, not a design choice.

Not changed — the isCEA route performs no automatic revert. That is accepted behaviour by design.
Recovery is manual, via the rescue route, when the token mint fails. When the mint succeeds and the
payload then fails, the principal remains with the recipient the sender nominated and under their
control. The sender chooses the CEA recipient and is spending their own funds, so this is integrator
responsibility rather than a protocol defect. The choice is explicit in the code
(// isCEA failures never create an INBOUND_REVERT outbound). No INBOUND_REVERT was added to the
isCEA path, rescue eligibility was not widened, and the deposit-commit ordering is untouched.

The change

Both affected handlers, and only these two files:

  • x/uexecutor/keeper/execute_inbound_funds_and_payload.go
  • x/uexecutor/keeper/execute_inbound_gas_and_payload.go

On smart-contract callback success, outbounds are now attached from contractReceiptinside the
existing cacheCtx, before writeCache()
.

Why inside the cache

The gateway burn happens in cacheCtx. Attaching there means the burn and the resulting
OutboundTx / PendingOutbounds rows land in the same cache and commit together on writeCache(),
or not at all. CacheContext() also gives the branch its own EventManager, which writeCache()
flushes to the parent, so the OutboundCreated event is atomic with the rows it describes. There is
no window in which supply is burned but no outbound exists.

If the attach fails, writeCache() is not called: the whole callback — including the burn and the
gas-fee deduction — is rolled back, and the PcTx is recorded as FAILED with the attach error under
an outbound attach failed: ... prefix.

Why the RevertError pattern was not copied

The UEA branch stashes attach errors in UniversalTx.RevertError, a field with 9 writes and 0 reads
chain-wide — it silently loses the failure, and leaves the burn committed with no outbound. Copying
that here would have reproduced the exact stranding this PR is meant to remove. The failure is put on
the PcTx instead, where operators and the audit trail already look, and the state is rolled back so
there is nothing stranded to reconcile. The UEA branch itself is unchanged.

Tests

New: test/integration/uexecutor/inbound_cea_contract_outbound_test.go.

A mock recipient contract re-enters UniversalGatewayPC during its callback (raw runtime bytecode:
SSTORE a witness into slot 0, then CALL the gateway with a fixed withdraw calldata blob). The
slot-0 witness is what proves commit vs. rollback of the callback body.

  • FUNDS_AND_PAYLOAD — callback invoking a gateway outbound produces exactly one non-empty
    OutboundTx (destination chain, recipient, amount, PRC20, PENDING) and a populated
    PendingOutbounds entry pointing at the UTX.
  • GAS_AND_PAYLOAD — same, for the sibling handler.
  • Attach failure, both handlers — outbound disabled for the destination chain makes
    BuildOutboundsFromReceipt fail. The callback is fully rolled back (slot 0 stays 0), no
    OutboundTx, no PendingOutbounds row, no gas fee collected, and the PcTx is FAILED carrying
    outbound attach failed: ... outbound is disabled for chain .... The deposit, which happens before
    the cache scope, stays committed.
  • Regression — a CEA callback that emits nothing still yields a SUCCESS PcTx with no spurious
    outbound or PendingOutbounds rows.
  • Regression — the UEA branch still attaches its outbound and indexes it.

GAS_AND_PAYLOAD cases use a zero inbound amount so the handler skips gasAndPayloadDepositAutoSwap,
which needs a live Uniswap quoter/router the integration harness does not deploy. isSmartContract is
set from the recipient's code hash regardless of amount, so the branch under test is still exercised.

Each new case was verified as a genuine regression detector: with the fix reverted, the four
attach-related cases fail (missing OutboundTx; PcTx SUCCESS instead of FAILED) and the two
regression cases still pass.

go test -tags=test ./x/uexecutor/... ./test/integration/uexecutor/...
ok  x/uexecutor/keeper
ok  x/uexecutor/types
ok  test/integration/uexecutor

Full ./test/integration/... (uexecutor, upgrades, uregistry, utss, uvalidator) is green.

Reply to Hacken

The isCEA route intentionally performs no automatic revert; recovery is manual via the rescue path
when the mint fails, and where the mint succeeds and the payload fails the principal remains with
the recipient nominated by the sender, under their control. The sender selects the CEA recipient and
spends their own funds, so we treat this as accepted behaviour rather than a protocol defect.
We have separately fixed the missing outbound attach on the successful smart-contract callback path
(both the FUNDS_AND_PAYLOAD and GAS_AND_PAYLOAD handlers), so a nested gateway burn now produces an
OutboundTx and a PendingOutbounds entry, committed atomically with the burn.

Attach inside the callback CacheContext so a nested UniversalGatewayPC burn and its OutboundTx/PendingOutbounds rows commit atomically; a failed attach discards the cache and records a FAILED PcTx.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant