admin_importChain rewrites the chain from a file on disk. It defaulted to
enabled, with a comment saying it was on so that importChain would work, so
every node carried the ability to have its history replaced by anything that
could reach its RPC port -- to spare an operator one line of config during a
restore that happens rarely and is supervised when it does.
A node that is restoring sets admin-api-enabled in its chain config for as long
as the restore takes. Nothing else needs it, and nothing else should offer it.
The tests set the field explicitly and are unaffected.
Co-authored-by: Hanzo Dev <[email protected]>
State sync is skipped when the chain is not far enough ahead of local state,
because bootstrapping would be faster. That trade only exists while
bootstrapping can run at all. It replays blocks against the state beneath them,
so a node missing the state at its own head cannot replay a single block — it
fails to materialize the next one and stops there, whatever the gap is.
On such a node the threshold declines the one mechanism that could repair it,
and small-gap corruption becomes permanent: it cannot execute forward, and it is
not far enough behind to be allowed to sync. The threshold now applies only when
the node holds the state its head is built on. A chain that cannot be read
answers no — the cost of being wrong that way is a sync that was not strictly
needed, against a node that could otherwise never come back.
Co-authored-by: Hanzo Dev <[email protected]>
It was the zero value, so a chain got it only by carrying a per-chain config
file that said so — and most deployments have no chain-config path at all, which
made the one mechanism for rejoining after a deep gap unreachable on whole
fleets. There is no posture where refusing it and re-executing instead is the
right answer, so it is not a decision to hand an operator. The bound is
StateSyncMinBlocks, which lives inside the mechanism: a smaller gap replays and
never reaches this path.
Co-authored-by: Hanzo Dev <[email protected]>
The C-Chain runs as a plugin, so the ZAP server that answers the node lives in
this binary, not in luxd. A node bump alone leaves the old server in place.
v1.3.8 refuses a VM that returns neither a block nor a reason instead of
dereferencing it, and stops reporting an unnamed failure with the code that
means success.
Co-authored-by: Hanzo Dev <[email protected]>
A node was discarding 948 builds every ten minutes on a HEALTHY chain, and had
spent 41 hours doing it on a halted one. The cause was a barrier this fork
dropped.
Upstream holds the builder until the transaction pool has finished resetting onto
the accepted head: `for !needToBuild() || pendingPoolUpdate()`, where
pendingPoolUpdate is chainHeadHash != mempoolHeadHash. We kept the loop and
deleted the second half, along with both hashes and the two setters that maintain
them.
Without it the builder wakes in the window between a block being accepted and the
pool reorganising onto it. The pool still lists what that block just mined, so
needToBuild says there is work; the miner assembles against the new head, finds
nothing left, and returns an empty block; the anti-empty policy discards it.
Nothing changed, so the builder wakes again — immediately, and forever, until the
pool happens to reset.
Restored rather than reinvented: the same two hashes, the same comparison, the
same loop condition, fed by the pool's own reorg event on one side and Accept on
the other. Both are initialised to the current head so a fresh or restarting node
starts converged instead of gated shut.
This also removes proposeEmptyBuild, added earlier today. It built whenever the
pool was non-empty, which is exactly the spin state — it would have turned 948
discarded builds into 948 empty blocks on chain. The barrier removes the
condition it was guarding against, so the guard goes.
Co-authored-by: Hanzo Dev <[email protected]>
An assembled block with no transactions is two conditions wearing one shape. No
demand is the demand-driven property working — discard it, or an idle chain grows
forever. Demand the assembler failed to collect is a bug, and discarding THAT
makes it permanent: no block means no nonce advances, so the pending transaction
is never mined, so the pool never drains, so the condition under which a block
would be built can never return.
The gate read the assembled block, which cannot tell the two apart, so it took
the second for the first. Measured on lux-devnet: the chain stopped at 20:38 UTC
on 2026-08-11 holding ONE executable transaction, sat 41 hours, and behind it the
traffic generator logged 7,100 consecutive failures while luxd-0 and luxd-3
logged errEmptyBlock ~2000 times per six hours. Every node was healthy. Nothing
was wrong with consensus — CERTs gossiped the whole time.
It reads the pool now. Nothing pending is idleness and the block is discarded;
anything pending builds, whatever assembly managed to collect, and logs the
mismatch where somebody can see it. Liveness stops depending on the assembly path
being correct.
The rule moves into proposeEmptyBuild so it can be tested without standing up a
VM, which is why it had no test before.
This does not fix the proximate cause, and is not meant to: the transaction was
in exactly one node's pool (luxd-2) and never reached either proposer, so EVM
transaction gossip is the next thing to look at. What changes here is that such a
failure degrades throughput instead of halting the chain.
Co-authored-by: Hanzo Dev <[email protected]>
The comment claimed Block.Accept passes a batch to sm.Apply(reqs, batch) so the
marker and the shared-memory mutation land in one atomic write. It does not, and
deliberately: a batch cannot cross the plugin's process boundary, atomiczap
refuses one, and passing it made every settling block a fatal Accept.
Accept commits the state first and applies second, which orders a crash toward a
skipped op rather than a replayed one. A comment describing the opposite reads
as a bug on the money path and invites reordering the writes back.
Co-authored-by: Hanzo Dev <[email protected]>
The 0x9999 swap intent gains the operation segment (market, side, limit, size)
appended to the unchanged 69-byte value head. Without it the D-Chain could
consume an intent and credit the taker, but had nothing to place an order with,
so no swap ever crossed and no settlement object was ever produced for
ImportSettlement to consume.
No change to this repo's own code: the D->C settlement object it authenticates
at Block.Verify is still the canonical 69 bytes. Only the C->D leg widened, and
only the C->D leg instructs.
Co-authored-by: Hanzo Dev <[email protected]>
Picks up the settle-path change that makes a settled swap replayable: both
C-side consume paths bind to the object bytes the transaction carries and
declare the consumption, instead of reading shared memory mid-execution.
The block-level authentication of that declaration is in this repo
(plugin/evm/dex_atomic_verify.go).
Co-authored-by: Hanzo Dev <[email protected]>
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]>
Picks up the cross-chain atomic capability across the ZAP plugin boundary.
The EVM runs out of process, so its rebuilt Runtime had SharedMemory nil and
BCLookup absent; every DEX 0x9999 settlement reverted "requires the
cross-chain atomic capability" and the Accept-time flush of staged C->D
exports silently no-opped. vm v1.3.6 dials the node's per-chain atomic
server at Initialize and installs both.
precompile v0.19.5 additionally tags C->D swap intents with the discovery
trait the D-Chain enumerates by, without which a real intent is invisible to
D's import drive even once the transport works.
accel v1.2.4 -> v1.3.1 is forced by MVS through precompile, not chosen:
pinning it back drags precompile to v0.19.3.
Co-authored-by: Hanzo Dev <[email protected]>
Currency bump: golang builder images -> 1.26.5 (latest stable).
Adds ENV GOTOOLCHAIN=auto to each Go builder stage. The official golang
images set GOTOOLCHAIN=local, so a builder image older than the module's
go directive is a hard mid-build failure. The pin stays the hermetic fast
path; auto is the safety net for the next go.mod bump.
No go.mod go directive was changed. Builder images move; directives stay.
Co-authored-by: Hanzo Dev <[email protected]>
GOPRIVATE switches off the module proxy AND checksum verification together.
Covering github.com/luxfi/* meant every luxfi module was fetched straight from
git and trusted without verification — which is how moved tags got captured into
module caches and served from them indefinitely, making the same commit build on
one machine and fail on another.
Nearly all of luxfi is public and on proxy.golang.org, so it belongs under
normal verification. The private org is lux-private. Twelve luxfi repos are
still private AND still 404 on the proxy; where a repo depends on one it is
listed by name rather than by wildcard, so the exemption matches what actually
needs a direct fetch.
Co-authored-by: Hanzo Dev <[email protected]>
GOPRIVATE switches off the module proxy AND checksum verification together.
Covering github.com/luxfi/* meant every luxfi module was fetched straight from
git and trusted without verification — which is how moved tags got captured into
module caches and served from them indefinitely, making the same commit build on
one machine and fail on another.
Nearly all of luxfi is public and on proxy.golang.org, so it belongs under
normal verification. The private org is lux-private.
Twelve luxfi repos are still private AND still 404 on the proxy, so they are
listed individually rather than by wildcard — coverage now matches what actually
needs a direct fetch, and every other luxfi module is verified against
sum.golang.org again:
bridge broker compliance erc20-go fasthttp graph hsm indexer
mpc ordering password pool
The right end state is those repos moving to lux-private; until then the
explicit list keeps the exemption honest instead of blanket.
Co-authored-by: Hanzo Dev <[email protected]>
node v1.36.35 bumped luxfi/vm to v1.3.3 for a chain.State map race. The C-Chain
does not run in the luxd binary — it is a plugin built from THIS module, and
this module still pinned v1.3.1. So the fix shipped in the process that does not
verify blocks and was missing from the one that does. Read straight off the two
binaries in a v1.36.35 pod:
/luxd/build/luxd github.com/luxfi/[email protected]
/luxd/build/plugins/mgj786NP7… github.com/luxfi/[email protected]
It fired in production on lux-testnet luxd-0 within 5 minutes of the roll:
fatal error: concurrent map writes
github.com/luxfi/vm/components/chain.(*BlockWrapper).Verify
luxfi/[email protected]/components/chain/block.go:44
github.com/luxfi/vm/rpc.(*zapVMServer).handleBlockVerify
luxfi/[email protected]/rpc/vm_server_zap.go:580
v1.3.3 is exactly the fix for that line — it takes state.blocksLock around
verifiedBlocks[blkID] = bw, which v1.3.1 wrote unlocked from every concurrent
ZAP RPC handler.
Worth flagging separately: the plugin dies while luxd survives, so the pod stays
Ready with restarts=0 and info.isBootstrapped(C) keeps answering true while
eth_blockNumber on /v1/bc/C/rpc times out. The readiness probe cannot see this
failure class; only a per-node tip probe can. Recovery is a pod delete.
go build ./plugin/... ./core/... clean, go vet clean, plugin/evm tests pass.
Co-authored-by: Hanzo Dev <[email protected]>
The --import-chain-data startup import is re-entered on every boot, because
production pod specs keep the flag set. Once the chain is already at or above
the file's tip, importBlocksFromFile imports 0 blocks and returns
"no blocks imported (parsed=0)"; Initialize treated that as fatal, so the
C-Chain failed to initialise on every restart of an already-imported node —
/v1/bc/C/rpc 404, node contributes no consensus vote.
This was fixed once. Commit 15e75f43 ("startup RLP-import consensus-wedge +
idempotency") landed BOTH halves on hotfix/v1.104.9. Only the consensus-refresh
half was forward-ported to main (it is here as "FIX (v1.104.11)"); the
idempotency half was not, and isNothingToImportError does not exist on main.
Verified: git merge-base --is-ancestor 15e75f43 main => false; the commit is
reachable only from hotfix/v1.104.9 and release/v1.104.9-hotfix.4.
Observed live on lux-testnet 96368 today rolling node v1.36.35, which is built
from evm main: two nodes came back with a dead C-Chain on exactly this error.
The v1.36.24 plugin binary in the same cluster still contains the string
"Startup import: chain already at/above import tip; nothing to import"; the
v1.36.35 binary does not.
Restores isNothingToImportError and the guard, unchanged in meaning from
15e75f43: idempotent ONLY when the chain already holds accepted content
(curHead > 0). On a fresh chain the same messages still mean a genuinely
empty or corrupt file and stay fatal.
startup_import_idempotency_test.go locks the contract, both RLP shapes plus the
fresh-chain safety case. Red without the fix (undefined: isNothingToImportError,
build failed); green with it:
--- PASS: TestStartupImportIdempotency_AlreadyAtTip (0.02s)
ok github.com/luxfi/evm/plugin/evm 1.260s
Co-authored-by: Hanzo Dev <[email protected]>
gpu v1.0.1/v1.1.0 declare `#cgo pkg-config: lux-gpu` while including
<lux/gpu.h>. Those are two different libraries with confusingly similar names:
luxgpu.pc unified GPU acceleration, -lluxgpu, Cflags -I${includedir}
lux-gpu.pc the DEX order matcher, -llux_gpu, Cflags -I${includedir}/lux
Under lux-gpu the include resolves to .../include/lux/lux/gpu.h, so every cgo
build failed with "fatal error: lux/gpu.h file not found" — the arm64 stall
signature documented in CLAUDE.md. libluxgpu.dylib is also what actually
exports _lux_gpu_ecrecover_batch.
gpu v1.1.2 already declares luxgpu; only the pin was stale. Builds clean with
CGO_ENABLED=1.
Co-authored-by: Hanzo Dev <[email protected]>
These hashes were recorded from direct VCS fetches made while GOPRIVATE covered
github.com/luxfi/*, which switches off the module proxy and checksum
verification together. Tags moved afterwards, so what was written down no longer
matches what sum.golang.org holds, and the build refuses to verify.
proxy.golang.org still serves the originally-published bytes and those verify
against the log, so dropping only the disagreeing lines and re-tidying restores
the build with no version change. Lines that agree were left alone — this is not
a regenerated go.sum.
The mismatch followed the machine, not the repository: the same commit built
wherever the cache was clean. Any machine or CI runner that fetched luxfi
modules under the old GOPRIVATE holds the same poisoned entries.
Co-authored-by: Hanzo Dev <[email protected]>
Six curl examples still told a reader to POST to localhost:9650/ext/...
Same defect as the code fixed in 98af1ed7: luxd pins baseURL="/v1" and has
never served /ext, so every one of these returns 404 page not found when
pasted into a shell.
Co-authored-by: Hanzo Dev <[email protected]>
luxd pins baseURL="/v1" (node/server/http/server.go:33) and has never
served /ext, so both exported constructors — plugin/evm/client.NewClient
and warp.NewClient — built a path no node answers. They are the seam
every out-of-tree caller goes through: cli/pkg/utils/staking.go,
sdk/validatormanager/uptime.go and netrunner/api all hand them a bare
node URI, and staking.go's own SplitRPCURI already parsed /v1/bc/<id>/rpc.
Those callers were already on /v1; this package was the lagging outlier,
so fixing it here fixes all of them without touching one.
Also corrects the simulator's default websocket endpoint, the hardhat
default, and the docs — including api.testnet.lux.network, which does not
serve RPC at all. The testnet host is api.lux-test.network (chainId
0x17870). Prefix-only sweeps miss that class of error.
Tests pin the constructed path against an httptest server, with both an
alias and a blockchain ID, so a future prefix change fails loudly instead
of silently 404ing in production.
Co-authored-by: Hanzo Dev <[email protected]>
TypeScript 7 is the native Go compiler and removes `baseUrl` and
`moduleResolution: node|node10`. Both appear here, so `tsc` from TS7
refuses the config outright (TS5102 / TS5108) and cannot typecheck.
`paths` targets resolve relative to `baseUrl` when it is set and relative
to the tsconfig file otherwise. Every `baseUrl` folded here already
pointed at the config's own directory, so dropping it moves nothing and
the targets are left byte-identical. Where a baseUrl pointed elsewhere,
each affected target was rewritten as join(baseUrl, target).
`moduleResolution` was chosen from the declared `module`: commonjs ->
node16, esnext/preserve -> bundler. Configs whose `module` is unset or
exotic were left alone rather than guessed at.
The result is accepted by BOTH toolchains, so nothing has to upgrade
TypeScript in lockstep. Verified on hanzo/chat packages/api: tsc 5.9
779 -> 778 errors (no regression), and tsc 7.0.2 now runs the project
in 2s where it previously refused the config.
Co-authored-by: Hanzo Dev <[email protected]>
A ballot is replayable by construction — the same bytes verify every time —
so hasVoted[proposalId][nodeID] is the only thing between a validator and
counting its weight twice. Keyed by nodeID, never by address.
Co-authored-by: Hanzo Dev <[email protected]>
P-chain stake is not natively readable from the C-chain EVM, and the obvious
fix — a precompile that reads the validator set in Run() — is consensus-fatal.
The BlockContext handed to Run() (precompile/contract/interfaces.go:63) carries
Number, Timestamp and GetPredicateResults, and nothing else; a validator-set
read from there would have to use the node's own current P-chain height
(node vms/platformvm/validators/manager.go:214 getCurrentHeight ->
state.GetLastAccepted), so two honest nodes executing the same block a second
apart would compute different weights and fork the chain.
The one place a P-chain height IS agreed is predicate verification:
Block.VerifyWithContext passes ProposerVMBlockCtx.PChainHeight to
VerifyPredicate (plugin/evm/block.go:283), the pass/fail bitset is written into
the header (miner/worker.go:514) and re-checked on replay
(plugin/evm/block.go:366). A ballot verified there is a consensus-committed
fact, and historical sets stay reconstructible from P-chain weight diffs.
So the ballot CLAIMS a weight and the predicate refuses any overstatement:
weight <= weightAt(ballot.pChainHeight) - no flash-stake
weight <= weightAt(block.pChainHeight) - no exited or shrunken validator
totalWeight == totalAt(snapshot) - the denominator is exact
Under-claiming is always allowed, so a voter never has to predict which block
its transaction lands in.
The unit of weight is the validator, and it already includes delegations: the
P-chain keeps one weight per nodeID and folds each delegator into it
(node vms/platformvm/state/state_validators.go:570,582 — AddStaker(own stake)
then AddWeight(each delegator) under the same key). One map entry per nodeID is
what makes double-counting a delegation impossible. Delegators do not get a
separate ballot; their override is exit, which needs no code.
Authorisation reuses the BLS key the P-chain already binds to the nodeID, so
there is no registry to capture, no admin to hold it and no key ceremony. The
signature covers (domain, chainID, nodeID, voter, authEpoch) only — a standing
authorisation, reusable for every later proposal, rotated by signing a strictly
greater epoch.
Uptime is deliberately not a gate: it is absent from GetValidatorOutput and is
a per-node subjective measurement (all five mainnet validators report
uptime 0.0000 through the public API), so no deterministic rule can read it.
Proven against a read-only fork of mainnet 96369 — the real five-validator set,
real registered BLS keys, real 5e17/2.5e18 weights — including that no ballot
is accepted for any of them without the operator's key.
Address 0x0200...0006 is the next free slot after warp; eth_getCode on 96369
returns 0x for it and 0x01 for warp, so predicates are already live there.
Co-authored-by: Hanzo Dev <[email protected]>
The fee split braided two independent concerns into one line: how much of a
fee is kept (a protocol constant, floor(fee/2)) and where the kept part goes
(compiled-in extras.FeeRewardVault = 0x0100..02). The second half made the
destination ungovernable and silently bypassed the RewardManager precompile
the moment FeeSplit activated, so turning the burn on would have DISABLED the
one on-chain knob the chain already has.
Split the concerns:
ratio -> stays a right shift. Fixed, auditable, not settable by any
transaction. Changing it remains a release plus a coordinated
fork, deliberately: monetary policy should not move at the
speed of a multisig.
destination -> the configured coinbase, i.e. BlockChain.GetCoinbaseAt, i.e.
the RewardManager precompile's stored address. Already part of
header validity (dummy.verifyCoinbase), so it is verifiable
from the header and movable by one admin transaction.
creditTxFee collapses to one credit site; extras.FeeRewardVault is deleted.
Also adds the ordering gate this created a need for: ChainConfig.Verify now
refuses a config that schedules feeSplitTimestamp while rewardManagerConfig is
not enabled at that time (and allowFeeRecipients is off). Without RewardManager
GetCoinbaseAt falls back to the keyless blackhole, so such a config would burn
half of every fee and strand the other half forever -- the C-Chain already has
~3867 LUX stuck at that address. mainnet/cchain.json currently ships exactly
that shape (feeSplitTimestamp 1785715200, no rewardManagerConfig in any of the
49 precompileUpgrades); the node will now refuse to start on it instead.
This is a no-op on every live chain: mainnet 96369's running config has no
feeSplitTimestamp at all (eth_getChainConfig), the vault balance is 0, and the
branch has never executed. The correction is free today and costs a fork later.
Tests:
core: destination follows the configured coinbase; a redirect mid-chain moves
the whole reward stream while the burn is unchanged; supply conservation
on executed blocks; genesis refuses the ungoverned shape.
dummy: a proposer naming any other coinbase is rejected with ErrInvalidCoinbase,
so crediting the header coinbase is not a validator-capture hole.
extras: the verbatim upgrade.json fragment activating rewardManagerConfig and
feeManagerConfig with the DAO Safe as adminAddresses parses through the
registry, and feeManager's governable surface contains no ratio.
Co-authored-by: Hanzo Dev <[email protected]>
eventstream -> v1.7.8, service/s3 -> v1.97.3 (minimum patched versions), plus
the luxfi escape versions that keep this module off the deleted geth v1.16.x
tags (crypto v1.20.2, constants v1.6.2, warp v1.24.1, ids v1.3.2, p2p v1.22.1).
Verified against a measured baseline: go test ./... is 58 ok / 7 fail BOTH with
and without this change — identical, so the failures are pre-existing.
Chasing these one at a time was whack-a-mole — every repair surfaced the next
moved tag (age, pq, threshold, zap, consensus, constants, sdk, keys…). This is
the systematic pass instead: every distinct luxfi/hanzoai module@version in the
workspace (692 of them) was resolved against proxy.golang.org + a direct fetch,
and any go.sum line disagreeing with what is actually served was corrected.
Not an attack, and checked rather than assumed: for each case the proxy and a
direct fetch AGREE with each other and differ from the recorded hash. Two
independent transports agreeing means nothing is rewriting bytes in flight —
the tags moved at source. sum.golang.org still holds the original, but
GOPRIVATE covers github.com/luxfi/* with GOSUMDB=off, so our own modules never
consult the checksum DB and a moved tag splits consumers silently instead of
failing at publish time.
Only the recorded hash changes. No selected version moves, and `go mod tidy`
was deliberately NOT run, so nothing is upgraded as a side effect.
Verified: `go list -m all` resolves with no checksum error.
The durable fix is upstream: treat a published version as immutable. Cut
x.y.z+1 instead of moving a tag — with GOSUMDB off, no consumer can detect it.
The cevmgpu overlay linked build-phase5b (Apr/May, cevm project 0.2.0) GPU
archives against Jul build-mpt (0.5.2) state libs — nm-clean but era-skewed
ABI layout. Repoint the four GPU .a from build-phase5b to build-mpt-gpu: a
Metal-ON build (LUX_CEVM_ENABLE_METAL=ON) co-built from the SAME current cevm
source tree AND the SAME conan deps as build-mpt (it references build-mpt's
own generators/conan_toolchain.cmake; the GPU backend luxgpu_core_static is
compiled in-tree at project version 0.5.2 — the version build-mpt reports).
GPU host archives and state libs are now the same era by construction, closing
the cross-build ABI-layout risk. Verified on darwin/arm64: `go build -tags
"cevm cevmgpu"` links all four archives from build-mpt-gpu (confirmed via the
full linker command line + a move-aside falsification that fails citing the
build-mpt-gpu paths, then rebuilds green deterministically); the default
`-tags cevm` build still links the build-mpt state libs unchanged.
Co-authored-by: Hanzo Dev <[email protected]>
FRONT 1 of the parallel GPU effort. New file bridge_cevm_gpu.go
(//go:build cgo && cevm && cevmgpu) is a pure LDFLAGS overlay, additive to
bridge_cevm.go: under -tags "cevm cevmgpu" both compile and cgo concatenates
their #cgo LDFLAGS, so the CPU state libs (build-mpt) link alongside the GPU
host archives (build-phase5b Metal-ON build of the same cevm source tree):
libevm-gpu.a + libevm-metal-hosts.a + libevm-kernel-metal.a + libevm-gpu-state.a,
plus -framework Metal -framework Foundation.
Symbol audit (nm): every evm:: core/state/precompile symbol the GPU archives
reference is satisfied by build-mpt's state libs; evm::gpu::* symbols resolve
among the four archives; only Metal/Foundation framework symbols come from
outside (no MetalPerformanceShaders, no luxgpu referenced). Device-free anchors
GPUABIVersion/GPUBackendName pull the GPU dispatch objects into the link.
Verified (darwin/arm64):
go build -tags "cevm cevmgpu" ./plugin/ -> links, 69,125,858 bytes,
Metal+Foundation+CoreFoundation linked, 88 evm::gpu symbols in binary
(EvmKernelHost::create, BlockStmGpuMetal::execute_block, execute_block).
go build -tags cevm ./plugin/ -> links, 68,978,754 bytes (CPU-only,
EvmKernelHost::create count 0 — default cevm stays CPU-only, unchanged).
go build ./plugin/ -> links, 68,701,170 bytes (stub path).
SCOPE: BUILD + LINK only. Metal shaders compile at runtime and
MTLCreateSystemDefaultDevice() returns nil headless, so GPU DISPATCH is
CI-gated on a Metal-capable interactive runner — not claimed here.
Co-authored-by: Hanzo Dev <[email protected]>
Picks up dex 34c2ad2 / v1.14.2, which gates the secp256k1 Metal anchor behind
dexgpu so a default darwin build links: previously the anchor referenced
secp256k1_ecrecover_address_batch_metal while the lib that defines it was only
linked under -tags dexgpu, breaking the link of the C-Chain plugin binary that
pulls dex/pkg/dex. `go build ./plugin/` now links the 68 MB plugin clean in the
default build.
Co-authored-by: Hanzo Dev <[email protected]>
The first cut registered the cevm checkpoint store over init.DB before
initializeDBs ran. That is wrong in standalone-database mode: initializeDBs points
the chain state at a separate standalone DB (vm.db), so checkpoints would land in
the base avaDB, not alongside the chain state. It also predated vm.db being set.
Fix: register AFTER initializeDBs, over vm.db — the RAW durable base DB
(NOT the versiondb), standalone-DB-aware (vm.db == the standalone chain-state DB
when UseStandaloneDatabase is set). This is exactly the pattern warpDB / validatorsDB
use (prefixdb.New(prefix, db) over the raw base) for state that need not commit
atomically with the last accepted block: cevm checkpoints now persist on WRITE and
survive a restart, with no versiondb pending-commit dependency — the durability gap
noted when the feature landed.
Still opt-in (cevm-resident-store, default false), inert without -tags cevm, and
safe by construction (a missing/stale checkpoint falls back to the Go-dump seed;
every root is verified). Default plugin build green; resident lazy-reload test green.
Co-authored-by: Hanzo Dev <[email protected]>
Wires StateLoadFromLazy into the resident applier so the C-Chain, on restart,
resumes cevm from a persisted checkpoint (bounded-RAM lazy load) instead of dumping
the full Go state into cevm — the "own disk-backed state" evolution the seed-cap
comment names. Removes the post-restart declined-too-large seed dump.
- core/parallel/cevm_resident_store.go: SetCevmResidentStore(db) (the VM registers a
durable KV; nil = off, unchanged behavior). tryLazyLoadCheckpoint pages the account
trie from disk via cevmbridge.StateLoadFromLazy and sets the resident to the
checkpoint height; persistCheckpoint writes StatePersist records + the height at a
bounded cadence (default 4096 blocks).
- cevm_shadow.go: getResident tries the checkpoint before a fresh handle;
persistCheckpoint runs after a block's cevm root is VERIFIED against the header.
- plugin/evm: CevmResidentStore config flag (default false) → registers a "cevmstate"-
prefixed sub-DB before chain init.
- cevmbridge/bridge_stub.go: StateLoadFromLazy no-op for the non-cevm build.
Safe by construction + triple-gated: nothing runs unless built -tags cevm, the VM
registered a store, AND a checkpoint exists; and the applier still verifies EVERY
cevm root against the header, so a stale/wrong checkpoint only forces a resync (a
Go-dump seed next block), never a wrong commit. nil store (the default) is the prior
in-memory, seed-from-Go behavior byte-for-byte.
Proof (cevm_resident_store_cevm_test.go, //go:build cevm): seed + apply a block,
persistCheckpoint, simulate restart (free handles, retain store), getResident
LAZY-LOADS the checkpoint (height + root recovered, no Go dump), then the next block
applies to a byte-identical FULL root vs a full-reseed reference. Full parallel cevm
suite green; default (non-cevm) plugin build unchanged.
Co-authored-by: Hanzo Dev <[email protected]>
Wires cevm's C-ABI lazy load into the Go bridge so a node applies blocks over
persisted state without materializing all of it. StateLoadFromLazy builds a
store-backed cevm StateDB (via C.cevm_state_load_lazy) whose account trie is a
from_store_root ref at the persisted root; reads and writes fault account leaves
and storage sub-tries in from zapdb on demand, so RAM is bounded to the block's
touched working set. StateApplyBlock on the handle produces the FULL state root
paged.
Lifetime: unlike a full reseed (which touches the store only during the load),
a lazy handle faults through the store on every later apply, so its cgo.Handle
AND its cevm_kv_store (C-heap allocated) are retained in lazyResources and freed
by StateFree after cevm_state_free has stopped the C++ side faulting through them.
Proof (store_lazy_cevm_test.go, //go:build cevm): seed + apply 2 blocks, persist
to a REAL on-disk zapdb, restart (free handle + close db), reopen, then load the
SAME state two ways — StateLoadFrom (full reseed) and StateLoadFromLazy — and apply
an identical next block to both. Both recover the committed root; the paged apply
root is byte-identical to the full-reseed apply. The full cevmbridge cevm suite
(incl. the existing restart-survival test) stays green.
Co-authored-by: Hanzo Dev <[email protected]>
Wire cevm's new persist/load C-ABI to a geth-style database.Database (in
production luxd's own zapdb), and prove restart-survival.
- store_cevm.go: StatePersist(handle, db) / StateLoadFrom(db) -> handle.
A cevm_kv_store whose get/put point at Go trampolines is built in C
(cevmbridge_make_store), so cevm (C++/cgo) calls db.Get/Put back through
THIS Go runtime — no second Go runtime is embedded (the two-runtime
constraint). The ctx is an opaque cgo.Handle token cast uintptr->void* in
C (no uintptr<->unsafe.Pointer in Go).
- store_callbacks_cevm.go: the //export get/put trampolines (kept separate —
//export forbids C definitions in its file's preamble) resolving the ctx
back to a *kvStore over a Get/Put backend.
- bridge_stub.go: !cevm no-op StatePersist/StateLoadFrom keep `go build ./...`
green without the tag.
- store_cevm_test.go (//go:build cevm): RESTART-SURVIVAL PROOF. Seed + apply
2 blocks against a resident cevm StateDB, StatePersist to a REAL zapdb
(zapdb.New on t.TempDir()), record the root; then StateFree the handle AND
Close the zapdb (full restart), reopen the zapdb from disk, StateLoadFrom
into a NEW handle, and assert StateRoot(new) == pre-restart root
byte-identical. Then apply the NEXT block on the reloaded handle and assert
it matches the stateless (geth-golden) reference.
Result: "persisted 300 accounts to zapdb, reloaded, root
0x5e9db5d6...794d74f == pre-restart root"; next block after reload matches.
All existing gates stay green: cevmbridge golden + resident-across-blocks,
core/parallel conformance 9/9, resident 256-block, shadow 256-block e2e.
MVP scope (honest): persists account state; the resident MPT is rebuilt on
load (root-identical), NOT yet bounded-RAM load-on-demand.
Co-authored-by: Hanzo Dev <[email protected]>
The cevm gated applier (cevm_shadow.go) dumped the WHOLE Go pre-state
every block and rebuilt a fresh cevm StateDB for each — capped at
maxShadowAccounts, which WAS the declined-too-large decline at scale.
Wire the persistent cevm StateDB behind a per-chain handle instead:
- bridge_cevm.go: StateCreate/StateSeed/StateApplyBlock/StateRoot/StateFree
wrappers over the new cevm_state_* C-ABI; marshalling factored into
shared marshal*/unmarshal* helpers (DRY with ProcessBlock). Stubs in
bridge_stub.go keep the default (non-cevm) build a no-op.
- cevm_shadow.go: a residentState per chain (keyed by chain ID). The FIRST
block seeds cevm once from the Go pre-state dump; SUBSEQUENT in-sync
blocks call StateApplyBlock with NO dump and compare cevm's resident root
to header.Root. On AGREE the four-way apply gate writes cevm's post-state
delta into the Go statedb (unchanged) and the resident advances to height
N. On DISAGREE/DECLINE/ERROR the resident is flagged out-of-sync and
re-seeded from the (correct) Go state on the next block. New counters
ResidentApplied / ResidentSeeds / ResidentReseeds. A wrong resident root
can never commit — the root check + four-way gate decline it.
Proof (CGO_ENABLED=1 -tags cevm):
- TestCevmResidentVerifiesRealBlocks: over the same 256 real InsertChain
blocks, cevm's RESIDENT root == Go header.Root at ALL 256 heights,
applied via the resident handle, with ResidentSeeds == 1 (not 256) and
0 resyncs — the declined-too-large killer: 1 full-state dump vs 256 blocks.
- TestStateApplyBlockGoldenRoots + TestStateResidentAcrossBlocksMatchesStateless
(cevmbridge): the resident C-ABI is byte-identical to the geth golden
roots and to a stateless reseed across blocks.
- Existing TestProcessBlockGoldenRoots (stateless), the 256-block
TestCevmShadowVerifiesRealBlocks, and the 9/9 applier conformance all
stay green.
Caveat: this makes the SHADOW/applier resident (no per-block dump). cevm as
the SOLE mainnet engine additionally needs zero declines + disk-backed state
+ node ownership — not claimed here.
Co-authored-by: Hanzo Dev <[email protected]>
The cevm gated applier declined EIP-2930 access-list txs as DISAGREE: the
access list never crossed the bridge, so cevm charged only the 21000 base
intrinsic where the Go EVM charges 21000 + 2400/addr + 1900/key. On a
fee-bearing block that 4300-gas shortfall under-credits the coinbase and
diverges cevm's keccak-MPT root from header.Root, so the four-way gate
(correctly) routed the block to the Go EVM.
Carry the access list end-to-end:
- cevmbridge.Tx gains AccessList []AccessTuple (nil for legacy txs); new
AccessTuple{Address, StorageKeys}.
- bridge_cevm.go marshals it across cgo into the appended cevm_pb_tx.access_list
(tuple array + flattened key buffers allocated in C memory — cTxs is Go
memory passed to C and cgo forbids nested Go pointers — freed on return).
- cevm_shadow.go populates it from tx.AccessList().
Pairs with luxcpp/cevm's process_block surcharge + EIP-2929 warm-set. The
09-access-list-tx conformance scenario flips DISAGREE -> APPLIED
(Agree:1 Applied:1), lifting the applier to 9/9 diverse tx-types. No
regression: the ABIVersion==2 guard, TestProcessBlockGoldenRoots, and the
256-block TestCevmShadowVerifiesRealBlocks (256/256 applied) stay green, and
the default (non-cevm) build is unchanged (additive shared type + stub path).
Co-authored-by: Hanzo Dev <[email protected]>
Adds core/parallel/cevm_applier_conformance_test.go (//go:build cevm) — a
permanent conformance gate that measures cevm's REAL per-tx-type apply/decline
coverage as the gated APPLIER (cevm_shadow.go). For each of 9 diverse scenarios
it builds a fresh preimages-ON chain, generates ONE block exercising exactly one
tx-type (CALL-target contracts pre-deployed in genesis so the measured block is
a single call), resets the shadow counters, runs chain.InsertChain (the applier
path), and records the ShadowStats delta.
Measured today (CGO_ENABLED=1 go test -tags cevm ./core/parallel -run Conformance):
APPLIED (5): value-transfer, call-sstore, call-log1, call-oog, multi(transfer+call)
DECLINED (4): contract-create, call-revert, precompile-identity, access-list-tx
every decline is DISAGREE (cevm's independent keccak-MPT root != Go header.Root);
none hit reverted-mismatch / errored / declined-*. InsertChain OK on all 9
(the Go EVM always applies regardless of cevm's verdict).
Headline: cevm applier covers 5/9 diverse tx-types. New findings vs the doc's
"safe subset" claim: LOG1 and out-of-gas calls DO apply; contract CREATE and
top-level REVERT do not. This table is the punch-list for widening cevm parity.
Measurement only — does NOT modify cevm_shadow.go or the bridge. Tag-gated, so
the default no-tags build is unchanged (build+vet green; -run Conformance finds
no test without -tags cevm).
Co-authored-by: Hanzo Dev <[email protected]>
Apples-to-apples benchmark of the cevm (C++ EVM) cgo ProcessBlock path vs
the Go EVM (luxfi/geth core/state) raw state transition, on the golden block
of N value transfers that both engines prove to the same keccak-MPT root
0x57b7250733ad7f7a...020099.
- TestBenchRootParity: validity gate. At N=1000 cevm==go==geth golden; at
N=10000 cevm==go mutual. If roots diverge the comparison is invalid.
- BenchmarkCevmProcessBlock: whole cgo ProcessBlock (seed C++ state + full
validated EVM block + real keccak-MPT commit + result marshalling).
- BenchmarkGoStateApply: Go state transition only (Sub/Add/SetNonce +
IntermediateRoot(true)); seeding excluded. Narrowest Go baseline.
- BenchmarkGoStateSeedApply: Go seed+apply+root, the pure-function analogue
of ProcessBlock.
Measured (Apple M1 Max, best-of-5, -tags cevm): at N=10k cevm ProcessBlock
~24.5ms / ~8585 Mgas/s vs Go-apply ~71.2ms and Go-seed+apply ~87.6ms — cevm
~2.9x / ~3.6x faster while doing strictly more work (full EVM + validation +
gas + cgo round-trip vs state-mutation-only).
Co-authored-by: Hanzo Dev <[email protected]>
The cevm C-ABI is now v2 (post-state delta export); the golden-roots test
guard still expected v1 and Fatalf'd before the byte-exact root check. Roots
unchanged across v1->v2. TestProcessBlockGoldenRoots now green (N=1/100/1000
byte-match geth).
Co-authored-by: Hanzo Dev <[email protected]>
cevm now ACTUALLY EXECUTES blocks (applies post-state) in luxfi/evm's block
executor, not just shadow-verifies the root — safely gated so it can never
diverge consensus.
Bridge (cevmbridge): ProcessBlock now surfaces the ABI-v2 post-state DELTA and
logs the C-ABI already exports — PostAccounts (address, absolute nonce/balance/
code, Deleted, CodeChanged), PostStorage (address, key, value), and Logs
(address, topics, data, tx_index) — alongside the root + per-tx results.
Executor (cevm_shadow.go): on the AGREE path (cevm root == header.Root) the
executor PROMOTES to applier under an APPLY-THEN-VERIFY-ELSE-DECLINE gate that
is consensus-safe BY CONSTRUCTION:
- reconstruct receipts from cevm's per-tx {status, cumulative gas, gasUsed,
logs} + txs (Type, TxHash, ContractAddress via crypto.CreateAddress for
CREATE, per-receipt Bloom) mirroring core/state_processor.go applyTransaction;
- replay cevm's post-state delta onto an ISOLATED statedb.Copy() and compute
its IntermediateRoot(IsEIP158);
- keep the writeback ONLY IF all FOUR quantities block_validator.ValidateState
checks byte-match the header: state root, aggregate bloom, receipt-trie hash
(DeriveSha), and cumulative gas. Then replay the identical delta onto the
LIVE statedb and return the receipts — the Go EVM does NOT re-run the block;
- on ANY mismatch, or an unrepresentable block (blob tx, cevm-rejected tx,
tx-result count skew, missing preimage, too-large, disagree, finalize-gap),
return (nil, nil) with the LIVE statedb UNTOUCHED so the Go EVM applies.
The trial runs on a deep Copy() rather than Snapshot()/RevertToSnapshot()
because IntermediateRoot -> Finalise -> clearJournalAndRefund invalidates the
journal, making a post-IntermediateRoot revert unreliable; verifying on a copy
leaves the live statedb pristine on every decline (nothing to revert). A wrong
or incomplete delta can therefore never reach the live state.
New honest counters: Applied (four-way gate passed, cevm EXECUTED the block) and
RevertedMismatch (agreed on root but the gated writeback/gate failed → declined
to Go). Invariant: Applied + RevertedMismatch == Agree.
Proven (-tags cevm, build-mpt CPU): the e2e test drives 256 real InsertChain
blocks (768 transfers) through Process -> ExecuteBlock and reports
Applied:256 RevertedMismatch:0 Disagree:0 — cevm executed every block in the
proven safe subset and InsertChain succeeded (ValidateState's four-way check
passed on the written-back state). Whole ./core suite stays GREEN: 9794 blocks
observed, all safely declining (preimages off => cannot seed cevm) and falling
through to Go, no regression. ./core/parallel suite green.
Co-authored-by: Hanzo Dev <[email protected]>
Wires the existing cevm (C++ EVM) byte-exact shadow verifier so it observes
EVERY block of the real ./core suite, and reports the true tally — without
weakening the gate or ever letting cevm apply state.
- core/main_cevm_test.go (//go:build cevm): a cevm-only TestMain that runs the
whole ./core suite then prints the aggregate parallel.CevmShadowStats(). Because
state_processor.go:124 routes every block through
parallel.DefaultExecutor().ExecuteBlock — which under -tags cevm is the
cevmShadowExecutor — this is the honest whole-suite count. main_test.go's goleak
TestMain is gated //go:build !cevm so exactly one TestMain exists per build.
- core/parallel/cevm_shadow_registration_test.go (//go:build cevm): asserts
DefaultExecutor() is the cevmShadowExecutor (not the no-op fallback) — the
precondition for the whole claim.
- core/parallel/cevm_shadow_e2e_test.go: scaled the real-StateProcessor agreement
proof from 3 to 256 genuine InsertChain blocks (768 transfers). cevm's
independent real-MPT process_block root byte-matched the Go EVM header.Root on
ALL 256/256 blocks (0 disagree, 0 finalize-gap).
- Four precompile test files (genesis_dexsettle_reproduce, precompile_alwayson,
precompile_statedb_subbalance, precompile_env_call) gated //go:build !cevm: they
alone pull precompile/registry -> luxfi/precompile/dex -> luxfi/dex/pkg/dex whose
cgo file #includes the absent dex_gpu.h, breaking the CGO_ENABLED=1 build the
bridge needs. They are precompile tests, not block-execution tests, and still run
in every non-cevm build — an orthogonal blocker isolated, not a cevm regression.
HONEST RESULTS (CGO_ENABLED=1 -tags cevm):
- Whole ./core suite: 78 PASS / 0 FAIL / 32 SKIP. cevm observed all 9794 blocks;
it declined every one as no-preimage (the production default runs preimages OFF,
and the shadow needs trie-key preimages to recover the 20-byte addresses that
seed cevm) — 0 disagree, 0 errored, 0 false roots. The Go EVM applied every
block; cevm never touched state.
- Real-block agreement (preimages ON, the config the shadow requires), driven
through core.BlockChain.InsertChain -> StateProcessor.Process -> the shadow:
256/256 blocks byte-exact agreement (768 transfers).
Default (!cevm) build unchanged: ./core and ./core/parallel green in both CGO
modes; the 10 gated precompile tests present in the default build.
Co-authored-by: Hanzo Dev <[email protected]>
Route-gated activation of the Lux C++ EVM (cevm) through the BlockExecutor
seam (SEAM 1): parallel.RegisterExecutor makes core/state_processor.go run
ExecuteBlock on every block. Because the CONSENSUS root is taken from the Go
statedb.IntermediateRoot() (block_validator.go), cevm-as-applier would need
host-callback state write-back (deferred); so this lands cevm as a pure
SHADOW VERIFIER — it recomputes the block's keccak-MPT root independently and
confirms it matches the Go EVM's header.Root, then ALWAYS returns (nil,nil)
so the Go path applies the block. Root-only checking sidesteps both known
cevm gaps (event logs, state write-back).
core/parallel/cevmbridge: cgo seam to cevm's extern "C" cevm_process_block
(luxcpp/cevm build-mpt CPU libs, real MPT commit — NOT the placeholder-root
gpu path). Two variants share one API: bridge_cevm.go (//go:build cevm) links
the verified build-mpt + Conan link line and calls the real process_block;
bridge_stub.go (//go:build !cevm) is pure Go and declines, so `go build ./...`
stays green on any machine and the C++ path is opt-in via -tags cevm (the
existing backend_cevm.go convention). Balance is LE limbs; value/gasPrice/ctx
256-bit fields are BE — matching go_process_block.h exactly.
core/parallel/cevm_shadow.go: the BlockExecutor. Snapshots the pre-block state
read-only via DumpToCollector (address<-trie-key preimage, nonce, balance,
code, storage), builds the cevm tx array (sender via block signer, effective
gas price matching evm/core/state_transition.go's full-fee-to-coinbase model),
runs cevmbridge.ProcessBlock, and cross-checks the root against header.Root.
Atomic counters tally agree / disagree / finalize-gap (withdrawals move
header.Root; cevm runs no engine.Finalize — a state no-op on Lux otherwise) /
declined-too-large / declined-no-preimage / declined-tx / errored, read via
CevmShadowStats. Panics in the snapshot/bridge are swallowed and counted: the
verifier can never destabilise consensus. Registration is gated on
cevmbridge.Enabled so default and -tags parallel builds are byte-unchanged.
parallel.go: initialize txExecutors at declaration instead of in init().
Backend files register from their own init() (backend_cevm.go under -tags
cevm), and Go orders init() funcs alphabetically by filename — backend_cevm.go
precedes parallel.go, so the old init()-time map creation raced too late and
panicked on a nil map under -tags cevm. Var initializers run before all
init() funcs, fixing the order deterministically.
Proven:
- cevmbridge golden roots through the Go marshalling byte-match luxfi/geth:
N=1 0xb9d849f9…805e9, N=100 0x2028713f…7cf0f, N=1000 0x57b72507…20099.
- e2e (TestCevmShadowVerifiesRealBlocks, -tags cevm): 3 real blocks through
core.BlockChain.InsertChain -> StateProcessor.Process -> shadow ExecuteBlock;
cevm's real-MPT root byte-matched the Go EVM header.Root on all 3
(Processed:3 Agree:3 Disagree:0, zero declines/errors).
- default (stub) core/parallel suite unchanged and green.
Co-authored-by: Hanzo Dev <[email protected]>
Range by index + take the slice element address instead of copying types.Block
(which carries an atomic.Pointer cache). go vet ./core/ is now clean.
Co-authored-by: Hanzo Dev <[email protected]>
creditTxFee runs once per tx. Bench (darwin/arm64, -benchtime=2s):
RawBaseline (pre-split, bare AddBalance) : 168.6 ns/op 4 allocs
Legacy (shipped dormant path) : 162.9 ns/op 4 allocs <- within noise
Split (active vault credit + burn) : 175.5 ns/op 5 allocs
The GetExtra+IsFeeSplit branch adds no measurable overhead when dormant; the
split path costs ~7% + 1 alloc only when the DAO activates it.
Co-authored-by: Hanzo Dev <[email protected]>
Completes the WIP: all 6 openChain call sites now pass snapshotVerify
(verifyAt honors --verify-snapshot; inspect/plan/resolve/apply use apply-mode
snapshots-off), and the missing --vote-guard-file flag is declared+bound. The
tool is the offline operator front-end for core.FinalizeRewind — the recovery
for incident 1085xxx (inner EVM ahead of proposervm finality after RLP
import->produce). Fail-closed: exclusive DB lock (luxd stopped), --yes gate,
max-loss fat-finger guard, identity-bound re-execution, dry-run inspect/plan,
post-rewind re-open verify. Compiles + vets clean.
Co-authored-by: Hanzo Dev <[email protected]>
Documents the owner decision: 100% of C-Chain tx fees route to the DAO Gov
Safe via the RewardManager precompile (config-only, proven on devnet), with
the 50/50 creditTxFee/FeeSplitTimestamp burn+stake split committed but DORMANT
(gated off) as a ready in-protocol option.
Co-authored-by: Hanzo Dev <[email protected]>