Skip to content

fix(evm): stop mirroring wasm bank events into EVM StateDB (wasm precompile accounting defect) - #220

Closed
mateeullahmalik wants to merge 1 commit into
masterfrom
zee/wasm-evm-accounting-fix
Closed

mateeullahmalik wants to merge 1 commit into
masterfrom
zee/wasm-evm-accounting-fix

Conversation

@mateeullahmalik

Copy link
Copy Markdown
Contributor

Fix: EVM→Wasm precompile accounting defect (unbounded mint)

Behavior change

The wasm precompile (0x0000000000000000000000000000000000000903) no longer installs the upstream BalanceHandlerFactory. Bank balance events emitted by a CosmWasm contract are native x/bank movements of the contract's own funds and are never mirrored into the EVM StateDB. No other precompile is touched; the ABI, execute/query semantics, sender identity, reentrancy guard, and gas metering are unchanged.

Root cause

precompiles/wasm/wasm.go wired cmn.NewBalanceHandlerFactory(bankKeeper), so every wasm execute replayed the native bank events into the EVM StateDB (cosmos/evm v0.6.0 precompiles/common/balance_handler.go):

  1. Address truncation + uint256 wrap + mint. The handler maps event addresses into EVM accounts via common.BytesToAddress, silently truncating the 32-byte wasm contract address. stateDB.SubBalance on the zero-balance truncated alias wraps (x/vm/statedb/state_object.go:121 has no insufficient-balance guard). At commit, keeper.SetBalance reconciles the wrapped target and calls MintAmountToAccount — minting 2^256 - amount extended units into the alias account.
  2. Double-apply on the recipient. The matching coin_received replay credits the recipient in the StateDB journal on top of the native bank credit. SetBalance reconciles the journal target against a bank view that does not include the in-flight native transfer and mints the amount a second time.

Observed end state on a real signed local chain (three validators, unmodified binary): fixture sends 111 ulume from the wasm contract → contract −111, recipient +222, truncated alias +115792089237316195423570985008687907853269984665640564039457583896 ulume (integer part of (2^256−111e12)/1e12, fractional 913129639936), with matching coinbase/mint events in the committed tx results and total-supply inflation.

Filtering only non-20-byte addresses was evaluated and is insufficient: case 2 persists for 20-byte recipients (proven RED by the regression test). The correct minimal fix is to not mirror at all: 20-byte EVM account events can never be produced by a wasm execute through this precompile (non-payable; submessages dispatch from the 32-byte contract address), so no legitimate behavior is lost.

Regression tests (RED→GREEN)

precompiles/wasm/balance_test.go — in-process, no devnet:

  • TestWasmBalanceReplayDoesNotMintOrDoubleApply — builds the replay through the production wiring helper; asserts no handler is installed, the truncated alias stays zero, and the recipient is not double-credited. A defect-demonstration subtest pins the vulnerable pre-fix wiring (alias wraps to ~2^256, recipient double-credited) so the assertions cannot pass vacuously. RED on unfixed code (verified by reverting the wiring: does_not_mutate_the_truncated_alias... FAILS with the giant wrapped balance).
  • TestWasmBalanceReplayKeepsEVMAccountBehavior — guards the legitimate upstream handler path still used by the other (unchanged) precompiles.

Verified:

  • go test ./precompiles/wasm/ ./app/evm/ — PASS
  • go test -tags='integration test' ./tests/integration/evm/precompiles/ -run 'TestPrecompilesSuite/WasmPrecompile' — 13/13 PASS (live node, 76s)
  • golangci-lint run precompiles/wasm/... — 0 issues
  • go build ./... — PASS; gofmt clean
  • go test ./app/...app/evm, app/openrpc, app/upgrades/* PASS; app package has a pre-existing TestEVMMempoolDisabledWhenMaxTxsIsNegative panic (chainConfig double-set) that reproduces identically on the unmodified base commit (verified via git stash).

Risks

  • Consensus-affecting. Nodes running this code produce different balances for wasm-payout executions than nodes without it. Requires a coordinated upgrade (upgrade handler/halt-height), NOT a rolling binary swap.
  • Previously minted alias balances / supply inflation on any affected chain are not healed by this change; remediation is a separate governance decision.
  • Event logs are unchanged (bank events still emitted); only the StateDB mirroring is removed.

Rollback

Revert this single commit; restore the factory wiring. No state migration is required for the revert itself (the mirroring is a per-tx computation), but any chain that executed the fixed code and then reverts would re-introduce the defect — so rollback should only ever mean "upgrade handler not yet activated."

Migration

No store/key migration. The change is purely behavioral (per-transaction computation). Upgrade handler / coordinated halt is required (out of scope for this PR; flagged for release planning).

Affected versions / commits

The vulnerable wiring was introduced with the Cosmos EVM integration (#108, commit f3083eb) and is present unchanged in v1.20.0, v1.20.1, v1.20.1-hotfix, v1.20.2, v1.20.3 and current master (3debe2dd; zero diff in precompiles/, app/evm.go, app/evm/precompiles.go, app/wasm_evm_plugin.go between v1.20.3 and this base). Mainnet (lumera-mainnet-1) currently runs v1.12.0 (verified read-only via /abci_info and /app/version at height ~6.9M), which predates PR #108 and does not contain the EVM stack — the vulnerable path is NOT present on current mainnet. It is present on every v1.20.x release candidate/release line and any testnet/devnet running them.

Observability

No new metrics. The unit regression tests are the primary guard; the defect-demonstration subtest fails if the vulnerable wiring is ever restored.

Evidence archive (local chain reproduction)

work/circuit-closure-20260911/final-pass/runtime/evidence/ (committed tx results, events, balances, supply) and work/circuit-closure-20260911/goal1-evm-wasm-accounting/DOUBLE-APPLY-ANALYSIS.md (full mechanism trace).

…ompile)

The wasm precompile installed the upstream BalanceHandler, which replays
native x/bank balance events into the EVM StateDB. For events emitted by a
CosmWasm contract this is doubly broken:

1. common.BytesToAddress silently truncates the 32-byte wasm contract
   address. SubBalance on the zero-balance truncated alias wraps uint256
   (x/vm/statedb has no insufficient-balance guard), and the commit-time
   SetBalance reconciliation mints ~2^256 extended-denom units into the
   alias account.
2. The matching coin_received replay credits the recipient in the StateDB
   journal on top of the native bank credit; reconciliation mints the
   amount a second time (observed on local chain: fixture sends 111 ulume,
   recipient gets 222, plus a giant alias mint and supply inflation).

Filtering only non-20-byte addresses is insufficient (case 2 persists), so
the precompile now installs no balance handler: wasm bank events are native
movements owned by x/bank and are never mirrored. Regression tests cover
the production wiring helper, the alias wrap, the recipient double-apply,
the defect demonstration of the pre-fix wiring, and the legitimate EVM
handler path used by the other (unchanged) precompiles.

Consensus-affecting fix: chains that already executed the vulnerable path
need a coordinated upgrade; previously minted balances are not healed by
this change.
@mateeullahmalik
mateeullahmalik force-pushed the zee/wasm-evm-accounting-fix branch from e28407d to 5d40441 Compare September 13, 2026 23:54
@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

Closing to handle through private security process.

@mateeullahmalik
mateeullahmalik deleted the zee/wasm-evm-accounting-fix branch September 14, 2026 16:07
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