fix: F-2026-18189 | [Dual Defense] Manual Module EVM Nonce Desync on Reverted Inbound Execution - #342
Merged
Merged
Conversation
…r in step Route every module-sender DerivedEVMCall through one helper that reads the module account's EVM nonce, burns one nonce per attempt, and writes both back.
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.
ModuleAccountNoncefed the manual nonce for every module-senderDerivedEVMCalland was incremented on the outer ctx before the call, so a failed attempt still burned it. Nothing ever moved the module account's own EVM nonce, so the two were never in step at all: the counter climbs 0, 1, 2, … whileeth_getTransactionCount(ue-module)stays 0 forever — not only after a revert.Call sites.
moduleSenderNoncefromdevelopdoes not exist onaudit-fixes; the prologue is inlined five times, all inx/uexecutor/keeper/evm.go:CallPRC20Deposit,CallPRC20DepositAutoSwap,CallUniversalCoreSetChainMeta,CallUniversalCoreRefundUnusedGas,CallExecuteUniversalTx. The UEA helpers (CallFactoryToDeployUEA,CallUEAExecutePayload,CallUEAMigrateUEA) passisModuleSender=falseand no manual nonce, so they readaccountKeeper.GetSequence(from)— and the three inbound executors deploy withfrom = ue-module, which meant a frozen 0.Which recommendation, and why
Rec 2, not rec 1 — settled by the rec-4 test rather than by argument.
Rec 1 (read
evm.GetNonce(module)immediately before each call, retire the counter) was implemented and measured. Test 1 passes under it. Test 2 does not:The residual is worse than "conditional, after a revert".
ApplyMessageWithConfigadvances a sender's nonce only in itscontractCreationbranch, and every module call is a plain CALL, soevm.GetNonce(module)is a constant. Under rec 1, byte-identical module calls collide whether or not anything reverted. The unconditional counter is load-bearing, as the write-up said.Shipped
Keep the counter and its unconditional advance — that is what keeps derived tx hashes distinct — and remove the desync by moving both values together.
derivedModuleCallis now the only place a module-senderDerivedEVMCallis made from. It takes the nonce fromnextModuleSenderNonce(max(counter, evm.GetNonce(module))), makes the call, and burns exactly one nonce whether the call committed, reverted, or never reached the EVM — writing the new value to both the counter and the module account's sequence. Collapsing five copies of the prologue into one also means a new module call site cannot forget the increment, which is the failure modefa6ffe1e/dde0f870fix ondevelop.ModuleAccountNonce == evm.GetNonce(ue-module)now holds by construction, so rec 3's invariant is structural rather than something to register and check, and drift is impossible in both directions. That makes those twodevelopcommits moot for these five call sites; they are not cherry-picked here.One assumption this fork does not hold up
SkipNonceChecksis set on thecore.Messagebut never read — cosmos/evm does not run geth'spreCheckforApplyMessageWithConfig. So the reported impact ("later module calls supply N+1 withSkipNonceChecks=falseand fail") does not reproduce: on unpatchedaudit-fixesthe module-sender call after a reverted deposit succeeds. The manual nonce's only consumer is the derived tx identity —ethtypes.NewTx(&DynamicFeeTx{Nonce, …}).Hash()→res.Hash→ theethereum_txevent — which is why hash uniqueness is the property that had to be protected, and why test 2 is the one that decided the design.Tests
test/integration/uexecutor/module_nonce_test.go, against the real EVM with the real contracts.TestModuleSenderNonceSurvivesRevertedDeposit— forcesdepositPRC20Tokeninto a codeless PRC20, then asserts the next module-sender call still succeeds, that the failed attempt still burned its nonce, and that counter and account nonce still agree. Fails on unpatchedaudit-fixes:expected 0x1, actual 0x0.TestModuleSenderNonceDistinctHashesAcrossFailedAttempt— three byte-identical deposits with a forced failure wedged between the first and second, asserting all threeethereum_txhashes differ. Passes on unpatchedaudit-fixes(the counter was already doing this) and fails under rec 1.Mutation-checked both directions: dropping the account-nonce mirroring fails test 1 on the sync assertion (
expected 0x1, actual 0x0); burning the nonce only on success fails test 1 on the burn assertion (expected 0x2, actual 0x1). Restored, both green.A note that is in the test as well: a module-sender call passes
gasLimit == nil, soDerivedEVMCallWithDatarunsEstimateGasInternalfirst, and for an always-reverting call that returns{Gas: 0, VmError: "execution reverted"}. The call then dies inApplyMessageWithConfigwith "intrinsic gas too low" before anyethereum_txis emitted — so a failing module deposit burns a nonce and produces no derived transaction at all.