Skip to content

fix(evm): NEDB single source of truth — remove executor cache split-brain + oracle nonce clobber#84

Open
Eth-Interchained wants to merge 1 commit into
mainfrom
hyperagent/2026-07-06-nedb-single-source
Open

fix(evm): NEDB single source of truth — remove executor cache split-brain + oracle nonce clobber#84
Eth-Interchained wants to merge 1 commit into
mainfrom
hyperagent/2026-07-06-nedb-single-source

Conversation

@Eth-Interchained

Copy link
Copy Markdown
Owner

Summary

Removes the executor's long-lived CacheDB so NEDB is the single source of truth for all L2 EVM state, and fixes the deposit oracle's nonce clobber. Together these fix both bugs Mark hit live on 2026-07-06: burns of freshly-minted aITC reverting ("nothing was burned"), and aITC sends that toasted success with a real txid but transferred nothing.

Why this matters

The system had two memories of the truth. The oracle minted deposits straight into NEDB; the executor validated transactions against a boot-time in-memory cache that never heard about those writes. RPC reads (eth_getBalance, eth_getTransactionCount) already bypassed the cache — so the wallet showed the true balance while execution used a stale one. Depending on the direction of divergence you got an honest failure on good funds (burn revert) or a dishonest success on nothing (phantom send). On top of that, every mint hardcoded nonce: 0 into the account document, erasing real transaction history — wallets then signed with a nonce execution rejected.

Engine impact (itc-evm)

  • NedbState implements revm's Database directly (delegates to its DatabaseRef impl) — execution reads live NEDB, no cache layer.
  • commit_changes() emits the whole post-tx diff as one atomic put_batch — a transfer's debit and credit land together or not at all — and returns Result, so a failed durable commit is a failed execution. No phantom successes by construction.
  • ItcEvm.cache is gone; the struct holds state: NedbState. execute_tx = transact + atomic commit. simulate_tx (eth_call / estimateGas) reads the same live state as real execution.
  • Intra-tx consistency is revm's own JournaledState — the DB only needs to be right at tx boundaries, which is exactly when NEDB is consulted. Cost: a handful of pread-cached point reads per tx at a 5s block cadence.

Oracle impact (itc-oracle)

  • mint_net now reads the full existing account and preserves nonce + code_hash — a mint changes the balance and only the balance. (Pre-fix, with the cache removed, the clobber would have graduated from "txs fail" to "nonce-0 replay accepted" — that's why both fixes ship in one PR.)

Wallet/bridge impact

No Elara changes required. Mint → immediate burn now validates against the true balance; sends after a mint sign with a nonce execution accepts. Exit detection, receipts, fees, and the exactly-once payout guard are untouched.

Tests run

Written blind — no Rust toolchain in the sandbox, so these are written but not executed here. Please run:

cargo test -p itc-evm -p itc-oracle && cargo test

New/updated tests:

  • external_nedb_write_visible_to_next_execution — THE regression: an oracle-style direct NEDB credit must be spendable by the very next execution. Fails on the old cached design.
  • nonce_reads_live_state_across_executions — sequential nonces validate against durable state; stale-nonce replay rejected.
  • mint_preserves_existing_nonce_and_adds_balance — mint leaves nonce 7 alone, adds exactly net_sats, keeps code_hash.
  • transfer_persists_to_nedb_with_provenance — extended to assert debit + credit land together (atomic batch).

Verification sequence after deploy (falsifiable)

  1. Mint (bridge in) → immediately burn a small amount → burn must execute and release on L1.
  2. Plain aITC send between two wallets → recipient balance changes, sender deducted, receipt success: true.
  3. Restart the node → balances and nonces identical (no in-memory state existed to lose).

Follow-up work (not this PR)

  • Elara: Send.svelte fires its success toast on broadcast, before any receipt — should adopt Bridge.svelte's two-phase honest pending. The node now refuses to lie; the UI should stop lying optimistically too.
  • Sequencer: consider surfacing Err from execute_tx in the receipt as a distinct dropped status (currently success: false receipts cover both reverts and pre-validation drops).

🤖 Generated with Claude Code

…che split-brain

Two field bugs, one root cause, found live on mainnet 2026-07-06:
burns of freshly-minted aITC reverted ("nothing was burned"), and
plain aITC sends toasted success but transferred nothing.

Root cause 1 — the long-lived CacheDB. ItcEvm held a CacheDB<NedbState>
created once at boot and never invalidated. The bridge oracle credits
deposits by writing straight into NEDB (evm_accounts), so any account
already resident in the cache kept its stale pre-mint balance for
EXECUTION while RPC reads (which already bypassed the cache via
NedbState::basic) served the true balance. Wallet says 184 aITC,
execution says less: burn reverts, send dies. Two views of one account,
silently diverged.

Root cause 2 — the mint clobbered the nonce. oracle.rs::mint_net wrote
the account document with a hardcoded nonce: 0, erasing the account's
real transaction count on every deposit. Wallets then signed with the
clobbered nonce, execution rejected the mismatch, and every send/burn
after a mint silently died. With the cache removed this would have
graduated from "txs fail" to "nonce-0 replay accepted" — fixed in the
same change, mint now preserves nonce and code_hash and touches ONLY
the balance.

Changes:
- itc-evm/state.rs: NedbState now implements revm's Database (mutable)
  by delegating to its DatabaseRef impl — revm executes directly
  against live NEDB state. commit_changes() rewritten to emit the whole
  post-tx state diff as ONE atomic put_batch (a transfer's debit and
  credit land together or not at all) and now returns Result so a
  failed durable commit is a failed execution, never a phantom success.
- itc-evm/executor.rs: ItcEvm.cache (CacheDB) removed; the executor
  holds NedbState directly. execute_tx = transact + atomic NEDB commit.
  simulate_tx reads the same live state as real execution.
  seed_genesis_account writes NEDB only.
- itc-rpc/handler.rs: evm_balance/evm_nonce/evm_code read evm.state —
  RPC and execution now share one state view by construction.
- itc-oracle/oracle.rs: mint_net reads the full existing account and
  preserves nonce + code_hash; only the balance changes on a mint.

Intra-tx consistency needs no cache: revm's JournaledState loads each
touched account once per transaction and journals all intermediate
reads/writes internally. The database only has to be right at
transaction boundaries — which is exactly when NEDB is now consulted.
Per-block cost is a handful of NEDB point reads (pread-cached since
v2.6.1) every 5s: noise.

Tests:
- external_nedb_write_visible_to_next_execution — THE regression: an
  oracle-style direct NEDB credit must be spendable by the very next
  execution (fails on the old cached design).
- nonce_reads_live_state_across_executions — sequential nonces validate
  against durable state; stale-nonce replay rejected.
- mint_preserves_existing_nonce_and_adds_balance — mint leaves nonce 7
  alone, adds exactly net_sats, keeps code_hash.
- transfer_persists_to_nedb_with_provenance extended to assert the
  debit and credit land together (atomic batch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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