The promise of Cosmos EVM is seductive: plug Ethereum's smart contract ecosystem into Cosmos's modular, sovereign-chain architecture. Ship an appchain with Solidity support in weeks instead of months.
The reality? Every Cosmos EVM chain inherits a dual-state machine — the EVM's StateDB and the Cosmos SDK's KVStore running in parallel — and the seams between them are where attackers live.
In Q1 2026 alone, three distinct precompile vulnerability classes have surfaced across the Cosmos EVM ecosystem, culminating in the $7M SagaEVM hack that drained an entire stablecoin's collateral pool. None were ordinary smart contract bugs. All exploited the fundamental tension between two state machines that were never designed to share a transaction boundary.
This post dissects each class, shows the exploit mechanics, and provides a concrete defense pattern — the atomic precompile wrapper — that would have prevented all three.
Why Precompiles Are the New Bridge
In vanilla Ethereum, precompiled contracts are boring: ecrecover, sha256, modexp. Fixed-function cryptographic utilities at hardcoded addresses.
Cosmos EVM chains do something far more dangerous. They use precompiles as gateways between the EVM world and native Cosmos modules:
-
Staking precompile → delegates/undelegates tokens via the
x/stakingmodule -
Distribution precompile → claims staking rewards via
x/distribution -
ICS20 precompile → executes IBC token transfers via
x/ibc-transfer -
Governance precompile → submits/votes on proposals via
x/gov
Each precompile must read from and write to both the EVM StateDB and the Cosmos KVStore within a single EVM call. This is where things break.
Class 1: The Infinite Mint — StateDB.Commit() During Precompile Execution
Affected: Evmos, FunctionX/PundiX (patched pre-exploitation)
Severity: Critical — unlimited token minting
Advisory: GHSA-68fc-7mhg-6f6c
The Bug
When a Solidity contract calls the staking precompile to delegate tokens, the precompile needs to:
- Debit the caller's EVM balance (StateDB)
- Create a delegation in the Cosmos staking module (KVStore)
The Evmos implementation called StateDB.Commit() inside the precompile execution to flush the EVM balance change to permanent storage. This seems reasonable — you want the balance debit to persist before the delegation is created.
The problem: Commit() bypasses the EVM's journal and snapshot mechanism.
The Exploit Flow
1. Attacker contract calls staking precompile: delegate(1000 EVMOS)
2. Precompile debits 1000 EVMOS from contract's EVM balance
3. Precompile calls StateDB.Commit() — balance change is now PERMANENT
4. Precompile creates delegation in Cosmos staking module
5. Attacker contract REVERTS the outer EVM call
6. EVM journal replays: restores contract balance to pre-call state
BUT the Commit() already flushed — the journal restore adds 1000 EVMOS
on top of the already-committed debit
7. Result: Attacker has 1000 EVMOS in EVM balance AND 1000 EVMOS delegated
8. Repeat infinitely
The root cause is a state desynchronization: the EVM's revert mechanism (journal + snapshots) doesn't know that Commit() already persisted the balance change, so it "restores" a balance that was never actually lost from permanent storage's perspective.
Why It's Subtle
In standard EVM execution, Commit() is called exactly once, after the entire transaction completes. Precompile authors who call it mid-execution are violating an invariant that go-ethereum's state management was never designed to handle.
// DANGEROUS: This pattern breaks EVM state consistency
func (p *StakingPrecompile) Delegate(ctx sdk.Context, stateDB *statedb.StateDB, args ...interface{}) error {
// Debit EVM balance
stateDB.SubBalance(caller, amount)
// THIS IS THE BUG: Commit() during execution
stateDB.Commit() // ← Bypasses journal/snapshot mechanism
// Create Cosmos delegation
p.stakingKeeper.Delegate(ctx, delegatorAddr, amount, ...)
return nil
}
Class 2: The ICS20 Recursive State Injection — SagaEVM's $7M Hack
Affected: SagaEVM (exploited January 2026, $7M stolen)
Severity: Critical — unbacked stablecoin minting
Advisory: ASA-2026-002
The Bug
The ICS20 precompile handles cross-chain token transfers via IBC. When processing an incoming IBC transfer, the precompile must:
- Verify the IBC packet proof
- Mint or unlock tokens on the receiving chain
- Update the EVM state to reflect the new balance
SagaEVM's implementation had a flaw in nested EVM execution paths: state updates made in recursive calls were not properly reflected in the outer execution context. This meant the same token balance could be "used" multiple times within a single transaction.
The Exploit Flow
1. Attacker deploys contract on SagaEVM
2. Contract crafts a message that triggers the ICS20 precompile
3. The precompile enters a nested execution path
4. Inside the nested context, state updates (token minting) occur
5. BUT the outer context doesn't see these updates
6. The same "incoming transfer" proof is effectively replayed
7. Each nested call mints fresh Saga Dollars without collateral
8. Attacker exits with millions in unbacked $D stablecoins
9. Swaps $D for real assets (yETH, yUSD, tBTC)
10. Bridges everything to Ethereum → Tornado Cash
The Deeper Problem
This isn't just a SagaEVM bug. Cosmos Labs confirmed the vulnerability existed in Ethermint's original codebase, meaning every EVM chain built on Ethermint was potentially affected. The vulnerability is a consequence of how the EVM's call stack interacts with Cosmos's CacheMultiStore:
// Simplified: The problematic state isolation pattern
func (p *ICS20Precompile) Transfer(evm *vm.EVM, ...) ([]byte, error) {
// Creates a new cached context for this precompile call
cacheCtx, writeFn := ctx.CacheContext()
// Execute IBC transfer logic in cached context
err := p.transferKeeper.OnRecvPacket(cacheCtx, packet)
// If the EVM call that invoked us gets reverted,
// writeFn is never called... but nested precompile calls
// within this context may have already committed their state
// to the Cosmos KVStore independently
writeFn() // Commit cached state
return result, nil
}
The key insight: Cosmos's CacheMultiStore and the EVM's StateDB have independent commit boundaries. A revert in one doesn't automatically revert the other, and nested execution contexts amplify this mismatch.
Class 3: The Gas-Limited Partial Write — Claiming Rewards Forever
Affected: All evmOS/Cosmos EVM chains using stateful precompiles
Severity: High — funds theft + chain halt
Advisory: GHSA-mjfq-3qr2-6g84
The Bug
EVM precompile calls consume gas just like regular contract calls. But what happens when a precompile runs out of gas halfway through its state modifications?
In standard Solidity, running out of gas reverts all state changes in that execution context. But Cosmos EVM precompiles write to the Cosmos KVStore during execution, not at the end. If gas runs out after the Cosmos state write but before the precompile returns, you get a partial state write.
The Exploit: Infinite Reward Claims
Consider the distribution precompile's ClaimRewards function:
// VULNERABLE: Non-atomic precompile execution
func (p *DistributionPrecompile) ClaimRewards(ctx sdk.Context, ...) ([]byte, error) {
// Step 1: Transfer rewards from module to user (Cosmos Bank module)
err := p.bankKeeper.SendCoins(ctx, moduleAddr, userAddr, rewards)
// ← At this point, rewards have been transferred in Cosmos state
// Step 2: Reset claimable rewards to zero
p.distributionKeeper.SetDelegatorRewards(ctx, userAddr, sdk.Coins{})
// ← If gas runs out HERE, rewards are transferred but NOT reset
// Step 3: Update EVM state, emit events, etc.
// ... more gas-consuming operations ...
return bz, nil
}
The attack:
1. Accumulate staking rewards normally
2. Call ClaimRewards with PRECISELY enough gas to complete Step 1
but NOT enough for Step 2
3. Rewards are transferred (Cosmos state committed)
4. Claimable balance is NOT reset (ran out of gas before Step 2)
5. EVM call reverts due to OOG, but Cosmos state persists
6. Repeat: claim the same rewards over and over
Even worse, this gas-manipulation technique can cause non-deterministic execution across validators (different gas metering implementations may fail at different points), leading to consensus failures and chain halts.
The Fix: Atomic Precompile Execution
The patch for all three vulnerability classes converges on a single principle: precompile state changes must be atomic across both state machines.
Here's the concrete implementation from the Cosmos EVM security advisory:
1. Snapshot the Cosmos Multi-Store Before Execution
type snapshot struct {
MultiStore storetypes.CacheMultiStore
Events sdk.Events
}
func (p Precompile) RunSetup(...) (..., snapshot, ...) {
cms := ctx.MultiStore().(storetypes.CacheMultiStore)
snap := snapshot{
MultiStore: cms.CacheMultiStore(), // Deep copy
Events: ctx.EventManager().Events(),
}
return ctx, stateDB, snap, method, initialGas, args, nil
}
2. Wrap Every Precompile in RunAtomic
func (p Precompile) RunAtomic(
s snapshot,
stateDB *statedb.StateDB,
fn func() ([]byte, error),
) ([]byte, error) {
bz, err := fn()
if err != nil {
// ROLLBACK: Restore Cosmos state to pre-execution snapshot
stateDB.RevertMultiStore(s.MultiStore, s.Events)
}
return bz, err
}
3. Handle Gas Errors Explicitly
func HandleGasError(ctx sdk.Context, contract *vm.Contract,
initialGas storetypes.Gas, err *error,
stateDB *statedb.StateDB, snapshot snapshot) func() {
return func() {
if r := recover(); r != nil {
switch r.(type) {
case storetypes.ErrorOutOfGas:
// On OOG: revert BOTH EVM and Cosmos state
stateDB.RevertMultiStore(snapshot.MultiStore, snapshot.Events)
*err = vm.ErrOutOfGas
}
}
}
}
The Key Insight
The RevertMultiStore function replaces the current Cosmos CacheMultiStore with the snapshot taken before execution began:
func (s *StateDB) RevertMultiStore(cms storetypes.CacheMultiStore, events sdk.Events) {
s.cacheCtx = s.cacheCtx.WithMultiStore(cms)
s.writeCache = func() {
s.ctx.EventManager().EmitEvents(events)
cms.Write()
}
}
This ensures that if anything goes wrong — revert, out-of-gas, or nested execution anomaly — the Cosmos state rolls back to exactly where it was before the precompile began.
Your Cosmos EVM Security Checklist
If you're building on or auditing a Cosmos EVM chain, check for these:
1. No Mid-Execution Commits
grep -r "StateDB.Commit()" x/evm/precompiles/
# Should return ZERO results during precompile execution
2. Atomic Precompile Wrapper Present
Every stateful precompile's Run function should use RunAtomic:
grep -r "RunAtomic" precompiles/
# Every precompile directory should have matches
3. Gas Error Handling Reverts Cosmos State
grep -r "RevertMultiStore" x/evm/
# Should appear in both HandleGasError and RunAtomic
4. No Nested Precompile Calls Without Isolation
Audit any precompile that calls another precompile or re-enters the EVM. Each nested call must have its own snapshot/rollback boundary.
5. Forked Code Audit Trail
If your chain forked Ethermint/evmOS:
- Document the exact commit hash you forked from
- Track all upstream security advisories (ASA-2026-xxx series)
- Diff your precompile modifications against upstream patches
- Run the upstream test suite against your fork
The Bigger Lesson: Dual-State Machines Are Hard
These vulnerabilities share a common ancestor: the assumption that two independent state machines can be coordinated within a single transaction boundary using ad-hoc synchronization.
The EVM was designed with one state machine, one journal, one commit. Cosmos SDK was designed with one KVStore, one cache context, one commit. Bolting them together creates a system where:
- Reverts in one don't propagate to the other
- Gas exhaustion creates asymmetric partial states
- Nested execution contexts multiply the possible desync points
The atomic precompile wrapper is a necessary patch, but it's treating symptoms. The fundamental architectural tension remains, and future vulnerability classes in this seam are virtually guaranteed.
If you're building a Cosmos EVM chain: treat every precompile as a bridge between hostile state machines, not a convenience function. Audit accordingly.
This analysis draws on security advisories from Cosmos Labs, Asymmetric Research, Halborn, and the Evmos/evmOS security teams. All vulnerabilities discussed have been patched in upstream repositories.
References:
Top comments (0)