evm: the 0x9999 seam halted at Accept and could never be synced past

Two defects, both fatal, both on the C<->D atomic settlement path.

1. Accept handed the versiondb batch to sm.Apply unconditionally. The
   C-Chain EVM is an out-of-process plugin, so its SharedMemory is the ZAP
   client, and that client refuses any batch — a database.Batch cannot
   cross a process boundary. Every block staging an atomic op would have
   failed Accept, which is fatal. Commit the versiondb first, then Apply
   with no batch. Without a shared batch exactly-once is unreachable; the
   choice is at-most-once or at-least-once, and replay is unsound here (a
   duplicate put is fatal, and after the peer consumes the object it
   re-creates it). A skipped op is survivable: C's settlement replay guard
   is EVM state, not the Remove, and a skipped intent Put is refunded by
   reclaimIntent. The residual single-node cost is stated in the code.

2. The settle path read the object from shared memory DURING EVM
   execution while the consuming Remove landed at Accept. A node
   re-executing that block — bootstrapping, state-syncing, re-tracing —
   found the object gone, computed a reverted receipt where the network
   computed a successful one, and died on the receipt root. The seam
   settled live and wedged every node that tried to sync past it.

   Execution now binds to the object bytes the transaction carries and
   declares the consumption in its logs; this adds the block-level rule
   that proves the declaration against shared memory at Verify, on both
   the consensus path and the producer's own pre-proposal verify, so a
   forged object costs a block rather than forking a receipt root. The
   check is gated on the bootstrap frontier, which the shared-memory layer
   requires of any chain consuming cross-chain state.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-07 06:37:17 -07:00
co-authored by Hanzo Dev
parent 6ac40f43f7
commit da70f3d430
4 changed files with 211 additions and 20 deletions
+47
View File
@@ -309,6 +309,11 @@ type BlockChain struct {
consensusCtx context.Context // Consensus context carrying chain identity (transport for the geth precompile boundary + warp); bound from the chain Runtime before reprocess, upgraded with SharedMemory by SetConsensusContext
// atomicImportVerifier authenticates a block's DECLARED cross-chain object
// consumptions against shared memory, at verify time. Installed by the VM (which
// owns the bootstrap gate); nil disables the check. See SetAtomicImportVerifier.
atomicImportVerifier AtomicImportVerifier
db ethdb.Database // Low level persistent database to store final content in
snaps *snapshot.Tree // Snapshot tree for fast trie leaf access
triedb *triedb.Database // The database handler for maintaining trie nodes.
@@ -817,6 +822,25 @@ func (bc *BlockChain) ConsensusContext() context.Context {
return bc.consensusCtx
}
// AtomicImportVerifier authenticates the cross-chain object consumptions a block
// DECLARES in its logs against the peer chain's atomic shared memory. Returning an
// error makes the block INVALID (verification fails and the block is rejected) —
// which is the whole point: execution can no longer read shared memory without
// making the block unreplayable, so the read moved here, where a mismatch costs the
// producer a block instead of forking a receipt root.
//
// It is installed by the VM because the VM owns the engine-state gate: a
// bootstrapping node must skip the check (it may not have applied the exporting
// chain's block yet), exactly as the shared-memory layer requires of any chain that
// consumes cross-chain state.
type AtomicImportVerifier func(block *types.Block, logs []*types.Log) error
// SetAtomicImportVerifier installs the cross-chain import authentication hook. It is
// called once at VM initialization, before any block is verified.
func (bc *BlockChain) SetAtomicImportVerifier(v AtomicImportVerifier) {
bc.atomicImportVerifier = v
}
// loadLastState loads the last known chain state from the database. This method
// assumes that the chain manager mutex is held.
func (bc *BlockChain) loadLastState(lastAcceptedHash common.Hash) error {
@@ -1837,6 +1861,29 @@ func (bc *BlockChain) insertBlock(block *types.Block, writes bool) error {
}
vtime := time.Since(vstart)
// CROSS-CHAIN IMPORT AUTHENTICATION. A block may DECLARE, in its logs, that it
// consumed atomic shared-memory objects exported by a peer chain (the 0x9999 C<->D
// settlement seam). Execution binds value to the object bytes the TRANSACTION
// carried — it never reads shared memory, which is what makes the block replay
// byte-identically on a bootstrapping node. Proving those bytes are the real
// recorded object is therefore a BLOCK rule, and this is where it is enforced: a
// declaration that does not match shared memory REJECTS THE BLOCK rather than
// diverging a receipt.
//
// It runs on BOTH verify paths (writes=true from consensus, writes=false from the
// producer verifying its own block), so a forged import is refused before it is
// ever proposed. The hook is installed by the VM, which owns the engine-state gate
// — during bootstrap the read MUST be skipped, because a syncing node legitimately
// has not yet applied the peer chain's export (the same constraint the shared-memory
// layer documents on RemoveValue). Nil hook (no VM installed / no shared memory)
// disables the check.
if bc.atomicImportVerifier != nil {
if err := bc.atomicImportVerifier(block, logs); err != nil {
bc.reportBlock(block, receipts, err)
return err
}
}
// Metrics for per-operation StateDB timing (AccountReads, StorageReads, etc.)
// are not exposed by the current luxfi/geth StateDB and are omitted here.
// storageHashTimer.Inc(statedb.StorageHashes.Milliseconds()) // Storage hashes are complete(in validation)
+54 -20
View File
@@ -97,30 +97,64 @@ func (b *Block) Accept(context.Context) error {
return fmt.Errorf("failed to put %s as the last accepted block: %w", blkID, err)
}
// SINGLE cross-domain commit point. When there are staged atomic ops, snapshot the
// versiondb (which now holds the advanced dexAtomicSeqKey marker AND lastAcceptedKey)
// into a batch and hand it to sm.Apply(reqs, batch): the atomic layer commits the
// shared-memory mutation and our batch in ONE database write (the platformvm acceptor
// pattern, mirrored from chains/dexvm commitAtomic). So the marker advance and the SM
// mutation are all-or-nothing — a crash between them is impossible, closing the
// re-apply -> duplicate-Put -> chain-halt window. With no atomic ops we take the plain
// versiondb.Commit() path unchanged.
// COMMIT THE STATE, THEN APPLY. The versiondb commit (carrying the advanced
// dexAtomicSeqKey marker AND lastAcceptedKey) lands FIRST; the shared-memory
// mutation follows as its own write.
//
// WHY NOT ONE ATOMIC WRITE. Handing our batch to sm.Apply is the in-process
// platformvm acceptor pattern, and it is simply unavailable here: the C-Chain EVM
// is an out-of-process plugin, its SharedMemory is the ZAP client, and a database
// batch cannot cross a process boundary — atomiczap.Apply refuses one outright
// (ErrBatchUnsupported). Passing it unconditionally, as this code did, turns EVERY
// block that stages an atomic op into a FATAL Accept: the seam would halt the chain
// on the first swap it ever settled.
//
// WHY THIS ORDER AND NOT THE REVERSE. Without a shared batch, exactly-once is
// unreachable; the choice is at-most-once (commit first: an op can be SKIPPED on a
// crash in the window between the two writes) or at-least-once (apply first: an op
// can be REPLAYED). Replay is not survivable here and is not made survivable by any
// property of the transport: the shared-memory layer's SetValue returns
// errDuplicatePut for a key already present, which is a FATAL Accept — the chain
// halt this seam has already caused — and, worse, a Put replayed after the peer has
// consumed the object RE-CREATES it, letting the peer be funded twice. A skip has no
// such edge:
//
// - a skipped C->D Put : the taker's principal stays locked on C and D never
// sees the intent; reclaimIntent refunds it once the
// deadline passes (every intent carries a finite one by
// construction — defaultedReclaimDeadline). Bounded
// liveness loss, no value created or destroyed.
// - a skipped D->C Remove: the object lingers in shared memory, but C's replay
// guard for a consumed object is EVM STATE
// (isSettlementConsumed), committed above — not the
// Remove. It can never be consumed twice. Inert garbage.
//
// So the failure mode of this ordering is recoverable and the failure mode of the
// other is a halt or a double-spend. The window itself is the microseconds between
// two writes on the accept path.
//
// STATED HONESTLY, THE RESIDUAL: a skipped Put leaves THIS node's shared memory
// short one object that its peers hold, so its D-Chain cannot verify a block that
// imports that intent and will stall on it until an operator re-applies. That is a
// single-node wedge, not a network fork and not a value leak. Closing the window
// entirely needs exactly-once across the process boundary, which needs a typed,
// exported duplicate-operation error on atomic.SharedMemory that survives the ZAP
// wire — then the flush could apply first and tolerate replays. Matching an
// unexported error by string across an RPC boundary ON THE MONEY PATH is not a
// trade worth making, so the simple ordering stands until that error exists.
//
// An Apply that ERRORS (as opposed to a crash) stays FATAL deliberately: a node
// that cannot mutate shared memory must stop rather than run on with a divergent
// view of it.
if err := vm.versiondb.Commit(); err != nil {
return fmt.Errorf("failed to commit accepted state for %s: %w", blkID, err)
}
if len(atomicReqs) > 0 && atomicSM != nil {
batch, berr := vm.versiondb.CommitBatch()
if berr != nil {
return fmt.Errorf("dex atomic commit batch for %s: %w", blkID, berr)
}
if aerr := atomicSM.Apply(atomicReqs, batch); aerr != nil {
if aerr := atomicSM.Apply(atomicReqs); aerr != nil {
return fmt.Errorf("dex atomic shared-memory apply for %s: %w", blkID, aerr)
}
// The batch (marker + lastAccepted + any other versiondb writes) was written by
// Apply; clear the in-memory layer so the deferred Abort and future Commits see a
// clean db (the versiondb.Commit() defer-Abort discipline).
vm.versiondb.Abort()
return nil
}
return b.vm.versiondb.Commit()
return nil
}
// handlePrecompileAccept calls Accept on any logs generated with an active precompile address that implements
+105
View File
@@ -0,0 +1,105 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package evm
import (
"fmt"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/precompile/dex"
)
// dex_atomic_verify.go is where THE SHIP RULE is proven for the 0x9999 C<->D seam:
// a C balance can be credited by a cross-chain settlement ONLY by consuming a real
// atomic object the peer chain exported.
//
// WHY IT LIVES HERE AND NOT IN EXECUTION. The rule used to be enforced inside EVM
// execution, by reading the object out of shared memory while the matching Remove
// landed at block accept. Those two facts cannot both hold: a node re-executing that
// block afterwards — bootstrapping, state-syncing, re-tracing an archive call —
// found the object already consumed, computed a reverted receipt where the network
// had computed a successful one, and died with `invalid receipt root hash`. The seam
// settled live and wedged every node that tried to sync past it.
//
// So execution now binds value to the object bytes the TRANSACTION carried (a pure
// function of the block, replayable forever) and DECLARES the consumption in a log.
// This hook proves each declaration against shared memory at VERIFY. A forged object
// makes the BLOCK invalid instead of diverging a receipt: the producer verifies its
// own block before proposing it (buildBlock -> blk.verify), so a forgery is refused
// before it is ever seen by anyone else, and every other validator refuses it too.
//
// This is the primary network's own import discipline — object in the transaction,
// shared memory consulted at Verify, consumption applied at Accept — expressed for a
// precompile seam instead of a first-class atomic transaction.
//
// THE BOOTSTRAP GATE IS REQUIRED, NOT AN OPTIMIZATION. A node replaying history has
// not necessarily applied the exporting chain's block yet: C and D bootstrap
// independently, so at C's block N the D->C object may legitimately be absent (not
// yet exported) or already gone (already consumed at C's own accept of N). The
// shared-memory layer states this constraint explicitly on RemoveValue — "chains
// interacting with shared memory must be able to generate their chain state without
// actually performing the read of shared memory ... It is up to the chain to ensure
// this based on the current engine state." Blocks below the bootstrap frontier were
// already validated by the network under ⅔-stake finality; that acceptance is the
// authority for them, exactly as it is for predicates (see Block.verify).
//
// KNOWN, INHERENT LIVENESS PROPERTY (shared with every shared-memory import model,
// including coreth's ImportTx): a validator that is caught up on C but LAGGING on D
// will not yet see D's exported object and will reject an otherwise-valid C block.
// It does not halt — the block is rejected, not fatal, and re-verification succeeds
// once D catches up. The operational rule that keeps this off the critical path is
// that the settlement transaction is only issuable AFTER the object is observable in
// shared memory, which means D's exporting block is already accepted network-wide.
// installDexAtomicImportVerifier wires the block-level authentication onto the chain.
// Called once at initializeChain, after the runtime (and therefore shared memory) is
// bound. It is unconditional: the hook itself is fail-open ONLY where no cross-chain
// capability exists at all, in which case no settlement can execute either.
func (vm *VM) installDexAtomicImportVerifier() {
vm.blockChain.SetAtomicImportVerifier(vm.verifyDexAtomicImports)
}
// verifyDexAtomicImports authenticates every cross-chain consumption the block
// declared. Each declaration names (sourceChainID, outputID) and commits to the
// object's bytes; we read the object shared memory actually holds and require the
// commitment to match.
func (vm *VM) verifyDexAtomicImports(blk *types.Block, logs []*types.Log) error {
imports := dex.DecodeSettleImports(logs)
if len(imports) == 0 {
return nil
}
// Engine-state gate. Below the bootstrap frontier the network's acceptance is the
// authority and the peer chain's export may not be locally applied yet.
if !vm.bootstrapped.Get() {
return nil
}
if vm.runtime == nil {
return nil
}
sm := vm.runtime.GetSharedMemory()
if sm == nil {
// No cross-chain capability wired: a settlement could not have executed
// (the precompile refuses without it), so there is nothing to authenticate.
return nil
}
for _, imp := range imports {
vals, err := sm.Get(imp.SourceChainID, [][]byte{imp.OutputID[:]})
if err != nil || len(vals) != 1 || len(vals[0]) == 0 {
return fmt.Errorf(
"dex atomic import unbacked in block %s (height %d): object %s from chain %s is not in shared memory: %w",
blk.Hash(), blk.NumberU64(), imp.OutputID, imp.SourceChainID, err,
)
}
if got := dex.SettleObjectHash(vals[0]); got != imp.ObjectHash {
return fmt.Errorf(
"dex atomic import forged in block %s (height %d): object %s from chain %s declared %s but shared memory holds %s",
blk.Hash(), blk.NumberU64(), imp.OutputID, imp.SourceChainID, imp.ObjectHash, got,
)
}
}
return nil
}
+5
View File
@@ -1155,6 +1155,11 @@ func (vm *VM) initializeChain(lastAcceptedHash common.Hash, ethConfig ethconfig.
consensusCtx = runtime.WithContext(vm.ctx, vm.runtime)
}
vm.blockChain.SetConsensusContext(consensusCtx)
// Prove the 0x9999 ship rule at VERIFY. Execution binds cross-chain settlements to
// the object bytes the transaction carried (so the block replays identically on a
// syncing node); this hook is what proves those bytes are the object shared memory
// actually holds, rejecting the block when they are not. See dex_atomic_verify.go.
vm.installDexAtomicImportVerifier()
vm.miner = vm.eth.Miner()
lastAccepted := vm.blockChain.LastAcceptedBlock()
debugLog("initializeChain: lastAccepted block number=%d", lastAccepted.NumberU64())