Under equivocation on a fresh chain, the consensus engine can finalize (via the
α-of-K cert / catch-up path) a block that this node verified only as a transient
non-canonical sibling — whose state trie was then dereferenced when that sibling
was rejected — WITHOUT re-verifying it on the finalize path. The node's head then
points at a block whose state root is absent from the trie database, so the next
BuildBlock fails 'failed to create new current environment: missing trie node'
and the chain wedges. This is the fresh re-genesised testnet/devnet block-2 halt
(node v1.32.4 / evm v1.99.52); mainnet is unaffected (its genesis lacks the fresh
precompile suite and its mature operation does not equivocate at the tip).
Fix: in BlockChain.Accept, if the accepted block's state root is not present, re-
execute it on the parent (== last accepted, whose state is present by construction)
before it becomes the head. reprocessBlock's ValidateState re-checks the computed
root against the header, so a non-deterministic transition surfaces loudly rather
than silently. On the common (non-equivocation) path this is a cheap HasState no-op.
The WARN log fires ONLY on the commit-gap, so it doubles as confirming instrumentation.
Co-authored-by: Hanzo Dev <[email protected]>
Pre-existing idle-builder wake race (not caused by QCv2): on a demand-driven
C-Chain with no empty blocks, a tx arriving while no builder is parked in
waitForNeedToBuild broadcasts pendingSignal to no waiter and is lost; the tx is
then only caught on the NEXT needToBuild() poll, which — depending on the
proposervm slot loop and engine re-call cadence — could leave the first tx after
idle pending for a long/indefinite time (the mainnet treasury-sweep-stuck and
v1.32.1 devnet DEXFill-didn't-complete symptoms).
Fix: startPendingTxPoll — a node-lifetime ticker (builderPollInterval = 500ms)
that periodically broadcasts pendingSignal so any parked builder re-evaluates
needToBuild() against the live mempool. It is decomplected from the tx-submit
subscription (awaitSubmittedTxs remains the primary, immediate wake) so the two
wake sources are independent. It NEVER forces an empty block — a wake only
produces a build when needToBuild() is true — so demand-driven chains stay
empty-block-free, and it changes wake TIMING only, never block CONTENTS, so
validators still build byte-identical blocks (deterministic). needToBuild now
routes through an injectable pendingTxs predicate so the wake path is testable
without a live txpool.
Regression: TestBuilderWakesOnPendingTxAfterIdle reproduces the node bridge's
exact waitForEvent call on an idle builder, delivers a pending tx with NO submit
broadcast, and asserts it returns PendingTxs within a bounded deadline. It FAILS
on the pre-fix code (times out — the lost wakeup) and passes after.
TestBuilderPollBackstopStopsOnShutdown asserts the ticker exits cleanly. Builder
tests green under -race.
Co-authored-by: Hanzo Dev <[email protected]>
Pre-existing idle-builder wake race (not caused by QCv2): on a demand-driven
C-Chain with no empty blocks, a tx arriving while no builder is parked in
waitForNeedToBuild broadcasts pendingSignal to no waiter and is lost; the tx is
then only caught on the NEXT needToBuild() poll, which — depending on the
proposervm slot loop and engine re-call cadence — could leave the first tx after
idle pending for a long/indefinite time (the mainnet treasury-sweep-stuck and
v1.32.1 devnet DEXFill-didn't-complete symptoms).
Fix: startPendingTxPoll — a node-lifetime ticker (builderPollInterval = 500ms)
that periodically broadcasts pendingSignal so any parked builder re-evaluates
needToBuild() against the live mempool. It is decomplected from the tx-submit
subscription (awaitSubmittedTxs remains the primary, immediate wake) so the two
wake sources are independent. It NEVER forces an empty block — a wake only
produces a build when needToBuild() is true — so demand-driven chains stay
empty-block-free, and it changes wake TIMING only, never block CONTENTS, so
validators still build byte-identical blocks (deterministic). needToBuild now
routes through an injectable pendingTxs predicate so the wake path is testable
without a live txpool.
Regression: TestBuilderWakesOnPendingTxAfterIdle reproduces the node bridge's
exact waitForEvent call on an idle builder, delivers a pending tx with NO submit
broadcast, and asserts it returns PendingTxs within a bounded deadline. It FAILS
on the pre-fix code (times out — the lost wakeup) and passes after.
TestBuilderPollBackstopStopsOnShutdown asserts the ticker exits cleanly. Builder
tests green under -race.
Co-authored-by: Hanzo Dev <[email protected]>
Decomplect the build-pacing decision out of waitForEvent into a pure
nextBuildTime(earliestBuild, lastBuildTime) function (no clock read), so the
logic the network-halt fix depends on is deterministically testable. The
wall-clock comparison stays in waitForEvent.
TestNextBuildTime covers all 5 cases (first-build paces to target rate; past
earliestBuild builds immediately; target rate dominates retry floor; retry
floor prevents hot-loop; boundary tie-break). TestNextBuildTime_NeverEarlier
ThanTargetRate locks the invariant the fix relies on: the next build is NEVER
scheduled before parent.Time+TargetBlockRate, so a too-early high-fee block can
never re-introduce the small-tip stall.
Second fresh-net block-production bug, exposed once WaitForEvent was unblocked.
The hand-rolled node notification bridge calls WaitForEvent in a tight loop and
builds a block the instant any tx is pending — with no pacing (ava's proposervm,
which Lux does not wrap, normally provides it). Two consequences:
1. Block gas cost: cost = parentCost + step·(TargetBlockRate − elapsed). A block
built immediately after its parent (elapsed≈0) requires step·TargetBlockRate
(e.g. 200000·2 = 400000) of tips to pass verifyBlockFee. A large deploy
covers it; a single small transfer (~100000) cannot → BuildBlock fails →
the tx never mines.
2. Equivocation: with all 5 validators building instantly off slightly different
mempool snapshots, they produce different blocks at the same height; two can
each gather an α-of-K cert → conflicting finality certs → Logger.Crit → the
node exits(1) and crash-loops.
Fix: pace waitForEvent to parent.Time + TargetBlockRate (the point at which the
block gas cost decays back to the parent's level, 0 from genesis), mirroring the
timing ava's coreth builder applies. Small-tip txs now mine, and the ~rate-long
window lets the mempool gossip-converge so validators build the IDENTICAL block
(same ID, one cert) instead of racing to finalize conflicting blocks. Retry
floor 1ms→100ms so a failed BuildBlock cannot hot-loop.
Fresh-start block-production stall root cause. The node-side notification
bridge (node/chains/manager.go) calls VM.WaitForEvent in a loop with a
non-cancellable context.Background(), starting BEFORE SetState(NormalOp)
initializes vm.builder. The first WaitForEvent therefore parks in the
builder==nil select { ctx.Done | stateSyncDone | shutdownChan } and, since the
context never cancels and state-sync is off, blocks FOREVER -- the serial
bridge loop never re-calls WaitForEvent to observe the now-ready builder, so no
PendingTxs ever reaches the engine and no block is ever built.
ava avoids this with common.NotificationForwarder + cn.OnChange=CheckForEvent,
which cancels the outstanding WaitForEvent and re-subscribes on VM state change.
Lux replaced that with the hand-rolled bridge and lost the recovery path.
Fix (self-contained, VM-side): add builderReady chan, closed once by
onNormalOperationsStarted after vm.builder is set, and add a select case so a
WaitForEvent that parked early re-fetches the ready builder and proceeds.
Makes WaitForEvent correct for any caller, cancellable or not.
go.sum: re-record luxfi/age,pq checksums after upstream re-tag (verify ON).
Picks up the enable-everything custom-precompile change (RefuseUnderStrictPQ
removed from all verify-only precompiles; fflonk fail-closed; accel byte-
identity; dex/stableswap/graph determinism). Pairs with this module's standard-
precompile enable so the full builder surface is live in one plugin build.
Enable-everything builder surface (public permissionless launch). The geth-layer
strict-PQ profile (LuxStrictPQ) refused ecrecover / p256Verify / sha256 /
ripemd160 / blake2f / bls12381 / kzg under vm.config.PQ — but ecrecover
(secp256k1) is THE Ethereum signature path every dapp depends on, and the rest
are verify-only / hash, key-safe. Refusing them breaks the builder surface.
The chain now installs NO forbidding pq.Profile: chainConfig.PQ stays nil,
which pq.RefuseUnder(nil) treats as permissive. The generic geth mechanism
(chainConfig.PQ + runPrecompile.RefuseUnder + legacypool nil-check) is kept for
any chain that wants it; Lux installs nothing.
This completes the decomplect started in luxfi/precompile: consensus PQ and the
EVM precompile profile were complected through config.PQ / StrictPQTimestamp.
After precompile dropped contract.RefuseUnderStrictPQ from every verify-only
precompile, the whole strict-PQ projection here was dead. Removed:
- plugin/evm/pq_profile.go (LuxStrictPQ)
- plugin/evm/vm.go: the `if vm.config.PQ { chainConfig.PQ = LuxStrictPQ() }`
install + the unconditional StrictPQTimestamp=0 genesis pin
- plugin/evm/config: the config.PQ bool field (no code writer; was JSON-opt-in)
- params/extras/network_upgrades.go: StrictPQTimestamp field + apply + IsStrictPQ
- precompile/registry/bridge.go + core/precompile_overrider.go: the dead
IsStrictPQ adapters (only existed to feed the deleted StrictPQReporter)
- tests: pq_test.go, strict_pq_activation_test.go, and the two StrictPQ
assertions (+ orphaned helper) in precompile_upgrade_rollout_test.go
Lux's PQ security is enforced at consensus / identity (quasar/p3q), not by
refusing an EVM verifier a dapp asked for. The live lux-mainnet upgrade.json
never actually set strictPQTimestamp, so this matches production.
Build: full module green. Tests: params/extras, plugin/evm/config,
precompile/registry pass.
warp v1.23.0 = typed finality-evidence model + Pulse->Corona relabel.
No evm consumer references the renamed symbols; warp/, warp/validators/, and
precompile/contracts/warp/ tests all pass. Pure dependency bump.
Rename across the evm warp backend and the warp precompile: warp.Core ->
warp.Message, NewCore -> NewMessage, ParseCore -> ParseMessage, the .Core field
-> .Message, and local core/msgCore/testCore vars -> msg/testMsg. deps: warp
v1.21.0 -> v1.22.0, precompile v0.11.0 -> v0.12.0.
geth's core/types and core/vm imports are untouched — the rename is scoped to
the warp message type, never the geth core package. ./warp/... and
./precompile/contracts/warp/... green, including TestWarpWrongNetworkID.
Red's re-review of the ZAP hard fork found the bespoke EVM verify path dropped
the NetworkID equality check that canonical warp.VerifyEnvelope enforces — a
defense-in-depth gap vs cross-network replay (mitigated by D folding NetworkID
and globally-unique SourceChainIDs, hence LOW, but the two verify paths must
enforce identical checks — one and one way).
VerifyPredicate now rejects Core.NetworkID != GetNetworkID(ctx). The predicate
test runtime's NetworkID is aligned to constants.UnitTestID (it defaulted to 1
while cores are built with UnitTestID, a latent harness mismatch the missing
check had masked); new TestWarpWrongNetworkID proves the rejection.
Track the upstream rename across the evm warp backend
(warp/{service,backend,verifier_backend}) and the warp precompile
(precompile/contracts/warp/{config,contract,contract_warp_handler},
precompileconfig): SignedCore->Core, WarpEnvelope->Envelope.
Identifiers only — the digest D, ZAP c14n bytes and BLS Beam / Corona Pulse /
ML-DSA signing domains are unchanged. ./warp/... and
./precompile/contracts/warp/... green, including the 10k-validator predicate
sign->aggregate->verify.
Hard-fork the last evm warp consumers onto the canonical ZAP message format
(one and one way only). warp v1.20.0 deleted UnsignedMessage; the signed
subject is now warp.SignedCore (D = keccak256(coreDST ‖ zap_c14n)) and the
signed wrapper is warp.WarpEnvelope. The BLS Beam signs warp.BeamSigningBytes(D).
deps: warp v1.19.5 -> v1.20.0, precompile v0.5.59 -> v0.10.0.
- warp/{backend,verifier_backend,service}: UnsignedMessage -> SignedCore;
NewMessage -> NewWarpEnvelope; sign/verify over BeamSigningBytes(core.ID()).
- precompile/contracts/warp: ParseMessage -> ParseWarpEnvelope; .UnsignedMessage
-> .Core; .Signature.(*BitSetSignature) -> .Beam (concrete). VerifyPredicate
verifies the Beam over D with exported primitives (Bits + bls +
BeamSigningBytes) because the precompile quorum denominator must include
keyless validators, which warp.VerifyEnvelope/GetCanonicalValidatorSet reject.
- precompileconfig.WarpMessageWriter.AddMessage(*SignedCore) (mirrors precompile v0.10.0).
- plugin/evm/vm.go: warpVerifierAdapter over *SignedCore; collapse the duplicate
external-warp import alias (warptypes -> luxwarp).
- remove redundant warp/{aggregator,bls_adapter,signer_adapter}.go: provably-dead
duplicates of warp.SignatureAggregator / warp.NewSigner (zero production refs).
- tests migrated to ZAP; fixed a pre-existing offchain message-key test bug;
revived the predicate suite (validator state injected via Runtime, the
production path, replacing the decoupled WithValidatorState key).
Verified: go build ./... green; go test ./warp/... ./precompile/contracts/warp/...
green (incl. 10k-validator TestWarpPredicate). grep UnsignedMessage = 0.
chains v1.3.19's dexvm/registry references AssertNoForbiddenAssetRefs,
looksLikeASCIITickerID, toHex and fromHex, but the file defining them
(forbidden.go) was dropped in that re-tagged release, so plugin/evm failed to
build (undefined symbols). The helpers are present again from v1.3.21; bump to
the latest v1.3 patch, v1.3.22.
MVS pulls the patch-level deps chains v1.3.22 requires: consensus v1.25.29,
crypto v1.19.26, dex v1.5.20 (zap v0.8.11 indirect). All x.x.x+ patch bumps, no
minor/major. Full repo builds; core/params/snapshot suites green and unchanged.
GenerateChain forcibly uses a hash-scheme trie db to read parent state, but the
snapshot suite runs the chain under both hash and path schemes. With an empty
genesis (pre genesis-active) parent.Root() is the empty root -- readable in any
scheme -- so block generation worked. With the genesis-active 0x9999 marker the
genesis state is non-empty, and a path-scheme chain commits its genesis nodes in
path format, invisible to GenerateChain's hash-scheme reader. state.New then
errored and GenerateChain returned 0 blocks (the error was discarded), so prepare
built an empty chain and restart recovered to genesis (head 0) instead of C4.
Generate blocks on a dedicated hash-scheme genDb via GenerateChainWithGenesis
(the pattern used by the rest of core's tests); the scheme-agnostic block
structures are re-executed into the chain's own db by InsertChain. The gapped and
wipe-crash variants source their extra blocks from genDb too.
The recovery engine is unchanged and verified correct for a non-empty genesis
under both hash and path schemes: on restart driven by lastAcceptedHash=C4 the
head, header and snapshot disk layer all land at C4 and block[4] is present.
genesis-active 0x9999 puts the precompile marker in every genesis, so the genesis
hash changes (correct for a first-run, always-on network): TestSkipPostMergeFields
GenesisHash 0x3f4f->0x5609, TestGenesisBlockForTesting 0x517c->0x4e97. Also fix
pre-existing TestUpstreamParamsValues drift: Lux raises MaxCodeSize to 64KB (65536)
and MaxInitCodeSize to 2x (131072) vs Ethereum's 24576 — the test asserted stale
Ethereum values. params + core-genesis now GREEN.
REMAINING (not merged): the snapshot-recovery tests (TestRestartWithNewSnapshot ...)
fail — a geth snapshot-engine limitation with a non-empty genesis (block[N] of the
snapshot disk layer goes missing on restart). genesis-active EXPOSES it (marker makes
genesis non-empty) but does not cause it; it's a foundational geth-engine fix.
The re-tag automation staled multiple luxfi module hashes ([email protected], [email protected],
[email protected], ...) so evm would not build/test (SECURITY ERROR). Re-recorded go.sum
fresh (GOPRIVATE bypasses sumdb for luxfi; public deps re-verified). Also removed a
stale DurangoTimestamp from a geth ChainConfig literal in config_test.go (the field
moved to extras.NetworkUpgrades). Unblocks the build. NOTE: params/extras (the
genesis-active StrictPQ work) passes; full evm green still needs the genesis-hash
regen (0x9999 genesis-active) + pre-existing failures (TestUpstreamParamsValues
param drift, snapshot geth-limitation) addressed — separate foundational pass.
First-run network, no legacy, no pre-activation history to protect, so the
DexSettleActivationTime dated-fork gate is pure complexity. Delete the concept of
an activation time: 0x9999 dispatches unconditionally and its EXTCODESIZE marker
installs at genesis (parentTimestamp==nil); StrictPQ pinned active-from-genesis for
the Lux plugin (whole ecosystem is PQ-native — removed the per-VM config conditional).
Deleted: DexSettleActivationTime const + pointer/accessor/predicates, the cross-repo
mirror, the drift-guard test, and the 'behaves-as-plain-account-before-activation'
replay-safety branches/prose. One activation path; less code. Genesis determinism via
the single core/genesis.go toBlock() derivation. Feature tests green (0x9999 registered/
dispatched/marked-at-genesis; StrictPQ genesis+nil-inert; plugin PQ).
Proves the unclean-restart fix: a non-nil *runtime.Runtime is readable on the
chain the instant construction returns (so it was present for any in-construction
reprocess), and nil stays nil for chains with no identity-gated precompiles.
The 0x9999 DEX value path is identity-gated: the asset resolver refuses to
admit a market unless the running chain's identity matches. core.NewBlockChain
re-executes accepted blocks during construction (loadLastState recovery /
populateMissingTries backfill), but the identity was only bound onto the chain
AFTER eth.New returned (vm.go SetConsensusContext) — so an unclean restart after
a 0x9999 swap re-executed that block with networkID 0 / C-Chain Empty, reverted
the committed swap, failed ValidateState, and the node could not boot.
Fix is purely timing: hand NewBlockChain the *runtime.Runtime the VM already
holds (eth.Settings.ChainRuntime -> core.NewBlockChain) and bind the consensus
context BEFORE reprocess. No new identity type — networkID/chainID already live
on runtime.Runtime; the context is just the last-mile transport to geth's
ConsensusContext API. stdlib runtime aliased stdruntime in blockchain.go.
precompile v0.5.58 carries the 0x9999 value-path soundness floor (Block-STM
access-set, decentralized halt, in-state-vault/EVM-Call resolution; contains
the gate-b commit 23f49b6) and runtime v1.1.3 the GovernanceController seam
the value path reads. Builds standalone without go.work; gate-b closed
(evm no longer links the pre-fix precompile v0.5.57).
The 0x9999 DEX precompile must see the true (networkID, cChainID) and prove
an ERC-20 has live code, on BOTH paths:
- block processing: ApplyTransactionWithResults now threads bc.ConsensusContext()
onto blockContext.ConsensusContext when absent (nil-guarded; the miner's
NewEVMBlockContext already sets it). The stale "StatefulPrecompileHook not
exposed" note is removed — vm.NewEVM preserves ConsensusContext onto evm.Context.
- eth_call/estimateGas: ChainContext.ConsensusContext now returns the backend
blockchain's consensus context (which embeds the chain Runtime via
runtime.WithContext), not the RPC request context — the latter made every
identity-reading precompile see networkID 0 / C-Chain Empty (fail-closed) on
eth_call. EthAPIBackend.ConsensusContext exposes it.
- stateDBBridge.GetCodeSize forwards EXTCODESIZE to the internal StateDB so the
OnChainAssetVerifier's C1 live-code proof works; returns -1 (not 0) when
unavailable so the value path fails CLOSED rather than admit a fabricated zero.
Adds cmd/dexlive (real end-to-end 0x9999 value-path harness over a running
localnet C-Chain: deploy 2 ERC-20s, open market, seed maker, taker swap, read
DEXFill + balance deltas; synthetic/no-code negative control must revert) and
the consensus-identity unit tests for both paths.
HIGH-3 host side. The always-on 0x9999 settlement precompile resolves its halt +
pot-seeding authority from contract.AtomicState.GovernanceController(); this is the
production wiring that supplies it — a per-network governance CONTRACT, never a
hardcoded mnemonic-derivable EOA.
- dex_value_wiring.go (installDEXValuePath): bind rt.GovernanceController =
dexGovernanceFor(networkID) onto the chain runtime at boot (the ONE production
site), alongside the asset resolver + native D client, and log it. dexGovernanceFor
is the genesis-set per-network governance-contract seam: it returns ids.ShortEmpty
for every network TODAY (no Governor/Timelock is deployed at a known address on any
Lux network yet), so every network runs FAIL-CLOSED (no halt authority — provably no
single key can DoS the DEX) until its real governance CONTRACT address is wired in,
per net. The directive is explicit: each entry MUST be a governance contract, NEVER a
dev-mnemonic EOA, and NEVER the retired 0x9011…94714. The boot log makes an unset
authority loud ("<unset: halt/seed FAIL-CLOSED — deploy + wire a governance contract>").
- core/precompile_overrider.go + precompile/registry/bridge.go: accessibleStateAdapter
and accessibleStateBridge implement GovernanceController() (ids.ShortID→common.Address,
exact 20-byte conversion); the internal twin interface gains the method. Returns the
zero address (fail-closed) when the runtime/atomic capability is absent.
- precompile/contract/atomic.go (evm-internal twin): AtomicState gains GovernanceController().
Builds clean (CGO off) across plugin/evm, core, precompile/registry; the dexsettle_guard
parity test passes. Normal trading is unaffected (halt switches are emergency controls,
off by default); only the ability to halt is gated — which previously only a mnemonic-EOA
holder had (a vuln), so no legitimate capability is lost and a censorship/DoS vector is removed.
This is the SOLE production install of the always-on 0x9999 DEX value-path trust
root — the seam Red flagged as fail-closed-by-absence (InstallAssetResolver /
InstallDChainClient had zero production callers, so every swap reverted
ErrNoAssetResolver). vm.Initialize now constructs and installs, from the node's REAL
chain context (never a constant):
- a registry-backed dexcore.AssetResolver bound to (networkID, cChainID): it derives
the canonical AssetID with the node's own ids and resolves it in a registry built
from the per-network manifest (EmbeddedManifestFor by EVM chainID, or the runtime-
rooted localnet native manifest). The manifest is identity-bound via
NewRuntimeVerifier (wrong-net/wrong-chain refused) and admitted under the locked-down
DefaultDexAssetPolicy synthetic-config deny-scan.
- the local in-process NativeDChainClient for the book/ledger custody seam (NOT a
remote keeper/venue/ZAP engine).
Wired for ALL networks (mainnet 96369 / testnet 96368 / devnet 96370 / localnet 1337)
so the DEX is live out-of-the-box. FAIL-CLOSED, NEVER FATAL: a construction failure is
logged and the resolver is left UNINSTALLED (swaps revert), never opened; the node
still boots for all non-DEX usage. The install binds the SAME (networkID, cChainID)
the swap path's AtomicState cross-checks, so a mismatch is fail-closed at swap time.
- core/precompile_overrider.go: forward GetCodeSize (EXTCODESIZE) on the concrete
stateDBAdapter so the value path's live-code verifier reads authoritative state.
The determinism half of the unified executor: parallel-eligible settlement
reproduces the canonical sequential state root. Reuses the REAL kernels
(detectConflicts via ConflictEdges, SettleBatch, geth IntermediateRoot) — no
second engine, no mock conflict detector.
Test9999_DisjointFillsParallelRootEqualsSequential
(== TestBlockSTM_DifferentMarketsParallel): fills on different markets ->
detectConflicts finds 0 edges -> SettleBatch root == sequential root. In-test
fail-without: collapsing the two onto one market yields an edge (proves the
detector keys on the market, not vacuously empty).
Test9999_ConflictingFillsReExecToSequentialRoot
(== TestHotMarketBatchDeterministicRoot): two fills sharing an account on one
HOT market -> detectConflicts returns edge (0,1) -> SettleBatch serializes in
ascending-INDEX order -> root == C-then-D AND != the swapped D-then-C. Uses an
order-SENSITIVE hot-market slot (keccak(prev||taker)) so the root genuinely
depends on apply order — a balance-only fill is commutative and would make the
determinism claim vacuous; the in-test guard rejects a commutative Apply.
Fail-without PROVEN by temporary revert: reversing SettleBatch's apply order
makes the root diverge to the swapped order and the conflicting-fills test FAILS
(restored after). Owner-named tests satisfied by the same bodies. Full
core/parallel suite green; no existing tests weakened.
Consumes luxfi/precompile v0.5.57 — the RED-CLEARED money-path fix adding the
CALL-only guard (ErrSettleWrongContext) on 0x9999/0x9996 Run. Pairs with this
repo's callableEnvAllowlist gate so the env Call surface reaches only the DEX
settlement family. go.sum regenerated via 'go mod tidy' against the tagged
module (GOWORK=off — the CI condition).
GetPrecompileEnv() is on the SHARED external contract.AccessibleState interface,
so the live Call surface (whose sub-call msg.sender is the asserting precompile's
OWN address) is reachable by every external precompile that type-asserts the
optional callableEnv capability. Bound that blast radius with defense-in-depth:
- precompile/registry/bridge.go: callableEnvAllowlist keyed on the geth env's
self-address (env.Addresses().Self), populated from the canonical
dex.LXSettleAddress (0x9999) + dex.DEXPositionManagerAddress (0x9996) constants
(one source of truth). Every other external precompile (PQ/ZK verifiers at
0x0122xx, future ones) gets a nil callable env and falls back to fail-secure
refusal. No live behaviour change today (none assert callableEnv yet) — it
prevents a FUTURE precompile silently acquiring sub-call power. Sits on top of
each settlement Run's CALL-only guard.
- core/precompile_overrider.go: GetPrecompileEnv comment corrected — the accessor
is off the EVM-INTERNAL interface but IS on the shared external one; reach is
bounded by the bridge gate + the settle CALL-only guard.
- precompile/registry/bridge_precompileenv_test.go: recordingEnv carries a self;
TestBridge_GetPrecompileEnv_NonSettlementSelf_Gated (0x012204 verifier -> nil,
no Call) + _PositionManagerSelf_Allowed (0x9996 -> non-nil); WiredForwardsCall
now models the 0x9999 self.
ROOT CAUSE: the external DEX 0x9999 settlement precompile's ERC-20 leg (transferFrom/
transfer/balanceOf on the token contract) needs the EVM Call surface, but the adapter
chain that delivers its AccessibleState narrowed Call away:
geth evmPrecompileEnv{Call}
-> evm/core accessibleStateAdapter{env} (env captured, not exposed)
-> evm/registry accessibleStateBridge (GetPrecompileEnv() => nil)
-> luxfi/precompile contract.PrecompileEnvironment (no Call)
so poolStateAdapter.callable() got nil and every ERC-20 deposit/withdraw refused with
ErrERC20VaultUnavailable (fail-secure — never minted). Native LUX settlement was
unaffected because it uses GetBalance/SubBalance, already plumbed through the bridges.
FIX (minimal, off the shared many-implementer interfaces):
- evm/core accessibleStateAdapter.GetPrecompileEnv() exposes the geth env it already
holds (concrete accessor, NOT a widening of the internal contract.AccessibleState).
- evm/registry accessibleStateBridge.GetPrecompileEnv() type-asserts that accessor and
returns precompileEnvBridge, which adds the callableEnv-shaped Call the precompile
type-asserts; nil (fail-secure) when no geth env is present.
- Call forwards with NO caller proxying: the token's msg.sender is the precompile self
(0x9999), matching the depositor's approve(0x9999) allowance. Proxying would check
the wrong allowance and break the pull.
geth is UNTOUCHED: vm.PrecompileEnvironment already declares Call; the DEX never runs
through geth's own accessibleStateAdapter (that path serves only the PQ precompiles).
Regression guards: core pins *accessibleStateAdapter satisfies the envProvider accessor
and returns its env; registry pins the bridge returns a non-nil callable env that
forwards Call (value passed, ZERO options) when wired, and untyped-nil otherwise
(no-env + typed-nil traps).
Asserts DefaultGPU().Available()==true and GPU-recovered sender == CPU sender,
proving the block-insert ecrecover path dispatches to the real Metal backend
(libluxgpu_backend_metal.dylib) rather than the CPU fallthrough.
Round-2 follow-up to the luxfi/genesis upgrade.json reschedule fix (17
live-at-genesis precompiles pinned to blockTimestamp 0; warp two-step
strict-PQ toggle moved after the zero-timestamp block; dead dexConfig
0x9010 dropped).
- relaunch_safety_test.go (new): drives the node's REAL fork-application
primitive extras.IsForkTransition (parent=nil reduces to fork <=
genesisTime, exactly what GetActivatingPrecompileConfigs evaluates when
a relaunched node builds its first rule set) against the embedded
canonical fixtures. Asserts the invariant: every precompile that
activates at/under the C-Chain genesis time (mainnet 1730446786 /
testnet 1730517266) is in the live-at-block-0 set AND pinned to exactly
0; every other entry is strictly future; and all 17 live precompiles are
present (no relaunch divergence). No entry sits in the (0, genesisTime]
retroactive-reschedule window.
- mainnet_upgrade_v46.json / testnet_upgrade_v47.json: regenerated
byte-for-byte from the corrected canonical genesis configs (46 / 44
entries; the 25 timestamp diffs were the 1766708400 -> 0 normalization
plus the warp reorder).
- precompile_upgrade_rollout_test.go: WarpRequiresPrimaryNetworkSigners
updated for the two-step toggle — warp is declared in the genesis
chainConfig, and the schedule carries disable@strictPQ-1 then
re-enable@strictPQ; the PQ signer-policy assertion targets the re-enable
entry, and same-key strict-increase is enforced (mirrors
verifyPrecompileUpgrades).
- upgrade_unmarshal_test.go / testnet_upgrade_unmarshal_test.go: fixture
doc comments rewritten to the live-at-0 / strict-PQ-toggle / BLS /
Quasar-Edition tier layout; RegressionProof entry-count assertion 45 ->
46 to match the embedded fixture.
Verified: go vet clean; CGO_ENABLED=0 go test ./params/extras/...
./params/extras_e2e_test/... — RelaunchSafe (mainnet+testnet),
IsForwardCompatibleWithLiveActivations, WarpRequiresPrimaryNetworkSigners,
PrecompileTimestampsAreMonotonic, RegressionProof all PASS.
node v1.30.27/28 dropped the 0x9010 DEX precompile; its config key
dexConfig is unregistered under the pinned luxfi/precompile v0.5.55, so
the vendored canonical fixtures carrying it made
TestMainnetUpgradeJSON_UnmarshalsAgainstRegistry FAIL with 'unknown
precompile config: dexConfig' (the C-Chain brick-on-boot the gate exists
to catch). Remove dexConfig from both fixtures (sync source: luxfi/genesis
configs/{mainnet,testnet}/upgrade.json, cleaned in lockstep), update the
canonical entry-count assertion 46->45, and drop dexConfig from the
lux-mainnet live-activations const so the forward-compat check stays
consistent. Verified CGO_ENABLED=0 go test ./params/extras_e2e_test/ -> ok.
SetEthUpgrades wrote a [SET-ETH-UPGRADES] line to stderr and GetFeeConfig
wrote two DEBUG lines to stdout on every call — leftover debugging, not
intentional logging. Removed all three; dropped the now-unused fmt and os
imports from config_extra.go.
NOTE: coordinated cascade — another agent is bumping this repo's version for
an unrelated change. This is a pure code edit (print removal) on its own
branch; fold into the cascade.
Pins the precompile tag that exports dex.DexSettleActivationTime so the
dexsettle_guard DRY test compiles and enforces extras==dex mirror equality.
go test -tags dexsettle_guard ./core/ PASSES against the pinned tag.
1766704800 is 2025-12-25T23:20:00Z, not midnight (1766620800). Value is the
canonical protocol constant, already live gating 0x9999 dispatch on devnet;
changing it would fork chains that crossed the boundary. Annotate the true
instant; warn against rounding. Comment-only — zero consensus change.
The guard TestDexSettleActivationTime_LayerMirrorsMatch references
dex.DexSettleActivationTime, which is defined in luxfi/precompile and post-dates
evm/go.mod's pinned precompile tag (v0.5.54). Under GOWORK=off (CI), evm/core
failed to build (undefined: dex.DexSettleActivationTime); it only compiled via an
untracked local go.work. The guard was therefore both inert in CI and a latent
CI break.
Move the single cross-repo guard into precompile_alwayson_dexsettle_guard_test.go
behind //go:build dexsettle_guard. Default CI excludes it, so evm/core builds and
tests against the pinned precompile tag with no go.work. The release step bumps
evm/go.mod to a precompile tag that exports the symbol and runs
`go test -tags dexsettle_guard ./core/`, enforcing the DRY invariant atomically
with the bump. precompile cannot host the guard (it does not import luxfi/evm; the
graph is strictly evm->precompile, so referencing extras.DexSettleActivationTime
from precompile would create a cycle). All other 0x9999 activation pins in
precompile_alwayson_test.go use only evm-local symbols and run on every CI build.
The 0x9999 activation timestamp lives in TWO layers that cannot share a symbol: the
canonical extras.DexSettleActivationTime (this evm layer, used by the dispatch gate +
marker transition) and a layer-local mirror dex.DexSettleActivationTime (luxfi/
precompile, used to gate the precompile's new consensus-visible logs — precompile is
below evm in the import graph so it cannot import extras). If they drift, a settlement
could dispatch (extras gate) where the precompile withholds its log (stale mirror) ->
settlement-without-log window -> consensus split on replay. This test imports BOTH and
fails CI the instant either constant moves without the other.
NOTE: compiling this test against the new dex export requires evm go.mod to pin a
luxfi/precompile release that exports DexSettleActivationTime (>= v0.5.55, to be cut
from the precompile branch). Verified locally PASS against the local precompile via a
temporary replace (reverted); the go.mod bump is part of the coordinated release.