fix: F-2026-18819 | [Dual Defense] IBC Refund Conversion Swallows EVM Error After Escrowing Coins Into erc20 Module - #45
Open
0xNilesh wants to merge 1 commit into
Open
fix: F-2026-18819 | [Dual Defense] IBC Refund Conversion Swallows EVM Error After Escrowing Coins Into erc20 Module#450xNilesh wants to merge 1 commit into
0xNilesh wants to merge 1 commit into
Conversation
… fails The escrow was applied to the parent context before CallEVM, so a VM failure left the sender's coins stranded on the erc20 module account - permanently on the IBC refund path, which swallows the error.
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
ConvertCoinNativeERC20mutates bank state on the parent context before touching the EVM:CallEVMWithDatacaches only the EVM execution. On a VM failure it discards the EVM cache and returns an error, leaving the bank send applied. Every later failure in the function has the same shape — the return-value check, the balance-invariance check, and the burn.Called directly via
MsgConvertCointhis is survivable, because baseapp discards the whole message on error. The IBC refund path is where it becomes permanent:ConvertCoinToERC20FromPacketswallows the error, emitsFailedConvertERC20, returnsnil, and the block commits.That swallow is correct in intent — the refund itself already succeeded and must not be undone by a failure to re-wrap it into the ERC20 representation. But it was resting on an assumption the code did not honour. The doc comments on
OnAcknowledgementPacketandOnTimeoutPacketboth stated:False once the escrow had succeeded. The coins were on the
erc20module account, the ERC20transfernever credited the user, and there is no shipped message to reclaim a module balance. Recovery would have needed gov or an upgrade handler.OnRecvPacketdoes the opposite for the identical error (return channeltypes.NewErrorAcknowledgement(err)atibc_callbacks.go:137) — the swallow is specific to the refund path.Changes
ConvertCoinNativeERC20is now atomic. The body moved to an unexportedconvertCoinNativeERC20; the exported function wraps it in aCacheContextand callswriteCache()only on full success. Escrow, EVM transfer and burn commit together or not at all.Structured deliberately as a thin wrapper plus an untouched inner body, so that PR #44's
validateApprovalEventDoesNotExistinsertion lands inside the inner function and merges mechanically.Comments corrected. The
OnAcknowledgementPacket/OnTimeoutPacketpromises are now true, and each says why — they hold only because the conversion is atomic. The swallow inConvertCoinToERC20FromPacketstates the precondition it depends on.Two behaviours worth naming explicitly, since neither is obvious:
CacheContext'swriteCachereplays the branch's events onto the parent, so events survive success and vanish on failure. No manual handling needed.Tests
Two integration tests in
tests/integration/x/erc20/test_ibc_callback.go: one at the keeper level, one on the IBC refund path that made this permanent. Both assert balances, not just the error — an error assertion alone would not have caught this.Getting the failure to land in the right place took two corrections worth recording:
Pausing the token does not work. It fails the
balanceOfread atmsg_server.go:292, which runs before the escrow, so nothing is ever escrowed and the test passes vacuously. The setup instead leaves the module account with no token balance, sobalanceOfsucceeds, the escrow succeeds, andtransferreverts with nothing to send — the only ordering that can strand coins.The context must be fetched after
setupRegisterERC20Pair. A context taken earlier does not see the deployed contract andbalanceOfreturns nil. The helper returns its own context for this reason.Mutation-verified — wrap removed, tests re-run, fix restored:
CacheContextwrap with a direct callThe first version of these tests passed under that mutation. They were rewritten until they didn't.
go test ./x/erc20/...and theERC20|IBCintegration suites are green.Hacken's remediations
CacheContext, write only on success — done.Merge order
PR #44 (F-2026-18822) touches the same function. Land #44 first; the split into wrapper + inner body should let this rebase cleanly.