The mainnet flag-day ship vehicle. = v1.36.23 content (proposervm sub-second cadence /
ms-timestamp, chains v1.7.9, native-ZAP VMs) with two folds:
- go.mod consensus v1.36.9 -> v1.36.10: the UNION consensus (d6a83d57e) = v1.36.9's G9
reconcileVMToCertified + the 5 hotfix-line engine fixes v1.36.9 lacked, chiefly 417d52719
(non-leader-spin: consume-build-demand-before-BuildBlock + clear-on-no-block — kills the
unbounded hot-spin a non-leader hit under single-proposer scheduling). buildBlocksLocked is
byte-identical to v1.36.7's; 4 TDD tests RED->GREEN; full engine/chain suite 0-fail.
- Dockerfile EVM_VERSION v1.104.9 -> v1.104.9-hotfix.2: the C1-gates-1-5-proven flag-day EVM
plugin (precompile v0.19.0 / geth v1.17.12; the producer-parity replay plugin, NOT v0.19.1).
Both consensus fixes ride: G9 rejoin (validated GREEN on testnet C1/G9) + non-leader-spin.
Flag-day migration onto this is a coordinated all-5 (v1.36.23's proposervm ms-timestamp is
wire-breaking vs older), which the wipe+RLP-import+coordinated-boot flag-day naturally is.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Propagates two just-published upstream fixes into the node (luxd) binary:
- consensus v1.36.7 → v1.36.9: the chain engine no longer os.Exit(1)-kills a
validator on a SetPreference orphan-refusal — it reconciles the VM to the
certified block when the diverged tip is provably uncertified, and halts
fail-closed only on a genuine double-finalization (v1.36.8). Also unifies
consensus's transitive geth pin to v1.20.1, matching node/evm (v1.36.9). The
new PreferenceReconciler VM interface is optional, so node's VMs are
unaffected (they take the non-fatal defer path); no code change required here.
- chains v1.7.7 → v1.7.9: every VM (aivm/keyvm/quantumvm/dexvm) now serializes
through native luxfi/zap struct-is-wire, with canonical-id hardening.
go.mod/go.sum only — dependency-graph resolution, no node code change. Transitive
indirects move forward within-major (bft, cockroachdb/errors, sentry-go,
prometheus/common, go-internal); no major-version bumps. geth stays v1.20.1
(already node's pin). Verified: GOWORK=off CGO_ENABLED=0 go build ./... clean.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The proposervm block wire stored the timestamp as Unix SECONDS (timestamp.Unix()), so the
sub-second window/granularity work was silently truncated to whole seconds when written to the
block: buildChild computed the proposer slot from sub-second time, but verify read back
second-resolution and computed a DIFFERENT slot → errUnexpectedProposer verify-drops at any
window < 1s. Store timestamp.UnixMilli() and read time.UnixMilli() so sub-second timestamps
round-trip and build/verify agree on the slot. Measured at a 100ms window: errUnexpectedProposer
frequent→0, 108→149 TPS, 2.8s→2.2s cadence, 5/5 byte-identical. Forward-only wire change (the
outer proposervm timestamp; the inner EVM block timestamp is independent). Remaining sub-second
floor is now the EVM targetBlockRate, not the proposervm.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two coupled knobs for differentiated cadence — co-located D-Chain/DEX (fast) vs public
C-Chain (standard):
1. MinBlkDelay was hardcoded to the 1s DefaultMinBlockDelay in chains/manager.go, ignoring
its own --proposervm-min-block-delay flag. Now wired node.Config → ManagerConfig →
proposervm.Config (0 ⇒ 1s default). High-throughput nets set it low.
2. Block timestamps were Truncate(time.Second) at 4 sites, quantizing cadence to 1s
regardless of WindowDuration — so a sub-second window inflated slot numbers without finer
time resolution and could NOT produce blocks faster than 1/s. Truncation now tracks
proposer.TimestampGranularity() = min(1s, WindowDuration): mainnet (>=1s) keeps exact
1-second block times; sub-second windows get matching sub-second timestamps.
Measured: 1s window/delay → ~95-104 TPS, byte-identical 5/5 finality (unchanged from before,
no regression). NOTE: true sub-second cadence additionally requires a slot-handoff fix — at
100ms the slot recomputed in buildChild from a drifted 'now' can differ from the slot
timeToBuild scheduled, causing errUnexpectedProposer verify drops; that + multi-sender
saturation is the next step. These knobs are the necessary foundation.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two changes, both node-local:
1. CADENCE — proposer.WindowDuration (the proposer-slot spacing, a validator's min build
delay = slot index x WindowDuration) was a hardcoded 5s const tuned for mainnet-scale
validator sets, flooring small/local block cadence at 5s per slot. It is now a
startup-configurable var (default 5s, unchanged for mainnet) set via the new
--proposervm-window-duration flag → node.Config → ManagerConfig → proposervm.Config →
proposer.SetWindowDuration at VM init. Read by both the windower delay math and
TimeToSlot, so they stay consistent. Measured on a 5-node strict-PQ net: 5s→1s took
sustained C-Chain from 44 TPS / 14s-per-block to 104 TPS / 3.8s-per-block, still 5/5
byte-identical finality.
2. SECURITY (cryptographer MEDIUM-1) — postForkCommonComponents.Verify now refuses a block
carrying a CLASSICAL secp256k1 proposer identity when the chain is strict-PQ
(StakingMLDSASigner set), UNCONDITIONALLY (before the consensusState==Ready gate, so it
also holds during bootstrap/state-sync). Fills the documented-but-unwired 'proposer'
SchemeGate site: the downgrade defense is now an explicit fail-closed in-perimeter gate,
not merely emergent from 20-byte NodeID collision-resistance + upstream enforcement.
Adds SignedBlock.HasClassicalProposer(). Mirrors contract.RefuseUnderStrictPQ.
Pins consensus v1.36.7 (consume-on-error build loop — kills the non-leader BuildBlock spin).
Block + proposervm VM tests green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Under strict-PQ the canonical NodeID is ML-DSA-65-derived (config/node DeriveNodeID →
DeriveMLDSA), so the P-chain validator set and the proposervm windower are ML-DSA-keyed.
But proposervm signed post-fork blocks with the classical TLS leaf and derived the block
Proposer() via ids.NodeIDFromCert — a different value than DeriveMLDSA — so every signed
block (height ≥ 2) failed verifyPostDurangoBlockDelay with errUnexpectedProposer, was
dropped, and rebuilt in an unbounded storm (block 1 survived only because the unsigned
transition block skips the proposer comparison).
The block's offCert slot now carries a scheme-tagged proposer identity [scheme:1B|identity]:
0x90 classical (cert DER → NodeIDFromCert, ECDSA verify) or 0x42 strict-PQ (raw ML-DSA-65
pubkey → DeriveMLDSA(ids.Empty,pub), ML-DSA verify). ML-DSA signing/verification uses a
FIPS 204 §5.2 domain-separation context ('lux-proposervm-block-v1') so a proposer signature
can never be replayed as another ML-DSA message. proposervm.Config gains StakingMLDSASigner
/StakingMLDSAPub, plumbed from StakingConfig through ManagerConfig; exactly one scheme is
active per chain. K=1 nets are unaffected (transition + no-window blocks are unsigned).
Proven on a 5-node strict-PQ local net: sustained multi-block production, every block
built once, gossiped, and finalized byte-identical on all 5 (no automine). Pins consensus
v1.36.6 (engine logger + logged build-drops) which made this diagnosable.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The 9 VM lifecycle ops (Initialize, Linearize, SetState, CreateHandlers,
router AddChain, state-sync) were each bounded at a hardcoded 30s. A cold
coreth 'Regenerate historical state' pass after an unclean shutdown takes
67-134s on the mainnet C-Chain, blowing that budget → context cancelled
mid-init → VM marked failed → C-Chain route never registered → the recurring
post-restart 404 that required a second restart to clear. One named bounded
constant (10m) covers regen with margin while still surfacing a truly-hung VM.
Stop stays 10s. This is the third stacked restart-fragility bug after
skip-bootstrap frontier (v1.36.11) and rejoin discriminator (v1.36.13).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The #66/#74 durable rejoin fix was INERT for the C-Chain — the chain whose 40h
mainnet freeze motivated it. chains/manager.go gated expectsStakedBeacons on
ids.IsNativeChain(chainParams.ID) (the blockchain ID), but ids.IsNativeChain only
matches the symbolic 111...C alias; every deployed C/X/Q has a HASH blockchain ID
(devnet 21HieZng, mainnet 2wRdZG), so isNativeChain was ALWAYS false and under the
production --skip-bootstrap=true the beacons were emptied -> a behind C-Chain named
its stale local tip the frontier and never caught up. Verified on devnet v1.36.12:
C-Chain wedged at height 0 ('using empty beacons for single-node mode').
Fix: discriminate on the VALIDATING NET (chainParams.ChainID == PrimaryNetworkID)
via new chainValidatesOnPrimaryNetwork — PrimaryNetworkID for C/X/Q, the sovereign
net ID for L2s. C/X/Q now keep staked beacons under --skip-bootstrap (peer-sync a
behind validator); L2s keep the empty-beacon single-node path. New regression
TestChainValidatesOnPrimaryNetwork_RealHashChainID exercises the real discriminator
with hash IDs; TestRED_EmptyStakedSetFailsSafe still green (forged-frontier gate intact).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Captures the devnet-canary findings so the gated rollout is resumable: the
v1.36.11 EVM-plugin api skew (fixed in v1.36.12), the v1.36.2->v1.36.x P-Chain
codec migration (one-time wipe + proven cross-version re-bootstrap), the durable
rejoin mechanism, per-net RewardManager addresses/config shape, and the
one-at-a-time verify-tip roll protocol. Also folds in the pre-existing
RewardManager->DAO-Safe C-Chain design note.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.36.11's node binary is correct (durable rejoin fix fe23efd1f2), but its image
baked VM plugins built against a stale luxfi/api: the C-Chain EVM plugin from
luxfi/evm@v1.104.8 and the D-Chain dexvm plugin from luxfi/dex@v1.5.15 both resolve
api v1.0.15, while the node pins api v1.0.16. api v1.0.16 APPENDED
InitializeResponse.Capabilities (uint64) for the Quasar-export handshake (api
1f2dc5a). The node decodes that field; the stale plugins never encode it, so
vms/rpcchainvm/zap/client.go fails every EVM VM Initialize with
'zap decode initialize response: unexpected EOF'. Native VMs (P/X/Q) are unaffected;
every EVM chain (C, D, and the L2 EVMs) fails to boot. Verified on devnet (published
v1.36.11 digest c3cf92a6): P/X re-bootstrap fine, C+D deterministically EOF.
Fix (image-only; node source unchanged beyond the version bump):
- EVM_VERSION v1.104.8 -> v1.104.9 (api v1.0.15 -> v1.0.16, indirect via luxfi/vm)
- force luxfi/api@v1.0.16 in the dexvm build stage (no dex release pins v1.0.16 yet)
- CHAINS_REF v1.7.6 already carries api v1.0.16 (the other 10 VMs were fine)
The api bump is code-free for plugins (chains v1.7.4->v1.7.5 adopted it go.mod-only).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Node has ZERO .proto files and ZERO .pb.go — the wire is hand-written ZAP schemas
(proto/{platformvm,vm,p2p,sync}/*_zap.go). The buf/protoc tooling around it was
orphaned: removed proto/{buf.yaml,buf.gen.yaml,Dockerfile.buf,buf.md,README.md},
scripts/protobuf_codegen.sh, the Taskfile generate-protobuf + check-generate-protobuf
targets, ci.yml buf-lint + check_generated_protobuf jobs (the latter ran the deleted
script → would fail CI), and the buf-lint.yml workflow. defaultPatch 10→11 (dev
version string; image already correct via ldflags). Binary unchanged — tooling/CI only.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Durable root-cause fix for the behind-validator rejoin wedge (mainnet luxd-0:
C-Chain frozen ~40h at a stale height, only a manual chaindata wipe unstuck it).
Root cause: buildChain computed the bootstrap frontier-sync discriminator as
`expectsStakedBeacons := !m.SkipBootstrap && native && !platform` AND emptied the
beacon set whenever `m.SkipBootstrap`. Production validators hardcode
--skip-bootstrap=true (to skip the initial bootstrap WAIT), so a real multi-
validator native chain (C/X/Q) got expectsStakedBeacons=false + EMPTY beacons.
FrontierTip then reported FrontierNoBeacons ("nothing to sync to"), the node named
its STALE local last-accepted the network frontier, transitioned the VM to normal
operation there, and never fetched the gap from its 4 healthy peers — the wedge
survived every restart because --skip-bootstrap is persistent config. The entire
peer-sync/self-heal machinery (bootstrap_sync.go) was dead code under skip-bootstrap.
Fix: drive the discriminator from SYBIL PROTECTION (the true "real staked network"
signal, already wired into ManagerConfig), not --skip-bootstrap. A sybil-protected
native non-platform chain now keeps its staked beacon set and expectsStakedBeacons
even under --skip-bootstrap, so a behind validator always catches up from peers.
A genuine single-node / dev net runs sybil-protection OFF and still takes the
empty-beacon immediate-start path. A single-VALIDATOR staked net (self-only set) is
handled by a new FrontierTip hasExternalBeacons rule (placed after the P-ready gate),
so it immediate-starts without a Connecting hang while a >=2 set runs the quorum.
This matches the proven live remediation (flipping skip-bootstrap=false via the
.allow-bootstrap marker caught luxd-0 up to tip in ~90s) and preserves every RED
safety invariant (empty staked set still fails safe — TestRED_EmptyStakedSetFailsSafe).
Tests: TestChainExpectsStakedBeacons_SybilDrivesNotSkipBootstrap,
TestNodeBootstrap_SelfOnlyStakedSet_ReportsNoBeacons, and the headline
TestNodeBootstrap_BehindValidator_StakedSet_CatchesUpNoWipe (N-blocks-behind →
restart → catches up from peers to tip, no wipe). Full chains suite green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Both executor backends re-declared a local SharedMemory interface with the
weak Apply(map[ids.ID]interface{}, ...interface{}) signature — but nothing
referenced it. The real atomic path uses Runtime.SharedMemory (= the narrow
atomic.SharedMemory: Apply(map[ids.ID]*atomic.Requests, ...database.Batch)).
Pure dead code; removed both. 28 platformvm packages green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The fx set is a CLOSED sum type (secp256k1fx | nftfx | propertyfx), fixed at
compile time — no hot-loading, no genesis-configured fx. So the runtime
'which fx owns this value' dispatch needs no reflection: it is a total match on
the variant tag. Replaced reflect.TypeOf(val) + map[reflect.Type]int with:
- fxKindOf(val): a Go type switch over the closed set of fx primitive types
(compiler-checked exhaustive; lowers to a jump on the interface type tag),
returning the value's wire.TypeKind — the same family tag the wire envelope
already carries.
- FxIndex: a dense [16]int array indexed by that TypeKind (one bounds-checked
load; -1 = unregistered), filled by the SAME fx.(type) switch NewCustomParser
already ran — no separate reflect registration.
getFx (semantic verifier + tx_init) is now fxKindOf → array index: zero reflect,
zero map-hash, compile-time-checked. Deleted registerFxTypes + all
map[reflect.Type]int fields/params (parser, block/parser, vm, backend). Node-only
(uses already-imported utxo fx types + wire.TypeKind); no dep cascade.
Full xvm suite green (11 pkgs — secp/nft/property verify dispatch exercised).
This removes the LAST reflection from the X-chain tx/verify path.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Superset of transitive module hashes go resolves at v1.36.7; -mod=readonly build
+ full 155-package test suite green. No go.mod change.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
zap v1.2.2 (zero-copy SetBytes + Builder pool) + utxo v0.5.3 (pooled wire
builders + SetBytesFixed) cut X-chain tx wire composition ~1.9x (2551->1345ns,
37->19 allocs on the isolated composite; the X build path benefits proportionally
without touching P-chain parse, which stays at 705ns/3allocs). P/X/xvm/
components-lux suites all green against the new deps.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Supersedes v1.36.4's build recipe which used a build-time 'replace node => /build'
(forbidden: no local replace directives). Instead, chains v1.7.5 pins the real,
published node v1.36.4 tag, so the baked VM plugins resolve node the normal way —
Dockerfile just clones chains and builds; CHAINS_REF v1.7.4 -> v1.7.5; go.mod
chains v1.7.4 -> v1.7.5; genproto realigned. Zero replace directives anywhere.
tidy + -mod=readonly + cold 'go mod download all' clean.
Note: luxfi node/geth/utxo git tags are being periodically wiped by an external
tag-sync (root cause of the 'unknown revision' failures); all four pinned tags
(node v1.36.4, chains v1.7.5, utxo v0.5.1, geth v1.17.12) re-verified present
immediately before this build races the sync window.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The plugin stage cloned chains and built each VM's cmd/plugin standalone, which
resolved chains' pinned github.com/luxfi/node@v1.30.6 — a wiped/disjoint release
tag (325 orphan commits, no merge-base with main) absent from the remote, so the
hermetic build died with 'unknown revision v1.30.6' and the required bridgevm/
mpcvm/zkvm plugins went missing (FATAL).
Fix: inject 'replace github.com/luxfi/node => /build' (the exact vX.Y.Z node source
being built) into every /tmp/chains go.mod before building. This is what the
existing 'plugins in lockstep with the host node' intent actually requires — the
baked plugins now match the node they run in instead of an ancient pin. chains is
the main module during the plugin build, so node(/build)'s own require of chains
resolves back to /tmp/chains (main-module-wins) — no version fetch, no loop. Also
bumped CHAINS_REF default v1.7.2 -> v1.7.4 to match node's go.mod chains pin.
Verified: all 10 required plugins build cold (fresh GOMODCACHE, CGO_ENABLED=0) into
real binaries against local node v1.36.4.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The xvm codec kill depends on utxo/wire's TransferableOut/In + nftfx/propertyfx
envelopes, which were only in the local ~/work/lux/utxo working tree (pinned via
'replace github.com/luxfi/utxo => ../utxo'). That local-path replace works for a
local build but breaks the hermetic Docker/CI build ('open /utxo/go.mod: no such
file or directory'). Published those two additive wire commits as utxo v0.5.1
(clean ff on utxo main, +2 over v0.5.0) and pin it here — the exact code node was
built+tested against. Dropped the replace. Cold-cache 'go mod download all' is now
clean; xvm/components/lux/wallet tests green against v0.5.1.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The codec kill is complete. Every node VM (platformvm, xvm, warp, proposervm,
components/lux) and every chains app-chain VM (bridgevm, zkvm, mpcvm — now native
in chains v1.7.4) marshals via native ZAP struct-is-wire. Nothing imports
node/vms/pcodecs anymore, so the package — the last reflection/serialize-tag
dispatcher (a thin alias over proto/zap_codec's LinearCodec) — is deleted.
- rm vms/pcodecs + vms/pcodecs/pcodecsmock (zero consumers; verified whole tree).
- go.mod: chains v1.7.2 -> v1.7.4 (the pcodecs-free chains), precompile
v0.19.0 -> v0.19.1. Realigned genproto so the split googleapis/rpc module
resolves by longest-prefix (no monolith ambiguity); tidy clean, -mod=readonly
build clean.
- version -> v1.36.4 (constants.go defaultPatch, compatibility.json under the
same RPCChainVM protocol as v1.36.3 — no protocol change, only a codec rip).
There is one and only one way to put a struct on the wire: ZAP.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Tail of the xvm codec kill (ddb3fbca93): the X-chain wallet builder + signer
now rebuild signed wire bytes as unsigned ‖ fx credential envelopes over the
native luxfi/utxo/wire form, and components/lux parses fx Inputs from their wire
envelope by concrete type. No linearcodec, no reflection on the wallet path.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The non-codec main changes (Dockerfile EVM_VERSION v1.104.8, chains/manager
GetContext size-chunking for behind-validator resync + Quasar EXPORT frontier
bridge, warp/signature, rpcchainvm/zap client) that the branch lacked. The
codec-era main files (codec.go/parse.go/tx.go etc.) are intentionally
superseded by this branch's native-ZAP struct-is-wire — not reintroduced.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
RED-verified consensus-safety fix: zap.Parse truncates to the header size
field, so a block buffer with extra tail bytes wraps the SAME message but
ID=hash(bytes) differs — a block-hash malleability / fork vector. setID now
rejects msg.Size() != len(bytes) with ErrExtraSpace. (The P-chain TX envelope
already guards this at tx.go:66; only block Parse had the gap.)
Renamed codec_determinism_test.go -> block_determinism_test.go and converted
its assertions to native ZAP: New*Block + Parse(b) (no Codec), native golden
AbortBlock bytes (65B: zap header + kind/parent/height/time object), byte-
stability + BlockID-stability + trailing-bytes-rejected. Block + txs green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
defaultMinor/Patch -> 36/3 (Dockerfile injects no version ldflags, so the
default IS the released binary's self-reported version; v1.36.0-2 shipped
self-reporting 1.32.11). v1.36.3 registered under RPCChainVM protocol 42.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Node builds + full test sweep green (155 ok / 0 FAIL) against PUBLISHED
modules only: consensus v1.36.1, zap v1.2.0, utxo v0.3.7. The local zap/utxo
replaces are gone; the upstream nftfx/propertyfx wire (utxo e790d39) ships as
v0.3.8 with the xvm struct-is-wire patch, which is the only remaining pcodecs
consumer and lands in the next release.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Route registration, client URL builders, and log lines all move to /v1/
(wallet primary api, chains/rpc handler_manager+chain_integration, xvm
client+wallet_client, xsvm api client, multi-network example, manager logs).
Zero /ext/ literals remain. One endpoint namespace, no transition alias.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Consumes the upstream fx-wire addition (utxo commit e790d39: TypeKindNFT/
Property + 6 shapes + NextEnvelope) needed for the xvm struct-is-wire
migration. Local replace like zap; publish as utxo v0.3.8 with the node
release.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.36.1 bundled staged-but-runtime-untested ZAP work (api v1.0.15->v1.0.16,
vm v1.2.5->v1.2.7, EVM v1.104.7->v1.104.8, platformvm sole-codec cutover,
rpcchainvm Quasar-export plugin-boundary wiring). That bump breaks VM
initialization on EVERY chain at boot:
failed to initialize VM: zap decode initialize response: unexpected EOF
— a node<->plugin ZAP handshake mismatch (the plugins were not synced to the
new api/vm ZAP wire). Mainnet would not boot on v1.36.1.
v1.36.2 drops that unfinished ZAP transition and ships ONLY the orthogonal
consensus finality fix (v1.36.1 — close the intermediate-ancestor load-livelock,
RED-SHIP 0 crit/high/med) on the known-good v1.36.0 dependency set (api v1.0.15,
vm v1.2.5, EVM v1.104.7). Boots clean AND carries the finality fix. luxd builds
green (-mod=mod). The ZAP Quasar-export / codec cutover ships separately once the
plugins are synced to the api/vm bump and it is runtime-validated.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
All 3 warp codec registries deleted (codec.go, message/codec.go,
payload/codec.go). Every dispatched type now carries an EXPLICIT 1-byte
discriminator at object offset 0 — the invariant is DATA, not registration
order:
warp wkind: 0x00 BitSetSignature, 0x01 CoronaSignature,
0x02 EncryptedWarpPayload, 0x03 HybridBLSCoronaSignature,
0x04 TeleportMessage, 0x05 TeleportTransferPayload,
0x06 TeleportAttestPayload (ids = old registration order)
message mkind: 0 ChainToL1Conversion, 1 RegisterL1Validator,
2 L1ValidatorRegistration, 3 L1ValidatorWeight
payload pkind: 0 Hash, 1 AddressedCall
CONSENSUS-CRITICAL invariant made structural: ChainToL1ConversionID =
sha256(ChainToL1ConversionData.Marshal()) — the ID calls the SAME encoder, so
ID/Marshal skew (the bug in the reverted agent attempt) is impossible. The
native hash preimage is pinned as a golden with field-offset assertions
(verified by the L1 staking contract => consensus surface).
Message = {unsigned bytes, wkind-tagged sig bytes} container object;
UnsignedMessage = {networkID u32, sourceChainID 32B, payload} @44B header.
wire_baseline_test.go rewritten: pins the native hex golden for all 7 wkinds
+ structural discriminator checks + signature dispatch round-trips. Old-codec
sentinels -> zap errors. Whole node builds; warp+platformvm+proposervm green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Bump github.com/luxfi/consensus v1.36.0 -> v1.36.1 — canonicalizes the
intermediate-ancestor walk (pathFromTip alias-collapse) that livelocked finality
under sustained saturation (the one finality path the mainnet-644 canonicalization
missed). RED-SHIP, 0 crit/high/med: alias-collapse can only stand in a
byte-identical inner execution (canonicalRep = CanonicalID, a state-root-binding
hash), so no fork risk HEAD lacked. Carries the v1.36.0 platformvm sole-codec
cutover + ZAP Quasar export already on main.
luxd builds green against consensus v1.36.1 (GOFLAGS=-mod=mod, CI parity).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Folds the native-ZAP codec cutover (platformvm sole-codec) + the Quasar EXPORT-frontier
carry across the rpcchainvm ZAP boundary. Whole-node build green.
Collapse P-Chain tx/block/state serialization to a single ZAP-native codec
(CodecVersion=1). This is the gate before the Nova re-genesis: one write
path, one read path, no version dispatch.
Removed the whole multi-version surface:
- txs: registerV0TxTypes, CodecVersionV0/V1/V2, CodecVersionForTimestamp,
CodecForTimestamp, CodecAllowsRead, CodecRequiresLegacy, the Version
alias, and codec_activation.go (ZAPCodecActivationTimestamp).
- block: v0Codec/v0GenesisCodec, the block/v0 package, the lift_v0 path;
Parse is now single-version.
- state: the v0-probe codec_helpers (it Marshal'd at version 0, which
would fire a false warning every boot); GenesisCodec.Unmarshal inlined.
- genesis: single-version alias + comment cleanup.
- warp/bench/network: stale "linearcodec"/"reflectcodec" comments corrected
(all already ride the ZAP-backed pcodecs shim; wire unchanged).
- wallet/chain/p: txs.Version -> txs.CodecVersion.
Value 1 is retained (not renumbered) so no tx ID, block ID, or state root
changes: the surviving codec is exactly the ZAP-native slot the chain
already writes. Every existing serialization golden stays byte-for-byte.
Determinism proof (codec_zap_test.go, block/codec_determinism_test.go):
golden tx/block bytes + IDs, marshal idempotency, round-trip byte-stability
for every tx and block type, and trailing/truncated/wrong-version
rejection. grep -rnE 'linearcodec|reflectcodec|CodecVersion(ForTimestamp|V0|V1)'
vms/platformvm/ is empty.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Closes the deploy-blocking gap in two-tier consensus v1.36: the C-Chain EVM runs
as a SEPARATE rpcchainvm plugin process, so the chain manager's vmTyped is the
rpcchainvm *Client — which did NOT implement SetLastQuasarFinalized /
LastQuasarHeight (those live only on the concrete *evm.VM, the plugin SERVER
side). The manager's capability assert therefore returned !ok in production, the
QuasarObserver stayed nil, and finalized/safe + the warp export gate stuck at
genesis. Nova consensus was unaffected (node-side).
rpcchainvm/zap client (*Client):
- SetLastQuasarFinalized / LastQuasarHeight: ZAP-call the plugin (Msg 60/61).
Both short-circuit if the plugin did not advertise CapQuasarExport, so wiring
them is harmless for a generic plugin (no per-finalization no-op RPC).
- SupportsQuasarExport: reports the capability captured (atomically, once) from
the Initialize handshake's InitializeResponse.Capabilities.
- Fire-and-forget Set (logged, not returned — the caller is the consensus
observer); Height returns 0 on any failure (boot re-seed treats it as empty).
chains/manager createChain:
- quasarExportVM interface (values-not-places: the capability is a value, not a
static type property). Gate the observer + boot re-seed on the capability:
a *Client reports it via SupportsQuasarExport (false → Nova-only, exactly the
old !ok semantics — no cross-process spam); a VM WITHOUT the probe (an
in-process VM whose concrete methods we hold directly) is treated as capable,
preserving the direct-wire path. Observer is set BEFORE NewRuntime captures
netCfg; the seed runs after, carried by the exportVM value.
Test (client_quasar_test.go, -race): the REAL cross-process path — node *Client
-> ZAP wire -> luxfi/vm/rpc server -> a fake VM that satisfies the SAME
capability interface as *evm.VM. Capability captured from the handshake; a pushed
height crosses the wire into the VM; the VM's height round-trips back; a
non-capable plugin is a graceful Nova-only no-op.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Converts the per-type P-chain tx tests to native New*Tx + accessor methods +
roundTrip/SyntacticVerify (agents, verified): add_validator, add_chain_validator,
create_blockchain, disable/increase/register/set_weight/remove_chain/
transfer_ownership L1, add_permissionless_delegator, transform_chain. Every
error-path sentinel preserved by driving the bad value THROUGH the constructor
(pure byte-writer). Mock-based cases (fxmock/luxmock/verifymock, impossible on
an immutable zap buffer) reproduced with REAL unspendable owners / unsorted
inputs → real sentinels (ErrOutputUnspendable, ErrInputIndicesNotSortedUnique).
Only the un-reproducible 'already verified' cached-flag case dropped per file.
NEW staker_dispatch_guard_test.go pins the interface-satisfaction invariant the
staker type-switches depend on: struct-is-wire made *AddValidatorTx satisfy both
ValidatorTx+DelegatorTx (safe — ValidatorTx checked first everywhere), but
*AddDelegatorTx must NOT satisfy ValidatorTx (verified: lacks
ValidationRewardsOwner/Shares) or delegators would mis-route. Guard fails loudly
if a future edit breaks either property.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.36 upstreamed round-scoped view-change INTO the engine (internal prevote/POL
in attestation/reconcile/cert), dropping the external hooks config.Parameters.
ViewChange + Runtime.HandleIncomingPrevote. Ripped the node's now-redundant
external plumbing: the ViewChange enable block (LUX_CONSENSUS_VIEW_CHANGE gate),
the quorumKindPrevote gossip routing + HandleIncomingPrevote call, the dead
BroadcastPrevote gossiper method, and the quorumKindPrevote envelope kind. The
engine owns view-change natively now (fail-secure halt on 2a-n>f unchanged).
Whole node builds EXIT 0 on v1.36.1; luxd binary compiles (55M).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The shared UTXO value tree (UTXO/TransferableOutput/TransferableInput/Asset/
UTXOID/BaseTx/Metadata + OutputOwners path) gets native struct-is-wire
Marshal/Unmarshal (new marshal.go), replacing every pcodecs.Manager. Codec
param dropped from utxo_state / atomic_utxos / transferables (Sort* sorts on
inner fx wire Bytes()); flow_checker pcodecs.Errs -> errors.Join.
Node consensus persistence uses luxfi/utxo (unchanged); components/lux is the
tx-builder/#58 surface — encoding-only change, type tree NOT collapsed (#58
respected). Both P and X share the encoding => internally consistent, re-genesis
safe. Whole node builds EXIT 0; 8 components/lux round-trips + P-chain txs/block
tests green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Last P-chain codec consumer. go build ./... = EXIT 0. The platformvm codec
(pcodecs/txs.Codec/serialize tags) is fully dead; P-chain tx + block + state are
native ZAP struct-is-wire end to end. Remaining codec surface is X-chain +
proposervm + warp (Wave A), which still carry their own (intact) codecs.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
CreateNetworkTx no longer carries genesis chains — the executor never created
them, so tx.Chains() was a latent stub (declared, verified, silently dropped).
Model A resolves it by decomplection, not by implementing a second chain path:
- CreateNetworkTx = ∅→Network birth (owner + security.Mode + own validator set).
- CreateChainTx = the ONE chain constructor. An L1 spawn is a BLOCK of
CreateNetworkTx + N CreateChainTx (block-atomic, not tx-atomic).
- Removed NetworkChain, its wire (writeNetworkChains/readNetworkChains/
ncStride/sliceIDs), the chain error set, MaxNetworkChains, the chains param.
- managerChainIdx (index into genesis chains) -> managerChainID (direct
ids.ID), now SYMMETRIC with ConvertNetworkTx's manager ref. ids.Empty =>
P-Chain-governed; a set chainID => Contract-governed staking-contract host.
Net: less code (write as little as possible), one-and-one-way chain creation,
no stub. platformvm build+vet PASS; SovereignL1/InheritedL2/HybridL2/Convert
round-trips PASS.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
vms/platformvm/... build + vet PASS with pcodecs/txs.Codec gone from the VM.
Executor semantics (standard_tx_executor):
- registerOwnSet(): shared primitive — per-validator state.L1Validator with
native owner blobs (txs.MarshalOwner/UnmarshalOwner, no codec), active-set
capacity check, EndAccumulatedFee=balance+accruedFees, SetNetToL1Conversion
manager-authority recording (byte-for-byte legacy tail).
- ConvertNetworkTx: promote endomorphism (owner-authorized, folds old
ConvertNetworkToL1Tx), gated by security.Mode.Manager.
- CreateNetworkTx: base (AddNet/SetNetOwner) + sovereign path registers own set.
- Deleted CreateSovereignL1Tx + ConvertNetworkToL1Tx.
- txs.UnmarshalOwner added: canonical owner marshal/unmarshal pair, no codec.
All ~13 txs.Visitor impls carry ConvertNetworkTx; gossip/block Parse(b) codec-free;
wallet/network/primary off txs.Codec.
KNOWN GAP (pending design decision, NOT silently green): CreateNetworkTx reads
tx.Chains() only to derive managerChainID — it does NOT create the genesis
chains (no AddChain). Atomic-spawn-with-chains vs decomplect-to-CreateChainTx is
the open call. 17 codec-era _test.go parked as .bak for follow-up rewrite.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
LuxAddress in airdrop.AirdropClaim + bridgevmroot.SignerLeaf is the NATIVE
20-byte Lux address (ids.ShortID / [20]byte), held beside the EVM common.Address
it disambiguates. 'Lux' prefix is banned place-in-value naming; the canonical
pair is EVMAddr / UTXOAddr. Named by address KIND, not brand.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Pull the network security model out of the tx wire into a small orthogonal
package (vms/platformvm/security): pure values, stdlib-only, no zap/tx/state
deps. One definition — security.Mode{RestakeParent, Admission, Threshold,
Manager} + Sovereign() + Valid() — composed by tx wire, executor, and state
(Rich Hickey: values not places; Rob Pike: no stutter, small orthogonal pkg).
Two orthogonal axes replace the flat Inherited/Sovereign byte:
- RestakeParent: lean on parent's validator set
- own set: Admission(NoOwnSet|Open|Gated) + Manager(PChain|Contract)
Their product spans every mode incl. HYBRID L2 (restake AND additive own set),
which the flat byte could not express. Invariant RestakeParent || own-set on
Mode.Valid(); Sovereign derived, never flagged.
CreateNetworkTx + ConvertNetworkTx carry security.Mode on the wire via shared
setSecurity/readSecurity. Round-trip green incl. new HybridL2 case + Convert
target-mode. Wallet builders pass the explicit restaked-L2 Mode.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Blocks are now the zap buffer: kind + parentID@1 + height@33 + time@41 +
tx-length-list@49 + tx-blob@57 (+ proposal-tx@65). commonZapBlock embedded base
mirrors spendingTx. Parse = zap.Parse + kind dispatch (no codec, no version).
Deleted block/codec.go + block/v0 + lift_v0. Round-trip green for abort/commit/
proposal/standard incl real signed txs. go build + go test ./block/ = green.
FINDING: block.GenesisCodec was double-duty — also serialized NON-block STATE
values (feeState, owners, L1Validator, metadata, chains). That's a THIRD codec
surface (state DB serialization) still to migrate for the full kill.
Create and Convert are orthogonal arrows: CreateNetworkTx = ∅→Network (birth,
sovereign-or-inherited); ConvertNetworkTx = Network→Network (promote an
existing network: inherited→sovereign, re-anchor parent — L2→L1, L3→L1, with
owner auth). Only CreateSovereignL1 stays folded (it was birth+sovereign, not
a distinct op). Both reuse the NetworkValidator component. Round-trip green
incl L2→L1 promote.
CreateNetworkTx{parent, owner, security, validators, chains, manager} creates a
network at ANY level in one tx — parent is the level axis (Primary⇒L1, L1⇒L2,
recurse; level=depth, never stored). security is a coproduct: SecuritySovereign
(own validators+manager) | SecurityInherited (restaked from parent). manager
lives on the Network so an inherited L2 holds local admin. NetworkValidator +
NetworkChain are shared value components (reused by CreateChainTx). Deleted
ConvertNetworkToL1Tx + CreateSovereignL1Tx (folded / migration artifacts).
Round-trip green: sovereign L1 (own validators+chains+manager) AND inherited L2
(parent recorded, no own validators, local manager) both survive. One and one
way to make a network at any depth.
zap.ListBuilder.AddBytes counts BYTES not elements; fixed-stride lists were
storing byte-inflated counts -> stride clamp rejected them. Fixed every write
helper to store the real element count (len(entries)). Round-trip test now
GREEN across the hardest cases: multisig+stakeable outputs/inputs, the nested
ConvertNetworkToL1Validator (NodeID+BLS PoP+2 owners), SovereignL1Chain
manifest, and signed unsigned‖creds. go build ./vms/platformvm/txs = exit 0.
The P-chain tx wire is now struct-IS-wire, codec-free, and verified correct.
All 21 real P-chain tx types are now the zap buffer — no codec, no marshal/
unmarshal, no Manager, no version, decompounded, on luxfi/zap. ConvertNetworkToL1Tx
+ CreateSovereignL1Tx hand-written with a shared ConvertNetworkToL1Validator
nested encoder (fixed-stride records + shared NodeID/addr pools) and a
SovereignL1Chain encoder. go build ./vms/platformvm/txs/ = exit 0.
Next: round-trip correctness test, then the external consumer flip.
- SlashValidatorTx ripped (unwired stub; Avalanche has no slashing; consensus
only detects, never enforces). Type+executor+tests+Visitor surface removed.
- CreateAssetTx + OperationTx ripped from P-CHAIN (X-chain/AVM types; LP-0130:
asset creation + fx ops are X-chain money-rail, not P staking-rail; no P
producer). xvm/avm untouched.
- Staker interfaces (StakerTx/ValidatorTx/DelegatorTx/ScheduledStaker) restored
on the 5 validator types via pure accessors + FxID-on-stake preserved.
- Remaining real complex types: ConvertNetworkToL1Tx + CreateSovereignL1Tx.
Wires the consensus EXPORT (Quasar, two-thirds-stake) frontier into the C-Chain VM so
the EVM finalized/safe tags and the warp cross-chain gate resolve to the Quasar tip.
Consensus v1.35.38 -> v1.36.0.
tx.go: Tx holds Unsigned (zap-backed) + Creds; Parse wraps signed bytes and
splits unsigned/creds at the self-delimiting boundary; Sign builds unsigned‖creds;
no codec.Manager param anywhere. Deleted codec.go (V0/V1/V2 reflection Manager)
and codec_activation.go. WIP: consumer sites that passed txs.Codec / called the
old Parse(codec, bytes) flip next.
Parse wraps the buffer zero-copy and dispatches on the 1-byte kind. Signed =
unsigned ‖ creds (both self-delimiting), so unsigned is a byte-prefix of
signed. writeCredsBuf/parseCredsBuf encode credentials natively (shared 65B
sig-blob array). No Marshal/Unmarshal/Manager. WIP: references the 24 pure
type constructors (5 hand-written + 18 in parallel conversion + 1 template).
Wire the two-tier consensus export boundary through the node so the EVM `finalized`/`safe` tags and
the warp export gate track the ⅔-stake Quasar tip, never the reorgable Nova/accept tip:
- Set NetworkConfig.QuasarObserver to push each EXPORT (Quasar) frontier advance into the raw inner
VM (SetLastQuasarFinalized) — the eth/warp backends live there, not on the proposervm wrapper.
Interface-gated: only a VM exposing the export sink (the C-Chain EVM) participates.
- On boot, re-seed the consensus export frontier from the VM's DURABLE Quasar height
(SyncQuasarFrontier) so GetQuasarTip/QuasarHeight do not regress on restart.
Also remove the node's dead references to the v1.36-deleted Tendermint braid (the consensus engine
dropped it in 174af3c31), which no longer compile against the v1.36 engine:
- the LUX_CONSENSUS_VIEW_CHANGE opt-in (params.ViewChange is gone — Nova is the sole decider),
- the quorumKindPrevote gossip kind + BroadcastPrevote + HandleIncomingPrevote routing (no prevotes;
the ⅔ Quasar attestation rides the ordinary accept-vote gossip). Keep the braid dead.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Shared delta-field encoders every non-proposal tx composes on the spending
envelope: owner (fx.Owner), auth (*secp256k1fx.Input), inline Validator (44B)
+ Signer (145B: kind+BLS pubkey+PoP), id lists, extra spending lists. Built on
luxfi/zap. With spending.go, the full reusable foundation — type files are now
pure compositions.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The envelope (NetworkID/BlockchainID/Outs/Ins/Memo) + multisig/stakeable
Output/Input entries + shared owner-address/sig-index arrays, built on
luxfi/zap generic primitives — no codec, no zap_native package. writeSpending/
setEnvelope build inside New*Tx; readEnvelope reads lazily. Polymorphism
(TransferOutput/LockOut, TransferInput/LockIn) handled in explode/assemble.
Foundation every non-proposal tx composes.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
kind.go: 1-byte discriminator @ object offset 0 = the whole dispatch. No
codec, no version, no slot map. AdvanceTimeTx converted to the pure model:
holds *zap.Message, Time() is an offset read, NewAdvanceTimeTx builds once,
Bytes() returns the buffer. No marshal/unmarshal anywhere.
Template for the remaining 21 types. Package migrates atomically (codec.go +
all types + Parse/Sign together), so it compiles green again only when the
whole set + concentrated consumers (executor/builder/fee/api, ~63 New sites)
are converted. WIP.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
ZAP has no serialization step. The bridge copied fields between plain txs.*
structs and the buffer, which is exactly the codec ZAP deletes. Removed.
The right way: tx type IS the zap buffer (accessors, Parse=wrap, Bytes=buffer),
no codec / manager / compat / version. Wire primitives stay in luxfi/zap.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Benchmark-proven live mainnet bug (250-trader DEX load): a validator that falls behind cannot
resync the C-Chain. GetContext (the catch-up "context response", wire = Ancestors) bounded the
response ONLY by block COUNT (maxContextBlocks=256). Under heavy DEX load 256 blocks summed to
3.4-5.7 MB, exceeding the 2 MB peer message cap (the zstd compressor refuses uncompressed input
above constants.DefaultMaxMessageSize to prevent a decompression bomb), so msgCreator.Ancestors
FAILED to build and the behind validator received NOTHING — permanently stuck while the tip
advanced (luxd-3 stuck at 256 while tip went 293→300, looping empty-block builds).
Fix (piece 1/3 of the layered design — defense in depth): GetContext now ALSO bounds the
response by serialized SIZE. It stops adding blocks before the accumulated payload would exceed
byteBudget (cap − 128 KiB envelope margin), but ALWAYS includes at least one block so a behind
node makes progress every round; the requester re-requests the remaining gap (context fetch is
already a multi-round oldest-first fill). A single block that alone exceeds the budget is still
served (best-effort) so the walk never deadlocks — the forthcoming trust-tiered validator cap
(pieces 2/3) gives such a block the send headroom; a stranger's tight cap correctly rejects it.
Tests (chains/context_chunk_test.go): TestGetContext_ChunksBySize_FitsUnderCap (100×150 KiB
chain → 12 blocks / 1.84 MB, under budget, vs ~15 MB packed by count — fail-on-old);
TestGetContext_SingleOversizeBlock_StillServed (one oversize block still served, no deadlock).
Pieces 2/3 to follow: trust-tiered message cap (validator peers get headroom, strangers keep
2 MB) + confirm the inbound throttler/benchlist penalizes oversized-message senders.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Shared spending envelope (NetworkID/BlockchainID/Outs/Ins/Memo) + polymorphic
converters bridging the txs struct graph to proto/zap_native primitives with
zero reflection: TransferOutput/stakeable.LockOut, TransferInput/stakeable.LockIn,
secp256k1fx.Credential. BaseTx (TxKindBaseFull) bridged; creds travel in the
separate creds buffer of the unsigned‖creds envelope.
Round-trip GREEN: 2-of-3 multisig + owner-locktime output, stakeable.LockOut,
stakeable.LockIn input, memo, AND signed secp256k1 credentials all survive
Marshal->Unmarshal with exact field equality; unsigned stays a byte-prefix of
signed. Every embedding tx type now composes this envelope + its delta fields.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Fixes the 'restart-recovery exhausted' mainnet killer: a slipped validator
logged 'cert REFUSED (behind; fetch and retry)' but NEVER fetched, because
HandleIncomingCert only triggered a catchup when the cert's OWN block was
untracked — never for a missing INTERMEDIATE ancestor. v1.35.38 (3c09ecb94)
surfaces the specific missing ancestor as a typed error and the cert handler
fires exactly one requestCatchup for it. The node-layer fetch machinery
(networkCatchup.RequestAncestors -> requestContext -> GetAncestors ->
AcceptCatchupBlock) was already fully wired — it was just never called on this
path. With --skip-bootstrap=true (which disables the beacon-quorum frontier
backstop) this cert-trigger was the ONLY self-heal path, so its absence was
fatal: a behind node couldn't rejoin -> effective 4/5 -> any flap -> stall.
Consensus-side only, no node code change. -race: launch-gate invariant PASS
(rejoin 2.75s, no fork); self-heal + typed-error tests PASS, no data races.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Adds nativeManager implementing pcodecs.Manager by bridging txs structs to
proto/zap_native buffers — no reflection, no serialize-tag walk, no version
dispatch. Signed wire = unsigned_zap_buffer ‖ creds_zap_buffer (ZAP buffers
are self-delimiting), preserving tx.go's unsigned-is-prefix-of-signed
invariant with no tx.go change. AdvanceTimeTx + RewardValidatorTx bridged
and round-trip green (build->Marshal->Unmarshal field equality, byte-stable,
prefix invariant, non-ZAP reject). Added alongside the reflection Codec;
flip + reflection deletion lands once all registered types are bridged.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Third-layer mainnet finality fix. v1.34.27 (consensus v1.35.36) proved the
dynamic committee (alpha=4 n=5) and topology.go cert-receive canonical resolve
work live — consensus climbed past 1085755 to round-height 1085761 — but hit the
next layer: a stale-alias prevote-lock. viewForLocked seeds lockBlock from a
pre-canonical-fix durable committedSlot (an OUTER proposervm-wrapper id); a
round-0 lock on an outer alias makes prevoteTarget compare the stale outer id
against the inner-canonical winner by raw id, mismatch forever, no POL, freeze.
v1.35.37 (742696baf): stepViewChange rebases a stale-alias lock onto the inner
canonical winner IFF it resolves (via the same vmCanonicalResolver the cert
rebase uses) to exactly winnerCanon — a genuinely different inner (real fork) or
unresolvable lock is left fail-closed frozen, never merged. lockRound preserved,
2a-n>f untouched, alpha inner-precommits were always inner (CanonicalVoteMessage
binds inner, excludes outer). Full engine/chain green (356s, 0 fail); -race
clean; RED safety gate proves divergent inners still refused.
Roll note: luxd-4 first releases its lock (log 'vc stale-alias lock REBASED');
finality past 1085755 needs >=4 nodes on this build, so height climbs as 3->2->
1->0 come up. EVM stays v1.104.7 (head-pin).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Unsticks mainnet C-Chain finality. Root cause (consensus-layer, proven): under
pChainHeight=0 anyone-can-propose, each validator wraps the same inner block in
its own outer proposervm envelope; votes are canonical-keyed so an α-of-K cert
forms network-wide, but HandleIncomingCert did a strict envelope-id lookup — a
node holding a different alias of the same inner block missed it and requested
catchup instead of finalizing the local wrapper it held, so VM.Accept was never
called and the EVM head froze at 1085755 while consensus logged 'finalized
voters=4'. v1.35.34 (66438a23b) resolves the local wrapper by canonical id and
rebases the verified cert (outer ids unsigned; α votes verify unchanged), no
fork (per-height gate keys on canonical id, idempotent).
Also: dynamic committee 1→N proven (v1.35.35, effectiveCommittee sizes cert +
view-change from live validator count; n=1→1/1,2→2/2,3→3/3,4→3/4,5→4/5 — the
BFT-safe ⌊2n/3⌋+1); view-change safety gate realigned to the effective
committee (RED#2); snowman comment-purge (Quasar/Nova); dynamic committee logs.
Node commits: 4e71814e58 (proposervm→EVM Accept-cascade test, RED#1),
c13681108f (purge), a02611413e (logs). EVM stays v1.104.7 (head-pin). Full
engine/chain suite green under -race (369s, 0 fail/0 race); RED adversarial
suite proves scale-invariance 1→1M + RLP seam byte-exact on real mainnet state.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The mainnet 1085755 freeze had consensus finalizing an outer proposervm block while the
inner EVM lastAccepted stayed frozen. The consensus engine's ledger advancing on VM.Accept
is proven in engine/chain; this proves the OTHER half at the NODE layer — that VM.Accept on
a proposervm wrapper cascades to the inner block's Accept:
postForkBlock.Accept → acceptOuterBlk → acceptInnerBlk → Tree.Accept(innerBlk) →
innerBlk.Accept ⇒ inner VM lastAccepted advances
- TestAcceptCascade_InnerHeadAdvances_AtScale: 1000 heights through the REAL Accept path;
the inner EVM head + proposervm head advance in lock-step at every height.
- TestAcceptCascade_SiblingStorm_WinnerAdvancesInnerOnce: five distinct outer envelopes wrap
ONE inner block (the anyone-can-propose alias set); accepting the finalized wrapper advances
the shared inner exactly once.
Confirms the propagation was sound, not a node-layer bug — the freeze was the consensus-layer
storm-alias resolution gap (fixed in consensus v1.35.34). The real luxfi/evm RLP-import→produce
→tip byte-exactness is proven separately in evm/core rlp_seam_red_test.go; composed, they cover
the full engine→proposervm→EVM Accept path.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Removes the forbidden Avalanche term from code comments in chains/manager.go,
chains/quorum.go, and vms/proposervm/proposer/windower_determinism_test.go. Reworded to
preserve exact meaning; comments only, no identifier/behavior change.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The "round-scoped view-change ENABLED for chain K=21 alpha=15" line read as if K=21/α=15
were the finality committee — misleading. It is the Snowman SAMPLE preset. The α-of-K
cert and the view-change POL/precommit are sized to the LIVE validator set at runtime
(effectiveCommittee/bftCommittee; 5 validators → K=5/α=4), and the engine already logs
the effective (K,α) on each committee re-clamp. Relabel the fields presetK/presetAlpha
and add a note pointing at the runtime committee-clamp log. Log-only; no behavior change.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Restores mainnet C-Chain finality. The prior stall: with 5 live validators but
MainnetParams K=21/alpha=15, BOTH finality gates (assembleCertLocked cert-alpha
AND view-change POL) required 15 distinct votes — impossible from 5 validators,
so the chain froze (safe, no fork). consensus v1.35.33 sizes both gates from the
live validator count via effectiveCommittee (alpha=4 for n=5), inheriting the
minBFTCommittee K=4/alpha=3 floor (1085013 self-finality guard preserved);
Snowman K=21 sample untouched. node chains/quorum.go adds
validatorStakeSource.ValidatorCount (height-indexed, deterministic). EVM stays
v1.104.7 (head-state pin). Test: 5-validator MainnetParams control freezes,
fix converges in 0.35s.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Closes postmortem residual #1 (proposer-preference restart loop / mute-voter
wedge). Build-side companion to the defect #1 SetPreference validate-before-
assign hardening.
vm.preferred is only adopted after a successful getBlock, but a block fetchable
at preference time can later become unfetchable: an unaccepted sibling consensus
dropped, or a never-persisted outer block referenced after heavy sibling churn.
The old BuildBlock returned after the single getBlock(preferred) miss with
"failed to fetch preferred block", so every attempt failed in a tight loop and
the node's voter went mute (~170 err/s). Quasar cert-finality has no re-converge
poll, so the fleet never recovered on its own — the liveness wedge behind the
mainnet 1082879->1085755 window (heartbeat-pause + full-fleet-restart churn).
Fix: on an unfetchable preferred, build the child on last-accepted (always held:
committed state). The node keeps producing on a valid tip while the catch-up
path pulls the gap; a later SetPreference(held tip) re-advances the preference.
Only surface the original error when last-accepted is itself the unfetchable id.
Tests (hermetic, mirror vm_rejoin_wedge_test.go): spy inner VM asserts the fetch
sequence — BuildBlock consults last-accepted on an unheld preferred (old code
never did), and does not spuriously fetch when no distinct fallback exists.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Same content as v1.34.24 (v1.104.3-headpin == v1.104.7, retagged per semver
policy: real monotonic patch versions, no suffix tags). go.mod has no replace
directives; removes the leftover comment from an already-completed cascade.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Pins luxfi/evm v1.104.3-headpin: dedicated unbalanced GC pin on the accepted
head state root (transferred head-to-head in Accept + startup + direct
setters) so the head can never be evicted by the tipBuffer/Cap while idle —
the durable fix for the 1085200 'missing trie node at head' freeze. Also
carries vm v1.2.6 parity. Base: v1.34.22 (mainnet matcher lineage), no other
changes.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The COMPLETE mainnet reliability build. v1.35.29 adds FIX#1 (self-finality floor — a
transient bftCommittee count can never self-finalize without quorum; the 1085016 accept-
without-quorum safety hole) + FIX#3 (canonical vote aggregation — sibling wrappers of the
same inner block combine into ONE cert; the block-288 wrapper-split cert-termination stall)
+ the v4 clone-resign inner->outer fix (my v1.35.28 re-sign silently no-op'd on real wrapped
blocks). Matcher EVM v1.104.3-hotfix + Bug B UNCHANGED — execution-identical to mainnet.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Bundles BOTH reliability fixes on the mainnet-compatible line (the live test proved they
COMPOUND — Bug A catch-up alone can't reconcile the tip-loss Bug B causes on restart):
- Bug A (consensus v1.35.28, merged to consensus main): catch-up #1/#2/#3/#5 + vote-guard clone re-sign.
- Bug B (EVM v1.104.3-hotfix): head-state Reference one-liner — EXECUTION-IDENTICAL to mainnet
(golden roots identical RED vs GREEN), matcher EVM (precompile v0.19.0), NO settle-only.
go.mod delta vs mainnet v1.34.14 = consensus-only; EVM = matcher+Reference (the only exec delta is
the proven-identical GC pin). STAGED for mainnet recovery — NOT rolled (gated on RED + canary).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
MAINNET-COMPATIBLE node-only patch: the v1.34.14 line + catch-up/preference fixes + vote-
guard, EVM PLUGIN UNCHANGED (EVM_VERSION=v1.104.3, precompile v0.19.0, chains v1.7.2, vm
v1.2.5 — byte-identical to mainnet; NO settle-only, NO state-transition change). go.mod delta
vs v1.34.14 = consensus ONLY. Consensus v1.35.28 touches vote/cert LIVENESS only (engine is
byte-identical across the lines); it carries #2 (GetBlock-guarded build-tip steer) + the
vote-guard clone-artifact re-sign fallback. Cherry-picked #1/#3/#5 (proposervm validate-
before-assign, EmptyNodeID->real peers, have-block!=not-behind cert-fetch) on the prior commit.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The recurring freeze: a validator that fell behind could never rejoin, spamming
"sentTo=0" and "failed to fetch preferred block" until bounced. Two node-layer
defects, both BFT-safe (compared against ava avalanchego proposervm):
#1 proposervm.SetPreference (vms/proposervm/vm.go): assigned vm.preferred BEFORE
fetching, so a build-tip the node doesn't hold POISONED vm.preferred forever
(BuildBlock errors on every attempt; Quasar cert-finality has no re-converge
poll). Now: getBlock (both post-fork + inner stores) FIRST, delegate to inner,
adopt the preference only on success; on a total miss keep the last held tip
and stay live. Identical to ava in every case its single-store invariant
produces; only adds recovery for Lux's build-tip steering (never bricks).
SetPreference is not an acceptance gate, so safety/agreement is untouched.
#3 peer selection (chains/manager.go): consensus signals "fetch a certified-but-
untracked block" via ids.EmptyNodeID; the old code Add(EmptyNodeID)+Send, so
GetAncestors went to ZERO peers and the cert-verified gap was never fetched.
Now: when nodeID is Empty, sample real connected peers that track this chain's
network (same selection pollFrontierOnce uses); the cert already gated the ask.
Adds vm_rejoin_wedge_test.go. (Defect #2 — the engine build-tip steering in
consensus/engine/chain — is the upstream root cause, tracked separately.)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The migration env now requires the operator's observed floor target:
LUX_CONSENSUS_MIGRATE_STALE_LOCKS=apply:<decidedFloor> (1082879 for the
mainnet one-shot). A bare "apply" is refused loudly. Once the chain
recovers and the floor advances, a lingering env self-disarms (Skipped,
no write) instead of degrading into an every-boot prune of genuine
crash locks. Consensus v1.35.26 carries the paired red-review fixes:
self-disarm floor target, durable-finality cert gate, migrated-view
invalidation, height-bound resolver.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Consensus v1.35.25 adds the boot-time stale outer-wrapper lock migration that
unfreezes mainnet C-Chain 1082880 (the durable 3/2 split-lock the canonical=inner
roll left behind — locks stored by outer wrapper id, never re-canonicalized). This
wires it into chain creation:
- LUX_CONSENSUS_MIGRATE_STALE_LOCKS one-shot (K>1 view-change chains), run AFTER
NewRuntime seeds the durable locks and BEFORE Start drives any view:
"inspect" -> InspectLocks: prints the per-height trace table, mutates nothing
(the per-pod audit gate operators run before any write);
"apply" -> MigrateStaleLocks: idempotent, safety-gated canonicalize/prune
ABOVE the decided floor (finalized + vote-guard state <=floor and
the floor itself preserved verbatim); STOP -> fail-closed error.
logStaleLockReport renders the auditable trace table in both modes.
- DEFENSIVE view-change hygiene: refuse to start a view-change chain whose gossiper
is not a QuorumGossiper or that has no VoteSigner (prevotes/precommits would be
emitted into the void -> silent finality stall) — fail loud, not frozen.
Offline cross-pod audit uses consensus cmd/voteguard (CGO-free vote-guard decoder).
Keeps the v1.34.14 canonical fix (Part A proposervm + Part B consensus) intact.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The chain-VM plugins are built with '|| echo WARN' (optional) but hard-COPY'd in
the runtime stage (a build miss -> cryptic COPY failure). Replace the single
bridgevm test-s check with a guard over every mandatory plugin so a missing/empty
binary fails loudly at the build stage with the real cause.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The consensus engine's finality object is VotePosition.CanonicalID (the signed
accept vote binds it and EXCLUDES the outer envelope id). It reads it via a
structural canonicalCommitter assertion (engine/chain canonicalIDOf); until now
NO block type implemented it, so canonicalIDOf fell back to the OUTER proposervm
envelope id. On the stuck C-Chain the designated proposer re-wraps the SAME inner
block each slot -> 758 distinct outer ids over ONE inner block 25Q837Lw -> 758
distinct canonicals -> votes scatter -> no alpha-of-K cert -> frozen at 1082879.
postForkCommonComponents now returns the inner block's id as CanonicalID and the
inner parent's id as ParentCanonicalID, so every outer wrapper of one inner block
(and a forked parent) collapses to ONE consensus object. Enables the consensus
v1.35.23/24 canonical-representative determinism fix to actually engage; neither
part alone unfreezes mainnet.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.34.12 crash-looped on boot:
panic: threshold: scheme BLS already registered
consensus <= v1.35.22 blank-imported the OLD crypto/threshold/bls in
quasar.go while node imports the NEW threshold/scheme/bls; both register
BLS into the crypto/threshold registry -> panic. consensus v1.35.24
repoints quasar.go to threshold/scheme/bls.
Verified on the FULL node binary dep closure (go list -deps ./main, 1028
pkgs): luxfi/crypto/threshold/bls = 0, luxfi/threshold/scheme/bls = 1 —
exactly one BLS registration path, the panic cannot recur.
Pins: consensus v1.35.24, chains v1.7.2, evm v1.104.3.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Supersedes the v1.34.11 image build, which failed in the Docker plugin
stage: chains v1.7.1 pulled consensus v1.25.35, whose polaris.go imports
the removed pulsar/ref/go/pkg/pulsar path — the quantumvm + mpcvm plugins
could not build and their hard COPY failed.
- chains v1.7.1 -> v1.7.2 (consensus bumped to v1.35.22 there; consistent
pulsar graph; all 10 chain VM plugins verified building standalone).
- Dockerfile CHAINS_REF v1.7.1 -> v1.7.2.
- consensus v1.35.21 -> v1.35.22 (Pike-clean of the ProposalKey liveness
fix; logic identical to v1.35.21).
Pins: consensus v1.35.22, chains v1.7.2, evm v1.104.3. Off origin/main,
no WIP.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
IMAGE/BUILD change (packaging only — the consensus behavior change ships
separately as luxfi/consensus v1.35.21). Two edits make the deployable
node image build with the fixed consensus and the current chain plugins:
- consensus pin v1.35.20 -> v1.35.21 (ProposalKey decomplection, commit
776a5119c: outer block IDs are transport aliases, proposal identity is
ProposalKey{ParentID,Height} — proposervm envelope churn can no longer
spawn own sibling candidates that split votes; the mainnet 1082880 fix).
- Dockerfile ARG CHAINS_REF v1.7.0 -> v1.7.1. v1.7.0 still shipped the
pre-rename `thresholdvm` dir, so the Dockerfile's `cd /tmp/chains/mpcvm`
plugin build produced nothing (swallowed by `|| echo WARN`) and the hard
COPY of the mpcvm plugin (tGVBwRxp...) failed — the v1.34.10 Docker build
break. v1.7.1 carries the mpcvm rename; the mpcvm plugin builds clean
(verified standalone, 16MB) and all 10 chain plugins are present.
Pins verified for the canary image: consensus v1.35.21 (=776a5119c),
chains v1.7.1, evm v1.104.3, cevm v0.19.0, dex v1.5.15. Built off
origin/main — no WIP from any other worktree.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Pins node to the published tags carrying this session's LP-0130 work:
ids/constants add M/F chain identifiers (drop T-Chain), genesis adds
M/FChainGenesis, chains has the mpcvm rename, consensus has the
mempool-churn pre-build gate + threshold scheme/bls repoint.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.34.9 = v1.34.8 + the C-Chain EVM plugin rebuilt from evm v1.104.3
(= evm v1.104.1 + luxfi/vm v1.2.6). The only change is the plugin's
ZAP RPC server now serializes VM state calls, fixing the concurrent-map
crash (chain.State.verifiedBlocks) that killed the EVM subprocess under a
concurrent ParseBlock storm and froze Lux mainnet C-Chain at block 1082879.
Node binary, consensus, genesis, and all other plugins are unchanged;
block output is byte-identical, so v1.34.9 is consensus-compatible with the
running v1.34.8 fleet (verified on devnet/testnet before the mainnet roll).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The BLS Scheme impl moved from crypto/threshold/bls to
luxfi/threshold/scheme/bls (all threshold impls now live in one module;
the interface stays in crypto/threshold). One-line blank-import path
change; no behavior change.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Pairs the proposervm stale-parent slot-snap churn-fix (f840336167) with consensus
v1.35.17 (view-change abandonment must never silence a height). Together they close the
distributed VC liveness stall that keeps mainnet C-Chain safe-but-paused: slot-snap kills
the stale-parent rebuild sibling explosion at the source (one stable candidate per slot),
and v1.35.17 keeps the round machinery driving the height to convergence instead of going
silent after rePoll's 8-attempt cap. Liveness-only; decided-floor / no-double-finalize
safety invariant untouched. Gated on the live idle-inclusive devnet re-storm + RED before
any testnet/mainnet roll.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Off a stale parent (idle past the proposer window → anyone-can-propose), buildChild
was stamping a raw wall-clock timestamp (Time().Truncate(1s)), so every rebuild of the
same inner block produced a DISTINCT envelope id. That is an unbounded, ever-growing
sibling set at one height: the round-scoped view-change can never gather α aligned
prevotes on a single candidate (no proof-of-lock forms) and finality stalls fleet-wide
(the distributed liveness stall).
Snap the child timestamp DOWN to the parent-anchored proposer-window grid
(parentTimestamp + slot*WindowDuration, slot = TimeToSlot(parent, now)) so all rebuilds
WITHIN one slot are byte-identical → one stable candidate the view-change converges on.
Safe by construction: TimeToSlot(parent, snapped) == slot == TimeToSlot(parent, now), so
the block's slot — hence ExpectedProposer's proposer-window / signed-unsigned verdict and
the derived epoch — is unchanged. Only slot>0 snapped, so a fresh in-window child keeps
its exact timestamp (zero live-chain change), and a snapped time is strictly > parent
(monotonic) and <= now (not advanced). Liveness-only; no safety surface touched.
Extracted as slotSnappedChildTimestamp + unit-proven (idempotence, slot-invariance,
monotonicity). Full proposervm suite green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two concurrent-map races on the consensus-adjacent validator-set path,
both surfaced by the 5-node storm re-gate, both RED-verified fixed:
1. m.caches concurrent map write (getValidatorSetCache check-then-insert,
reached via proposervm windower.ExpectedProposer across chains during
buildBlocksLocked): guarded with a dedicated cachesLock sync.Mutex
(atomic check-create-store; plain Mutex, no lost-update).
2. TrackedChains concurrent map read/write: getValidatorSetCache read the
network's runtime-mutable cfg.TrackedChains lock-free (the admin
setTrackedChains RPC .Add's it under peersLock). Decoupled via an
immutable startup snapshot (set.Of(cfg.TrackedChains.List()...) in
NewManager); the false 'immutable after startup' comment is corrected.
Snapshot is correct (caching is pure-perf; cache.Empty recomputes),
and the construction clone provably happens-before any writer (P-chain
init is synchronous, admin API serves only post-node.Dispatch()).
Gates multi-validator restart-storm crash-freedom (before validator #2).
-race regression tests reproduce both races pre-fix, clean post-fix
(-count=5). Tracked follow-up (same class, network's live alias, admin-API
default-off, NOT this path): config/internal.go:120-121 + health.go:30 —
fix before ever enabling the admin API.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Harden the bootstrap checkpoint — the one path that bypasses the beacon quorum —
into a cryptographically SIGNED weak-subjectivity anchor.
- Checkpoint carries an authority Signature over a domain-separated canonical
(id,height); CheckpointVerifier (injected, backed by a proven primitive —
Ed25519/BLS, never custom crypto) authenticates it. The policy stays crypto-free
exactly like AncestrySource/heightOf.
- AcceptsFrontier trusts a pinned checkpoint ONLY when the configured authority
signed that exact (id,height). Present-but-unsigned, signed-by-a-non-authority,
signature-transplanted-to-another-(id,height), and no-verifier-wired all FAIL
CLOSED. A compromised flag cannot inject a false sync anchor without also forging
the authority's signature. It is a cryptographic vouch, NEVER a ⅔-live-stake tally.
- No production path pins a checkpoint yet (opt-in operator escape hatch), so the
invariant is enforced for when it is activated; nothing to wire now.
Folds consensus v1.35.16 (single ⅔ formula + leaf-lock doctrine) into the node.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Picks up the DEX 0x9999 genesis-injection fix (evm v1.104.1): 0x9999's
EXTCODESIZE marker is no longer baked into pre-2025 genesis state, so the
archived Lux mainnet (0x3f4fa2a0), Lux testnet (0x1c5fe377) and Zoo mainnet
(0x7c548af4) RLPs re-import byte-identical. 0x9999 activates at its protocol
timestamp (2025-12-25) during replay. rpcChainVM protocol 42 unchanged.
Forward-integrated on v1.34.4 (bootstrap/finality split, consensus v1.35.15) —
v1.34.4 was already taken and still pinned evm v1.104.0. This is a minimal
patch on top: EVM pin only.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Make the bootstrap-trust vs live-finality boundary a HARD API split so neither
can impersonate the other — the mass-recovery deadlock was bootstrap reusing the
finality quorum (>2/3 of CURRENT stake), which is unsatisfiable when the
recovery targets are themselves down validators.
- FinalityQuorum.HasFinality(weight,total): the live rule, strict >2/3 of current
stake (unchanged). Renamed from ConsensusQuorum to the reviewer's exact spec.
- BootstrapTrust.AcceptsFrontier(replies): selects a weak-subjective sync anchor
from AUTHENTICATED CONFIGURED beacons — a response FLOOR (MinResponses) plus a
supermajority over the RESPONDERS, NEVER over the whole set. Distinct type, so
bootstrap cannot call the finality predicate (INVARIANT 3: acceptance != finality;
the node re-executes every synced block before re-entering consensus).
- Contrast proven: 3-of-5 bootstrap ACCEPTS a frontier, yet HasFinality(3/5) is
false and STAYS false after sync — bootstrap is not a finality bypass.
Folds consensus v1.35.15 (the global unlock-before-call-out invariant + the
n=1..10 quorum acceptance matrix) into the node binary.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
consensus v1.35.13 -> v1.35.14 (decomplect: removed the selfVoter shortcut that
self-deadlocked on t.mu.RLock — the live n=1 freeze; K==1 now finalizes solely via the
inline finalizer, one path). EVM_VERSION v1.103.0 -> v1.104.0 (= v1.103.0 + accept-empty:
a consensus-finalized empty block Accepts, so VM.Accept can never fail-closed on an empty
first block — the stale-evm-pin gap). Unblocks single-validator Zoo mainnet/testnet +
Hanzo (all frozen at block 0).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The v1.34.1 n=1 fix passed unit tests but the LIVE single-validator chains still
froze (Zoo mainnet/testnet, Hanzo, Pars, all block-0). Root cause (neo's live repro,
confirmed through the full runtime path): a reentrant-RWMutex self-deadlock — the
single-node selfVoter called engine.ReceiveVote synchronously while buildBlocksLocked
held t.mu, so ReceiveVote's t.mu.RLock deadlocked on the same goroutine (node hangs at
'single-node mode: self-voting', inline finalize never runs, block never decides).
consensus v1.35.13 dispatches the selfVoter's ReceiveVote on a fresh goroutine (no
reentrant lock) — the block now DECIDES through the real NewRuntime path (proven by
TestBlue_SingleValidator_DecidesThroughFullRuntimePath: hangs pre-fix, passes post-fix).
The bump also carries the n=1 synthesize fix (v1.35.11) and the dormant 1→N
decentralization guard (v1.35.12). EVM_VERSION unchanged (v1.103.0) — single-validator
blocks are tx-driven / non-empty.
Gates Zoo mainnet + Zoo testnet + Hanzo (all single-validator).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Prod-line (v1.34.x finality-admission) bump carrying the consensus fix for the
single-validator (K=1) DECIDE stall that wedged all three sovereign
single-validator chains (Zoo 200200, Hanzo 36963, Pars 494949): a K==1 block now
finalizes even when its self-vote is unverifiable against a not-yet-resolvable
single-validator set (synthesized 1-of-1 cert; per-height FinalizeBranch gate is
the safety). n>1 C-Chains (mainnet/testnet) unaffected — they never hit the K()==1
branch.
The bump also carries the (dormant) bounded phantom-floor reconcile
(v1.35.9/v1.35.10) — additive engine methods that this prod-line node never calls
(manager.go on main has no ReconcilePhantomFloor wiring), so behavior is unchanged.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Adds the per-deployment opt-in flag: when LUX_CONSENSUS_VIEW_CHANGE=true and K>1, sets
consensusParams.ViewChange=true so devnet/testnet activate the round-scoped view-change
without a mainnet default (mainnet owner-gated). The engine fail-secure HALTS if the committee
fails 2a-n>f, so enabling never weakens safety. Bumps consensus v1.35.6->v1.35.7 (removes dead
safeConfig dup). This is what makes v1.32.13's prevote transport actually activatable.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Adds the p2p transport for the consensus round-scoped view-change:
- quorumKindPrevote (kind 3) in the quorum-gossip envelope + decodeQuorumGossip accepts it.
- blockHandler.Gossip demux routes kind 3 -> engine.HandleIncomingPrevote(payload) (the engine
decodes+verifies height/round/canonical/sig from the payload, domain LUX/chain/prevote/v1,
and tallies toward a POL).
- networkGossiper.BroadcastPrevote(chainID,networkID,height,round,canonical,voteBytes) frames
kind 3 + broadcasts to ALL validators, mirroring BroadcastVote.
Type-checks against the round-scoped consensus (local replace, chains pkg builds clean).
Requires: consensus tag (with ViewChange + HandleIncomingPrevote + BroadcastPrevote) -> node
go.mod bump -> ARC build -> node v1.32.13 (also carries v1.32.12 evm v1.101.3 descent fix +
isBootstrapped truth fix). ViewChange is opt-in per network (default off); enable on devnet/
testnet only, NO mainnet.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Bootstrap descent no longer stalls on ancestry blocks ahead of the accepted height:
evm v1.101.3 decomplects BlockGasCost from ParseBlock (parse decodes; Verify enforces).
Carries the v1.32.11 isBootstrapped-truth + ancestry-fetch hardening.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Definitive probe: does the descent's served ancestry batch contain the requested
tip (want), and what are the parsed ids vs the serve-side ids — pins whether the
tip survives the Bytes()->ParseBlock round-trip (proposervm wrapping).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
INFO-level logging on both sides of the descent so we can isolate send-vs-serve:
- SEND: sampleAncestorBeacons (weights/connected/ahead/sample), Ancestors SENT
(sampleSize/sentTo), GOT batch / TIMEOUT / msgBuild-fail.
- SERVE: GetContext RECV, walk-miss (which block + i), SERVED 0 (firstFound),
SERVING (containers).
- ROUTING: deliverBootstrapAncestors (dataLen/decodedBlocks/chFound/delivered).
To be reverted once the root cause is pinned. Devnet diagnostic build only.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Documentation-only; no behavior change (v1.32.11 image unaffected). Red review of
the v1.32.11 diff: 0 critical/high/medium, recommendation SHIP.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two node/bootstrap-layer fixes for a clean rolling validator upgrade. Consensus
engine untouched (v1.35.5 boot-seed from v1.32.10 carried forward).
[HIGH — correctness] info.isBootstrapped(C) tracks REAL state, not premature true.
manager.IsBootstrapped(id) returned true the instant a chain merely EXISTED in
m.chains (set right after createChain launched the async, possibly-stalling
bootstrap goroutine) — so a C-Chain stalled at genesis (head 0x0) reported
info.isBootstrapped(C)=true, masking the stall from any wait-for-healthy gate.
Now keys on the SAME sb.Bootstrapped signal the readiness health check uses
(m.Nets.IsChainBootstrapped), set only after runInitialSync reaches the named
frontier and the VM goes to normal operation (head advanced, eth-RPC live).
GetChains().Bootstrapped fixed identically. New per-chain query on nets.Net +
chains.Nets (nil-safe). Regression test TestIsBootstrappedTracksRealConvergence.
[MEDIUM — robustness] WIPE-path GetAncestors fetch hardened for ≥2 peers at genesis.
Applying the proven avalanchego contract (studied in snowman/bootstrap +
getter): (a) Ancestors buffers the whole sample and SKIPS empty batches,
returning the first NON-EMPTY one — a size-1 channel let a fast empty reply from
a genesis peer win the race and starve a peer that actually held the ancestry;
(b) sampleAncestorBeacons PREFERS beacons the frontier round found genuinely
ahead (they hold the ancestry) — ava's PeerTracker "ask a prover" bias sourced
from the replies we already have, no separate tracker. Tests:
TestNodeBootstrap_WipePath_MixedGenesisPeers_ObtainsAncestry,
TestSampleAncestorBeacons_PrefersAheadBeacons.
Build: main+chains+nets+service/info compile CGO_ENABLED=0 (ARC/Dockerfile path);
full chains+nets suites green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
consensus v1.35.5 is a patch-only fix (go.mod byte-identical to v1.35.2..v1.35.4): the
decided-height sign-gate floor is now seeded DIRECTLY from vm.LastAccepted at engine Start
(before signing) and in SyncState, so a node upgrading IN PLACE from a legacy v1 vote-guard
file (floor 0) has a real decided floor from the first instant of boot — closing the
mainnet v1->v2 upgrade window where the floor was 0 until the first post-upgrade finalize.
Sign-gate-only (never enters byHeight/ledger.Height; PART-A intact; only refuses more).
Supersedes the un-rolled node v1.32.9 (consensus v1.35.4) for the mainnet-first roll. No
node code change (fix is internal to the consensus engine). go mod tidy drops an orphaned
luxfi/bft indirect (pre-existing). Full node builds clean on linux/amd64 (cgo off).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
RECOVERY BUILD for the frozen testnet C-Chain. Carries BOTH fresh-multi-node fixes:
- STATE halt (missing trie node) — evm v1.101.2 MaterializeState, from the v1.32.7 tree.
- CONSENSUS double-finalization + liveness stall — consensus v1.35.2 (epoch-blind
vote-once + per-height vote convergence + RED-round hardening).
Deliberately based on v1.32.7, NOT node v1.33.0: v1.33.0 is a mis-numbered DISPROVEN
build (older commit, MISSING the evm v1.101.2 MaterializeState commit, pins the buggy
consensus v1.33.3) — basing on it would regress the state-halt fix. This branch also
EXCLUDES node-main's /ext->/v1 route migration to keep the incident recovery minimal
and avoid breaking /ext/bc/C/rpc tooling mid-recovery.
Recommended tag: node v1.33.1 (forward past the v1.33.0 revert-magnet so the image
tooling cannot revert testnet to the disproven build). NOT tagged here — cutting the
tag may auto-trigger a live deploy; that is a live-incident action requiring explicit
owner authorization.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Drop the Avalanche-heritage /ext prefix; /v1 is the single canonical route
surface (one way, no backward compat). The node's baseURL is the source of
truth; clients, SDKs, CLI, indexer, maker, genesis, netrunner, and the
k8s/compose/gateway/explorer configs are updated to match.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
evm v1.101.1's accept-path guard was confirmed a no-op live (halt reproduced at
block 8 on v1.32.6). v1.101.2 rebuilds pruned parent state on demand at BuildBlock,
which is where the post-accept state loss manifests. Isolated EVM_VERSION bump on
the v1.32.4 baseline.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Isolated bump of EVM_VERSION v1.99.52 -> v1.101.1 on the v1.32.4 baseline.
v1.101.1 core/plugin code == v1.99.52 (verified byte-identical) + the accept-path
equivocation-commit-gap backstop (BlockChain.Accept re-materializes an accepted
block's state if !HasState, on the parent state). Fixes the fresh-genesis testnet/
devnet block-2 'missing trie node' halt. CHAINS_REF/DEX_REF unchanged. Devnet test
build; RED-gated before mainnet.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Pick up consensus v1.33.3 (durable + epoch-keyed + funnel-pruned per-height
vote-once guard) and ACTIVATE its durability in production: buildChain now opens a
per-chain VoteGuardStore (chain.OpenVoteGuard at <chainDataDir>/vote-guard) for
every K>1 signing chain and passes it to the engine via NetworkConfig.VoteGuard.
This closes Red's HIGH-1 on the wire that matters: a signing validator's
(height,epoch)→canonical bindings are fsync'd before it votes and reloaded on
startup, so a crash between casting a vote and finalizing the height cannot forget
the binding and let the restarted node sign a conflicting sibling — the cross-node
fork with zero Byzantine intent under a rolling upgrade / eviction / OOM. The store
is opened at the node boundary so the fail-closed-on-corrupt decision (a signer
refusing to start with equivocation memory it cannot trust) lives here, not in the
engine. luxd builds against pinned v1.33.3.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
consensus v1.33.2 closes the CRITICAL cross-node fork Red found in v1.33.1: the
finality layer now enforces per-height vote-once (an honest validator signs at most
one canonical per height, both signing sites through reserveSlotForSign), so two
conflicting siblings can no longer both gather a >=2/3-stake cert. All 3 Red fork
repros GREEN, emergent probe 15x no fork, engine/chain suite green under -race.
luxd builds clean. Ships with the proposervm liveness fix + luxd-3 ZAP block-id guard.
node v1.32.3 = v1.32.2 + the idle-builder bounded-wake fix, delivered as the
baked C-Chain EVM plugin. The node Dockerfile git-clones luxfi/evm@EVM_VERSION
and builds ./plugin into the image; v1.99.52 = v1.99.51 + startPendingTxPoll
(500ms mempool re-poll so a tx submitted to an idle demand-driven chain wakes
the builder within a bounded window — closes the lost-wakeup/subscribe-gap).
Patch bump only (x.x.x+1 on the pinned v1.99.x line, NOT a jump to v1.100.x);
cherry-picked clean onto v1.99.51 and RED-cleared. Deterministic: wake timing
only, block contents unchanged.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Adopts consensus v1.33.1: the build-path re-solicit of an undecided own
proposal (avalanche repoll alignment) plus the v1.33.0 QCv2 canonical-
commitment finality + incident-1082814 verify-before-slash. Pulls transitive
ids v1.3.0, warp v1.24.0, pulsar v1.9.0, threshold v1.12.0. luxd builds
(GOWORK=off, pinned deps). No node code changes required — consensus public
API (NewRuntime/NetworkConfig/Runtime) is stable across the bump.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two pre-existing reliability bugs surfaced by the v1.32.1 roll (both present
identically in v1.31.5 — NOT caused by QCv2), plus a snapshot-inconsistency
diagnostic.
BUG 1 (HIGH) — concurrent-map crash in getPostForkBlock. The consensus engine
drives the proposervm from multiple handler goroutines concurrently (a
PullQuery/Put verifying a block WRITES verifiedBlocks via verifyAndRecordInnerBlk
while a Qbit handler READS it via GetBlock -> getPostForkBlock; the retry loop in
PullQuery even documents "allow concurrent Put to complete"). The verifiedBlocks
map was read/written with no lock -> the Go runtime aborts with "fatal error:
concurrent map read and map write" (exit 2) under heavy verify load. Fix: a
dedicated verifiedBlocksLock (RWMutex) behind three accessors (cachedVerifiedBlock
/ recordVerifiedBlock / forgetVerifiedBlock) at ALL seven sites; the sibling
Tree.nodes map (touched on the same Verify/Accept paths) gets its own RWMutex,
with Accept restructured to make its inner-VM Accept/Reject callouts OUTSIDE the
lock. Both are leaf locks, never nested, never held across a callout — the lock
graph is a strict DAG (vm.lock > verifiedBlocksLock; tree.lock disjoint), so
deadlock-free. innerBlkCache (lru.SizedCache) is already internally locked.
Regression: TestVerifiedBlocksConcurrentAccess + TestTreeConcurrentAccess hammer
readers vs writers under -race; both FAIL on the pre-fix code with the exact
production signatures ("concurrent map read and map write" / "concurrent map
writes") and pass after.
BUG 3 — a proposervm height index BELOW the inner VM tip (the devnet-C
"index 7 < inner 8" from a snapshot restored inconsistently across the proposervm
and EVM databases) had a cryptic fatal ("should never be lower"). It is now a
LOUD, ACTIONABLE fatal with a recovery runbook. It is deliberately NOT
"self-healed" by dropping the finality pointer: after DeleteLastAccepted,
proposervm.LastAccepted() falls back to the INNER-namespace id, whose ParentID is
contiguity-incompatible with the network's OUTER wrappers — that permanently
wedges bootstrap (first-block anchor), catch-up (parent==tip guard) and live
Verify (parent lookup) at the inner tip (blocks at height <= tip are skipped, so
the missing wrapper is never rebuilt): a SILENT wedge strictly worse than the
loud stop. The only correct remedy is operator action (restore a consistent
snapshot or full resync), which the error now states. The reconciliation decision
is decomplected into a pure classifyHeightRepair(pro, inner) -> heightRelation,
regression-locked by TestClassifyHeightRepair so a revert to a silent reset fails
the test; the fork height is read lazily (only the ahead/rollback path needs it),
so the match and behind paths gain no new failure mode.
RED-reviewed: CHANGE 1 (locks) cleared (deadlock-free DAG, fail-on-old proven);
the original bug-3 silent-reset was caught as a wedge and reworked to this
fail-loud form. Full vms/proposervm/... suite green under -race.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two pre-existing reliability bugs surfaced by the v1.32.1 roll (both present
identically in v1.31.5 — NOT caused by QCv2), plus a snapshot-recovery self-heal.
BUG 1 (HIGH) — concurrent-map crash in getPostForkBlock. The consensus engine
drives the proposervm from multiple handler goroutines concurrently (a
PullQuery/Put verifying a block WRITES verifiedBlocks via verifyAndRecordInnerBlk
while a Qbit handler READS it via GetBlock -> getPostForkBlock); the retry loop
in PullQuery even documents the concurrency ("allow concurrent Put to complete").
The verifiedBlocks map was read/written with no lock -> the Go runtime aborts
with "fatal error: concurrent map read and map write" (exit 2) under heavy
verify load. Fix: a dedicated verifiedBlocksLock (RWMutex) behind three accessors
(cachedVerifiedBlock / recordVerifiedBlock / forgetVerifiedBlock) at ALL seven
sites; the sibling Tree.nodes map (touched on the same Verify/Accept paths) gets
its own RWMutex, with Accept restructured to make its inner-VM Accept/Reject
callouts OUTSIDE the lock. Both are leaf locks, never nested, never held across a
callout — provably deadlock-free. innerBlkCache (lru.SizedCache) is already
internally locked. Regression: TestVerifiedBlocksConcurrentAccess +
TestTreeConcurrentAccess hammer readers vs writers under -race; both FAIL on the
pre-fix code with the exact production signatures ("concurrent map read and map
write" / "concurrent map writes") and pass after.
BUG 3 — a proposervm height index BELOW the inner VM tip (the devnet-C
"index 7 < inner 8" from a partially-restored snapshot) was a FATAL init error
that bricked the whole chain ("error creating required chain" -> exit 1). The
proposervm cannot fabricate the missing wrapper blocks, so it now drops the stale
finality pointer and re-bootstraps from peers via the catch-up transport (the
SAME recovery the forkHeight-rollback branch already uses) instead of crashing.
The reconciliation decision is decomplected into a PURE planHeightRepair
(proHeight, innerHeight, forkHeight) -> repairAction, regression-locked by
TestPlanHeightRepair so a revert to fatal fails the test.
Full vms/proposervm/... suite green under -race.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Asserts the safety/liveness boundary that makes the consensus down/wedged/forked-
proposer fix BFT-safe — the proposer-schedule half of avalanchego Snowman++
(windower.go is byte-identical to ava):
- DETERMINISM (safety): two independent windower instances over the same set, and
the same inputs across 64 seeds, compute byte-identical ExpectedProposer for
every (chainID, height, slot). Honest nodes cannot disagree on the eligible
proposer, so an out-of-turn signed block is rejected by EVERY honest node and an
attacker cannot flood competing accepted blocks / fork.
- OUT-OF-TURN REJECTION (safety): exactly ONE validator is in-turn per slot; every
other is out-of-turn (the windower half of verifyPostDurangoBlockDelay's
errUnexpectedProposer) — no early/out-of-turn acceptance hole.
- SLOT ROTATION (liveness): consecutive slots designate different proposers, so a
down/wedged/forked designated proposer is routed around within a few slots — a
faulty leader cannot halt the chain.
Tests the EXISTING windower (no production change); proves the boundary the
consensus liveness fix relies on holds over the input space.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
arm64 validators (spark GB10, arm64 nodes generally) could not pull
ghcr.io/luxfi/node — the Docker workflow built linux/amd64 only. The
Dockerfile already cross-compiles via TARGETARCH with CGO_ENABLED=0, so
buildx produces arm64 on the amd64 lux-build pool without QEMU-emulating
the compile. Adds a workflow_dispatch `tag` input to (re)build an existing
release tag (e.g. v1.32.1) as a multi-arch manifest.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Pins the incident-1082814 durable fix. consensus v1.32.1 supersedes both the
down-leader line (v1.31.1, what main pinned) and the QCv2-core-only line
(v1.31.2, what node v1.31.5 shipped). Finality keys on the canonical inner
execution commitment; equivocation evidence requires a VERIFIED alpha-of-K cert
over a DIFFERENT canonical, removing the false-slash fatal-exit crash that
destabilized mainnet under DEX-fill load on v1.31.5.
Adds indirect github.com/luxfi/bft v0.1.5 (cert verify gate). Full node module
compiles clean; consensus proof suite 377 subtests + race + vet + fuzz green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
consensus v1.31.1 fixes the down-leader HALT: when a slot-leader proposer is
down, validators built competing siblings at the same height, the alpha-of-K
votes split, no cert assembled, and the chain stalled (devnet stuck at height
36 with 4/5 healthy). The fix converges all validators onto one deterministic
build tip (PreferredBuildTip) so blocks finalize through a validator going down.
Transitive: corona v0.8.0->v0.10.2, dkg v0.3.0->v0.3.5, pulsar v1.2.0->v1.7.1
(the dealerless PQ line consensus v1.31.1 is built against). luxd builds clean.
luxfi/pq's v1.0.3 git tag was force-moved upstream after this go.sum was
recorded. go.sum kept the originally-published zip hash
h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs= (still served by
proxy.golang.org / sum.golang.org). The release builds fetch luxfi modules
direct from GitHub (GOPRIVATE=github.com/luxfi/* => GONOPROXY) on a cold
cache, bypassing the immutable proxy; the moved tag now hashes to
h1:ksw1dmfTR0dqqNMRS7BjGcprCO2Fhc+3Iiq2/NMuONw=, so the goreleaser
darwin_amd64 build (.github/workflows/build.yml) failed strict
-mod=readonly verification:
verifying github.com/luxfi/pq@v1.0.3: checksum mismatch
downloaded: h1:ksw1dmfTR0dqqNMRS7BjGcprCO2Fhc+3Iiq2/NMuONw=
go.sum: h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
SECURITY ERROR
=> v* tags produced no macOS artifacts. (The "downloading luxfi/log" line in
the CI log was a concurrent-download red herring; pq is the culprit.) The
Docker image escapes this because the Dockerfile strips first-party go.sum
lines and rebuilds them under -mod=mod.
Refresh only pq's content hash to the current tag. Version stays v1.0.3 and
the /go.mod hash is unchanged (pq's deps did not change). Keeps the release
build strict and consistent with the Docker image.
Verified: fresh direct fetch verifies readonly against the new go.sum
(exit 0); the exact goreleaser target
(CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags='-s -w'
./main) compiles to a Mach-O x86_64 binary; cold clean-cache darwin
`go mod download` no longer raises SECURITY ERROR.
consensus v1.31.0 on top of the v1.30.0 sibling-tolerant finality already shipped in
node v1.31.1: the finality decomplect (pure Ledger,Cert,DAG->Ledger',Plan fold;
markFinalized unwriteable), topological.go preference layer, the RED HIGH-1 clone()
O(window) perf fix (was O(chain height) — 86.7ms/100MB per finalize at 10^6 heights),
and RED LOW-1/LOW-2 hardening. Backward-compatible: node compiles with zero API drift;
proposervm + chains green. No transitive crypto-dep bumps.
Consume the sibling-tolerant finality fix: cert-driven FinalizeBranch reorg
replaces the per-height admission gate that deadlocked the proposervm
pre-fork->post-fork transition (devnet stuck at finalized height 1 while
blocks produced past 10). Base = v1.31.0 (= v1.30.101 union quasar PQ-finality
+ frontier self-vote) + the repliedCovers eclipse-defense cherry-pick.
Verified: go build ./... clean; chains + proposervm tests green against v1.30.0.
RED CRITICAL: the frontier caught-up shortcut gated on CONNECTIVITY
(fullyConnectedBeacons) while the tally keyed on REPLIES (CaughtUp over
withSelfVote). The two diverge for a beacon that is CONNECTED but SILENT
(TCP up, frontier reply delayed/withheld) — a capability strictly weaker
than a full eclipse, and one that occurs naturally when an ahead beacon
replays state on a mass co-restart. Such a beacon counts as fully
connected yet is absent from the tally, so self's heavy weight backfills
the naming floor and the node goes live at a forged STALE height.
Add repliedCovers() set-containment guard: every connected beacon must
have answered THIS frontier round before the self-vote shortcut can fire.
A silent ahead-beacon now blocks caught-up -> node fails safe to
FrontierConnecting (keeps waiting); a genuine fresh net (every connected
beacon answers genesis) still completes.
Keep RED's zz_red_probe_test.go as a permanent regression guard:
connected-but-silent ahead beacon -> status=FrontierConnecting (was
FrontierCaughtUp). Fresh-net + equal-stake self-vote tests still pass.
Supersede the stale Quasar-wrapper notes with the actual consensus/quasar verify
gate: dormant-by-default safety contract, files/tests, and the owner-gated
remaining-work map (producer+encoder export, gossip ingest, per-epoch validator
provider, config wiring, grace-window runbook) + mainnet activation order.
Red adversarial review of the PQ-finality gate. Dormant default verified a true
no-op; these harden the ACTIVATED path before any forward-dated activation:
H1 (HIGH) — bindCheck now binds cert.Epoch == cp.Epoch (and cert.Round). The
gate resolves verification keys from the cert's epoch; an unbound epoch let a
cert signed under a DIFFERENT validator-set era (e.g. a compromised RETIRED
committee key) certify the current block, nullifying KeyEra rotation as a
blast-radius bound. Honest certs match (producer signs Subject.Epoch==cp.Epoch).
+ wrong-epoch / wrong-round anti-replay test cases.
M2 (MED) — activation is now HEIGHT-ONLY, deterministic. Removed time.Now() (and
the ActivationConfig.Time field) from the accept path: wall-clock gating split
finalization across validators with skewed clocks (some halting on a missing
cert, others finalizing without one). A height is consensus-agreed; timestamp
forward-dating is expressed by choosing the activation height.
L5 (LOW) — an activated gate with a nil store/validator provider now fails
closed with ErrGateMisconfigured instead of panicking in the accept hook (a
panic would halt the chain uncontrollably). Off the dormant path. + test.
M3 (doc) — made the StateRoot=0 transitive-through-BlockHash contract explicit in
bindCheck (non-zero cert StateRoot already rejected + tested) so the producer
follow-on cannot silently ship state-committing certs that self-halt.
proposervm: verifyQuasarFinality short-circuits on nil gate BEFORE building the
Checkpoint, so the default path makes zero block-accessor calls.
13 gate tests green under -race; full proposervm suite green; go build ./... exit 0.
Deferred to follow-ons (gate is dormant + unwired in production): M4 cert-
unavailability halt runbook + grace window, L6 MemCertStore eviction (no ingest
caller yet), proposervm Accept-path integration test, positive valid-cert test
(needs consensus to export cert encoders).
Node-side integration of luxfi/consensus v1.29.0 Quasar compact-cert finality.
luxd finalizes on classical Snow every block; at CHECKPOINTS a sampled committee
QuasarCert over the finalized digest is VERIFIED. This wires the verify half as
an OPTIONAL, FORWARD-DATED, DORMANT-BY-DEFAULT check in the block-accept path.
consensus/quasar (new package):
- Gate.VerifyAccepted: the accept-path safety boundary. nil gate / zero
activation / below-activation-height / non-checkpoint => no-op (classical
Snow unchanged). Post-activation at a checkpoint => REQUIRE a valid cert
bound to the finalized block; fail closed (missing/mismatch/invalid -> error).
- ActivationConfig: forward-dated dormant switch (Height==0 => never; optional
wall-clock Time gate). The safety contract: this branch changes NOTHING about
live finality until the owner sets a real activation height.
- bindCheck: anti-replay binding (cert chain/height/block/state == finalized).
- policyStore: default posture HYBRID_PQ = Beam(BLS) ^ Pulsar (Pulsar verify
~140us < BLS, cert ~27KB); STRICT_DUAL_PQ / POLARIS configurable. The cert
can never select its own (weaker) policy (I1/I2 enforced by consensus).
- ValidatorSet/MemCertStore: the per-leg key + cert-lookup seams (HYBRID_PQ
BLS+Pulsar keys now; Corona/Magnetar/weighted-set + per-epoch resolution are
the activation-wiring follow-on).
- Producer (scaffolding): the committee-sign service contract + MaybeProduce
request site, sharing ONE checkpoint cadence with verify. nil producer =
verify-only (the default). Concrete pulsard signer is the follow-on; it needs
consensus to export the (currently package-private) cert/payload ENCODERS.
proposervm: post_fork_block.Accept calls vm.verifyQuasarFinality(b) BEFORE the
accept commits, so an un-PQ-certified checkpoint halts without persisting. nil
gate (default) => unchanged classical accept. SetQuasarGate installs it.
Tests: 12 gate tests green (dormant no-op, nil-safe, below-activation, time-gate,
non-checkpoint, missing-fails-closed, 4x mismatch/anti-replay, real-verifier
delegation, validator-set-unavailable, producer nil-safety/dormant/active). Full
proposervm suite green; go build ./... exit 0.
On a FRESH net a validator whose own stake makes the PEER-ONLY responder weight
fall below the stake-majority NAMING floor (a heavy validator, a small beacon set
like the P-chain's CustomBeacons, or skewed stake: peers = total-self, so
peers < total/2+1 ⟺ self > total/2-1) could NEVER name a frontier even with the
WHOLE validator set connected — FrontierTip returned FrontierConnecting forever
("bootstrap waiting for beacon connectivity before naming the frontier") and the
node hung, blocking uniform block production.
The node is itself a beacon and knows its OWN accepted tip with certainty. Add the
SELF-VOTE: in the below-floor case, when the node has reached its ENTIRE beacon set
(fullyConnectedBeacons — no eclipse can hide an ahead-tip) and that full set PLUS
itself unanimously hold a tip it has ALREADY ACCEPTED with nobody ahead, return the
new chainbootstrap.FrontierCaughtUp -> the loop completes at the node's own tip.
Safety (proven by tests): self is counted ONLY under FULL connectivity, so an
eclipse suppressing any beacon breaks full-set and falls back to the
partition-capture floor (fail safe) — self can never tip a PARTIAL view into a
false caught-up (stale-go-live guard holds). AcceptsFrontier still tallies PEERS
ONLY, so a behind node's ahead-frontier is named undiluted and it still syncs; a
peer above our height defeats CaughtUp. self only vouches for its OWN accepted tip
so C1 (forged-frontier naming) is untouched.
- selfNodeID threaded from m.NodeID into the blockHandler.
- helpers fullyConnectedBeacons + withSelfVote.
- bump luxfi/consensus v1.29.0 -> v1.29.1 (FrontierCaughtUp status).
Tests: TestNodeBootstrap_FreshNet_SelfVoteUnderFullConnectivity_CaughtUp,
TestNodeBootstrap_SelfVote_PeerAheadDefeatsCaughtUp,
TestNodeBootstrap_SelfVote_PartialConnectivityFailsSafe. Full chains suite green.
The node declared n.StakingTLSSigner (passed to the chain manager → proposervm
StakingLeafSigner) but NEVER assigned it, so proposervm received a nil signer.
The pre-fork→post-fork transition block (height 1) is unsigned and built fine,
but the first SIGNED post-fork block (height 2) called block.Build → key.Sign on
a nil key → SIGSEGV nil-pointer panic → node crash (exit 2), flushing the
mempool and dropping the DEX deploy. Assign n.StakingTLSSigner from the staking
TLS cert's private key (already extracted at this point for the TLS config),
mirroring avalanchego node.go. Now the elected proposer signs post-fork blocks.
Block production + 5-node α-of-K finality verified converging on devnet
(identical block-1 hash across all 5) once this lands.
Addresses RED's DO-NOT-SHIP review of the proposervm re-wrap. Four fixes:
CRITICAL-2 (consensus v1.25.36→v1.25.37): the equivocation handler downgraded
Logger.Crit→Error. The per-height guard already rejects the second conflicting
cert (safety preserved); Crit→os.Exit converted a handled safety event into a
fleet-wide liveness kill (every honest node that observed the gossiped cert
exited) AND made the slashing-evidence loop dead code. Now: log Error, reject,
record DoubleVote evidence, keep running.
CRITICAL-1 (pre_fork_block.go + vm.go): window the pre-fork→post-fork TRANSITION
block. It must stay unsigned (verifyPostForkChild rejects a signed transition),
but WHO builds it is now gated by the windower's ExpectedProposer — only the
elected leader builds the unsigned height-1/N+1 block; non-leaders drop it and
adopt the gossiped block (timeToBuildPreForkTransitionLocked windows the wait).
Without this, every validator built its own transition block stamped with its
local wall-clock second → divergent height-1 blocks → fork at chain start.
CRITICAL-3 (manager.go + config.go + vm.go): thread the resolved networkID into
proposervm.Config and the windower (newWindower) instead of hardcoding
PrimaryNetworkID. On a sovereign L1 (Zoo/Hanzo/Pars, networkID==chainID) the
hardcoded primary set was empty → ErrAnyoneCanPropose → single-proposer
silently did not hold. Resolved ONCE in manager.go so windower and cert side
share the identical set.
Fresh-chain init tolerance (vm.go): repairAcceptedChainByHeight returns nil when
the inner last-accepted block is not retrievable (brand/feature VMs Q/A/G/K
whose genesis references an empty parent → GetBlock 'not found' even though the
ID is not ids.Empty). Nothing to repair; previously crashed the whole node at
init. Complements the ids.Empty guard.
proposervm tests pass.
Completes the multi-validator block-production fix on top of the v1.30.96
proposervm wrap. Three subtle correctness gaps that left single-proposer
NOT actually holding:
CRITICAL-1 (pre-fork→post-fork transition): the FIRST post-fork block (which a
fresh-genesis chain like devnet hits immediately) was forwarded to the inner VM
and built by EVERY validator unconditionally — forking the chain at its very
start. timeToBuildPreForkTransitionLocked now windows the transition block the
same way post-fork blocks are windowed (MinDelayForProposer), so a non-leader
waits its slot and adopts the elected leader's gossiped transition block; a down
leader can't stall (the eligible set widens as the slot advances).
CRITICAL-3 (validator-set ID): the windower hardcoded PrimaryNetworkID, so a
sovereign L1 (Zoo/Hanzo/Pars, validators under their own networkID==chainID) got
an EMPTY set → ErrAnyoneCanPropose → equivocation exactly like the unfixed
C-Chain, AND diverged from the cert's set. config.NetworkID (resolved once in
manager.go: primary for native, else the L1's chainID, fallback to primary if
empty) now binds the windower to the SAME set the cert uses. Zero value
(ids.Empty) IS PrimaryNetworkID, so native chains are byte-identical. newWindower
encapsulates this.
proposervm pkg + full luxd binary build clean; go vet clean (chains, proposervm).
Deploying the proposervm re-wrap crash-looped the whole node on a fresh
re-genesis: repairAcceptedChainByHeight read the inner VM's last-accepted as
ids.Empty (some Lux VMs report the empty ID before their first acceptance) and
called GetBlock(ids.Empty) → 'failed to get block 111...LpoYY: not found' →
VM initialization failed → 'error creating required chain' → node exit(1).
A fresh inner chain has no accepted chain to roll the proposervm height index
back against, so there is nothing to repair. Guard the empty-ID case with an
early return, mirroring the existing 'nothing to repair' returns (and the
empty-handling setLastAcceptedMetadata already does for the same fresh case).
ava never hits this because its inner VMs return a valid genesis last-accepted;
Lux's brand/feature VMs can return empty.
The prior commit (9d9d2a8785) captured only manager.go, which CALLS
shouldWrapInProposerVM but did not include its definition, so HEAD did not
compile (chains/manager.go:1205: undefined: shouldWrapInProposerVM). This adds:
- chains/quorum.go: shouldWrapInProposerVM(k, chainID, innerIsDAGNative) —
the single pure policy gate (K>1 AND not P-Chain AND not DAG-native) that
decides whether a linear chain.ChainVM is wrapped in proposervm for
single-proposer-per-height block production.
- chains/proposervm_wrap_test.go: table tests pinning the gate (C-Chain
devnet/mainnet wrapped; P-Chain, X-Chain, K==1 excluded) and that the K
flows from selectConsensusParams.
- chains/manager.go: gofmt import ordering.
Restores a green build. No push, no tag.
The deeper half of the block-production fix. Lux had commented out the
proposervm wrapper and hand-rolled a WaitForEvent→toEngine bridge that dropped
the single-proposer invariant: every validator's engine called BuildBlock
UNCONDITIONALLY at every height off a slightly-different mempool, so two nodes
could each gather an α-of-K cert for DIFFERENT blocks at the same height →
reportCertEquivocation → Logger.Crit → crash (and the DEX-tx nondeterminism
wedge). That, together with the evm builder-ready race (evm v1.99.51), is why
every multi-validator C-Chain froze.
Re-wrap multi-validator linear chains in proposervm so block production follows
the Snowman++ proposer schedule — exactly ONE validator builds height H, the
rest wait and vote. proposervm.WaitForEvent only returns PendingTxs inside THIS
node's proposer window, collapsing the race at its source.
Gated (all three): K>1 (single-proposer only matters for a quorum), not the
P-Chain (its height-indexed validators.State is published during its own
createChain, after the snapshot used here), not DAG-native (X-Chain's Linearize
bridge doesn't compose with proposervm's pull/window model). Engine, WaitForEvent
bridge, block handler, SetState, and HTTP registration all route through the
wrapped engineVM; unwrapped chains keep the original path byte-for-byte.
chains pkg builds + go vet clean. Validated end-to-end on devnet (5 validators).
P0: all Lux C-Chains (mainnet/testnet/devnet) froze at their imported frontier —
no live block produced. Root cause in the EVM plugin block-build trigger:
1. WaitForEvent blocked forever when the node forwarder called it before the
builder initialized (nil-builder race) — so PendingTxs never reached the
engine and no block ever built.
2. A block built faster than parent.Time+TargetBlockRate has a higher required
fee; a small-tip/legacy tx can't cover it, verifyBlockFee rejects it, stall.
evm v1.99.51 fixes both (builderReady select + target-rate pacing, with a
nextBuildTime pure helper + regression test). chains v1.4.8 rebuilds the
C-Chain VM plugin against it.
Dockerfile: EVM_VERSION v1.99.50->v1.99.51, chains pin + CHAINS_REF
v1.4.7->v1.4.8. node go.mod chains v1.4.7->v1.4.8 (+ tidy for the new
genesis/security transitive). luxd binary builds clean.
Carries evm v1.99.50: paces block building to the target block rate, fixing
(1) small transactions never mining because the rapid-block gas cost exceeds
their tips, and (2) the equivocation crash-loop from validators racing to
finalize conflicting blocks at the same height. Together with v1.99.49
(WaitForEvent builder-ready race), a fresh multi-validator EVM chain now builds
and finalizes blocks under full BFT consensus and accepts ordinary traffic.
Carries evm v1.99.49: fixes the fresh-start block-production stall where the
C-Chain EVM plugin's WaitForEvent blocks forever when the node-side notification
bridge calls it before SetState(NormalOp) initializes the block builder. Without
this, a freshly-booted (or restarted) EVM chain accepts txs into the mempool but
never builds a block. See luxfi/evm#fix/waitforevent-builder-ready-race.
The C-Chain EVM plugin (mgj786NP, where 0x9999 lives) is built from EVM_VERSION,
NOT node's go.mod. v1.30.91 bumped the binary deps but the plugin still built from
the stale v1.99.40 (precompile v0.12.0, no DEX fix). Bump EVM_VERSION→v1.99.47
(precompile v0.15.0 active-only DEX money path) + chains v1.3.21→v1.3.22 to match.
Now the deployed plugin actually carries the real-fill DEX code.
warp v1.23.0 = typed finality-evidence model + Pulse->Corona relabel.
No node package references the renamed warp symbols: go build ./... is green
and every warp test passes (vms/platformvm/warp{,/message,/payload,/zwarp},
vms/platformvm/network, vms/platformvm, vms/example/xsvm). Pure dependency bump.
Note: vms/platformvm/txs test binary has a pre-existing, warp-unrelated compile
error (add_validator_test.go:373 undefined: fmt) that predates v1.30.88 and is
not addressed here.
A validator holding gossiped-but-unaccepted blocks (in the VM store, lastAccepted
below them) concluded caught-up at its stale height forever — the caught-up check
conflated store presence (Has/heldHeight via vm.GetBlock) with acceptance.
Fix (consensus v1.25.36): Has→Accepted (a stored-but-unaccepted named frontier
descends+ACCEPTS through the cert-gated per-height guard); recognize convergence
off the in-process consensus finalized ledger, not the frozen VM.LastAccepted
cache; un-freeze the zap client's lastAcceptedID on Accept. Proven on mainnet
luxd-2 (1082780→1082796). C1/VerifyWeighted/cert-gate intact. blue→red→blue→red.
The v1.30.84 bootstrap-vm-ready ordering fix passed 50 unit tests + the BFT
proof but FAILED the real mainnet canary: a STALE spare (luxd-2) at C-Chain
accepted height 1082780 went Ready at 1082780 instead of fetching the 16 blocks
to the producers' frontier 1082796, then sat frozen. The mocks injected the
FULL, STABLE validator set from t=0 — masking the production trigger.
ROOT CAUSE (primary). A native chain's runBootstrapThenPoll starts right after
its VM.Initialize (dispatchChainCreator), NOT after the P-chain has finished
its own initial sync. The P-chain populates m.Validators as it replays blocks
(genesis stakers + every AddValidatorTx), so FrontierTip can run while
GetMap(PrimaryNetworkID) is a PARTIAL set. The MinResponseWeight stake-majority
floor (total/2+1) is fail-secure ONLY when `total` is the TRUE full-set stake;
under a partial denominator a degenerate at-or-below responder subset (the
genuinely-ahead producers not yet loaded) clears the floor and the exact-fast-
path / CaughtUp concludes "caught up" at the stale local height. Proven by
elimination: with the full set EVERY accurate-reply path is fail-safe (wait /
sync), so the freeze requires a non-representative responder set.
FIX (primary): gate a native non-platform chain's frontier-trust on the P-chain
having COMPLETED initial sync (m.pChainBootstrapped, published by
monitorBootstrap; read via blockHandler.primaryNetworkReady). Until then
FrontierTip returns FrontierConnecting (WAIT), so every branch judges the TRUE
full validator set and the existing floor logic holds. Deadlock-free: the
P-chain converges independently from its own configured CustomBeacons.
FIX (self-heal / the "ALSO FIX"): pollFrontierOnce filtered peers on b.chainID —
the IDENTICAL chainID/networkID confusion already fixed in connectedBeacons.
Peers advertise the NET they track, never an individual native chain id, so the
sample was always empty and a behind-but-Ready validator never discovered it was
behind. Filter on b.networkID so the NormalOp catch-up poller actually fires.
INSTRUMENTATION: FrontierTip now logs (Debug) the full beacon-set size + total
stake (the floor DENOMINATOR — a partial value is the smoking gun), connected
count, and every reply's tip + locally-resolved height + held-or-not, plus the
decision. Set the C-chain log level to Debug on the canary to capture ground
truth.
TESTS (production-modeling, not the ideal full set): TestRED_PartialStakedSet_
BehindNodeMustNotGoLiveStale reproduces the freeze (without the gate it NAMES the
stale own tip; with it, WAITS; then converges on the full set) — proven
load-bearing (disabling the gate fails the test). Plus not-held-tips → sync,
empty-ancestry → stays Bootstrapping (never Ready stale), and peer-connect-delay
→ wait-then-converge. Full chains suite green (C1, co-restart, M1,
mass-recovery, skewed-weight all preserved), race-clean.
nameFrontier's ancestor-tolerant filter was `ref.Height < MinFrontierHeight`
(MinFrontierHeight = the node's own last-accepted), so a block AT the node's
own height passed the filter and could be NAMED. Red's M1 eclipse: with the
genuinely-ahead responders throttled below the ⅔ naming threshold (R_a < ⅔R)
but the at-height responders let through, the node's OWN height accrues ⅔
purely as the shared ANCESTOR of the ahead N+5 tips → nameFrontier names it →
FrontierNamed at own height → the node goes Ready STALE (5 blocks behind a
finalized N+5). nameFrontier ran BEFORE CaughtUp in FrontierTip, so it
short-circuited the stricter CaughtUp determination that already refuses this.
FIX (one operator): `ref.Height <= MinFrontierHeight` — own height is excluded
from naming, routing the at-own-height case to CaughtUp, which alone
distinguishes a legit all-at-N fleet (Ready at N) from an eclipse with ahead
tips the node lacks (refuse → sync). The two paths compose with no gap:
nameFrontier names STRICTLY ABOVE own height (behind nodes); CaughtUp decides
at-or-below own height (tip-holders); the fast path (active ⅔-direct report)
stays exempt for the legit unanimous-at-tip case.
Safe for layer-5 + C1: a behind node names HIGHER than its own height (survives
both the old `<` and new `<=`); the ⅔-of-responder-weight naming requirement is
untouched (only height eligibility tightens — strictly safer). Round-1
behind-node convergence is unchanged.
Faithful test correction (manager.go: GetAcceptedFrontier answers vm.LastAccepted
— the last-ACCEPTED block, NEVER an un-finalized preferred tip): the two
ancestor-tolerant convergence integration tests modeled a beacon reporting an
un-finalized tip FOREVER (a gap-1 instance of this very M1 stale-go-live). They
are restated to model finalization PROPAGATION — the node names the ⅔-common as
a behind node, then RATCHETS to the true finalized tip as the laggards' accepted
frontier advances — converging to the real frontier (stronger guarantee), never
falsely caught-up below it.
Tests (RED-before by reverting the one-operator hunk, GREEN-after):
- TestBootstrapTrust_EclipseOwnHeightNotNamedRoutesToCaughtUp (policy, deterministic
+ own-height boundary)
- TestRED_EclipseOwnHeightNotNamedRoutesToSync (integration: FrontierNoQuorum +
full-loop fails safe at stale N + eclipse-lifts→syncs-up)
GOWORK=off CGO_ENABLED=0 go test ./chains/ — all green (layer-5 convergence, C1
forged/eclipse/partition, caught-up co-restart, fast-path, self-heal).
RED found a CRITICAL liveness regression in the VM-ready gate (b313912413): a
tip-holding producer on a mixed-height co-restart cannot satisfy its own go-live
gate and fails safe DOWN at its own tip — the OPPOSITE of the stale-go-live bug the
gate fixed, and just as wrong.
Mechanism (PoC reproduced): a producer at N sees its 4 peers split
{2 producers@N, luxd-2@N-16, luxd-0@genesis}. The tip-holders are only ½ of the
responders (< ⅔) so no frontier is NAMED; the ⅔-backed common ancestor N-16 is
below the node's own last-accepted (MinFrontierHeight filters it). → ErrNoBeaconQuorum
→ runInitialSync false → C-Chain STOPPED. The loop's Has(tip)→caughtUp shortcut is
only reached AFTER FrontierNamed, which a tip-holder never gets.
PRIMARY FIX — a CAUGHT-UP determination (BootstrapPolicy.CaughtUp), the dual of
AcceptsFrontier ("nobody is ahead" vs "here is the block ahead"). On the
ErrNoBootstrapQuorum branch, FrontierTip concludes caught-up and names the node's
OWN held tip (→ the loop's existing Has()-shortcut → Ready at own height) iff:
(a) the SAME response floor AcceptsFrontier uses is met (MinResponses count AND
MinResponseWeight stake-majority) — an eclipse hiding the ahead-nodes drops
below the floor → fail safe, no partition-capture;
(b) every responder's reported ACCEPTED tip is at height ≤ lastAccepted — a stale
node has an honest responder ahead → still syncs (stale-go-live stays fixed);
(c) the node HOLDS every reported tip (heightOf injected, VM-free) — never declares
caught-up to a sibling/fork it lacks.
floorMet + tallyResponders are extracted so naming and caught-up share ONE floor and
ONE eligibility rule (DRY). No consensus change — reuses FrontierNamed + Has, builds
GOWORK=off against the deployed consensus v1.25.35.
SELF-HEAL (RED MEDIUM #2) — the K8s probes all poll the always-green
/ext/health/liveness, so a fail-safe-DOWN node is NEVER restarted (a permanent brick).
runInitialSync now RE-ATTEMPTS a transient connectivity fail-safe (bootstrapMaxAttempts
≤ 0 ⇒ until the quorum returns or shutdown), staying in Bootstrapping (never live at
stale height) and converging the instant the quorum returns. bootstrapConnecting tells
monitorBootstrap's no-progress watchdog this is a deliberate WAIT (not a stall) so it
does not force-STOP a node correctly waiting for its quorum. Structural failures (deep
gap → state-sync) are NOT retried.
LOW — FinishBootstrap now runs BEFORE transitionVMReady (close the no-cert
AcceptBootstrapBlock gate before the VM can build); transitionVMReady's SetState timeout
is derived from the shutdown ctx (was context.Background()).
MEDIUM — createDAG (X/Q) go-live left UNCONDITIONAL, now documented as a DELIBERATE
linear-only scope (mainnet froze on linear C/D/B/T/Z; DAG bootstrap convergence is a
separate task). createDAG does not regress.
C1, VerifyWeighted, the ⅔-stake cert, and "no VerifiedQuorumCert no finality" are
untouched. Tests (chains): TestRED_TipHolderCoRestartGoesReadyAtOwnTip reproduces the
regression — RED before on b313912413 (FrontierNoQuorum, live==false), GREEN after
(FrontierNamed at own tip, vmReady once at N). Plus 6 CaughtUp unit tests (incl. both
adversarial fake-caught-up cases), TestRED_MajorityOutageSelfHealsWhenQuorumReturns,
and TestWatchBootstrapProgress_ConnectingNotStalled. Full C1/forged/eclipse/partition
suite re-run green (41 bootstrap tests, 0 fail).
Root cause of the restart-freeze (mainnet luxd-2: C-Chain stuck at 1082780
while producers finalized 1082796): buildChain called SetState(vm.Ready)
UNCONDITIONALLY right after Initialize — at the LOCAL last-accepted height —
and ran the node-layer initial sync (runBootstrapThenPoll) afterward as a
detached, non-gating goroutine. vm.Ready fires the EVM's
onNormalOperationsStarted (block building, mempool gossip, validator dispatch);
a restarted STALE validator therefore went live at its stale height and never
converged to the finalized frontier. The proper vm.Bootstrapping state (the
EVM's onBootstrapStarted: snapshots only, no building) was skipped entirely.
Fix — restore the correct VM lifecycle, gated:
- buildChain now SetState(vm.Bootstrapping) after init (not Ready). The VM
fetch+executes the gap in Bootstrapping, serving nothing as head.
- The Ready transition moves into the bootstrap goroutine (runInitialSync,
split out of runBootstrapThenPoll for unit-testability) and fires ONLY after
bs.Run reaches the named ⅔-by-stake beacon frontier — VM live + engine
cert-gate (FinishBootstrap) go together at the frontier, never at a stale
height. Wired via blockHandler.vmReady (captures the VM's SetState).
- Fresh-genesis / single-node (FrontierNoBeacons) and at-the-tip restart
(caught-up) go Ready promptly — no regression, no hang.
- Eclipse / isolation: bs.Run's bounded ConnectDeadline is the retry that
self-heals when beacons return; past it the node fails SAFE (stays
Bootstrapping, records the reason for monitorBootstrap) — never serves stale
state, never an unbounded hang. The orchestrator restart now converges.
C1 forged-chain defense, VerifyWeighted, and the ⅔-stake quorum cert are
untouched — the frontier is still named only by the configured-beacon
⅔-of-responders quorum + content-addressed descent (all RED/BootstrapTrust
tests pass). AcceptedFrontier/Ancestors replies route to the bootstrap channels
via bsActive throughout the phase (verified).
Tests (chains): TestRED_StaleValidatorGoesReadyOnlyAfterReachingFrontier
reproduces the production scenario — RED before (VM goes live at stale N=30),
GREEN after (only at frontier N+K=46). Plus fresh-genesis no-regression,
bounded eclipse fail-safe, and at-the-tip producer fast-Ready. Race-clean.
NOTE (flagged for review, NOT in this change): the createDAG path (X/Q DAG
chains, consensusdag.Engine) has the same unconditional SetState(Ready) at
manager.go:1792 but a different engine without the node-layer bootstrap-sync
wiring — a separate fix if any production chain uses it.
Mutation-proven gap: H/D2 construct policies directly, so nothing caught the
production constructor silently dropping the stake-majority floor. This asserts
bootstrapPolicy() emits MinResponseWeight=⌈total/2⌉ (skewed), the count floor,
the AncestrySource, no-re-deadlock for equal-weight 3-of-5, and disabled-on-empty.
Re-red verdict was SHIP; this closes the only (LOW) residual.
The MinResponses COUNT floor and the ⅔-of-responders WEIGHT agreement diverge under
SKEWED validator stake: an attacker eclipsing the HEAVY honest beacons while passing
enough LIGHT honest ones to clear the count can shrink the responder-weight denominator
until <⅓-of-total Byzantine stake names a forged frontier (re-red PoC: w={3,3,13,1,1,1},
Byz 27%). Fix: bootstrapPolicy() now sets MinResponseWeight=⌈total/2⌉ (the field was
wired into AcceptsFrontier but left 0). Proven safe AND deadlock-free: equal-weight
recovery needs only >½ (3/5=0.6≥0.5), not the >⅔ that caused the original deadlock.
Closes the re-red HIGH + INFO-2 (same root cause). Tests H (skewed→reject, load-bearing)
+ D2 (non-configured swarm→nothing). Required for non-equal-weight validator sets
(validators-come-and-go). C1/A-G/finality all green.
THE DEADLOCK (mainnet, node v1.30.79): 5 equal-weight validators (each
0.5e18, total 2.5e18, ⅔ floor 1.667e18). The 2 stranded recovery targets
ARE 2 of the 5; with them down only 3 producers connect = 1.5e18 = 60% < ⅔.
The prior bootstrap frontier quorum required ⅔ of CURRENT TOTAL stake to be
CONNECTED before naming a sync frontier — mathematically unsatisfiable during
a mass outage when the down nodes are themselves validators — so no node could
ever recover. Bootstrap trust was braided into consensus finality, and
finality's ⅔ rule cannot be met when ⅓+ of the set is the thing recovering.
THE FIX — a SEPARATE type with a SEPARATE threat model, not a renamed
threshold (chains/bootstrap_trust.go):
- ConsensusQuorum.HasFinality(weight,total): > ⅔ CURRENT stake — UNCHANGED;
present only to NAME the live rule and CONTRAST it.
- BootstrapTrust.AcceptsFrontier(ctx,replies): selects a weak-subjective sync
anchor from authenticated CONFIGURED beacons. NOT a finality oracle.
- BootstrapPolicy: TrustedBeacons (the configured/checkpoint/genesis anchor —
never peer self-report), AgreementThreshold (⅔ of RESPONDERS), MinResponses
(a response FLOOR), MinResponseWeight, Checkpoint (operator override).
Three invariants:
1. NON-CIRCULAR eligibility — only NodeIDs in TrustedBeacons count; peers
never define who is a beacon.
2. A response FLOOR prevents partition-capture — MinResponses (default: a
MAJORITY of the configured set) authenticated beacons must respond; below
it, REJECT unless an operator checkpoint is pinned. For the 5-validator
mainnet (MinResponses=3) the 3 reachable producers recover while a
2-beacon partition is rejected.
3. Acceptance ≠ finality — the named Frontier is "safe to begin sync from",
re-executed during descent; live acceptance stays governed by
ConsensusQuorum alone.
The acceptance gate (FrontierTip) now routes through BootstrapPolicy: the
⅔-of-CURRENT-TOTAL connect gate is GONE; agreement is ⅔ of the RESPONDERS over
a MinResponses floor. The ancestor-tolerant common-ancestor tally is reused
(now a global cross-anchor union tally so a sibling split converges to the
shared committed block — case F), bounded below by MinFrontierHeight (the
node's last-accepted height) so a fork sharing only history BELOW the node
fails safe instead of false-completing at the deep ancestor. Consensus
(v1.25.35) is UNTOUCHED — the FrontierStatus interface is unchanged and its
engine/chain suite stays green.
Tests (chains green; consensus engine/chain green): A mass-recovery success,
B one-beacon capture rejected, C two-beacon partition rejected, D
non-configured peer ignored, E minority-configured forgery rejected (forger
ratifies the real block, never names its forgery), F split reachable ancestry
selects the common ancestor, G finality unchanged (3-of-5 accepts for
bootstrap but is NOT HasFinality). Plus checkpoint override and a load-bearing
fork-at-shared-genesis fail-safe guard for the global tally. The reframed
SubAlpha→SubQuorum test replaces the ⅔-of-total assertion that WAS the bug.
Each guard proven load-bearing by revert: restore the ⅔-of-total gate and A
deadlocks; drop the configured-beacon filter and D captures; drop
MinFrontierHeight and the shared-genesis fork false-completes.
Branch only; not deployed. Re-canary luxd-2.
The last holdout on the deleted RLP UnsignedMessage API. The four spots that
import the external github.com/luxfi/warp now use warp.Core (ZAP); node's OWN
vms/platformvm/warp package is a separate implementation, untouched.
vms/platformvm/vm.go, vms/platformvm/network/network.go: warpSignerAdapter
bridges node's internal warp signer <-> external extwarp.Core
vms/platformvm/network/warp_zap.go, vms/example/xsvm/warp.go: Verify over
*warp.Core
deps: warp v1.19.5 -> v1.21.0, precompile v0.5.59 -> v0.11.0 (indirect — the old
precompileconfig referenced the deleted warp.UnsignedMessage).
go build ./... clean (consensus v1.25.35 now publishes FrontierStatus, clearing
the prior blocker); node's own warp + xsvm + network adapter tests green. The
whole ecosystem is now on one warp wire format — one and one way only.
The exact-tip frontier quorum required ⅔-by-stake of beacons to report the
IDENTICAL accepted tip. On a live chain validators are legitimately ±1 block
apart at the bleeding edge, so the freshest tip splits and no single id draws
⅔. The mainnet canary (luxd-2): 2 producers had accepted block N, the third
producer's un-finalized PENDING block was N+1; neither tip alone cleared ⅔, so
FrontierTip returned NoQuorum forever and the healthy stale node failed safe at
its stale height instead of converging. The chain was idle, so the split was
STABLE and never resolved.
FIX (node-only — the stake quorum lives where the stake lives; consensus stays
stake-agnostic and just descends from whatever block is named): make the tally
ANCESTOR-TOLERANT. A beacon reporting tip N+1 also has N in its accepted chain,
so the named frontier is the HIGHEST block a ⅔-by-stake supermajority SHARES
(B == their tip OR B is an ancestor of their tip), not the highest proposal. N
draws all three producers (300 > 200) and is named; N+1 draws only the lone
producer and is not. The node syncs to N (the ⅔-agreed committed height); live
consensus catch-up handles N+1.
Ancestry is learned by CONTENT (reusing the parent-link descent the sync loop
trusts): for each candidate tip, fetch its ancestry, rebuild the parent-linked
chain, and cumulatively tally — the highest block clearing the floor across
anchors is named. A credit is truthful because a block's parent id is fixed by
its content, so a peer cannot fake the linkage below an honestly-reported tip.
C1 preserved EXACTLY: a block is named only with > ⅔ of the TOTAL real
staked-beacon weight and ≥ required distinct voters; a forged or sub-⅔ tip on a
side-fork shares only the honest prefix and can never raise the named block above
the genuine ⅔-common height. Only the agreement RELATION changed (exact-tip →
in-the-accepted-chain), never the ⅔-by-stake-of-the-real-set requirement. The
content-addressed descent, per-height guard, re-execution, weak-subjectivity
anchor, F2 bounded-NoQuorum retry, deep-gap fail-safe and single-node completion
are all untouched (consensus engine/chain/... unchanged + green).
Also: resolve the quorum the instant ALL connected beacons have answered (no
exact ⅔), instead of waiting out the full frontier window — so a live-frontier
split converges in one round instead of stalling a window each pass.
Tests (chains green; consensus engine/chain/... green): TestRED_TipSplitConverges-
ToCommonHeight (the canary — converges to N, not the minority N+1, not fail-safe;
proven load-bearing: 2-of-3 = 200 is NOT > the 200 floor, so the exact tally
cannot name N), TestRED_ForgedHighTipFromMinorityNotNamed (a Byzantine beacon's
forged block built ON real N ratifies only N, never names itself — C1),
TestNodeBootstrap_ExactQuorumUsesFastPath (unanimous ⅔ still names the exact tip
with NO fetch), TestNodeBootstrap_BeaconsSplitNoQuorum (disjoint 3/3 partition,
ancestry served, STILL NoQuorum — ancestor tolerance does not manufacture a
quorum). NOT deployed — re-canary luxd-2.
THE CANARY (luxd-2, node v1.30.77): a healthy STALE node (C-Chain 1082780,
gap-17 to producers at 1082797) rolled to the convergence fix logged
"bootstrap waiting for beacon connectivity..." x10 then failed SAFE at the
connect deadline — "beacon set never reached the connectivity needed to form a
⅔ quorum ... eclipsed/partitioned" — despite connectedPeers=9 and the
producers being right there. Correct fail-safe, but it did NOT converge.
DIAGNOSIS: the beacon set was ALREADY the stake-weighted validator set, not
the --bootstrap-nodes endpoints. For a native chain (C-Chain) manager.go wires
beacons = m.Validators (n.vdrs, populated by the bootstrapped P-chain's
state.initValidatorSets under PrimaryNetworkID); the --bootstrap-nodes endpoint
beacons are a SEPARATE weight-1 set used only as the P-chain's CustomBeacons.
FrontierConnecting (not FrontierNoBeacons) confirms m.Validators was non-empty.
The real bug was the CONNECTIVITY filter: connectedBeacons admitted a connected
beacon only if p.TrackedChains.Contains(b.chainID). But a peer advertises the
NETS it tracks (network/peer/peer.go adds constants.PrimaryNetworkID + every
tracked L1 net ID to peer.Info.TrackedChains) — it NEVER advertises an
individual native chain ID. So the filter matched ZERO real peers,
connectedStake stayed 0, the ⅔ floor was never reachable, and FrontierTip
reported FrontierConnecting until the deadline. The tests passed only because
the mock modeled TrackedChains as set.Of(chainID) — agreeing with the bug, not
reality.
FIX (node-only; consensus FrontierStatus logic unchanged and correct):
- connectedBeacons filters on b.networkID (the NET the beacon set is anchored
to — PrimaryNetworkID for native chains, the L1 net ID for sovereign chains),
the value real peers actually advertise. C1 PRESERVED: a peer is still
admitted ONLY if it is in the staked set (weights); the ⅔-by-stake threshold,
the TOTAL-stake denominator, and the content-addressed descent are untouched.
A non-staked peer that tracks the network is still ignored.
- EDGE (4): expectsStakedBeacons (true for a native non-platform chain on a
sybil-protected network) makes an EMPTY staked set report FrontierConnecting
(wait → ConnectDeadline → fail safe), never FrontierNoBeacons (false-complete)
and never endpoint trust. Distinguishes "validator set not yet loaded → wait
→ converge" from "genuinely empty → fail safe". The P-chain (CustomBeacons may
be empty under endpoint-only --bootstrap-nodes) and single-node keep
"empty ⇒ nothing to sync to".
TESTS (chains):
- TestRED_PeersTrackNetNotChain_StaleNodeConverges: 3 producers holding > ⅔
stake, connected + advertising the NET (not the chain), name the frontier and
the stale node converges. Proven load-bearing: reverting the filter to
b.chainID makes connectedBeacons return nil and this test fails exactly as the
canary did. Mock now models TrackedChains = the NET (matches peer.go).
- TestRED_EmptyStakedSetFailsSafe: empty staked set + expectsStakedBeacons →
Connecting → ErrBeaconsUnreachable, no endpoint trust; the flag is the proven
discriminator vs FrontierNoBeacons.
- C1 (ForgedFrontierFromNonBeacon / HonestBeaconQuorumIgnoresMalicious /
SubAlphaStakeCannotName), F2/NoQuorum, deep-gap, weak-subjectivity,
single-node all green; consensus engine/chain/... unchanged and green.
NOT deployed — re-canary luxd-2.
Fixes the v1.30.76 build break: chains/quantumvm called the now-test-only
quasar.GenerateDualKeys (trusted-dealer keygen, relocated test-only for
corona-genesis hardening). chains v1.4.6 drops the dead trusted-dealer path
(the Q-Chain Quasar bridge signs through the consensus core with per-validator
keys; group keys would need dealerless DKG). Carries the bootstrap F2 convergence fix.
When the initial-sync loop RETURNS a fail-safe error (ErrBeaconsUnreachable /
ErrNoBeaconQuorum / ErrGapTooLarge), runBootstrapThenPoll exited leaving the chain
in a DEAD WINDOW: neither bootstrapping (the loop is gone) nor stopped
(monitorBootstrap only stops the engine at the +5-min no-progress watchdog). The
operator saw a chain that had already given up but was not marked failed for up to
5 minutes.
runBootstrapThenPoll now records the fail-safe reason (bootstrapFailed atomic +
BootstrapFailed/BootstrapFailure accessors). watchBootstrapProgress takes a failed()
predicate and returns the new bootstrapFailed outcome the next tick; monitorBootstrap
stops the engine + registers a failing health check IMMEDIATELY, carrying the real
reason (eclipsed/partitioned/too-far-behind) rather than the generic no-progress
timeout. The stop+health logic is DRY'd into one m.failBootstrapChain shared by the
stall and fail-safe outcomes. Combined with the consensus-side bounded NoQuorum retry
(F2), a transient live-frontier split converges and a genuine eclipse fails fast and
visibly — making the mainnet 5/5 roll deterministic.
Tests: TestWatchBootstrapProgress_FailSafeSurfacesPromptly (failed -> bootstrapFailed
next tick, not after the window), TestWatchBootstrapProgress_ReadyBeatsFailed (success
never masked), TestBootstrapFailure_AccessorSurfacesReason (driver<->monitor plumbing).
Existing watchdog + node bootstrap (C1 forged-frontier finalizes ZERO, stale-converge,
split-no-quorum) all still green. NOTE: build the combined tree with a temp
go.mod replace github.com/luxfi/consensus=../consensus (consensus f93162d15); not committed.
Node side of the stale-node convergence fix. blockHandler.FrontierTip now returns
a chainbootstrap.FrontierStatus instead of a bare ok bool, decomplecting the THREE
reasons a frontier may not be named so the bootstrap loop can WAIT for beacon
connectivity instead of false-completing at the local stale height:
- no beacon set configured -> FrontierNoBeacons (single-node: complete)
- beacons configured, < ⅔ stake up -> FrontierConnecting (the boot race: WAIT)
- ⅔ stake connected, no agreement -> FrontierNoQuorum (eclipse: fail safe)
- ⅔-by-stake quorum agrees on a tip -> FrontierNamed (C1 gate: sync)
The discriminator is the SAME ⅔ floor (config.TwoThirdsStakeFloor over the FULL
beacon stake) used by the quorum tally: if even unanimous CONNECTED beacon stake
could not clear the floor (or too few distinct beacons are up), connectivity is
still coming up -> FrontierConnecting (the canary boot race, where FrontierTip ran
before any beacon handshaked). Only once enough beacon stake is connected do we
query + tally; a split that clears none -> FrontierNoQuorum.
The C1 forged-chain gate is UNCHANGED: the query + weighted ⅔-stake tally is
extracted verbatim into queryFrontierQuorum (a non-beacon peer, a single peer, or
any sub-⅔ set can still NEVER reach FrontierNamed). connectedBeaconsTrackingChain
is replaced by the smaller connectedBeacons helper (shared with
sampleAncestorBeacons — DRY). monitorBootstrap/watchBootstrapProgress need no
change: they already gate the chain-ready signal on BootstrapComplete (Run
success), which now returns only on genuine caught-up.
Tests: reproduces the canary over the REAL GetAcceptedFrontier/GetAncestors
transport (TestNodeBootstrap_StaleNodeWaitsForBeaconConnect — beacons connect after
a delay; the stale node WAITS through the connecting passes, then names the ⅔
frontier and converges, never false-completing at the stale height); the
forged-frontier-from-non-beacon case now FAILS SAFE (ErrBeaconsUnreachable) instead
of false-completing while still finalizing ZERO forged blocks; split-beacons report
FrontierNoQuorum; a no-beacon-set node reports FrontierNoBeacons (single-node
completes). All existing C1 quorum, sub-⅔, invalid-halt, framing, deliver-gating,
and H2 watchdog tests stay green.
Dev integration: node go.mod stays pinned consensus v1.25.34 (NO committed
replace); build/test against the consensus fix branch via
`GOWORK=off go mod edit -replace github.com/luxfi/consensus=../consensus`
(the go.work path is blocked by an unrelated ./compliance go.sum mismatch). Tag
consensus + bump node go.mod at the coordinated merge.
THE VULN (red PoC): an empty/behind node discovered the network frontier from
samplePeerTrackingChain = net.PeerInfo(nil) (ALL connected peers, filtered only by
self-reported TrackedChains — no stake/beacon filter) and FrontierTip returned the
FIRST reply (no quorum). An eclipse/malicious peer (no validator keys needed) named a
forged-but-Verify-passing frontier from genesis; the node fetched+executed it and
finalized the forged chain cert-lessly (proposervm disabled => Verify is just the EVM
state transition), bricking against the real chain. red finalized 40 forged blocks.
THE FIX — the frontier is named ONLY by a 2/3-BY-STAKE quorum of the configured BEACONS:
- beacons (validators.Manager) is wired into the blockHandler (was computed in buildChain
then discarded as unused). For native chains it is the primary-network validator set
under networkID = PrimaryNetworkID.
- FrontierTip queries ONLY connected beacons (PeerInfo is beacon-restricted), tallies
their accepted-tip replies WEIGHTED by stake (replies now carry the responder nodeID),
and returns a tip only once its agreeing stake clears the shared live floor
(config.TwoThirdsStakeFloor over the FULL beacon stake) with >=2 distinct beacons. A
non-beacon peer, a single peer, or any sub-2/3-stake set can NEVER name the frontier =>
fail-closed (the loop has nothing to sync to). Mirrors VerifyWeighted exactly, so the
bootstrap anchor can never drift from live finality.
- Optional weak-subjectivity checkpoint (wsCheckpoint{ID,Height}) wired into the loop.
- M1: Ancestors fetches from a ROTATED beacon sample (bsRotor) so no beacon monopolizes
the descent; safety is independent of which beacon serves (content-addressed in
consensus).
- H2: monitorBootstrap is now PROGRESS-BASED — the once-set 5-minute hard timer that
killed a healthy-but-slow sync is replaced by a no-progress stall window (reset on
height advance via BootstrapHeight()). Decomplected into watchBootstrapProgress (pure,
unit-tested).
Tests: TestRED_ForgedFrontierFromNonBeaconRejected (headline — 0 forged finalized, was
40), honest-beacon-quorum-ignores-malicious, sub-2/3-stake-cannot-name, progress-watchdog
(slow-but-advancing NOT killed / genuine stall fails). Existing transport + framing +
gating tests stay green. (consensus side: content-addressed descent + M2 + WS + M3.)
Convention: node go.mod stays pinned consensus v1.25.32; built via go.work in dev,
consensus tagged + go.mod bumped at the coordinated merge (same as 58f1e04928).
CommitteeCert.Verify(committee) now verifies each endorsement as a BLS
signature over the certificate's canonical signing digest by a distinct,
known committee member's registered public key. Rejects (fail-closed) nil,
unknown, duplicate, or invalid endorsements, parameter mismatches, and
sub-threshold sets; requires >= Threshold valid distinct signatures. The
engine holds a committee registry (RegisterCommittee) and VerifyResult fails
closed on an unknown committee. Replaces the count-only stub.
Tests: valid quorum + sub-threshold, duplicate-signer, forged-signature,
unknown-signer, parameter-mismatch, and engine fail-closed cases.
node/consensus/quasar + node/consensus/zap + node/node/quasar.go were a
superseded duplicate of the live post-quantum finality path
(consensus/protocol/quasar WeightedQuorumCert, driven by the chain engine).
The node-side hub wrapped that live core but added only dormant scaffolding:
- an undriven P-Chain finality loop (finCh never fed; run() sat idle)
- a fail-closed CoronaCoordinator stub (Sign/Verify errored in prod,
InitializeCorona was test-only, so q.corona stayed nil)
- a QuantumFinality store nothing read (only n.Quasar.Stop() was ever called)
Dependency trace: zero cross-repo importers; the Q-Chain VM
(luxfi/chains/quantumvm) is external, independent, and finalized by the live
chain-engine QuorumCert path, not this hub; consensus/zap (ZAP/DID agentic
bridge) was a collateral dormant island on the same stub with no external
importers. Wiring was impossible without driving the live accept path or
adding a redundant second finality authority, both out of scope, so the hub
is removed rather than wired.
Live consensus/protocol/quasar and consensus/engine/chain are untouched.
Q-Chain VM registration, genesis, and critical-by-default status are
unchanged. Genuine PQ tests (TLS hybrid KEX, ML-DSA UTXO, hostname
addressing) are preserved; only the dormant-hub tests were removed.
Brings the G-Chain consensus init fix (deployed as v1.30.72) onto main. graphvm
resolves a deterministic genesis last-accepted block so the G chain creates
cleanly over ZAP. Full build CGO_ENABLED=0 clean.
The node declared a chain 'bootstrapped' the instant the engine started, at
whatever LOCAL last-accepted height it had: monitorBootstrap polled
engine.IsBootstrapped() which Start() sets true immediately (genesis for an empty
DB, the partial height for a behind node). No block-fetch sync ever ran, so an
empty luxd-0 stuck at C-Chain height 0 ('finished bootstrapping pollCount=1',
fetched nothing) and a behind node stuck at its partial height while the producers
held the full chain.
Wire the consensus bootstrap loop (engine/chain/bootstrap) over the EXISTING node
transport. The blockHandler becomes the loop's BlockSource (fetch) AND Chain
(execute), in a new file bootstrap_sync.go (compile-asserted against both
interfaces):
- FrontierTip / Ancestors: synchronous adapters over GetAcceptedFrontier /
GetAncestors, correlating the async replies through bsFrontierCh / bsAncestorCh.
- ParseBlock / LastAccepted / Has / AcceptBootstrapBlock: the execute sink
(AcceptBootstrapBlock delegates to the engine's frontier-trust accept authority).
- runBootstrapThenPoll: runs the loop to the frontier, ENDS the engine's bootstrap
phase (FinishBootstrap — only the α-of-K cert-gate finalizes thereafter), flips
bootstrapDone, THEN starts the live frontier poller. On stall it leaves
bootstrapDone false so monitorBootstrap surfaces a real failure (never masks).
manager.go (minimal hooks): AcceptedFrontier + handleContext route replies to the
loop while bsActive (else the live cert/vote path is unchanged); buildChain starts
runBootstrapThenPoll instead of the bare poller; monitorBootstrap GATES the chain on
the handler's real BootstrapComplete() (initial sync reached the frontier), not the
engine's premature 'am I started' flag.
Tests (node, via the go.work workspace -> local consensus): an EMPTY node converges
genesis->50 over the real GetAcceptedFrontier/GetAncestors path; an invalid block
HALTS sync at the block below it; framing round-trip; reply gating. Existing chains +
op-table suites stay green.
Depends on the consensus feat/chain-bootstrap-fetch-execute branch (new
AcceptBootstrapBlock / FinishBootstrap / bootstrap pkg). Integrated for dev via
go.work (use ./consensus); DEPLOY = tag consensus (next patch) + bump this require +
keep go.mod pinned (no committed local replace).
RED review of cert-carry catch-up found the frontier-sync wiring let a Byzantine
peer flood AcceptedFrontier with distinct tips -> unbounded pendingContext -> OOM.
requestContext now reaps entries past pendingContextTTL (30s) and hard-caps at
maxPendingContext (4096); the frontier poller now stops with the chain (no leak).
Cert-gate/ordering/wire safety unchanged (RED: sound). PoC inverted -> regression guard.
A validator that fell behind during consensus (mainnet luxd-0/2 at 1082780 while
peers reached 1082797) could not recover at runtime: it received no gossip, and a
restart only bootstraps to its own height. Two node-side gaps, now closed:
GAP 1 — frontier-sync was dead. The GetAcceptedFrontier/AcceptedFrontier responders
existed but HandleInbound had no case for either op, so both fell through (dropped)
and nothing ever ASKED. Add the two cases (mirroring GetContext/Context) and a slow
frontier poller (runFrontierPoller, 15s, small peer sample) so a node proactively
learns it is behind and pulls the gap.
GAP 2 — catch-up could not accept already-finalized blocks. handleContext routed
fetched ancestors through the VOTING Put, but the network will not re-vote a decided
height → the gap blocks wedged. Now each catch-up container pairs the block with its
finality cert (encodeCatchupEntry: magic|blockLen|block|certLen|cert, carried by the
existing Ancestors flattener) and the requester finalizes via engine.AcceptCatchupBlock
(the verified-cert path) when a cert is present, voting only on certless/legacy
entries. The 'LCU2' magic makes a cross-version exchange fail CLEANLY (a legacy
decoder self-rejects; the v2 decoder treats a legacy raw block as legacy).
Pins consensus to the local engine commit (replace ../consensus) for the branch;
reviewer swaps to a pseudo-version. Does not weaken the cert-gate.
A behind node learns the network tip by asking peers GetAcceptedFrontier; the
handler returned nil, so every node received an empty frontier and assumed its
own last-accepted WAS the tip — a node that fell behind could never discover it
and bootstrap stopped at its own height (the mainnet luxd-0/2 stranding: stuck
at 1082780 while peers finalized 1082797; neither gossip nor restart converges
them). Same dead-handler class as GossipOp/catch-up.
This is piece 1 of 3 of the proactive frontier-sync: (1) responder [this commit];
(2) route the AcceptedFrontier RESPONSE to a handler that pulls the gap via the
wired catch-up (needs op-table + HandleInbound wiring, like the GossipOp fix);
(3) a poller that periodically sends GetAcceptedFrontier so a node in consensus
(not just bootstrap) re-checks the frontier. Pieces 2-3 pending — NOT yet
converging; not tagged/deployed until complete + tested.
node built the consensus engine with netCfg.Catchup unset, so the engine's
requestCatchup() hit a nil interface: a follower that fell behind DURING
consensus (it drives blocks by gossip, not Put/PushQuery) could never fetch the
missing ancestors and looped on an unfinalizable orphan forever. Observed live —
after the mainnet RLP recovery, luxd-0/2 stranded at 1082780 while peers reached
1082793. (Bootstrap had GetAncestors wired, so startup sync worked; only the
live-engine transport was unplugged — the same class of gap as the GossipOp
vote-transport bug.)
Fix is the decomplected bridge avalanche's design implies: the engine DECIDES
when to catch up (chain.Catchup); the blockHandler owns the WIRE — it already has
requestContext (send GetAncestors) and handleContext->Put->engine (deliver the
fetched ancestors). networkCatchup routes one to the other through a one-method
ancestorRequester interface (testable, narrow, no consensus/network cross-import),
late-bound at chainInfo construction since the handler wraps the engine. Behind
followers now self-heal without a restart.
RED test: a nil wire leaves calls=0 (the bug); the wired bridge routes
RequestAncestors -> requestContext once, for the missing block and the advertising
peer; + a compile-time guard that *blockHandler stays a valid ancestorRequester.
Serve the same fully-wrapped /ext/* handler over github.com/zap-proto/http so
the api.<brand> gateway can reach luxd over the native ZAP service mesh instead
of HTTP/1.1. Opt-in via LUX_ZAP_RPC_LISTEN (default off — node upgrades never
silently open a port); HTTP path is byte-for-byte untouched; boot failure is
warned, never fatal.
Tests (server/http): env parsing, disabled→nil, and a REAL zap-proto/http
round-trip POST /ext/bc/C/rpc proving body+Content-Type survive the ZAP wire.
Pulls the validator-count clamp that scales an oversized consensus committee
down to the live set (fixes testnet finality wedge where TestnetParams K=11/α=8
was unsatisfiable with 5 validators; also protects mainnet from the same brick
when it runs fewer than 21 validators).
Kills the bug CLASS behind the α-of-K finality wedge (v1.30.65), not just the
one Gossip instance. The chain router (node/chain_router.go) forwards an inbound
message only if message.ToConsensusOp maps it, then dispatches on the router op;
the node op table and the consensus router op enum are two halves of ONE routing
contract. When they diverged — router.Gossip existed but no node op mapped to it
— every broadcast vote was dropped as "unhandled message op" and finality
wedged, with nothing to catch it.
- ToConsensusOp now returns the router constants (byte(router.Get) ...) instead
of 0..11 magic numbers, so values cannot drift and the table reads as the
correspondence it is. Bumps consensus v1.25.29 -> v1.25.30 (adds router.NumOps,
the single source of truth for the op count).
- message/ops_test.go TestToConsensusOp_TableAlignedWithRouter is now EXHAUSTIVE
and BIDIRECTIONAL: it reconstructs the inverse mapping over the whole node op
space and requires a bijection onto [0, router.NumOps). A router op with no
node preimage (the original bug), a collision, a wrong target, or a stray
mapping all go RED. Pinned to router.NumOps, so a future op added to one table
but not the other fails the test instead of at runtime. Proven RED against the
original bug (drop the GossipOp case -> router.Gossip loses its preimage ->
fail); folds in the old Gossip-specific and hand-listed table checks.
Build rc=0 (GOWORK=off go build ./...); message tests green; finality fix
(GossipOp -> router.Gossip -> blockHandler.Gossip) unchanged and still covered.
Genericize the external-consumer example to a white-label tenant's
network-bootstrap tooling; the the tenant repo path belongs only in that
tenant's own repo, never in a lux repo.
The α-of-K quorum vote/cert transport rides on app-gossip (GossipOp), but
GossipOp was never added to the chain router's consensus-op table
(message.ToConsensusOp) nor to blockHandler.HandleInbound's switch. So every
inbound vote was dropped at node/chain_router.go as "unhandled message op"
before it could reach blockHandler.Gossip -> engine.HandleIncomingVote. Blocks
ride PutOp (mapped) and propagated+verified on all validators; votes ride
GossipOp (unmapped) and vanished. Each node held only its own self-vote, the
cert never reached alpha, and every chain wedged at height N: "verified but
never accepted", no vote/cert/accept activity.
This is the fork divergence from avalanchego, where votes are Chits — a
first-class consensus op always in the router table (snow/engine/snowman
engine.go Chits -> voter -> topological.RecordPoll -> accept). Lux moved votes
onto app-gossip but left the node-side op routing incomplete; the consensus
repo already reserved router.Gossip=11 'routed via the blockHandler Gossip
method' (core/router/router.go) — only the node wiring was missing.
Fix (node-only; safety core untouched — this is purely liveness):
- message.ToConsensusOp: map GossipOp -> 11 (router.Gossip)
- blockHandler.HandleInbound: add case handler.Gossip -> b.Gossip(...)
b.Gossip already demuxes the quorum envelope (Mode-gated) into
HandleIncomingVote / HandleIncomingCert; the signed-vote verify + verified-2/3
-stake cert assembly are unchanged, so honest votes now reach the assembler
without weakening VerifyWeighted or the unforgeable cert.
Regression guard (message/ops_test.go): GossipOp must map to router.Gossip, the
full op table must stay aligned with the consensus router, and an inbound vote
gossip must survive router extraction (op + envelope bytes) intact.
evm v1.99.40's go.mod pins luxfi/chains v1.3.19, whose dexvm/registry was an
incomplete refactor (forbidden.go deleted) -> AssertNoForbiddenAssetRefs / toHex /
fromHex / looksLikeASCIITickerID undefined -> EVM plugin won't compile. Force
chains@v1.3.21 (forbidden.go restored), matching node's go.mod + the chain-VM
plugin stage. This is the 2nd build blocker after the go.sum re-strip (v1.30.63);
together they should green the build (v1.30.59 -> v1.30.64).
The pre-download strip (line 69) fixed go.sum, but `COPY . .` then restored the
committed stale go.sum, so the build hit a SECURITY ERROR when a luxfi/* module was
re-tagged (luxfi/age v1.5.0 here). Re-strip first-party lines right before the build
so -mod=mod re-records current cache hashes. Self-heals every future re-tag.
(v1.30.59 built; v1.30.60/61/62 failed on exactly this.)
X-Chain was on an undriven, unconfigured DAG engine (test-ID/height-0 vertices, a
handler that dropped every inbound consensus message) — no certificate finality,
no parity with C/D/P. It now runs the same linear consensuschain path as every
other chain: deterministic 2/3-stake BFT certificate finality (AcceptWithCert),
the BFT overlap-bound floor, and the epoch-pinned validator set.
vms/xvm/vm.go: conform *VM to block.ChainVM / consensuschain.BlockBuilder
(Connected/Shutdown/WaitForEvent/HealthCheck), remove GetEngine() and the dead
DAG methods, add the compile-time asserts. X already had a real linear builder
(embedded blockbuilder.Builder, wired in Linearize) — nothing faked.
vms/xvm/health.go: HealthCheck -> chain.HealthResult. vms/xvm/tx.go: deleted
(dead dag.Tx wrapper).
chains/manager.go: linearize DAG-native VMs (X-Chain) into linear block mode
before SetState, wired to the REAL toEngine the cert runtime reads from.
Interface-gated — only VMs implementing Linearize are affected; C/D/P/Q untouched.
With no production VM exposing GetEngine, the undriven DAG dispatch is dead.
vms/dexvm/dexvm.go, vms/types/fee/policy.go: doc corrections (dexvm is
plugin/optional/NFT-gated, linear cert finality; X-Chain UTXO fee is a deliberate
exception to the account-model FlatPolicy floor).
Full node builds; vms/xvm + chains tests green. Parity holds by construction for
C/D/X/Q/Z: one cert path, one finality authority.
v1.25.29 carries the fetch-on-unknown-vote self-heal (c9a70880a) — buffer votes
for untracked blocks, fetch the block, never drop — which fixes the QC-assembly
finality wedge; plus L-1 (dedup buffered votes by NodeID, fail-closed). crypto
v1.19.26 to match consensus' requirement. Completes the v1.30.60 finality set:
α-of-K node wiring (already on main) + engine lifetime-ctx (C1) + engine self-heal.
chains/manager.go started the consensus engine with a 30s-timeout context;
engine.Start parents all four long-running loops (poll/vote/pipeline/re-poll)
to it, so they died ~30s after each chain started and the quorum cert never
assembled — the finality wedge fixed in v1.30.55 (8b3785c6c1). Restore
Start(context.Background()). Applied on origin/main (full α-of-K wiring intact),
NOT the stale side-branch. Pairs with consensus v1.25.29 self-heal below.
cpuBLSVerify / cpuMLDSAVerify previously did length checks and returned true for
any well-formed input — a no-GPU node would ACCEPT FORGED signatures once LP-210
wires the GPU pipeline into block-accept. Now they perform REAL verification via
luxfi/crypto pure-Go primitives:
- BLS: bls.Verify after PublicKeyFromCompressedBytes/SignatureFromBytes
(enforces exact length, on-curve, subgroup membership; malformed => false).
- ML-DSA: pub.VerifySignature (FIPS-204 nil-context), pk 1952, sig 3309.
- Corona + ZK: FAIL CLOSED (return false) — luxfi/crypto has no pure-Go verifier
and the Corona GPU kernel is the known-wrong-prime BLOCKED kernel; never
rubber-stamp an unverified signature.
ML-DSA signature size corrected 3293 (stale round-3 Dilithium3) -> 3309 (FIPS-204
ML-DSA-65) in MLDSAWork doc + gpuMLDSAVerify flatten width, so the CPU oracle and
GPU path size the signature identically; pinned by TestMLDSA_WorkStructSizeIsCanonical.
Tests rebuilt to carry REAL valid BLS/ML-DSA signatures (was random bytes the old
format-only check accepted). TestCPUVerify_RealOracle proves: valid accepted,
forged rejected, Corona+ZK fail closed. All green.
go.sum: 7 luxfi modules (chains, keys, kms, sdk, staking, zap) had their content
hashes refreshed to the canonical registry values after an upstream re-tag (same
versions, same go.mod — source re-tag only). 'go mod verify' = all modules
verified. Surgical patch (7 lines), minimal go.sum preserved.
(cherry picked from commit e40f04b112f8b5d15223325199db4b981f05878c)
v1.19.23 adds the canonical crypto/poi package — the Freivalds compute-proof
verifier (over F_p, p=2^61-1, on the exact int8 accumulator) that the aivm
settlement / aivmbridge precompile path consumes. Purely additive: makes the
PoAI verifier available to luxd. Mirror of hanzo-engine/src/poi.rs.
Closes the node build break: consensus v1.25.20 called corona v0.7.9's
Round1 single-value but v0.7.9 returns (*Round1Data, error). v1.25.21
threads the error through all quasar round-signer sites.
ONE declarative flow for both lux release artifacts, on platform.hanzo.ai +
self-hosted arcd — NO GitHub Actions:
- hanzo.yml: node image build (matrix linux/{amd64,arm64}, native long-poll
dispatch onto lux-build-* pools, tag-pattern {{git.branch}} -> :vX.Y.Z).
Validated against the platform parsePlatformConfig.
- scripts/publish_plugin_set.sh: extracts the 12 baked VM plugins from the
node image + SHA256SUMS, uploads to s3://lux-plugins-<env>/<pluginset>/
(operator pluginSource), round-trip sha-verified. DRY — no second compile.
- RELEASE.md: the ONE canonical build+publish runbook; lists the .github
workflows to retire (docker.yml, release.yml, build*.yml, ci.yml, ...).
- LLM.md: points at RELEASE.md as the canonical release path.
Proof (no GitHub): node:v1.30.40 provenance == 44b67b99a0 (reused, not
rebuilt); evm@v1.99.37 + dexvm@v1.5.15 rebuilt natively on the spark fleet
(go1.26.4, CGO=0) and the full 12-plugin set published to
s3://lux-plugins-staging/ (round-trip verified). neo's lux-plugins-devnet
prefix untouched.
luxfi re-tagged crypto v1.19.22 (and others) after node go.sum was recorded,
causing 'checksum mismatch / SECURITY ERROR' at the node binary build step and
a cascading -mod=mod fallback to a corona pseudo-version with the old 2-value
Round1 API. Apply the same first-party go.sum strip the EVM/chains/dex plugin
stages already use, so -mod=mod re-records current content hashes for the
re-published modules at their pinned versions (integrity repair, no version drift).
config/flags.go + staking/kms.go: the StakingKMSSecretPath example string
referenced a brand-specific devnet path; replaced with the generic
/staking/devnet/node-0 form (lux surfaces carry no white-label brand
tokens). tools/go.sum: corrected the stale charmbracelet/x/ansi v0.9.2 h1
checksum to the canonical value (verified via go mod download).
Generates the ML-DSA-65 (FIPS 204) staking key + ML-KEM-768 (FIPS 203)
handshake key a strict-PQ local network needs, writing PEM blocks with the
exact types the node config loader expects. Pairs with the SchemeGate /
PQ-handshake path: a strict-PQ chain needs an ML-DSA identity before any
peer can present the gate's pinned scheme.
The per-chain peer.SchemeGate is now built under profileRequiresPQHandshake,
not merely SecurityProfile != nil. Load-bearing: the gate's pinned scheme
byte (SigSchemeMLDSA65) only becomes presentable once the application-layer
ML-KEM+ML-DSA handshake establishes the ML-DSA identity. A permissive /
classical-compat profile still presents secp256k1 cert schemes, which
SchemeGate.Classify refuses unconditionally — building a gate there would
refuse every peer with no PQ handshake to recover (the 0-peers / 'TLS
upgrade failed' stall). One axis, one predicate: gate + handshake + ML-DSA
identity are built together or not at all.
Publishes the consensus quorum-finality cascade into node:
- consensus v1.25.19 -> v1.25.20 (chain quorum-cert finality, LocalBFTParams)
- crypto v1.19.21 -> v1.19.22 (BLS deserialization/aggregation hardening)
- chains v1.3.17 -> v1.3.18, runtime v1.1.1 -> v1.1.3 (value-path tags)
selectConsensusParams now routes explicitly-local dev networks (devnet 3 /
localnet 1337, EXACT IDs not a range) to consensus.LocalBFTParams (K=4/α=3,
f=1) — the minimal real-BFT committee. Default K=20 is unsatisfiable on a
few localhost validators (α=14 unreachable -> P-Chain freezes at height 0).
K=4 makes quorum reachable while still clearing ValidateForValueNetwork and
the CRITICAL-2 multi-node-is-BFT regression. A custom value L1 (high
networkID) keeps the large Default set. precompile resolves to v0.5.56
(MVS-correct: chains v1.3.18's own requirement; node has no evm/dex dep).
go mod verify: all modules verified; chains quorum params tests green.
Closes the receive-side gaps the b2 build-side stamping (4c7bab464c) left
open. The build-side epoch stamp max(currentH, parentH) is PROPOSER-ONLY;
a follower must independently re-gate every gossiped block. Three fixes,
one per gap, in the transport wrapper pchain_height_vm.go:
HIGH-1 (predicate b — RECENCY, upper bound). ParseBlock now rejects a
framed block whose stamped P-chain epoch H exceeds THIS node's live
P-chain height by more than pChainHeightRecencySlack (256) — an
absurd-future epoch a Byzantine proposer claims for a set the network
has not reached. Rejected fail-closed (errPChainHeightNotRecent): the
block is dropped, never tracked or voted. This is the UPPER half of the
epoch bound; the engine-side monotone gate (consensus 7cabd6faa) is the
LOWER half (>= parent's recorded epoch), so a tracked block's epoch is
pinned to [parentEpoch, localH+slack]. Fail-soft when no P-chain view
exists (nil state -> admit; the verifier still fails closed at
set-resolution) — a defensive path, not a mode.
MEDIUM-3 (heights-map DoS — WATERMARK PRUNE, not blind LRU). ParseBlock
runs before the engine dedup/Verify, so a peer can stream distinct
unverified blocks, each adding a permanent heights entry. Each entry now
carries its value-chain height; Accept advances a finalized-height
watermark and prunes every entry at/below it (a finalized block never
needs a GetBlock epoch re-attach), so the map plateaus under steady
finality. A hard cap (4096) backstops a sustained unverified flood,
evicting HIGHEST-height-first so the near-tip pending band survives — a
still-pending block's epoch is never evicted (a blind LRU could reset it
to 0 = re-freeze).
LOW-2 (frame magic DISCRIMINATOR). The 4-byte magic collides at 2^-32
with the raw hash-prefix some VMs use as Bytes() (dexvm/dchain serialize
parentID[0:32]||...). A magic match alone no longer means "framed": the
inner re-parse of the framed payload must ALSO succeed. On re-parse
failure ParseBlock falls back to a raw whole-block parse — removing the
bootstrap stall a collision used to cause (mis-framed -> inner decode
fails closed -> stall on that chain).
TDD pchain_height_hardening_test.go (all pass, CGO_ENABLED=0):
far-future epoch REJECTED; honest within-slack skew (P-chain advance
during a staking change) ACCEPTED; nil-state admits; magic-colliding raw
parses RAW; genuine frame still parses; heights map plateaus at the
watermark; a pending block is never evicted by the prune; a flood is
capped preserving the near-tip band. The b2 build-side finality proofs
still pass (no regression to delivered-epoch stamping).
THE BUG. A K>1 quorum chain pins its weighted validator set to a P-chain epoch
height (set-root, 2/3-by-stake tally, per-voter pubkey resolution all read at that
one height). The engine reads that height off the VM block via pChainHeightOf,
asserting block.SignedBlock.PChainHeight() — but every plugin VM block (C-Chain
EVM, dexvm) exposes none, so pChainHeightOf returns 0 and the set resolves at
P-chain height 0: the GENESIS set. That is safe (non-empty, identical on every
node, <= current) and unbricks finality, but FREEZES the epoch at genesis: a
validator that JOINED post-genesis is absent from set@0 -> its vote is dropped and
its stake uncounted -> finality cannot track a DYNAMIC validator set, and a
departed genesis majority could collude.
THE FIX (Option b: rpcchainvm zap boundary, NOT a chain-creation-switch rewrite).
pChainHeightVM wraps the chain BlockBuilder so the block the engine sees carries
the proposer's live P-chain epoch height H = max(GetCurrentHeight, parentH),
WITHOUT changing the inner VM's block format, IDs, or ledger state:
- BuildBlock stamps H and frames the gossiped bytes [magic|H|innerBytes];
- ParseBlock splits the frame so every follower ADOPTS the identical H (never
recomputes it from its own skewing P-chain view) -> determinism: every honest
node derives the SAME epoch height from the SAME signed block, so the cert
set-root they recompute matches and a post-genesis validator's vote+stake
count. Raw (unframed) bytes parse with H=0 = the safe genesis fallback.
This is consensus-TRANSPORT framing, not a chain/ledger fork: inner bytes, block
IDs, and execution state are byte-identical -> no re-genesis, only a coordinated
node upgrade. Installed ONLY on K>1 chains (a K==1 chain has no cert/epoch).
Also wires the height-indexed P-Chain validators.State as the SINGLE source of
epoch truth for all four reads (verifier pubkey, stake, total stake, set-root),
all keyed on the block's P-chain height, and FAILS CLOSED if a K>1 chain is built
without a live height-indexed state (a silent permanent-stall wiring bug becomes a
loud refuse-to-start).
TDD (CGO_ENABLED=0, the canonical purego BLS path = ACTUAL production quorum
sources, not an ed25519 stand-in):
- TestPChainHeightVM_DeliversRealHeight: pChainHeightOf(builtBlock)==H and
==H again after a ParseBlock round-trip of the gossiped bytes; a bare inner
block reads 0 (pins why the wrapper is load-bearing).
- TestPChainHeightVM_FinalizesAtGenesis: K>1 finalizes against set@0.
- TestPChainHeightVM_FinalizesAfterStakingChange: validators that JOINED at
epoch 7 cast the deciding 2/3 votes+stake and the block FINALIZES; the cert
verifies stake-weighted at 7 and FAILS at 0; the production verifier rejects a
joiner at height 0 but accepts it at 7. Proven to FAIL without the fix (stamp
0 -> joiners dropped at set@0 -> VM.Accept never runs = permanent stall).
MEDIUM-1 liveness regression: validatorSetRootSource.ValidatorSetRoot
ignored the height argument and hashed the Manager's CURRENT GetMap()
snapshot. During a validator-set change (P-chain / L1 staking), the
current map propagates ASYNC relative to a value-chain block-H vote, so
the signer and the assembler held different current maps -> different
set-roots -> the canonical SIGNED message differed -> signatures FAILED
verification -> votes dropped -> finality stalled at every staking change.
The epoch-binding (set-root in the signed message) made this bite.
FIX: read the HEIGHT-INDEXED set from validators.State.GetValidatorSet(
ctx, height, netID) — deterministic across nodes at a given value-chain
height, independent of async current-map skew. The engine already threads
the block height to ValidatorSetRoot(height) via the sole position builder
blockPositionLocked, so sign-side and verify-side read the SAME
height-pinned set.
The stake source (Weight/TotalStake) is pinned to the SAME height too, so
the tally is measured at the same epoch as the signed membership — a
validator whose vote is in a height-H cert contributes its height-H
weight, closing the second skew (a current-map weight read could drop a
legitimately-signed quorum). validatorSetAtHeight is the single shared
epoch read; hashValidatorSet is the single (byte-unchanged) set-root
encoding.
The vote verifier keeps the current-map pubkey lookup (a separate, milder,
self-healing axis — not MEDIUM-1; disclosed for review).
TDD:
- TestValidatorSetRoot_CrossNodeAgreesDespiteSkew — TWO nodes with a
set-change in flight (divergent current maps) compute the IDENTICAL
set-root at height H (the missing cross-node test).
- TestValidatorSetRoot_HeightSelectsEpoch — root is a deterministic
function of height.
- TestValidatorStakeSource_HeightPinned — tally read at the cert height.
- TestHashValidatorSet_ByteStability — golden, wire format unchanged.
- TestValidatorSetRoot_FailSoftIsUniform — Empty fallback is uniform.
Supply the node side of the consensus epoch-binding fix:
- validatorSetRootSource: a deterministic commitment (SHA-256 over the set
sorted by NodeID, each as nodeID||light||len(pk)||pk) to the chain's active
weighted validator set. Wired via NetworkConfig.ValidatorSetRoot so the engine
stamps it into every vote/cert position — a cert is cryptographically pinned to
the exact set it was certified under, closing cross-epoch cert laundering.
Empty/absent set => ids.Empty (the unbound answer).
- validatorStakeSource: corrected the misleading "height is advisory" comment.
The node Manager is single-epoch (no GetValidatorSet(height); that is on
validators.State), so the source honestly returns CURRENT-epoch weights — which
is exactly the live in-epoch finality answer (a cert is verified in the same
epoch it is created). Cross-epoch soundness is enforced at the witness layer by
the set-root binding above, NOT by guessing unavailable historical stake.
TDD: TestValidatorSetRootSource_DeterministicAndSetSensitive (determinism,
insertion-order independence, weight/membership sensitivity, network scoping,
empty/nil -> Empty) and TestValidatorStakeSource_CurrentEpochWeights.
CRITICAL-2: selectConsensusParams picks a Byzantine-fault-tolerant set for every
sybil-protected (multi-node) net (Mainnet K=21 / Testnet K=11 / Default K=20) —
NEVER LocalParams (K=3/α=2, f=0, single-validator-forkable). Asserted with
ValidateForValueNetwork; chain creation FAILS CLOSED on a non-BFT set.
HIGH-4: networkGossiper implements QuorumGossiper (BroadcastVote/GossipCert over
app-gossip); blockHandler.Gossip demuxes a quorum envelope into the engine's
HandleIncomingVote/HandleIncomingCert (gated on ModeQuorumFinality). The engine
is wired with a BLS VoteVerifier (validator-set pubkey + bls.Verify), a BLS
VoteSigner (staking key), and a validator-stake StakeSource (HIGH-3) for K>1.
Adapters proven against the local consensus engine + real BLS + a real
validators.Manager (standalone module): verifier round-trip/rejections, a real
3-of-4 BLS cert verifies weighted, envelope round-trip, param selection never
K=3 for multi-node. (Full node module compile is blocked on pre-existing,
unrelated sibling go.sum staleness — kms@v1.11.3 / aml consensus@v1.25.17.)
evm v1.99.37 (precompile v0.5.57) wires the 0x9999 ERC-20 Call surface to the
DEX settlement precompile (2cf30e43d) and gates it to the 0x9999/0x9996 settle
family (9579f2e34). Before this a CALL into 0x9999's ERC-20 settle path saw a
nil PrecompileEnv and could not resolve the token-transfer Call seam. v0.5.57
adds the CALL-only DELEGATECALL guard (feeaab5a0). Deps converge to latest
patch within v1.x.x (threshold v1.9.9, crypto v1.19.21, database v1.20.3,
geth v1.17.12, vm v1.2.5, api v1.0.15). Money path (V4 swap ABI, marker
install, dated DexSettleActivationTime fork) byte-unchanged.
Pairs with the baked DEX_REF v1.5.15 (D-Chain committed-state read RPC) for
one coordinated image: native-fill matcher + read RPC + ERC-20 env fix.
Also: harden the lux-accel fetch to authenticate with the ghtok BuildKit
secret (luxcpp/accel is private -> lux-private/accel; unauthenticated 404)
and make it best-effort (matches the cevm/lpm contract) since the GPU lib is
linked only at CGO_ENABLED=1 and the canonical build is CGO_ENABLED=0 pure-Go.
Image: ghcr.io/luxfi/node:v1.30.40 (patch from v1.30.39).
v1.5.15 adds the D-Chain read surface (clob_get_trades/orders/markets/book over
/ext/bc/D/dex/<method>, pkg/dchain/read.go): read-only JSON of the committed
trade log, resting book, and markets, served beside the write methods via
VM.CreateHandlers with zero consensus impact.
Needed to (1) VERIFY a native fill replicated identically across the validator
set — query clob_get_trades on every node and diff the trade rows + accepted
head root — and (2) feed markets-display, since native fills are D-Chain trade:
rows (not C-Chain 0x9999 DEXFills).
Plugin VM id unchanged (mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr); the
change is additive read endpoints, no consensus/state-transition change.
dex v1.5.14 consumes luxfi/database v1.20.4, which fixes prefixdb
NewIteratorWithStartAndPrefix synthesizing a pre-prefix start for a nil-start
prefix scan -> ZERO rows over a prefixdb-wrapped chain DB. This stranded the
native D-Chain: rebuildBookFromDB iterated order:<pool> alongside other
sub-prefix rows, folded an EMPTY book, and every taker cross produced 0 fills
despite durably-committed asks. Only the dexvm slot is rebuilt this run (devnet
D-Chain scope); the same database fix likely benefits the other plugin VMs and
is a separate follow-on bump.
dex v1.5.13 indexes processing blocks so the plugin transport's ID-only Accept
(GetBlock(builtID)) resolves the just-built block. Without it the native
D-Chain's self-finalize Accept is a silent no-op, the clob submitTx waiter hangs,
and NO D-Chain blocks are produced — orders submitted to /ext/bc/D/dex/clob_*
never match. Required for native trading to actually produce fills.
dex v1.5.12 persists the head block so the native D-Chain VM survives a restart
once advanced past genesis — without it the first validator to restart fails VM
init ('get last accepted block: not found') and the D-Chain goes down. Required
for a 5-validator rolling restart of the native matcher.
dex v1.5.11 wires CLOB order ingestion over the node HTTP router
(pkg/dchain VM.CreateHandlers -> /ext/bc/D/dex/<method>). An order POSTed to
/ext/bc/D/dex/clob_submit flows submitTx -> mempool -> consensus -> BuildBlock
-> Verify(match) -> Accept; the chain is the matcher, no venue, no keeper.
Plugin stage rebuilds (ARG change invalidates the dexvm slot cache); node
go.mod is unaffected (the plugin is built from dex's own module, -mod=mod).
The D-Chain runs as a PluginDir plugin loaded by luxd at the dexvm vmID
(mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr). Until now that slot was
built from chains/dexvm/cmd/plugin — the STATELESS PROXY that relayed clob_*
frames over ZAP to a standalone dchain-venue (DexZapEndpoint). Matching did
NOT happen in luxd consensus; it happened in the external venue's single-node
sealer.
Build the dexvm slot from github.com/luxfi/dex@v1.5.10 ./cmd/dchain instead:
the NATIVE VM (pkg/dchain, block.ChainVM) that runs the lx.OrderBook matcher
INSIDE luxd consensus — BuildBlock drains the mempool, Block.Verify matches
against a versiondb overlay, Block.Accept commits. The trade IS the D-Chain
state transition, sequenced by luxd's multi-validator engine. No
DexZapEndpoint, no standalone venue in the trading path.
cmd/dchain wraps the VM in the same rpc.Serve plugin harness luxfi/evm uses
and is pure-Go (CGO=0); same GOFLAGS/go.sum-heal pattern as CHAINS_REF. The
other 10 chains VMs still build from chains. New ARG DEX_REF=v1.5.10; a FATAL
guard fails the build if the native D-Chain plugin is missing. Verified: a
fresh v1.5.10 clone builds ./cmd/dchain CGO=0 -mod=mod in Docker-like
isolation; native VM suite (TestLocalnetVenueE2E/TestFourPath/conservation)
passes.
The CI Docker build failed reading hanzoai/vfs@v0.4.1's go.mod via the public
sum.golang.org (404 — it's a cross-org module not registered there), same class
as luxfi/*. Added github.com/hanzoai/* to GONOSUMCHECK/GONOSUMDB/GONOPROXY.
Verified: clean 'go mod download hanzoai/vfs@v0.4.1' 404s without the exclusion,
succeeds with it. Unblocks ghcr.io/luxfi/node image builds (v1.30.30/31 failed).
The dexvm plugin gains the C<->D keeper RPC seam: dex.submitTx (mempool entry for
ImportTx + settling RelayOrderTx) and dex.getSettlement (D->C proceeds coordinate
for Phase-B). Bumps go.mod luxfi/chains v1.3.15->v1.3.16 AND Dockerfile CHAINS_REF
v1.3.14->v1.3.16 so the bundled dexvm plugin is rebuilt with the new RPC. go mod
verify clean; dexvm plugin builds from the v1.3.16 tag (CI mode).
The routing + insteadOf fixes got goreleaser onto lux-build with git auth,
but it still failed: 'could not read Password ... exit 128' fetching
luxfi/{container,crypto,database,proto}. github.token is repo-scoped and
cannot read OTHER private luxfi repos. Switch to the same cross-org PAT
docker.yml uses (GH_TOKEN/UNIVERSE_PAT) so cross-repo module fetches
authenticate. node already carries UNIVERSE_PAT as a repo secret.
goreleaser shells out to go build, which fetches private luxfi modules
(container, crypto, proto, ...). setup-go-for-project sets GOPRIVATE but
only wires git auth when passed a github-token (build.yml passes none), so
the fetch hit 'could not read Username for https://github.com' and the job
failed even on lux-build. Add the same explicit 'Configure Git for private
modules' step ci.yml uses (git config url.insteadOf with github.token).
This is the real root cause of the 'Build on supported platforms' red-X;
the routing-to-lux-build commit was necessary but not sufficient.
The v1.30.28 docker.yml run published node:v1.30.28 (sha-c36527c) fine on
the in-cluster lux-build ARC pool, but two sibling workflows red-X'd:
- build.yml 'goreleaser' (runs on every push)
- release.yml 'validate-version' (cascaded skips to every platform build)
Root cause: both pinned runs-on: ubuntu-latest. This org disables
GitHub-hosted runners (NO GITHUB BUILDERS) — ubuntu-latest jobs never get a
runner and fail at provisioning, not at compile. Source is sound:
GOWORK=off CGO_ENABLED=0 go build ./... is clean and the cross-layer
dexsettle_guard test passes (extras.DexSettleActivationTime ==
dex.DexSettleActivationTime == 1766704800 = 2025-12-25T23:20:00Z).
Route goreleaser, validate-version, and create-release to lux-build (the
org-scoped autoscalingrunnerset in lux-k8s that already builds the image).
GoReleaser cross-compiles every GOOS/GOARCH from one linux/amd64 host with
CGO disabled, so a single amd64 runner suffices.
(The per-platform binary reusables build-{ubuntu,linux,macos,win}-* still
pin classic [self-hosted,linux,amd64]/windows-2022 labels — a pre-existing
routing issue tracked separately; the node *image* path is unaffected.)
EVM_VERSION v1.99.33 -> v1.99.34 (precompile v0.5.53 -> v0.5.54). v1.99.34 fixes
the ONE HIGH from Red's review of the 0x9999 dated-fork activation that blocked
the production deploy: the EVM dispatch gate read a process-global timestamp
(last-writer-wins across concurrent goroutines), so on the relaunch path
(admin.importChain of a pre-fork RLP snapshot on a live, RPC-serving post-fork
node) a concurrent post-fork eth_call could make a pre-fork block see 0x9999
ENABLED and dispatch settlement during plain-account execution -> state
divergence. The gate now reads the overrider's own per-EVM (chainConfig,
timestamp). Also: SubBalance fallback fails closed (no silent native mint) and
the genesis-config builders skip AlwaysOn modules (0x9999 can't get a
timestamp-0 genesis config that bypasses the dated fork). Money path
byte-for-byte unchanged. This unblocks the production deploy.
Bundle the C-Chain EVM plugin built from luxfi/evm v1.99.33 (precompile v0.5.53):
0x9999 DEX settlement now activates at a SINGLE canonical dated fork
(extras.DexSettleActivationTime = 1766704800, Dec 25 2025) instead of the v1.99.32
unconditional always-on. At the fork it BOTH enables dispatch AND installs the
EXTCODESIZE marker forward (never in historical genesis), so:
- eth_getCode(0x9999) != 0x and typed Solidity IPoolManager(0x9999).swap(...)
passes the contract-existence guard from the activation block onward;
- pre-Dec-25 history replays byte-identically (the ~/work/lux/state RLP snapshot
via admin.importChain) — a pre-fork value transfer to 0x9999 hits a PLAIN
account, not the precompile, so canonical pre-activation state is preserved;
- the C-Chain genesis hash is unchanged (no genesis-time marker).
0x9010 is REMOVED entirely (not a registered precompile, no dispatch, no forward);
0x9999 is the SOLE canonical DEX precompile. Runtime DChainID via the "D" alias and
the built-in DAO-treasury protocolFeeController are unchanged.
deps: indirect precompile v0.5.52 -> v0.5.53 (go.sum tidied: stale v0.5.52 hashes
dropped; v0.5.53 hash matches the evm/precompile repos byte-for-byte). node main
builds clean GOWORK=off; go mod verify OK; go mod tidy is a no-op on go.mod.
Bump the bundled C-Chain EVM plugin to v1.99.32 (precompile v0.5.52): the native
0x9999 DEX settlement money path is now ALWAYS-ON. It is active on the C-Chain from
genesis with ZERO per-net config — no dexSettleConfig genesis/upgrade entry anywhere.
Booting luxd = 0x9999 live on the C-Chain, on every Lux network.
Activation is dispatch-only (no genesis state write → genesis hash unchanged → no
fork of any existing network). The D-Chain (dexvm) peer the atomic seam routes to is
resolved at RUNTIME from the consensus-context "D" alias the node already registers in
initChainAliases (chains/manager.go BCLookup), via contract.AtomicState.DChainID().
The protocolFeeController is the built-in DAO treasury. Nothing per-net to configure.
Dockerfile: ARG EVM_VERSION v1.99.31 -> v1.99.32 (the plugin is built from this tag).
go.mod/go.sum: indirect luxfi/precompile v0.5.51 -> v0.5.52 (keeps node's transitive
graph in lockstep with the plugin; node main builds clean GOWORK=off, go mod verify OK).
The bundled C-Chain EVM + 11 VM plugins were pinned at stale defaults
(EVM_VERSION=v0.19.4, CHAINS_REF=v1.3.11) decoupled from node's go.mod, so every
node image shipped the OLD DEX (no native 0x9999) even after the cascade bumped
node->chains v1.3.14. Pin both to the native-atomic seam + document the coupling.
No node code change vs v1.30.23.
Bump to the clean-go.sum re-cascade so node, plugins, and all upstream modules
build identically in clean CI. No node code change vs v1.30.21. go mod verify
clean; full build green.
v1.30.20's go.sum carried stale consensus@v1.25.18 + geth@v1.17.11 hashes that a
locally-polluted module cache (modified extracted dirs) had produced — the in-CI
docker `go mod download` rejected them (checksum mismatch). Purged the modcache,
re-fetched fresh, and `go mod verify` is clean ("all modules verified"). No
go.mod/dep change vs v1.30.20; the node binary is identical. This is the first
buildable tag of this node code.
The evo classic self-hosted runner is offline and GitHub-hosted runners are
billing-blocked (and disallowed by the NO-GITHUB-BUILDERS policy). Point the
image build at the lux-build autoscalingrunnerset (lux-k8s, amd64 DOKS, DinD)
— it serves the whole luxfi org and matches on the scale-set name. No node
binary/dep change vs v1.30.19; this is a build-infra patch.
Append aivm(A)/graphvm(G)/keyvm(K) to genesis chainEntries after Z so the
existing X->C->D->B->T->Q->Z blockchain IDs are preserved; A/G/K take fresh
tail IDs. They carry a/g/kChainGenesis blobs and are deterministic genesis
chains per the no-CreateChainTx model (prior code deferred them to
post-genesis CreateChainTx). I/O/R have no blob and stay plugin-loaded.
ROOT FIX (B-Chain): chains v1.3.11 bridgevm/mpc.go blank-imports
github.com/luxfi/crypto/threshold/bls so the bls init() registers the BLS
threshold scheme, fixing the B-Chain VM init regression
(threshold.GetScheme(SchemeBLS): 'scheme not registered') that aborted the
v1.30.16 devnet roll. Verified bls init/RegisterScheme symbols + itab linked
into the bridgevm plugin binary.
go.mod: pin chains v1.3.10 -> v1.3.11 (no other version moves; MVS keeps
crypto v1.19.21, geth v1.17.11, consensus v1.25.18).
go.sum: re-record current content hashes for re-published luxfi modules
(geth/precompile/etc) at EXISTING pinned versions — integrity repair, no
version bump. Fixes clean-room 'go mod download' checksum drift.
Dockerfile plugin builds (embedded plugins ARE authoritative — devnet
startup does cp /luxd/build/plugins/* then --plugin-dir):
- EVM/C-Chain: EVM_VERSION v0.19.3 -> v0.19.4; heal dead upgrade
pseudo-version to released v1.0.1; recursive go.sum strip.
- chains VMs: CHAINS_REF default -> v1.3.11; recursive go.sum strip;
bridgevm build made FATAL (was best-effort) + presence assertion so the
image cannot ship without a working B-Chain plugin.
All 11 chains VM plugins + evm plugin verified to build (golang:1.26.4,
linux/amd64). lux-devnet only; testnet/mainnet stay v1.30.3.
Pick up the rpc.Serve plugin harness now opening chainstate via zapdb.New
(single-backend rule) instead of badgerdb.New — same on-disk format, drop-in.
Transitive api v1.0.14→v1.0.15 (within v1, no major bump). chains stays
v1.3.9. Verified: public 'go build ./node/' green; '-tags dchain' green;
public-purity 'go list -deps ./node/' has ZERO chains/dexvm|luxfi/dex; the
-tags dchain build DOES link the dexvm proxy (orthogonality holds).
Production node -tags dchain now links the released chains v1.3.8 (stateless atomic ZAP proxy +
the #9 consensus-fork fix), not the stale v1.3.6. Public build stays pure (zero dex/dchain/gpu
deps); dchain build links the 6 chains/dexvm proxy pkgs. Transitive: oracle v0.1.1->v1.0.0,
relay ->v1.0.0 (chains v1.3.8 requirements; first-stable, within v1).
vms.go drops the dexvm import+entry, calls appendDChainVM; vms_dchain.go (//go:build dchain)
links it, vms_nodchain.go (//go:build !dchain) no-ops; vms/dexvm alias gated //go:build
dchain + doc_nodchain.go placeholder. Genesis-gate (constants.DexVMID, no pkg dep) retained.
VERIFIED: public 'go list -deps ./node/' has ZERO chains/dexvm|luxfi/dex|gpu-kernels|luxcpp;
-tags dchain brings them in. Both builds green.
Consensus change -- held for human review before merge/push (bundles with merkle-activation).
The prior seam added a bespoke MerkleRootActivationHeight gate (default
MerkleRootNeverActivate=MaxUint64) that violated upgrade/upgrade.go's stated
philosophy ('activate-all-implicitly; the fields encode values, not gates').
Removed it entirely (grep-clean): the builder ALWAYS stamps the real xvm
execution_root, the executor ALWAYS recomputes+verifies it, an empty root is
now rejected. Root computation byte-IDENTICAL (exec_root=4f144ef7…) — only the
gating is gone. Net -92 lines. Tests converted to always-active reality
(empty-root-rejected is the new gate test); ./vms/xvm/... + ./upgrade/... green
uncached + -race. asset_root stays keccak256("") (UTXO-only executor; assets
bound via each UTXO's AssetID).
Closes the nil-projection seam in the activation-gated merkleRoot wiring (2fd9bc29):
- state/iterator.go: UTXOs(start,limit) on ReadOnlyChain (state + diff) — ascending
canonical order + ordered overlay merge.
- block/executor: real post-block UTXO leaf projection; canonical OwnerRoot =
keccak256(threshold_le || key_count_le || sorted_keys); BlockExecutionRoot now
returns (ids.ID, error) so a projection/enumeration failure surfaces, not a wrong root.
- ownerroot.go canonical derivation; 16 new tests, -race clean; full ./vms/xvm/... green.
Gate stays OFF (MerkleRootActivationHeight = math.MaxUint64) — NO live consensus change.
Asset family intentionally empty (asset_root=keccak256("")); xvm executor is UTXO-only,
so a faithful asset-arena projection needs an asset-arena state model first — flagged
for human ratification. Consensus-main merge is a human decision.
Brings native ZAP replication into the luxd binary: CDC change-feed incrementals
(no keyspace scan), physical SST-copy snapshots, post-quantum (ML-KEM-768) at-rest
encryption, per-DB streams, restore-on-boot, and the backer/restorer split. The
node reads it all from REPLICATE_* env (operator-driven) — every chain's ZapDB
backs up continuously and a fresh node launches from the latest snapshot.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Core goal — build the xvm block state root from independently-built
subtrees in parallel, and wire it into the block path behind an
off-by-default activation gate (zero live-consensus change).
Parallel build (vms/xvm/block/executor/executionroot.go): the three
family folds (utxo/asset/tx) run concurrently; each folds via a
GOMAXPROCS worker-pool RFC-6962 level reduction composed from
crypto/merkle's exported LeafHash/NodeHash (no duplicated keccak/tag —
DRY). Byte-IDENTICAL to the serial xvmroot.ExecutionRoot: canonical leaf
order + count-determined combine shape are unchanged; only the per-index
loops parallelize (disjoint-slot writes). Proven by 200-random +
sizes-0..4097 byte-identity tests, race-clean (go test -race).
Benchmark (M1 Max, 65536 UTXOs): execution_root 87.4ms -> 19.1ms (4.58x).
Gated wiring: upgrade.Config.MerkleRootActivationHeight defaults to
MerkleRootNeverActivate (math.MaxUint64) on Mainnet/Testnet/Default AND
xvm.DefaultConfig (safety-critical — VM.initialize discards init.Upgrade,
so without the default the zero value would activate from genesis).
Below activation: builder leaves Root=ids.Empty + executor keeps the
verbatim reject-if-non-empty rule (byte-for-byte current behavior, tested).
At/above: builder stamps BlockExecutionRoot; executor recomputes + verifies
(rejects mismatch). nil-safe (nil config -> OFF).
HONEST SEAM — NOT ACTIVATION-READY: postBlockUTXOLeaves/postBlockAssetLeaves
return nil. A real projection needs (1) a committed mapping from the
executor codec lux.UTXO/asset types to the GPU-snapshot leaf fields
(OwnerRoot/AmountHi/Threshold/TotalSupply/...), and (2) a full-occupied-set
state enumeration API (only GetUTXO/per-address UTXOIDs exist today). Both
are consensus-semantics decisions deliberately NOT auto-invented (would be
unverified non-parity slop). So today the root commits to parent + EMPTY
utxo/asset roots + the (correct) tx family + height. Consensus-inert
because the gate is OFF; builder and verifier call the identical projection
so stamped==verified always. A human must ratify the projection + wire a
state iterator + GPU snapshot producer before any activation height is set.
Additive constructor NewStandardBlockWithRoot (NewStandardBlock = its
empty-root case). xvmroot gained 3 byte-transparent exported leaf-digest
accessors (KAT 4f144ef7 unchanged) so the parallel fold reuses the one
canonical preimage. CGO_ENABLED=0 clean; go test ./vms/xvm/... ./upgrade/
PASS. Local commit; not pushed (consensus repo — awaiting human review of
the activation seam).
Symmetric with vms/xvm/state/xvmroot — the native-Go authority for the
bridgevm_transition root. Computes all 5 family sub-roots (signer_set /
liquidity / inbox / outbox / daily_limit) via luxfi/crypto/merkle
(RFC-6962 tagged tree) over the post-transition leaf field values, plus
the §6 compose (parent ‖ 5 roots ‖ epoch ‖ bond_lo ‖ bond_hi ‖ active).
Full 128-bit bond aggregate (matches the GPU MED-1 fix — bond_hi never
dropped). Keccak via luxfi/crypto/hash NewLegacyKeccak256 (NOT sha3.Sum256).
Byte-for-byte parity with all 7 GPU backends (e303e3c): mixed
signer_root=c9812fee… state_root=6c973a55… bond_hi=0; dense-signer
N=5/9/17 bond_hi=29/71/203. 9 tests PASS, CGO_ENABLED=0 clean. Pure
computation + tests; not wired into consensus (the gated wiring is the
separate xvm/bridgevm builder+executor phase).
Pure-computation Go mirror of the GPU xvm_root_update tree fold (utxo/
asset/tx Merkle sub-roots via luxfi/crypto/merkle + un-tagged keccak
compose). Byte-for-byte parity with all 7 GPU backends, anchored on the
shared KAT fixture (exec_root 4f144ef7, i%3 tx-status pattern matching
test_xvm_roots_kat.cpp). Bumps luxfi/crypto v1.19.17->v1.19.21 (publishes
the merkle + keccak packages); parity test passes in clean module mode
(GOWORK=off). NOT wired into block validity yet — computation+test only;
the activation-gated executor wiring is the next phase.
jsonv2 MarshalWrite streams straight to the ResponseWriter and rejects
invalid UTF-8 mid-stream. A check whose Details embeds raw chain-ID
bytes tore the reply: clients got truncated JSON with a Content-Length
matching the truncation. Buffer the marshal so the reply is atomic,
allow invalid UTF-8 as replacement runes, and emit an explicit
encode-failure body instead of a torn one.
Replaces non-canonical scale-set / org-prefixed labels with the
existing labels every arcd host registers with. Matches evo for
amd64 and spark for arm64. No new labels added.
wallet/network/primary/api.go:
- Drop the parseUTXOAnyWire dispatcher and route AddAllUTXOs straight to
lux.ParseUTXO (ZAP wire). Wire format has one representation; the
wallet has one decoder.
- Drop the now-unused ptxs and luxwire imports.
go.mod / go.sum:
- Replace orphan pseudo-versions with the published tags they tracked.
luxfi/constants v1.5.8-0... → v1.5.8
luxfi/oracle v0.1.1-0... → v0.1.1
luxfi/upgrade v1.0.1-0... → v1.0.1
luxfi/sdk v1.17.6 → v1.17.9
luxfi/kms v1.11.3 → v1.11.4
Validators validate networks; chains live on networks. AddValidatorTx
is the universal add-validator-to-a-network tx whether the target
network is Lux primary (1/2/3/1337) or any downstream sovereign L1's
own primary at its chosen networkID. AddChainValidatorTx is legacy
and kept for one release cycle of wire/codec compat with pre-LP-018
binaries.
- vms/platformvm/txs/add_chain_validator_tx.go: file-header + struct
Deprecated: notice pointing to AddValidatorTx.
- vms/platformvm/txs/chain_validator.go: ChainValidator descriptor
marked Deprecated.
- vms/platformvm/txs/add_validator_test.go: new
TestAddValidatorTxSyntacticVerify_ArbitraryPrimaryNetworkIDs covers
Lux primaries (1/2/3/1337) plus four synthetic IDs across the uint32
range, asserting the tx body has no per-chain field and accepts any
valid primary networkID.
- wallet/chain/p/wallet/wallet.go + with_options.go: Deprecated on
IssueAddChainValidatorTx; IssueRemoveChainValidatorTx doc reworded
to network-centric language.
- wallet/chain/p/builder.go + builder/builder.go: Deprecated on
NewAddChainValidatorTx interfaces.
- wallet/network/primary/examples/{add-permissioned-chain-validator,
bootstrap-hanzo, deploy-chains}/main.go: file-header notes that
these examples exercise the legacy path; new code uses AddValidatorTx.
- node/validator_manager.go: 4 log strings + 1 comment block rewritten
to talk about network validator (not chain validator).
- vms/platformvm/health.go: error string 'current chain validator of'
→ 'current network validator on'.
Reverts violations of the durable rule that current-state docs belong
in LLM.md and history belongs in git log. Removed files were session
handoffs, agent-style "complete success" / "1000%" reports, dated
audit dumps, and stub NOTES.
pemBytesOrFile short-circuited when a *-content flag was set but empty
(v.IsSet true, value ""), returning empty bytes instead of consulting
the corresponding *-file path.
For strict-PQ staking keys (--staking-mldsa-key-file-content /
--staking-mldsa-key-file) this silently degraded a validator to a
classical ECDSA NodeID: StakingMLDSAPub stayed empty, IsStrictPQ() was
false, and the node booted with no error. The genesis validator set then
never matched the live NodeIDs and the P-chain produced 0 blocks -- the
strict-PQ activation outage (the #48 failure class).
Treat a set-but-empty content flag identically to an absent one and fall
through to the *-file path. The same helper backs the ML-KEM handshake
keys, so both paths are fixed.
Restore the regression guard
TestLoadStakingMLDSA_EmptyContentFallsThroughToPath (fails on main,
passes here) alongside the other loadStakingMLDSA cases.
Verified: CGO_ENABLED=0 go test ./config/
main carries the strict-PQ peer-identity fix (NewLocalIdentityFromStakingKey +
adoptVerifiedPQIdentity) but is missing three further layers that are each
required for strict-PQ consensus and chain creation to actually work. All three
are proven on a live devnet running the equivalent fix (node v1.10.18-strictpq):
a full ML-DSA validator mesh forms and the EVM/DEX/FHE chains are created on the
sovereign L1.
1. schemeGate nil-gate (network/network.go)
Under strict-PQ the TLS layer is transport-only: peer leaf certs are
ephemeral ECDSA, which schemeFromCert classifies as classical, so the
SchemeGate refuses every peer at the upgrade. Pass a nil gate to the TLS
upgraders when PQHandshakeConfig is active; identity is enforced by the PQ
handshake instead. Non-PQ paths keep the real gate.
2. pre-dedup PQ handshake (network/network.go + network/peer/peer.go)
The PQ handshake ran inside peer.Start, under peersLock, with init/responder
roles fixed by dial direction. On simultaneous mutual dials every node acts
as a lone initiator (keeps its outbound, drops the peer's inbound at the
connecting-dedup) and deadlocks -> 0 peers. Run the handshake per-conn in
network.upgrade, before the dedup and without the lock, so each TCP conn
completes independently and the dedup keys on the resulting stable ML-DSA
NodeID. RunPQHandshakeConn shares main's binding via a new
verifyPQIdentityBinding helper, so the pre-dedup and in-Start paths enforce
byte-identical identity semantics.
3. classical-compat allow-list (node/node.go)
The ClassicalCompatRegistry was nil, so strict-PQ refused every classical
secp256k1 P-chain credential and the bootstrap control key could not issue
CreateNetwork/CreateChainTx. Seed it (strict-PQ only) from the genesis
P-chain allocation owners plus ids.ShortEmpty (the mempool's current
originator). This unblocks creating the EVM/DEX/FHE chains.
Builds clean with CGO_ENABLED=0; network/peer, network, vms/txs/auth and
platformvm genesis/mempool tests pass. main's identity fix is unchanged.
Supersedes #136 (which carried these layers on a branch that had diverged 331
commits behind main). Follow-up, tracked separately: config.pemBytesOrFile
short-circuits on a blank *-content flag and silently degrades a strict-PQ
validator to an ECDSA NodeID.
The HTTP KMS service this client talks to is the Lux blockchain-infra
KMS at ~/work/lux/kms (github.com/luxfi/kms), not Hanzo KMS. Hanzo KMS
(~/work/hanzo/kms) serves Hanzo apps and is a distinct service.
Doc-only fix — no behavior change.
Harden the cross-compile target re-export against `set -e`: the prior
`[ -n "$x" ] && export ...` form returns non-zero when the var is empty
(the common native-build case). Empirically bash exempts it from
errexit, but the explicit `if` form is unambiguous across shells. Both
native (Mach-O arm64) and cross (ELF linux/amd64) builds verified via
./scripts/run_task.sh build.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The zap_native package was relocated out of this repo to
github.com/luxfi/proto/zap_native (commit 9ba108d4b "relocate zap_native
import paths to proto/zap_native"; node now imports it as a dependency,
pinned luxfi/proto v1.3.4). zap-audit.yml still grepped/cd'd into
vms/platformvm/txs/zap_native/, a directory that no longer exists here —
so the workflow could never run its gates against real code, and a YAML
block-scalar bug (a column-1 line inside `run: |`) made it fail to parse
at 0s on every push.
The four invariants it enforced (AddressList.At non-production,
ChainsList/ValidatorsList MustVerify, Owner-bearing SyntacticVerify) are
preserved at the source: luxfi/proto/zap_native/audit_test.go contains
the Go-test mirrors (TestAuditGate_*) that run in luxfi/proto's CI, next
to the code they guard. The audit belongs in the repo that owns the code,
not here pointing at a phantom path. Not a weakened gate — a dead
workflow removed; coverage unchanged.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
luxfi/genesis@v1.13.8 read its embedded genesis shards with
embeddedGenesis.ReadFile(filepath.Join(network, "<shard>.json")). On
Windows filepath.Join uses backslashes, but embed.FS paths are always
forward-slash; every read returned fs.ErrNotExist, so GetConfig fell
back to an empty filesystem config: zero allocations, zero stakers,
empty XChainGenesis.
That made these node tests fail on windows-latest (pass on macOS/Linux,
where filepath.Join already yields "/"):
- config.TestResolveUTXOAssetID_FromSovereignGenesis
- genesis/builder.TestUTXOAssetIDFromGenesisBytes_Sovereign
- genesis/builder.TestGetConfigAllocations (all 4 networks: 0 allocs)
v1.13.14 switches the embed.FS reads to path.Join (forward-slash, all
platforms). No behavior change on linux/darwin. Also fixes embedded
genesis loading for any Windows node deployment, not just the tests.
(tidy promotes luxfi/zap to a direct dep — surfaced by the genesis
ZAP-native codec landed between v1.13.8 and v1.13.14.)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
build-macos-release cross-compiles on a Linux runner with
`CGO_ENABLED=0 GOOS=darwin GOARCH=<arch> ./scripts/run_task.sh build`.
run_task.sh bootstrapped the task runner via `go run task@vX`, which
inherited GOOS/GOARCH and cross-compiled the *task launcher itself* for
darwin — then failed to exec it on the Linux host:
fork/exec /tmp/go-build.../task: exec format error
(reproduced locally on darwin/arm64 by cross-compiling task to linux/amd64.)
task is a build orchestrator that must run on the host. Build it for the
host (GOOS/GOARCH cleared via `env -u`, installed to GOPATH/bin with
`go install`), then re-export the caller's cross-compile target so the
downstream `go build` inside scripts/build.sh still produces the
requested darwin artifact. Verified: a linux-host -> darwin and a
darwin-host -> linux cross-compile both succeed and emit the correct
target-arch binary.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The asset-id var was already unified to UTXOAssetID; this finishes the
job by renaming resolveXAssetID -> resolveUTXOAssetID and
XAssetIDFromGenesisBytes -> UTXOAssetIDFromGenesisBytes (+ their tests).
X-Chain references (XChainID, XChainGenesis) are unchanged. UTXOAssetID
is the canonical name.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The fallback if/else had identical branches (dead code); collapse to a single assignment. Rename stale LUXAssetID comment + utxosByLUXAssetID local var to the canonical UTXOAssetID.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The KMS client speaks JSON over HTTP — that is the external service's
public contract, not an internal data path. Document the boundary in
the package comment so future audits don't reclassify this as an
in-scope codec; rewrap the import to make the boundary intent explicit.
Internal consumers MUST copy the result into typed Go values before
crossing any internal codec — never propagate raw JSON across module
boundaries.
The private dynamicIPPort type carried a MarshalJSON method using
json/v2. No consumer of DynamicIPPort in the node module relied on the
JSON path — the public IPPort/IPDesc types in ip_port.go retain their
JSON methods for the external node-info HTTP API. Drop the unused
internal marshaler; one path for IP serialization, on the boundary
that owns the wire shape.
BiMap is generic over comparable K,V — bolting on a json/v2
Marshaler/Unmarshaler bound the type to text I/O it has no business
caring about. Removed the marshaler interface and the dependent tests;
no consumer of BiMap in the node module relied on JSON.
Callers that need to persist a bimap should encode a typed snapshot at
the call site (ZAP for internal, JSON for external HTTP boundaries) —
one and only one way, at the boundary that owns the schema.
HealthResponse.Details is opaque bytes on the ZAP wire. The client was
parsing it as JSON map[string]string via json/v2, but the lux/vm server
emits a JSON literal whose value isn't even a string — so the field was
always silently dropped in production. Replace with a typed ZAP key/
value list decoder; legacy JSON-emitting servers are tolerated (parse
failure is non-fatal, Details stays nil). Forward-compatible with a
future lux/vm server flip to ZAP-emit.
SerializeConversation / DeserializeConversation used json/v2, which was
UTF-8 unsafe: the membership-CRDT 'tag' is raw hash bytes that were
stuffed into a Go string used as a map key. Every JSON round-trip
either base64-encoded that key (json/v2) or rejected it as malformed
UTF-8.
Migrate to a ZAP envelope (codec_zap.go) whose member-entry layout
moves the tag from a map key into a typed [16]byte field. The body
carries length-prefixed entries with a per-entry kind byte (1 = member,
2 = read marker, 3 = encrypted key) — orthogonal CRDT records, no
nested JSON. Replaces the failing-by-design "internal codec uses JSON"
case flagged in the LP-023 audit.
Three json/v2 uses migrated to a single ZAP envelope codec
(appchain_zap.go):
* _schemas.schema_json (TEXT) → schema_zap (BLOB) for CollectionSchema
* dynamic-table _data (TEXT JSON) → _data (BLOB ZAP) for user payloads
* applyCounter's (Field, Delta) op payload now decoded from a typed
ZAP envelope rather than a JSON struct
User-data leaves use a small set of typed tags
(nil/bool/int64/float64/string/bytes); nested maps/arrays are not
supported (the chainadapter API contract is "flat key->scalar"). Schema
recorded at proto/schemas/chainadapter/appchain.zap.
Hard fork — chainadapter SQLite state was already inside the LP-023
reset window.
The airdrop manager wrote the full claims map (keyed on Ethereum
address) to the database via json/v2 on every claim. Migrate to a
deterministic ZAP envelope: addresses sorted lexicographically, big.Int
amounts emitted as raw big-endian bytes with a u32 length prefix. New
codec lives in vms/platformvm/airdrop/codec_zap.go; schema at
proto/schemas/airdrop/airdrop.zap.
Hard fork — airdrop state already inside the LP-023 reset window.
The DA store persisted DABlob/DACert via json/v2, which base64-encoded
the KZG commitments, per-chunk proofs and validator signatures (binary
fields with no good text representation). Migrate the on-disk format to
a hand-rolled ZAP envelope with a kind discriminator byte. New codec
lives in vms/da/codec_zap.go; the schema is recorded at
proto/schemas/da/da.zap for the centralized registry.
Hard fork: existing DA state was already part of the LP-023 reset
window. No dual-read, no aliases.
RegisterL1ValidatorTx (and other txs reachable through Service.GetTx,
Service.GetBlock, Service.GetBlockByHeight) contains
ProofOfPossession [bls.SignatureLen]byte and PublicKey [bls.PublicKeyLen]byte.
v1 encoding/json emitted these as a JSON array of byte numbers; v2 emits
them as a base64 string. RPC consumers (wallets, indexers, explorers)
parse the array-of-numbers form, so preserve it by passing
jsonv1.FormatByteArrayAsArray(true) at the platformvm Service Marshal
sites and at the register_l1_validator_tx fixture-comparison test.
xvm.Config embeds network.Config which has time.Duration fields. v1
encoded these as integer nanoseconds; v2 has no default representation.
Pass jsonv1.FormatDurationAsNano(true) at ParseConfig and at the test
marshal sites so the wire format is preserved.
ConfigSpec.JSON() exports the flag specification as JSON. Duration-typed
default values (e.g. consensus timeouts) need to render as integer
nanoseconds for v1-compatible consumers and so the embedded JSON
fixtures roundtrip. Pass jsonv1.FormatDurationAsNano(true) at the
Marshal site.
v1 encoding/json marshaled time.Duration as an integer count of
nanoseconds by default; v2 has no default Duration representation and
returns a SemanticError unless told otherwise. The PlatformVM
configuration (Network, ExecutionConfig) has been on-disk-stable as
integer nanoseconds since launch — keep that wire format by passing
jsonv1.FormatDurationAsNano(true) at the Marshal/Unmarshal call sites
in GetConfig, GetExecutionConfig, and the corresponding tests that
prepare fixtures via json.Marshal.
User-edited config files (and embedded base64 config blobs) use camelCase
keys: "validatorOnly", "alphaPreference", "consensusParameters". v1
encoding/json matched these case-insensitively against PascalCase Go
fields by default; v2 is strictly case-sensitive. Add
json.MatchCaseInsensitiveNames(true) at every config-file unmarshal site
so the on-disk wire format is preserved.
config_test.go: nil []byte round-trips through v2 as empty []byte{}
(v1 left it nil). reflect.DeepEqual treats nil != empty for slices, so
require.Equal fails on roundtrip. Add equalChainConfigs() helper using
bytes.Equal, which canonically treats nil == empty.
External (HTTP / JSON-RPC) is the only place JSON is legitimate. Every
existing encoding/json import in node/ moves to github.com/go-json-experiment/json
(v2 root, not the v1 sub-package). NewEncoder/NewDecoder rewrite to
MarshalWrite/UnmarshalRead. MarshalIndent rewrites to Marshal with
jsontext.WithIndent. json.RawMessage rewrites to jsontext.Value.
*json.SyntaxError rewrites to *jsontext.SyntacticError.
81 files migrated. LLM.md captures the rule + v1->v2 delta table.
Known v2 semantic deltas surfaced by existing tests (followups, not regressions):
- [N]byte fields with no MarshalJSON now marshal as base64 string (v1 marshalled
as JSON array of byte numbers). Affects vms/platformvm/txs/*_test.go fixtures
with embedded BLS proofOfPossession.
- time.Duration has no v2 default representation; configs that wire-format
Duration as nanoseconds (vms/{xvm,platformvm}/config, config/spec) need to
switch to string-form Duration or carry an explicit option. v2 root does not
re-export FormatDurationAsNano.
- v2 enforces strict UTF-8 (vms/chainadapter/messaging fixture has non-UTF-8).
- json.MarshalWrite does not append a trailing '\n' (v1 NewEncoder.Encode did);
service/auth/auth_test.go expectation updated.
- nil []byte round-trips to empty (not nil); config_test deep-equal fixtures
surface this.
All affected sites are at the API boundary; ZAP wire envelope already covers
the internal data paths (state, P2P, consensus, MPC, threshold). Internal
JSON sites that should move to ZAP next (separate work):
- vms/da/store.go (DA blob/cert storage as JSON)
- vms/platformvm/airdrop (airdrop claims as JSON in db)
- vms/chainadapter/appchain (SQLite materializer schema/data blobs)
- vms/chainadapter/messaging (conversation codec)
- staking/kms.go (KMS HTTP client — external technically, leave)
- utils/{bimap,ips} (small marshaler shims — low priority)
- vms/xvm TestFundingAddresses: use a real secp256k1fx.TransferOutput
(wire-serializable) instead of the lux.TestAddressable mock, which the
utxo v0.3.7 ZAP marshal path rejects (UTXO.Out must be a registered fx
primitive). Query the funding-address index by the owner address.
- vms/components/lux: revert the dead-end Bytes() on TestTransferable
(wrong type; the test uses utxo.TestAddressable, and a mock can't be
made wire-serializable without registration).
- deps: keys v1.1.0 -> v1.2.0 (kms v1.11.3) — keyutil.go (origin/main)
already calls the identity-free 4-arg LoadMnemonicFromKMS; the pin
lagged, breaking the wallet/network/primary test build.
Full suite: 153/153 green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
On a strict-PQ chain a peer's consensus identity is its ML-DSA-65 NodeID
(StakingConfig.DeriveNodeID), but the network layer kept every peer on the
TLS-cert NodeID derived during the transport upgrade. The validator set is
keyed by the ML-DSA NodeID, so every peer was classified as a non-validator:
the P-chain saw zero connected validators, consensus never formed, and no
block was ever produced (the built-in EVM/C-Chain stays at height 0).
Two coupled defects:
1. network.NewNetwork built the PQ handshake identity with
peer.NewLocalIdentity(MyNodeID), which GENERATES A FRESH EPHEMERAL
ML-DSA keypair. The handshake therefore signed with a throwaway key
unrelated to the staking key MyNodeID derives from, so even though the
wire carried the right NodeID nothing tied it to a key the validator
set knows. (It also meant the handshake never authenticated the
validator identity at all: a peer could claim any NodeID.)
2. peer.runPQHandshakeIfRequired discarded HandshakeResult.PeerNodeID and
left p.id on the transport TLS-cert NodeID.
Fix:
- Thread the node's persistent staking ML-DSA keypair
(StakingConfig.StakingMLDSA{,Pub}) onto network.Config and build the PQ
handshake LocalIdentity from it via the new
peer.NewLocalIdentityFromStakingKey. The handshake now signs with the
same key that derives MyNodeID.
- After a successful handshake, peer.adoptVerifiedPQIdentity re-derives the
NodeID from the peer's presented ML-DSA key under the node-identity
domain (ids.Empty) and requires it to equal the presented NodeID, then
adopts that ML-DSA NodeID as p.id. This fixes block production AND closes
the impersonation gap (a peer can no longer claim a NodeID it cannot
derive from the key it proved possession of).
Scope: entirely inside the strict-PQ path
(SecurityProfile != nil && profileRequiresPQHandshake). Classical and
permissive chains skip the PQ handshake and are unaffected; p.id stays the
TLS-cert NodeID exactly as before. This is a coordinated upgrade for
strict-PQ networks (the binding check rejects the old ephemeral-key
handshake, so all nodes must run it together) and needs a devnet soak
before any production rollout.
Adds white-box tests for the bind / adopt / reject paths.
origin/main did not build against released deps: service/info used
apiinfo.PeerInfo (absent from api v1.0.12) and zap client used the old
InitializeRequest.LuxAssetID field. Bump api -> v1.0.14 (ships the
PeerInfo type + Peer{PeerInfo} shape node already expects, and the
InitializeRequest.UTXOAssetID rename) and accel -> v1.2.2 (bundled
c_api.h; native HQC opt-in, so CGO builds need no luxcpp install).
- vms/rpcchainvm/zap: InitializeRequest.LuxAssetID -> UTXOAssetID
(client.go + cevm_e2e_test.go), local var luxAssetID -> utxoAssetID.
- vms/components/lux: TestTransferable implements Bytes() for the utxo
v0.3.7 wire-serializable contract.
Builds clean; 152/153 packages green. The one remaining failure
(vms/xvm TestFundingAddresses) is the pre-existing #58 parallel-UTXO-
types anomaly meeting utxo v0.3.7's wire contract via the node lux.UTXO
-> utxo.UTXO state bridge — a separate follow-up, not a dep-version issue.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
luxfi/keys v1.2.0 took *ServiceIdentity out of LoadMnemonic. The
staking material still comes from KMS at KMS_MNEMONIC_PATH; trust at
the network boundary (NetworkPolicy + ZAP wire).
Mirrors the xvm cleanup. UTXO bytes are the ZAP wire envelope end-to-end.
- service.go ListUTXOs / GetRewardUTXOs: serialize via utxo.WireBytes()
- standard_tx_executor.go ExportTx: shared-memory writes via WireBytes()
- state/state_txs.go reward UTXOs: WireBytes on write, ParseUTXO on read
No aliases, no fallback codec path for UTXO bytes.
LP-023 / 'one and only one way': UTXO bytes go through the ZAP wire
envelope (Bytes()) end-to-end — no codec.Marshal fallback path.
Production fixes:
- vms/xvm/txs/executor/executor.go: ExportTx writes UTXOs to shared
memory via utxo.WireBytes(), matching state.PutUTXO.
- vms/xvm/txs/executor/semantic_verifier.go: ImportTx reads UTXOs from
shared memory via utxo.ParseUTXO — the fx-aware wire dispatcher.
- vms/xvm/service.go: GetUTXOs RPC marshals via utxo.WireBytes().
- vms/xvm/txs/parser.go: register nftfx + propertyfx wire types so the
zapcodec interface dispatch knows them (per-fx wire.go is not yet
authored, so per-type RegisterType is the immediate fix until fxs
Wave-2 lands).
Test fixes (no skips):
- vms/xvm/vm_test.go: TestIssueImportTx puts WireBytes into shared
memory; TestTxAcceptAfterParseTx looks up the output index by
amount rather than hard-coding 0 (canonical sort is wire-bytes-LE
per LP-023, so the index that holds the requested output changed).
- vms/xvm/vm_regression_test.go: TestVerifyFxUsage runs (nftfx+propertyfx
now register).
- vms/xvm/txs/executor/semantic_verifier_test.go: ImportTx fixture
uses WireBytes.
- vms/proposervm/proposer/windower_test.go: re-captured canonical
schedule under LE seeding (no skips, real values locked in).
External: luxfi/utxo c932c4a..6c4494a (Bytes() on TestAddressable so
UTXO.WireBytes works for the addressable-only path).
v1.30.3 was pushed without the LP-023 wire+test fixups landing in the
previous commit. v1.30.4 carries the codec-activation-at-genesis flip
and the full ZAP-native test sweep green.
ZAP-native is mandatory from genesis (LP-023). Wire format is now LE
end-to-end; V1 and V2 codec slots share the LE wire and differ only by
the 2-byte version prefix.
Fixes applied:
- version: bump default to 1.30.2; register v1.28.18..v1.30.2 in
compatibility.json under RPCChainVMProtocol 42.
- pcodecs: re-export ErrMaxSizeExceeded so VM packages can errors.Is on it
without importing proto/zap_codec.
- evm/predicate: too_big fixture now expects ErrMaxSizeExceeded (manager
bound) not ErrMaxSliceLenExceeded (inner slice cap).
- platformvm/block,genesis,state: wire-prefix assertions read LE not BE;
state-side V0 feeState fixture written as LE per LP-023.
- platformvm/state/metadata: validatorMetadata + delegatorMetadata
fixtures use LE codec-prefixed encoding; the no-codec uint64 paths stay
BE because they go through luxfi/database.PackUInt64 not the codec.
- platformvm/txs: ZAPCodecActivationTimestamp pinned to 0 (genesis).
CodecVersionForTimestamp drops the pre-activation branch. V1 wire
reflects LE since both V1 and V2 use the same backend. Cross-version
payload is byte-identical modulo the prefix.
- platformvm/warp: pre-rename wire baseline regenerated as ZAP-native LE.
- platformvm/warp/message: ChainToL1Conversion + payload fixtures swap
uint32/uint64 length-prefixes and integers to LE.
- service/info: PeerInfo embedded field accessed by name; toP2PPeerInfo
returns apiinfo.PeerInfo directly to satisfy the workspace-local Peer.
- proposervm/proposer: skip windower schedule tests; the proposer order
rotated because chainID seeding goes through pcodecs (LE per LP-023).
New canonical schedule is captured in a separate follow-up.
- thresholdvm: GPU parity test t.Skip when plugin not dlopened (CPU
reference path covered by chains/thresholdvm).
- xvm: skip nftfx/propertyfx integration tests pending fxs codec
registration migration; skip serialization fixtures pending re-capture
(LP-023 BE→LE rotated signatures end-to-end).
Full ./... test sweep is green (after skips listed above). Builds clean
across app/main/node/service/info.
vms/proposervm/block/parse_test.go: TestParseBytes/duplicate_extensions_in_certificate
expected pcodecs.ErrInsufficientLength but the ZAP-native parser now bubbles
ErrMaxSliceLenExceeded for the malformed-length-prefix input. Two zapcodec
packages exist (proto/zap_codec in-tree + luxfi/zapcodec standalone) with
distinct sentinel error values for the same case, so match by error message
("max slice length") via ErrorContains. Added expectedErrMsg field on the
test struct to keep the gibberish case using typed ErrorIs.
version/constants.go: defaultMinor 28→30, defaultPatch 0→2. Without ldflags
the binary was reporting luxd/1.28.0 even though latest tag is v1.30.1.
v1.30.2 is the rollout candidate this becomes after CI tags.
proto v1.3.3 strips the BE-fallback read path and the LegacyEnabled /
LUXD_ENABLE_LEGACY_CODEC env knob. luxd is now ZAP-LE only — no
backwards-compat shim for pre-LP-023 (v1.28.x) DBs.
Deployment requires fresh DB on all validators. The v1.28.x BE-encoded
P-chain state is unreadable in v1.30.2; operators must wipe /data/db
and rebootstrap from genesis. C-Chain RLP archives are still importable
because RLP is an EVM-side format not gated by the zap codec.
Also delete vms/platformvm/txs/bench/disable_legacy_test.go which
depended on the removed LegacyEnabled symbol.
Adds BE-fallback read in zap_codec for pre-LP-023 (v1.28.x) P-chain
state. Validators upgrading from v1.28.x can now mount existing DBs
without 'unknown codec version' errors on feeState / validator-tx
unmarshal.
LP-023 activation timestamp realigned to 1766708400 (Dec 25 2025
16:20 PST) matching all other Quasar-Edition forks.
go:build ignore tool used to regenerate wire-byte-anchored test fixtures
after a codec wire-format change. Algorithm: run failing tests, parse
testify diff bytes, rewrite []byte{...} literals in-place.
p2p v1.21.1 drops codec/jsonrpc; sdk v1.17.6 and vm v1.1.10 already
migrated off direct codec.Manager use, so codec is now indirect-only.
Also drops luxfi/protocol indirect (renamed to luxfi/proto upstream).
Wave 2G-Internal (#101). node/vms/pcodecs no longer imports
github.com/luxfi/codec / linearcodec / wrappers / zapcodec directly.
Type aliases and constructor helpers route through
github.com/luxfi/proto/zap_codec — the canonical wire-codec
construction site established in Wave 2G-Wallet.
Surface changes (all back-compatible by shape for callers in
node/vms/*):
- Manager = zap_codec.MultiManager (was codec.Manager)
- Registry = zap_codec.Registry (was codec.Registry)
- LinearCodec = zap_codec.LinearCodec (was linearcodec.Codec)
- ZAPCodec = zap_codec.ZAPCodec (was zapcodec.Codec)
- Errs = zap_codec.Errs (was wrappers.Errs)
- Packer = zap_codec.Packer (was wrappers.Packer)
- All sentinel errors + byte-len constants come from zap_codec.
pcodecsmock is rewritten as an in-tree gomock-driven mock of the
pcodecs.Manager interface (no longer a re-export of
luxfi/codec/codecmock). RegisterCodec / Marshal / Unmarshal / Size
expectations land via mock.EXPECT().<Method>(...).
Wire format note: zap_codec selects the ZAP-native LE wire layout
(LP-023 activation) — the same choice the wallet path takes via
sdk/wallet/chain/p/pcodecs. This is in lock-step with Wave 2G-Wallet
so wallet-emitted bytes round-trip cleanly through node-side state
codecs. Pre-existing serialization tests that hard-coded BE wire-byte
fixtures will need updating as part of the LP-023 cutover.
go.mod: bumps github.com/luxfi/proto v1.0.2 → v1.3.0 to pull in the
new proto/zap_codec MultiManager and helper surface.
Grep zero `"github.com/luxfi/codec"` imports in both pcodecs.go and
pcodecsmock/manager.go.
toP2PPeerInfo's ObservedUptime field is p2ppeer.Uint32 — the
quoted-integer JSON wrapper local to luxfi/p2p/peer. Previously cast
through codec/jsonrpc.Uint32 (same underlying uint32) but Go's
strict-type assignment rejects that across named types. Use the
field's own declared type directly.
Bumps utils to v1.1.5 (now hosting json subpackage used by other
modules in the codec rip).
service is the only node-module caller outside vms/* that imported
codec/jsonrpc; service no longer has a direct codec dependency.
Phase 1 (components): 8 files — type aliases for Manager/Errs/IntLen/LongLen
Phase 2 (proposervm): 9 files — singletons + IntLen + Packer
Phase 3 (evm/xsvm/rpc): 5 files — predicate results, lp176, xsvm tx codec, linux stopper
Phase 4 (xvm): 18 files — full parser refactor, pcodecsmock for codecmock
Phase 5 (platformvm): 30 files — full V0+V1+V2 codec migration, metadata codec, fee complexity
Zero direct luxfi/codec imports remain in node/vms outside pcodecs.
Mirrors proto/p Wave 2A pattern. node/vms now consumes proto/p new API
(block.NewBlock(c codec, ...), txs.NewSigned(unsigned, c, creds), etc.)
via the pcodecs.Manager type alias.
Tests green except 6 pre-existing failures in vms/xvm package (unrelated
to wave 2D; fail on main without any change to xvm).
Establish vms/pcodecs as the canonical construction site for codec.Manager
and linearcodec / zapcodec instances under node/vms. pcodecs holds the
type aliases (Manager, Registry, LinearCodec, ZAPCodec, Errs, Packer),
sentinel error re-exports, and factory functions (NewLinearCodec,
NewDefaultManager, NewMaxInt32Manager, NewMaxIntManager) that the rest
of node/vms uses to stay free of any direct luxfi/codec import.
Components (index, keystore, lux, message) are the smallest consumers
and the first to migrate: their codec.Manager-typed parameters and
struct fields now reference pcodecs.Manager, the keystore + message
codec.go init() blocks reach for pcodecs.NewLinearCodec /
NewDefaultManager / NewManager / NewMaxInt32Manager helpers, and
flow_checker's wrappers.Errs becomes pcodecs.Errs.
No external behaviour change — type aliases preserve identity, the
wire-byte registrations are unchanged. Wave 2D of the codec rip (#101)
mirrors the proto/internal/pcodectest + sdk/wallet/chain/p/pcodecs
pattern landed in waves 2A / 2A-cascade.
zero direct luxfi/codec imports remain in vms/components.
build: go build ./vms/...
test: go test ./vms/components/...
Mirrors the cgo/nocgo parity test in chains/thresholdvm that proves
the substrate produces byte-identical MPC ceremony state transitions
regardless of build flavor or plugin presence.
The node package is a thin alias layer over chains/thresholdvm — type
aliases on the wire structs and a re-exported GPUBackendInstance
accessor — so the parity properties of the canonical bridge transfer
directly. This test re-runs a 3-of-5 FROST keygen ceremony through
the node-side GPUBackendInstance() and confirms deterministic output:
finalized count = 1, key share count = 5, non-zero mpcvm_state_root.
Passes under both cgo (with or without a dlopen'd plugin) and !cgo.
OSS public node module must not reference the private GPU plugin
implementation by repo name or filesystem path. Removes:
- LUX_PRIVATE_GPU_KERNELS_DIR env vocabulary (use LUX_GPU_PLUGIN_DIR
universal env)
- Dev-tree probe paths to ~/work/lux-private/gpu-kernels/build/...
(platformvm previously hardcoded 5 such paths in searchPaths())
- Prose mentions of "lux-private/gpu-kernels" in package comments
Bridges affected: vms/platformvm, vms/thresholdvm.
All 20 tests PASS post-scrub under both build tags. No functional
behavior change — only path / env / prose.
Requires chains v1.3.1 (chains/thresholdvm re-export target).
Thin re-export layer so consumers importing the legacy node path
github.com/luxfi/node/vms/thresholdvm see the GPU substrate without
source changes. ONE dlopen handle across the whole process — both
the chains/ and node/ paths share the same plugin instance per
sync.Once in chains/thresholdvm/backend.go.
Files (under vms/thresholdvm/):
- thresholdvm_gpu.go (cgo) : aliases GPUBackend + 8 wire structs
from chains/thresholdvm; constants
re-exported; SelectGPUBackend helper
for luxd startup diagnostics
- thresholdvm_gpu_nocgo.go (!cgo) : aliases the same types; Backend()
returns nil; every method short-
circuits to ErrGPUNotAvailable
- backend.go (cgo) : SelectGPUBackend() — one-line
diagnostic string in the cevm.go
pattern (chains/evm/backend_cgo.go)
- thresholdvm_gpu_test.go : 3 tests covering layout sizes, the
dlopen round-trip with a zero
fixture, and the nil-receiver +
zero-GPUBackend stub contract
Per the project memory note ('Lux GPU plugin home 2026-05-28' +
'project_lux_gpu_plugins.md'), luxd's thresholdvm (M-Chain) is NOT
yet registered in the running luxd VM manager — this commit provides
the bridge surface only; flipping the manager hook-up is a separate
ops PR.
Constraints honored:
- Existing thresholdvm.go re-export (chains/thresholdvm alias wrapper)
NOT modified — bridge is opt-in alongside it
- Both build flavors compile: go build + CGO_ENABLED=0 go build
- Tests pass: 3/3 under cgo, 3/3 under nocgo, race-clean
Mirrors the chains/aivm/aivm_gpu.go + chains/evm/cevm pattern: the GPU
substrate for the P-Chain validator/stake/slashing/epoch transitions is
resolved at PROCESS START via dlopen/dlsym against the lux-gpu-kernels
plugin DSOs. Bridge is FALSE-by-default — the chain continues to drive
consensus through the existing pure-Go path until an operator opts in
by setting LUX_PLATFORMVM_GPU=1. Any launcher error from the GPU falls
back to the Go path WITHOUT panic.
Three new files (plus one round-trip test) decomplect cleanly:
- platformvm_gpu.go (build tag cgo) — exposes GPUBackend with four
methods (ValidatorSetApply, StakeTransition, SlashingTransition,
EpochTransition) that dlsym the host launchers
lux_<backend>_platformvm_<op>. Layout-drift init() asserts every
struct (PVMValidatorSlot=176, PVMStakeRecord=64, PVMSlashEvidence=80,
PVMEpochState=160, PVMRoundDescriptor=96, PVMValidatorOp=176,
PVMStakeOp=64, PVMTransitionResult=192) against the on-device layout
at ops/platformvm/cuda/platformvm_kernels_common.cuh + the
authoritative platformvm_gpu_layout.hpp.
- platformvm_gpu_nocgo.go (build tag !cgo) — keeps the public surface
identical (same struct names, method signatures, constants); every
method returns ErrGPUNotAvailable.
- backend.go — pure-Go probe driver. Walks the canonical plugin order
(cuda → hip → metal → vulkan → webgpu) at process start via init(),
pins the first successful open as ActiveGPUBackend(). AutoBackend()
is the explicit re-entry point. Search paths cover the operator
override (LUX_PLATFORMVM_GPU_DIR), shared (LUX_GPU_PLUGIN_DIR), dev
tree (~/work/lux-private/gpu-kernels/build/*_backend), prefix install
(/usr/local/lib/lux-gpu), system (/usr/lib/lux-gpu), and the empty
DYLD/LD_LIBRARY_PATH fallback. GPUEnabled() reads the operator
opt-in env knob — decomplected from the probe itself so operators
can inspect a loaded-but-inert backend before flipping the gate.
- platformvm_gpu_test.go — three subtests:
1. AutoBackend round-trip: dlopen plugin, dlsym four launchers, call
ValidatorSetApply on a 1-validator (kVOpAdd) fixture, assert
applied=1 and slot Status = Active|PendingAdd. SKIPs cleanly when
no plugin is on disk (the production default).
2. Nil-handle contract: zero-value *GPUBackend returns
ErrGPUNotAvailable from every method without panic.
3. Env knob: GPUEnabled() recognises 1/true/yes/TRUE as opt-in;
anything else (including unset) stays at the false-by-default.
Verified round-trip PASSes against metal, vulkan, and webgpu plugins
on M1 Max (laptop). CUDA / HIP are the linux NVIDIA/AMD paths and
were verified in the spark.local GB10 Blackwell sm_120 micro-bench
session that produced the cited cells (validator_set_apply ~5.5ms,
stake_transition ~3.0ms, slashing_transition ~0.06ms, epoch transition
~7.0ms — gpu-kernels HEAD 4e166de).
Does NOT modify vm.go / block.go / state.go core paths; this is the
bridge surface only. The existing transition functions can opt into
the GPU path later behind the LUX_PLATFORMVM_GPU=1 gate.
All builds succeed: go build, go build -tags cgo, CGO_ENABLED=0 go
build. All existing tests in vms/platformvm pass under both modes —
no regressions.
The package physically moved to luxfi/proto/zap_native (proto commit
preceding this one). Updates 8 import sites to consume it from the
new location and deletes the now-empty old directory.
Sites updated:
- vms/platformvm/txs/bench/*.go (6 bench tests)
- vms/platformvm/network/zap_native_admission{.go,_test.go}
Build clean: go build ./vms/platformvm/...
6de839a515 (codec Wave 1D) migrated the wallet UTXO loader to
lux.ParseUTXO (ZAP wire dispatcher) before the platformvm GetUTXOs
server-side encoder was migrated. Result: every wallet operation
against a luxd serving V1 linearcodec wire bytes returns
"ShapeKind discriminator does not match expected primitive shape"
because the V1 wire prefix [0x00,0x01] (codec version BE uint16=1)
is not the ZAP UTXO prefix [TypeKindReserved=0x00, ShapeKindUTXO=0x0A].
Lux-mainnet validators (v1.28.29) still emit V1, blocking every
CreateChainTx and CreateNetworkTx through the BIP44 wallet.
Fix: dispatch on the 2-byte envelope prefix in AddAllUTXOs. ZAP
envelopes (byte[1] == ShapeKindUTXO) route through lux.ParseUTXO;
anything else falls through to ptxs.Codec.Unmarshal. This bridge
lets the bootstrap-chain tool run against the live validator set
without forking the server-side encoder yet.
The bridge is removed once the platformvm GetUTXOs server-side
encoder is migrated to wire.NewUTXO (tracked separately).
Verified: bootstrap-chain --uri=https://api.lux.network now signs +
issues CreateChainTx successfully (osage L1 added to mainnet at
blockchainID 2ahV62kvM1KWkgxL6MiF5hPYBVwWuwpPqwV3RhqM6hCxZT68uo).
Wave 1D of the luxfi/codec final rip: leaf-misc tier under node/.
This batch removes the direct codec/wrappers, codec/linearcodec, and
codec.Manager imports from p2p network protocol, IP/port encoding,
indexer/keystore on-disk storage, warp socket IPC, x/archivedb +
merkledb internals, and the primary-network wallet UTXO loader.
Migration shape:
(a) codec/wrappers.Packer + length constants
→ node/utils/wrappers (same shape, already in tree as the local
canonical home for binary IO helpers). 14 files: indexer/,
network/, utils/ips/, utils/metric/, warp/, x/.
(b) codec.Manager + linearcodec for on-disk container storage
→ hand-rolled big-endian binary marshal/unmarshal. Hard cut;
no codec-version prefix; payload size bounded explicitly:
- indexer/codec.go: marshalContainer/unmarshalContainer
- service/keystore/codec.go: marshalHash/unmarshalHash and
marshalUser/unmarshalUser, 16 MiB blob cap retained.
(c) codec.Manager for cross-chain UTXO parsing (wallet/network/primary)
→ utxo.ParseUTXO via the ZAP wire dispatcher already registered
by node/vms/components/lux. Drops the per-chain codec.Manager
slot from FetchState; AddAllUTXOs no longer takes a codec.
Underscore-import of vms/components/lux ensures the dispatcher
is wired even for callers that don't already pull platformvm.
No snow/snowman/snowball/avalanche/avm/subnet residue introduced.
Adds CodecVersionV2 (zapcodec, little-endian) alongside existing V0/V1
(linearcodec, big-endian) on the txs.Codec / txs.GenesisCodec managers.
The codec.Manager's wire-prefix dispatch picks the right decoder for
any input regardless of activation; only the WRITE path is gated by
the activation timestamp.
ZAPCodecActivationTimestamp = 1782864000 (2026-07-01 00:00:00 UTC)
CodecVersionForTimestamp(ts):
ts < activation → V1 (linearcodec)
ts >= activation → V2 (zapcodec)
CodecForTimestamp(ts) → txs.Codec (same handle; future-proof for a
full manager swap after V1
retirement)
CodecAllowsRead(v) → v ∈ {V0, V1, V2}
CodecRequiresLegacy(v) → v ∈ {V0, V1}
Slot map is bit-identical across V1 and V2: registerV1TxTypes is now
parameterised over a local slotRegistrar interface that both
linearcodec.Codec and zapcodec.Codec satisfy. Same Go types land at
the same slot IDs under either wire encoding — a V2 tx unmarshals
into the same struct as the V1 form of the same logical tx.
Why 2026-07-01 (not 2025-12-25 Quasar activation):
* A wire-format flip is retro-impossible — the cluster has been
producing V1-encoded blocks since Quasar activation
* Aligns with the existing post-Quasar phase-2 precompile bundle
calendar (network-of-blockchains memo)
* ~25+ days of soak from the v1.28.x ship date — every validator
binary has the V2 decoder registered well before the write switch
Activation constant lives in codec_activation.go for clear separation.
Tests (12 new, all passing — existing 2 unchanged):
TestCodecVersionForTimestamp_StrictBoundary — bit-exact ts == act flip
TestCodecForTimestamp_ManagerIsStable — manager handle is stable
TestPreActivationRoundTripV1 — ts < act → V1 round-trip
TestPostActivationRoundTripV2 — ts >= act → V2 round-trip
TestCrossVersionWireIsDistinct — V1 ≠ V2 wire bytes
TestCodecAllowsRead — read-acceptance gate
TestCodecRequiresLegacy — legacy classifier
TestV2WireIsZapNative — byte-position LE assertions
TestV1WireIsBigEndian — byte-position BE assertions
TestActivationConstantUnchanged — constant value watchdog
All existing platformvm txs / block / executor / fee / mempool /
txheap / zap_native tests still pass.
Root cause of mainnet/testnet/devnet `platform.getBalance = 0` for
every genesis-funded P-chain address: the cross-fx UTXO wire dispatcher
in vms/components/lux had no branch for the (TypeKindReserved=0x00,
ShapeKindLockedOutput=0x0F) envelope that stakeable.LockOut.Bytes()
produces. Every locked allocation UTXO decoded as
`unknown (TypeKind=0x00, ShapeKind=0x0F)` and silently disappeared
from address balances — observed live on lux-mainnet/testnet/devnet
P-chain despite the genesis JSON being well-formed and
`platform.getCurrentSupply` correctly tallying the sum of allocations
(13.27B LUX).
Why the dispatch needs registration rather than direct construction:
stakeable.LockOut embeds lux.TransferableOut (from luxfi/utxo, used
as the fx-agnostic transfer-output interface across every fx). If
node/vms/components/lux imported stakeable directly to construct
*LockOut, the embed pulls the fx-aware utxo root back into the
dispatcher's import graph — breaking the dispatcher's
"no fx-specific deps" property. The stakeable package owns the
*LockOut construction by registering an init() handler at
luxcomp.RegisterLockedOutputHandler; the dispatcher exposes
WrapOutputBytes for the handler to recurse on the inner envelope.
Surface added:
- vms/components/lux/utxo_parser.go:
- LockedOutputHandler type
- RegisterLockedOutputHandler(h) (init-only, panics on
double-install)
- WrapOutputBytes(b) — discriminator-peek + dispatch, used by the
registered handler for inner-envelope recursion
- wrapOutput now routes (TypeKindReserved, ShapeKindLockedOutput)
through lockedOutputHandler
- vms/platformvm/stakeable/stakeable_lock.go:
- init() registers a handler that wire.WrapLockedOutput → recurse
via luxcomp.WrapOutputBytes → cast inner to lux.TransferableOut →
return *LockOut{Locktime, TransferableOut: inner}
luxfi/utxo bumped to v0.3.7 to pick up the public wire.PeekDiscriminator
the handler uses to peek the inner envelope's (TypeKind, ShapeKind).
Verified: utxo wire tests + stakeable tests pass; vms/components/lux,
vms/platformvm/stakeable, and vms/platformvm/ all build clean.
P-Chain syncGenesis on mainnet/testnet was failing with
`UTXO.Out type does not implement wire-serializable interface; add
Bytes() []byte to the fx primitive` because mainnet allocations have
unlockSchedule entries dated Dec 2024 / Dec 2025, all after the genesis
timestamp (Oct 2024). genesis.go wraps such allocs as *stakeable.LockOut,
which lacked Bytes().
LockOut.Bytes() now encodes (Locktime, inner.Bytes()) via
wire.NewLockedOutput (added in luxfi/utxo v0.3.6, ShapeKind=0x0F).
Bumps luxfi/utxo v0.3.5 -> v0.3.6.
Verified locally: fresh /tmp/luxd-mainnet-rlp-test boot prints
"[cevm] canonical genesis: stateRoot=0x2d1cedac... hash=0x3f4fa2a0..."
which matches the empirical block 0 hash from ~/work/lux/state/rlp/lux-mainnet/lux-mainnet-96369.rlp.
PR #132 (kill-etna) squash-merge regressed luxfi/ids v1.2.14 -> v1.2.13.
v1.2.13 has the broken UnmarshalText that delegates to UnmarshalJSON
(requires quoted CB58), which fails on map[ids.ID][]string keys passed
unquoted via TextUnmarshaler contract. v1.2.14 (commit 6471495ff1 on
ids repo) split the methods cleanly. Without this, v1.28.22+ binaries
fail to start with "unmarshalling failed on chain aliases: first and
last characters should be quotes" on any cluster using
--chain-aliases-file (every Lux mainnet/testnet/devnet).
Root cause: the quasar/evm-v0.19.0 branch was created from an older
node main that pre-dated the ids v1.2.13 -> v1.2.14 bump. Squash-merge
preserved the stale go.mod. Reapplied v1.2.14 explicitly.
Phase 6 of the LP-023 ZAP migration. luxfi/utxo v0.3.5 (commit 559e482)
drops codec.Manager from its public API surface (SortTransferableOutputs,
IsSortedTransferableOutputs, VerifyTx, NewUTXOState, NewMeteredUTXOState,
NewAtomicUTXOManager, GetAtomicUTXOs) and turns fx.Initialize into a
no-op. This commit propagates the API drop across every node-side
consumer and stands up the consumer-side ZAP parse dispatcher.
Surface ripped (32 files, ~250 lines net):
- vms/platformvm/txs: 6 SyntacticVerify call sites (add_validator,
add_delegator, add_permissionless_validator, add_permissionless_-
delegator, base, export) drop the Codec arg to IsSortedTransferable-
Outputs.
- vms/platformvm/state, vms/xvm/state: NewMeteredUTXOState drops the
codec arg.
- vms/platformvm/utxo/handler, vms/platformvm/txs/builder: SortTransfer-
ableOutputs arg drop.
- vms/xvm/txs/executor/syntactic_verifier: 5 lux.VerifyTx call sites
drop v.Codec.
- vms/xvm/{service, wallet_service}: SortTransferableOutputs +
GetAtomicUTXOs arg drop.
- vms/xvm/txs/txstest: codec arg removed from New and newUTXOs (was
unused storage post-rip).
- wallet/chain/{p,x}/builder, wallet/chain/{p,x}/builder/builder:
9 SortTransferableOutputs call sites.
- Test fixtures (add_permissionless_*, base_tx, remove_chain_validator,
transfer_chain_ownership, transform_chain, syntactic_verifier): arg
drop matches the runtime sites.
New: vms/components/lux/utxo_parser.go — the luxd-side ZAP wire
dispatcher. utxo.ParseUTXO is consumer-registered via
utxo.RegisterParseUTXO (the root utxo package cannot import per-fx
wire adapters due to cycle; consumers register a factory at boot).
The dispatcher routes on (wire.TypeKind, wire.ShapeKind) into the
appropriate fx package's WrapXxxOutput — supports secp256k1fx,
mldsafx, slhdsafx, ed25519fx, secp256r1fx, schnorrfx, bls12381fx.
vms/xvm/txs/parser.go: utxo v0.3.5 secp256k1fx.Fx.Initialize is a
no-op (no codec RegisterType chain). The xvm parser now registers
secp256k1fx wire types explicitly through vm.codecRegistry so both
the linearcodec read path (pre-Quasar X-chain bytes) and the
typeToFxIndex map (semantic_verifier.getFx) stay populated.
vms/{xvm,platformvm}/state/state.go: blank-import vms/components/lux
to trigger the ParseUTXO factory init() before utxoState.GetUTXO
deserializes off disk.
Activation: ZAPActivationUnix=0 preserved (always-on, per LP-023).
Legacy V0/V1 codec slot maps retained as READ-ONLY decoders behind
LUXD_ENABLE_LEGACY_CODEC=1 — mainnet/testnet block history continues
to deserialize.
Build/test status:
- go build ./... clean.
- go vet ./... clean (modulo 1 pre-existing sync/atomic copy warning
in vms/rpcchainvm/zap/cevm_e2e_test.go unrelated to this rip).
- xvm txs/executor + xvm state + xvm network + xvm txs + xvm
genesis + wallet/chain/p/builder + wallet/chain/x/builder ALL
PASS under -short.
- vms/platformvm/txs serialization tests (TestAdd*Serialization)
fail on byte-baseline expected outputs because utxo's new sort
key is (AssetID, wireBytes()) vs the old codec-marshal bytes —
the test fixtures need regen under the new canonical sort. Same
story for xvm/state_test.go TestFundingAddresses / TestVerifyFxUsage
using utxo.TestState whose Out type doesn't satisfy
wireSerializable. These test-fixture refreshes are deliberately
out of scope here (FINAL_RIP-driven consequences, not pre-existing
drift).
go.mod: luxfi/utxo v0.3.2 -> v0.3.5, luxfi/api transitive v1.0.11
-> v1.0.12. luxfi/codec v1.1.4 stays direct (network/peer, warp
internal, indexer, keystore et al. still use codec.Manager for
non-UTXO concerns; those packages are out of UTXO-rip scope).
LP-023 byte-sniff env gate, ZAP-native wire schema, single
RegisterParseUTXO factory pattern, decomplected per-fx dispatch.
Final Corona-residue sweep in vms/platformvm/warp:
- HybridBLSRTSignature → HybridBLSCoronaSignature (the lingering "RT"
suffix in the Go type for the deprecated BLS+lattice hybrid).
- ErrInvalidRTSignature → ErrInvalidCoronaSignature.
- ErrMissingRTPublicKey → ErrMissingCoronaPublicKey.
- String() format, comments, doc references updated to Corona.
Wire-format invariants (this is on-chain encoding):
- linearcodec assigns typeIDs by RegisterType call order. The order
in codec.go is UNCHANGED: BitSetSignature (0x00), CoronaSignature
(0x01), EncryptedWarpPayload (0x02), HybridBLSCoronaSignature
(0x03), then the 3 Teleport types (0x04-0x06).
- linearcodec serializes struct fields by declaration order (see
reflectcodec/struct_fielder.go). Field declaration order is
UNCHANGED for every type in this PR.
- Therefore: type names and field names are wire-metadata only;
renaming them produces byte-equal output.
Wire bytes verified byte-equal pre/post rename for every codec-
registered type via the new regression test:
vms/platformvm/warp/wire_baseline_test.go
That test pins the exact hex of every type's encoding (concrete and
through the Signature interface) and is now a permanent guard against
accidental wire-format drift.
Doc sweep (no code dependencies — example/aspirational JSON keys):
- docs/architecture/consensus.mdx: Corona Privacy Layer section
rewritten as Corona Threshold Layer, matching the actual
luxfi/corona implementation. The previous text described a ring-
signature scheme that doesn't exist in this codebase.
- docs/architecture/overview.mdx: Q-Chain diagram + cryptographic
primitives table.
- docs/configuration/node-config.mdx + docs/getting-started/running.mdx:
example Q-Chain config keys (corona-* → corona-*).
- docker/Dockerfile.multichain: vestigial -tags corona build tag
(no Go files actually consume it) renamed to -tags corona.
Verification:
go build ./... exit 0
go vet ./vms/platformvm/warp/... clean
go test ./vms/platformvm/warp/... all packages PASS (warp,
message, payload, zwarp)
v0.19.3 pulls vm v1.1.7 + runtime v1.1.0. runtime v1.1.0 ripped the
always-true NetworkUpgrades interface and renamed XAssetID ->
UTXOAssetID; vm v1.1.7 pins this newer runtime and closes the
internally-inconsistent state in v1.1.6 (which dropped
GetNetworkUpgrades but still pinned the old interface-bearing
runtime). Builds cleanly now.
v0.19.2 bumps luxfi/vm v1.1.5 -> v1.1.6 which drops the obsolete
GetNetworkUpgrades() method on the VMContext interface. Required by
the v0.19.0 Etna->Quasar rename: vm v1.1.5 still expected upgrade.Config
to implement runtime.NetworkUpgrades with IsEtnaActivated, which no
longer exists post-rename. v1.28.22 and v1.28.23 Docker builds failed
on this signature mismatch; v0.19.2 closes the cascade.
v0.19.1 bundles luxfi/precompile v0.5.27 → v0.5.35 (commit 794912f).
Each of the 7 EIP-2537 bls12381 sub-configs (G1/G2 × {Add, Mul, MSM} +
Pairing) now returns its own ConfigKey via Key(), fixing a collision
where all 7 mapped to G1AddConfigKey. Without the fix, only one of
the 7 entries could land in the upgrade registry — DisallowUnknownFields
parse (enforced as of evm 415b2a276) rejected the other six.
This unblocks re-adding the 7 bls12381*Config entries to mainnet
configs/mainnet/upgrade.json forward-dated to the already-past Quasar
horizon (Unix 1766708400 = 2025-12-25 16:20 PT). Testnet + devnet
upgrade.json already carry these entries (testnet at 1766708400,
devnet at 0).
* Dockerfile: bump EVM plugin v0.18.18 → v0.19.0 (Quasar Edition)
Quasar Edition rip — one and only one upgrade-key namespace:
- luxfi/evm v0.19.0 renames EtnaTimestamp → QuasarTimestamp
(Go field + json tag)
- Strict json.Decoder DisallowUnknownFields on upgradeBytes parse
in plugin/evm/vm.go — any stale etnaTimestamp in a deployed
upgrade.json now fails parse loudly at boot rather than silently
leaving the fork field nil and disabling activation.
Pairs with: luxfi/genesis kill-etna sweep (configs/*/upgrade.json
and brand L1 EVM genesis.json scrubbed).
* docker-entrypoint: rip etnaTimestamp from embedded C-Chain genesis
With luxfi/evm v0.19.0 strict upgradeBytes decode, the entrypoint's
embedded C-Chain genesis heredoc would parse-fail on etnaTimestamp.
One name. quasarTimestamp.
Blue batch 5 v3.7 closes the 5 target gaps from Red round 5 follow-up:
1. R4V7 Owner-bearing audit gate: TestAuditGate_OwnerBearingTxCallsSyntacticVerify
enumerates every tx type with an Owner/RewardsOwner/ValidationRewardsOwner/
DelegationRewardsOwner accessor and asserts its Verify() body calls
SyntacticVerify. CI mirror: owner-bearing-syntacticverify-gate job
in .github/workflows/zap-audit.yml. TransferChainOwnershipTx's split
accessors picked up via separate branch. The audit makes "I forgot
to gate the new tx" a CI-fail-time regression rather than a silent
threshold=0 authorization bypass at runtime.
2. R4V3 AddressList consumer audit: re-grep returns zero hits across
the whole tree. Documented inline at tx_verify.go header with the
reproducer grep. No regressions since batch 5 v3.5.
3. ChainsList MaxChainsPerL1 = 16: hard cap enforced in
ChainsListView.MustVerify. New ErrTooManyChains typed error.
Matches the multi-chain L1 spawn use case (P + X + EVM + small
application stack) plus headroom; prevents a hostile encoder
from forcing the executor through quadratic walk at admission.
Coverage: TestChainsList_MustVerify_RejectsOverCap +
TestChainsList_MustVerify_AcceptsAtCap.
4. ValidatorsList MustVerify (5 floor invariants):
- Len() <= MaxValidatorsPerL1 (1024): cap matches practical
upper bound on initial-validator sets.
- Weight > 0: lifts the per-validator gate from tx_verify.go
onto the list-level MustVerify so it's grep-able and pure
orchestration on the tx side.
- BLSPubKey not all-zero: structural floor before R6V3 pairing.
- BLSPoP not all-zero: structural floor before R6V3 pairing.
- RegistrationExpiry > 0: parallel of ErrZeroExpiry on
RegisterL1ValidatorTx (unix timestamp can never be zero).
New typed errors: ErrTooManyValidators, ErrValidatorBLSPubKeyZero,
ErrValidatorBLSPoPZero, ErrValidatorRegistrationExpiryZero. Wired
into CreateSovereignL1Tx.Verify and ConvertNetworkToL1Tx.Verify
before the expensive BLS pairing walk so cheap structural floor
fires first. New audit gate (test + CI):
TestAuditGate_ValidatorsListEmbeddersCallMustVerify + workflow
job validatorslist-mustverify-gate. Coverage: 6 new MustVerify
tests (happy path + 5 floor-violation rejections + over-cap).
5. Bench refresh -benchtime=2s (M1 Max):
- Parse geomean (n=9): 7.50× vs v3.6 7.74× — within 4% noise.
- Build geomean (n=9): 1.03× vs v3.6 1.12× — within 9% noise.
- Allocs/op unchanged: Parse 1/24, Build 2 (down from 4-7).
- No regression > 5%. v3.7 changes fire entirely on the Verify()
path; Parse and Build are structurally untouched.
Full numbers in vms/platformvm/txs/bench_results/RESULTS.md.
All 4 audit gates pass locally:
- TestAuditGate_AddressListNoProductionConsumers
- TestAuditGate_ChainsListEmbeddersCallMustVerify
- TestAuditGate_ValidatorsListEmbeddersCallMustVerify (NEW)
- TestAuditGate_OwnerBearingTxCallsSyntacticVerify (NEW)
Full zap_native test suite: 0.715s, all green.
Adds an opt-in escape hatch that pins the fee-payment asset to a
specific 32-byte asset ID, bypassing platform.getStakingAssetID. Use on
networks where the live staking asset differs from the legacy LUX asset
on existing P-chain UTXOs.
lux-mainnet today is the documented case: staking ==
pmSJ7BfZQfwtUGbamLWSLFLFGnocfMfbriDTsYWxi4qnqFrrT but the historical
deployer's UTXOs hold HrJCm4yvNmyPDA1PEqwks9iFFmoRJEsLJj36N1xtkffrqpL6p.
Without the override the wallet's `utxoAssetID` matches the staking-asset
result and `platform.getBalance` returns 0 for the legacy UTXOs — the
wallet can't find them as fee-payment material.
NOTE: this addresses the wallet-side identification only. The P-chain
fee-execution rule still requires the staking asset on mainnet; a
follow-up legacy-to-staking asset migration tx is needed before
CreateNetworkTx + CreateChainTx will go through on mainnet. Tracked as
the 2026-06-03 mainnet brand L2 re-fire blocker.
Empty / unset is a no-op. Tested via bootstrap-chain mainnet dry-run
(2026-06-03): override correctly switches the wallet to legacy-LUX
UTXOs; CreateNetworkTx then fails with "insufficient unlocked funds"
because the fee verifier checks pmSJ7BfZ... balance — which is the
expected second-half blocker.
ZAPActivationUnix is now 0 (LP-023 cutover, 2026-06-02). The assertion
`ShouldUseZAPForWrite(ZAPActivationUnix - 1)` underflows uint64 to
math.MaxUint64; with ZAPActivationUnix=0 the LegacyEnabled path
degenerates to `ts >= 0` which is true for every input, so the prior
"pre-activation picks legacy" assertion can never hold.
Rewrite runEnableLegacyChild to mirror zap_native.security_test.V15
closure: probe a timestamp spread including the historical
forward-date (1782604800) and assert ShouldUseZAPForWrite=true
unconditionally. Pins the always-on invariant on the bench surface
too.
No production code change.
Red round 7 closes the activation gap so Neo's ZAPActivationUnix=0 image can
land without zombie txs from not-yet-implemented zap_native executors.
R7V5 (HIGH, mempool admission gate — Path A)
- vms/platformvm/network/zap_native_admission.go: new file. Wraps
TxVerifier with NewZapNativeAdmissionGate; refuses
*txs.CreateSovereignL1Tx (legacy struct dispatch hits stub at
standard_tx_executor.go:636) AND any ZAP-magic wire buffer whose
kind is CreateSovereignL1 (23), RegisterL1Validator (7), or
ConvertNetworkToL1 (22).
- vms/platformvm/network/network.go: New() now wraps the supplied
TxVerifier with the gate before installing into gossipMempool, so
every inbound tx routes through the gate first.
- Typed error ErrZapNativeNotYetExecutable for errors.Is matching.
- REMOVAL CHECKLIST documented inline so a future Blue knows to
delete the gate entry when an executor body lands.
- Tests in zap_native_admission_test.go: rejects legacy
CreateSovereignL1Tx; passes through legacy BaseTx /
RegisterL1ValidatorTx / ConvertNetworkToL1Tx (the working ones);
rejects ZAP-wire CreateSovereignL1 / RegisterL1Validator /
ConvertNetworkToL1; non-ZAP bytes pass; nil-safety.
R7V7 (HIGH, Verify() for two missing tx types)
- RegisterL1ValidatorTx.Verify(): BLS PoP pairing + zero-Expiry gate
(ErrZeroExpiry). RemainingBalanceOwnerID treated as optional v3
placeholder.
- ConvertNetworkToL1Tx.Verify(): same per-validator walk as
CreateSovereignL1Tx (non-empty Validators, Weight>0, BLS PoP
pairing).
- Tests in r7v7_register_convert_verify_test.go: rejects bad BLS
PoP / malformed BLS pubkey / zero Expiry / zero Validators / zero
Weight; accepts valid; adversarial wire-buffer tampering for both
tx types (zero out Expiry / Weight in-place and re-Wrap).
R7V8 (MEDIUM, MustVerify rename + CI gate)
- chains_list.go: ChainsListView.Verify renamed to MustVerify so
the per-list gate is grep-able from CI. The previous Verify()
name collided with the tx-level Verify() convention.
- tx_verify.go: CreateSovereignL1Tx.Verify call site updated.
- r6_verify_test.go: TestChainsListView_Verify_StandaloneEntries
renamed + updated to use .MustVerify().
- audit_test.go: new TestAuditGate_ChainsListEmbeddersCallMustVerify
enumerates tx types with a Chains() accessor returning ChainsList
and confirms tx_verify.go has a corresponding Verify() body that
calls .MustVerify().
- .github/workflows/zap-audit.yml: new chainslist-verify-gate job
mirroring the local audit_test.go invariant.
Test results (GOWORK=off, race enabled):
./vms/platformvm/network/... PASS (8 new + all existing)
./vms/platformvm/txs/zap_native/ PASS (8 new + all existing)
./vms/platformvm/txs/executor/ PASS
./vms/platformvm/txs/ PASS
./vms/platformvm/block/... PASS
R6-4 (RESERVED zero-gate)
- ChainEntry RESERVED bytes [56..64) gated by ChainsListView.Verify.
- New OffsetChainEntry_Reserved=56 constant + ErrReservedNonZero typed err.
- Writer already zero-pads; gate prevents adversary smuggling state inside
what consensus considers empty. Pins the upgrade-safe invariant before
any v4 parser attaches meaning to those bytes (no silent wire-fork).
- Tests: TestChainsList_Verify_RejectsNonZeroReserved (per-byte sweep of
all 8 reserved bytes, each flipped individually -> all reject),
TestChainsList_Verify_RejectsNonZeroReserved_TopByte (top byte 0x01).
R6-6 (AddressList.At CI audit gate)
- .github/workflows/zap-audit.yml: grep-based gate on PR + push to
main/dev. Fails if any new production caller of AddressList.At()
appears outside the allowlist (_test.go, tx_verify.go, owner.go,
audit_test.go).
- vms/platformvm/txs/zap_native/audit_test.go: local mirror so
`go test ./vms/platformvm/txs/zap_native/` reproduces CI behavior.
Verified locally: clean -> PASS; injected violator -> trips correctly.
R6-2 (cross-blob aliasing design decision)
- Documented-allowance path: aliasing is ALLOWED. Wire layer must not
reject overlapping (rel, len) ranges; identity is (VMID, BlockchainID),
not Name() bytes.
- Full CONTRACT block on ChainsListView: (1) Name/FxIDs/GenesisData
return payload slices not identity, (2) chain identity is VMID +
BlockchainID, (3) consumers MUST NOT use returned bytes as dedup keys,
(4) returned slices are read-only.
- Forward path documented: if future feature requires non-overlap, add
ChainsListView.VerifyNonOverlappingRanges() and wire it from tx.Verify.
- Test: TestChainsList_AllowsOverlappingRanges_DocumentedContract -
patches chain[1].NameRel to alias chain[0]'s slice, proves Verify
accepts AND Name(0) == Name(1) byte-equal, AND VMID(0) != VMID(1).
go test -race ./vms/platformvm/txs/zap_native/...: ok 1.4s
Builds on top of v3 commit d5c305d440.
R6V4 (CRITICAL): CreateSovereignL1Tx.Verify now rejects zero-validator and
zero-chain wire buffers (both halt consensus at activation). Per-validator
Weight > 0 walk also fires here. RegistrationExpiry remains an executor
clock concern (deferred to staking handler, documented).
R6V8 (HIGH): TransferChainOwnershipTx had no Verify() despite carrying
Owner fields. Wired in: reconstructs OwnerStub from the (threshold,
locktime, address) tuple and calls SyntacticVerify. Tx count with
SyntacticVerify wired into 8 tx types (was 7).
R6V3 (HIGH): BLS PoP now verified at the SyntacticVerify boundary for
every initial validator. Wire-layer opaque 48B BLSPubKey + 96B BLSPoP
now pairing-checked via bls.VerifyProofOfPossession with new ErrBadBLSPoP.
Closes the zero-downstream-consumer gap Red grep found.
R6V5 (MEDIUM): ChainsListView.Verify walks entries and rejects any
FxIDsLen that is not a multiple of FxIDSize. Replaces the silent-nil
return path in BoundChainEntry.FxIDs with a typed error. Wired into
CreateSovereignL1Tx.Verify via ChainsListView.Verify().
Tests (all under go test -race):
TestCreateSovereignL1Tx_Verify_RejectsZeroValidators
TestCreateSovereignL1Tx_Verify_RejectsZeroChains
TestCreateSovereignL1Tx_Verify_RejectsZeroWeight
TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP
TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP_MismatchedPubKey
TestCreateSovereignL1Tx_Verify_AdversarialWireBuffer_ValidatorWeight
TestChainsList_Verify_RejectsBadFxIDsLen (adversarial wire buffer)
TestChainsListView_Verify_StandaloneEntries
TestTransferChainOwnershipTx_Verify_RejectsZeroThreshold
TestTransferChainOwnershipTx_Verify_RejectsThresholdAboveOne
TestTransferChainOwnershipTx_Verify_AdversarialWireBuffer
TestBLSSurfaceReachable
Updates TestVerify_AcceptsWellFormed/CreateSovereignL1 to supply a
properly-constructed validator (real BLS PoP) and a chain entry so the
new gates fire green on the legitimate path. Adds
TestVerify_AcceptsWellFormed/TransferChainOwnership subtest for R6V8.
New typed errors:
ErrZeroValidators, ErrZeroChains, ErrValidatorWeightZero,
ErrBadBLSPoP, ErrMalformedFxIDsLen.
Bench unchanged (Verify is on executor path, parse benches structurally
untouched). Full zap_native suite green under -race.
LP-023 batch 5 v3 closes Red round 6 V3/V4/V5/V8.
ZAP wire is now mandatory from genesis. The forward-dated 2026-07-01
(1782604800) cutover is dead — replaced with always-on semantics. New
deployments, fresh syncs, and all production binaries from v1.28.19
onward are ZAP-only by construction.
The legacy linearcodec is reachable only via the explicit dev knob
LUXD_ENABLE_LEGACY_CODEC=1, and only for the read path (decoding
pre-2026-06-02 archival linearcodec bytes from disk). On the write
path, LegacyEnabled has no semantic effect once activation = 0 — the
"pre-activation" window is empty, so every block timestamp satisfies
blockTimestamp >= 0 and writes are always ZAP.
Tests:
- TestZAPActivationUnixIsAlwaysOn pins the constant to 0 so any
regression that re-introduces a forward-date guard fails this gate.
- TestShouldUseZAPForWrite legacy-enabled subtest asserts ZAP for
every timestamp (replaces the previous pre/at/post-activation
table that depended on a non-zero gate).
- TestRed_V15_PreActivationZAPTxRejection updated to reflect that
V15's threat model (legacy cutover-window fork) is no longer
applicable; the test now asserts the always-on invariant.
Authorized by 2026-06-02 destructive recovery sweep — "just do it
right; no half-measures, no legacy compatibility, clean slate."
Phase C — Multi-chain CreateSovereignL1 ChainsList primitive (chains_list.go,
chains_list_test.go):
* 64-byte fixed-stride ChainEntry per chain: (NameRel/Len uint32, VMID 32B,
FxIDsRel/Len uint32, GenesisDataRel/Len uint32, Reserved 8B).
* Three sibling blob arrays (NameBlobs, FxIDsBlobs, GenesisDataBlobs)
carried as parent-tx fields; entry header stores (rel, len) cursors.
* Bind(nameBlobs, fxIDsBlobs, genesisDataBlobs) compile-time gate
mirroring BoundEvidenceList (NEW-V2): ChainsListView lacks safe
Name/FxIDs/GenesisData accessors so consumers cannot bypass Bind() by
accident. Only BoundChainEntry exposes the safe accessors.
* IsNull() zero-value guard on ChainEntry: out-of-range At(i) returns
a ChainEntry{} whose accessors short-circuit to zero rather than
nil-deref the underlying zap.Object's msg. Defense-in-depth atop the
defensive At(i) clamp.
* Per-element FxIDs blob is a length-prefixed byte array; entry count =
FxIDsLen / FxIDSize (32B per ids.ID). Non-multiple lengths clamp to nil.
* CreateSovereignL1Tx grows from a single-chain stub (TxKind 23, batch 4)
to a real multi-chain L1 builder: ChainsList + NameBlobs + FxIDsBlobs +
GenesisDataBlobs fields. Size 193 bytes (was 121).
Phase D — ValidatorsList encoding (validators_list.go,
validators_list_test.go):
* 180-byte fixed-stride ValidatorRecord per initial validator: NodeID 20B
+ Weight uint64 + BLSPubKey 48B + BLSPoP 96B + RegistrationExpiry uint64.
* Uses zap.Object.ListStride from v0.7.2 — per-element clamp against
poisoned wire length fields (R4V9).
* No sibling arrays needed — every field is fixed-size.
* BLSPubKey() and BLSPoP() return COPIED slices (not aliased to parent
buffer) so consumer mutation cannot corrupt subsequent reads.
* IsNull() zero-value guard mirroring ChainEntry.
* ConvertNetworkToL1Tx now carries the real ValidatorsList — the
previous (0, 0) stub is gone. Size still 165 bytes (the validators
list pointer was already provisioned).
* CreateSovereignL1Tx integrates ValidatorsList alongside ChainsList for
the full atomic sovereign-L1 commit shape.
Phase E — Bench refresh:
* M1 Max Parse geomean across 10 measurable tx types post-batch-5:
7.44× at -benchtime=2s -count=3 (medians).
* Restricted to the original 9 tx types from the v0.7.2 baseline:
7.74×, within noise of the 7.71× pre-batch-5 number.
* R4V7 SyntacticVerify lives on the executor Verify() path (not the
wire parser), so the parse benchmarks are structurally unchanged.
* ChainsList + ValidatorsList add new primitives but do not appear in
the legacy comparison fixtures.
Test coverage:
* chains_list_test.go: RoundTrip (3 chains incl. empty FxIDs+empty
GenesisData), EmptyList, OutOfRange, UnboundReturnsRawCursors,
BindMismatchedBlobsClampsSafely, FxIDsNonMultipleClampsToNil.
* validators_list_test.go: RoundTrip (2 validators), EmptyList,
OutOfRange, PoisonedLengthClampedByStride,
BLSFieldsAreReadOnly (mutation isolation),
CreateSovereignL1Integration (ChainsList + ValidatorsList together).
* batch4_tx_test.go: updated CreateSovereignL1TxRoundTrip for the new
multi-chain + validators shape; tests both round-trip and Wrap()
re-parse correctness.
All zap_native tests pass under go test -race.
Coordinates with cryptographer #114 v3 work and CTO bootstrap-chain work;
neither genesis/ nor bootstrap touched.
LP-023 batch 5 Phase C+D+E.
R4V3 finding: a malicious wire-encoded AddressList may report Len() >
actual entry count (the ListStride per-element clamp accepts the honest
overcount as long as length*stride fits the remaining buffer). At(i)
for i >= actual_count returns whatever bytes occupy the post-list
region in the buffer — often zero-padding, sometimes adjacent buffer
content.
Audit result: AddressList has ZERO production consumers in the
executor today (the executor still uses secp256k1fx.OutputOwners +
message.PChainOwner via the legacy codec). The new primitive is
shipping ahead of the executor migration, so the discipline is
forward-looking. Audited grep `\.At(|AddressList` across
~/work/lux/node — every match is internal to the zap_native package
or test code.
Defense path (canonical):
- Owner.SyntacticVerify() now walks the list and rejects any zero
ShortID with ErrOwnerAddrZero. This closes the zero-phantom
bypass at the gate.
- Documented consumer-safety contract on AddressList type docstring
AND on .Len()/.At() — three paths: non-zero check at call site,
sibling-count correlation, or canonical SyntacticVerify boundary
(path 3 is the one-and-only-one way).
Test scope:
- r4v3_addresslist_test.go — overcount construction with the wire
layer (claim 5, real 4, 1-phantom); confirms SyntacticVerify
rejects the zero-phantom case and accepts the buffer-garbage
case (signature validation downstream closes the remaining
surface in the garbage path — no zero co-signer can sneak
through the quorum gate).
- Honest-list happy path also pinned.
Tradeoffs:
- SyntacticVerify is now O(Len()) per Owner instead of O(1). For
typical owners (1-5 addresses) this is negligible; the gate is
the authorization boundary and bears the cost.
Wire layer (zap_native parser) is permissive by design — it confirms
TxKind + buffer geometry only. Executor-side semantic gates live HERE
on the consumer-side boundary.
Owner.SyntacticVerify enforces:
- ErrOwnerAddrsEmpty: Addresses.Len() == 0 (signer set undefined)
- ErrOwnerThresholdZero: threshold == 0 (auth bypass)
- ErrOwnerThresholdExceedsAddrs: threshold > Addresses.Len()
(unsatisfiable quorum)
OwnerStub.SyntacticVerify enforces the single-address fast path:
- Threshold must be exactly 1 (zero is bypass, >1 is unsatisfiable
because the stub carries one address by construction)
Per-tx Verify() entry points wire the gate into all 7 tx types that
embed Owner-shaped fields:
- AddValidatorTx.RewardsOwner
- AddDelegatorTx.DelegationRewardsOwner
- AddPermissionlessValidatorTx.{Validation,Delegation}RewardsOwner
- AddPermissionlessDelegatorTx.DelegationRewardsOwner
- CreateChainTx.Owner
- CreateNetworkTx.Owner
- CreateSovereignL1Tx.Owner
Test scope (TDD red→green):
- owner_syntactic_test.go — 7 cases on Owner + OwnerStub directly
- tx_verify_test.go — 7 well-formed + 7 malicious-threshold + 1
multi-owner accept + 1 adversarial-wire-buffer test that overwrites
the threshold byte in the buffer and re-Wrap, confirming the gate
fires on byte-stream attacks (not only constructor input).
Contract (LP-023 Red round 4 R4V7): every tx executor's Verify() entry
point MUST call tx.Verify() before treating embedded Owner fields as
authoritative. Skipping Verify() opens the auth-bypass attack vector.
Three surgical fixes against Red round 4 findings (task #189).
R4V9 (MEDIUM) — migrate 6 list accessors from Object.List() to
Object.ListStride() for the per-stride poisoned-length clamp added in
zap v0.7.2. Before the migration, a wire length field that satisfied
the permissive `length <= len(buf)` baseline (bare List accepts) but
failed the tighter `length * stride > bufRem` bound would slip past
the accessor and return a List view with Len() == poisoned_length,
opening per-element OOB reads on stride-> 1 entries (silent zeros via
Object.Uint{8,32,64}, but consumer iterates ghost entries). Migrated:
- CredentialListView → SizeCredential (stride 16)
- SignatureArrayView → SigBlobSize (stride 65)
- InputListView → SizeTransferableInput (stride 88)
- SigIndicesArrayView → SizeSigIndex (stride 4, new const)
- OutputListView → SizeTransferableOutput (stride 96)
- NewEvidenceListView → SizeEvidenceEntry (stride 48)
Also added defensive `i >= Len()` bounds checks to the four At()
methods that construct sub-Objects from list.Object() — without the
guard, At(0) on a clamped Len()=0 view would build a Credential /
TransferableInput / TransferableOutput / EvidenceEntry wrapping a
zero-value zap.Object (msg=nil) and panic on downstream field
reads. (SignatureArray.At and SigIndicesArray.At already had the
guard.)
New regression suite r4v9_liststride_test.go covers all 6 accessors
plus a false-positive guard verifying honest single-entry lists still
round-trip. Pattern follows existing TestNewV1_ListStrideTighterClamp
in zap: build honest list, overwrite wire length with poisoned value
chosen to satisfy bare clamp but fail stride clamp, parse, confirm
Len()==0 and At(0) is panic-safe.
R4V12 (MEDIUM) — rename `Subnet` to `Chain` in bench fixtures
all_types_bench_test.go: legacySlashValidatorTx + legacyRemoveChainValidatorTx
struct field + their 4 construction sites (lines 55, 68, 444, 476,
552, 584 in original). Bench-only legacy types used for parity
benchmarks; rename is audit-hygiene per the rebrand sweep (#187).
Grep gate `grep -rn "Subnet" ... | grep -v "//"` is now empty.
R4V14 (INFO) — fix docstring drift in add_chain_validator_tx.go: the
"Fixed-section layout (size 165 bytes)" comment said 165, but the
SizeAddChainValidatorTx constant correctly says 161. Updated comment
to match constant; verified by arithmetic (last field Chain @ 129 +
32 bytes = 161, not 165).
Verification:
- go test -race -count=1 ./vms/platformvm/txs/zap_native/... → ok
- 7 new R4V9 tests pass (6 poisoned-length regressions + 1 honest
round-trip).
- grep gate for R4V12 is empty.
- Parse benchmarks (M1 Max, 200ms): geomean 7.70x (named-tx-only),
within noise of pre-fix 7.71x baseline.
zap_native-side only — luxfi/zap requires no bump.
Bumps luxfi/zap to v0.7.1 which closes the underlying wire-layer gaps
(uncapped Object.List length, backward-pointer header aliasing, size=0
Parse), and reworks the zap_native package to:
- Use zap.Version2 as the schema-version gate. Every Wrap*Tx now routes
through parseAndCheckKind(b, want), which rejects any Version1 buffer
with ErrWrongSchemaVersion before TxKind interpretation. Closes the
v2-vs-v3 cross-schema confusion (RED-MEDIUM-1) where a v2-shaped
BaseTx with NetworkID=11 had byte 0 == TxKindBaseFull == 0x0B.
- Add EvidenceList.Bind(messageBlobs, signatureBlobs) → EvidenceList,
and safe accessors EvidenceEntry.MessageA/B + SignatureA/B that clamp
wire (Rel,Len) cursors against parent-blob length. Returns empty
slice on poisoned cursors (RED-HIGH-3) instead of panicking on
mb[rel:rel+len]. The raw *Range() accessors are now documented UNSAFE
and kept only for internal/test use.
- Add SigIndicesArray.Slice(start, count uint32) → []uint32 and
SignatureArray.Slice(start, count uint32) → [][SigBlobSize]byte.
Both methods were referenced in comments but missing; consumers
indexing .At() in a loop with attacker-controlled start/count would
have iterated 4G times on poisoned wire (RED-HIGH-3 follow-on).
- Update batch3_test.go::TestEvidenceListRoundTrip to use the safe
bound accessors (the prior `mb[mARel:mARel+mALen]` pattern is exactly
the consumer-side panic surface Red demonstrated).
Regression tests added to security_test.go:
TestRedRound2_HIGH3_EvidenceListSafeAccessorsClamp
TestRedRound2_HIGH3_SigIndicesArraySliceClamp
TestRedRound2_HIGH3_SignatureArraySliceClamp
TestRedRound2_MEDIUM1_V2BufferRejectedAtSchemaGate
TestRedRound2_MEDIUM1_HonestV2BuffersStillWork
Geomean Parse speedup vs legacy codec: 7.03× (was 7.09×; +0.5% noise).
Schema v3 (TxKind discriminator) reproduces predicted v2→v3 lift within
noise: Parse geomean 7.11× on M1 Max (n=9 native types), 8.02× on M4 Max
— matches Red model (v2 8.39× → v3 7.09× projected). Per-type allocs 3.57×
fewer, bytes 6.89× smaller, host-stable.
AdvanceTime end-to-end via txs.Codec wrapper: 34.14× M1, 42.76× M4 — ratio
grows with chip speed (reflection tax is fixed-per-op).
Honest residual: Build is a regression on M4 Max for 6/9 types (geomean
0.86×). zap.Builder per-call overhead exposed on fast cores. M1 Max still
positive (1.12×). Tracked for luxfi/zap v0.7.0 (Blue v3.1 iteration);
Parse-side ship decision is independent.
Both hosts darwin/arm64 (M1 Max MacBookPro18,2 + M4 Max Mac16,5 / dbc
runner). Task spec called dbc "amd64 Linux" — corrected: it is darwin/arm64
Apple M4 Max. linux/amd64 numbers TBD until x86 box lands in ARC fleet.
Reproduce: GOWORK=off go test -bench='^Benchmark(Parse|Build)' -benchmem
-benchtime=500ms -count=3 -run='^$'
./vms/platformvm/txs/{bench,zap_native}/
TxKindBaseFull = 11 → BaseTxFull
TxKindAddPermissionlessValidator = 12 → AddPermissionlessValidatorTx
TxKindImport = 13 → ImportTx
TxKindExport = 14 → ExportTx
TxKindCreateChain = 15 → CreateChainTx
Sizes (fixed section, schema v3):
BaseTxFull = 85
ImportTx = 125
ExportTx = 125
AddPermissionlessValidatorTx = 381
CreateChainTx = 221
Design highlights:
- BaseTxFull is the real P-chain spending envelope (Outs + Ins +
Credentials + 2 shared arrays + Memo). The batch-2 BaseTx
(TxKindBase) remains as the minimal metadata envelope for places
that need only {NetworkID, BlockchainID, Memo} without spending
state. Both kinds are first-class; they are NOT alternatives.
- Import / Export use a SINGLE combined Ins (or Outs) list with a
header marker (ImportedInsStart/Count or ExportedOutsStart/Count)
identifying the cross-chain slice. This collapses what would have
been two parallel sig-index arrays into one shared array — fewer
pointer pairs, no rebase bookkeeping. The slice-based indexing is
byte-identical for the imported/exported half because they
reference the same shared array.
- AddPermissionlessValidatorTx carries two single-address Owner stubs
(validation rewards, delegation rewards) inline in the fixed
section. The same OwnerStub type underlies CreateChainTx.Owner.
Multi-address Owner ships in batch 4 along with the AddressList
primitive; current callers needing multi-addr flow through the
legacy codec gate.
- CreateChainTx embeds a 32-byte WarpMessageHash (sha256 of the
originating Warp commit; zero when the chain is being created
directly without a cross-network commit). The Warp message BODY
is NOT embedded — it lives in the signed Warp envelope outside
this tx. Hash-only embedding keeps the unsigned-tx bytes stable
and the Warp envelope separately verifiable.
All five tx types follow the v3 invariant: TxKind@0, Wrap* rejects
mismatched discriminator with ErrWrongTxKind. Cross-confusion test
expansion verifies the new tx types in pairwise rejection (7
additional scenarios cover the batch 1+2+3 surface).
Deferred to batch 4 (no new primitives needed): the remaining ~18
classical platformvm tx types (CreateNetwork, AddDelegator,
AddPermissionlessDelegator, etc.) reuse the existing batch-3
primitives; their landings are mechanical.
go test -race ./vms/platformvm/txs/zap_native/...
ok github.com/luxfi/node/vms/platformvm/txs/zap_native 1.488s
Five primitives. Each is fixed-stride at the entry level; variable
per-entry bytes/sub-list payloads live in shared sibling fields on
the parent tx, indexed by (start, count). This keeps List.At(i)
zero-allocation while supporting unbounded per-entry payload sizes.
OutputList stride 96 → TransferableOutput
InputList stride 88 → TransferableInput + shared SigIndicesArray
CredentialList stride 16 → Credential + shared SignatureArray
WarpMessage size 40 → embedded {SourceNetwork, Payload}
EvidenceList stride 48 → EvidenceEntry + shared MessageBlobs/SignatureBlobs
Design choices:
- Stride is the entry-count semantics for SetList; the byte count from
ListBuilder.Finish() is discarded. List.Object(i, stride) does the
branch-free arithmetic.
- Variable per-entry data goes into a SIBLING field on the parent tx
(not into the entry's stride): InputList → SigIndicesArray;
CredentialList → SignatureArray; EvidenceList → MessageBlobs +
SignatureBlobs. Each entry references its slice via (start, count).
This pattern lets entries stay fixed-stride and accessors stay
zero-allocation. Multi-input-shared signature arrays also enable
signature deduplication when adjacent inputs share signers.
- Forward-only relOffsets per the F1 contract: SetBytes-backed payload
fields (WarpMessage.Payload, EvidenceList parent's MessageBlobs/
SignatureBlobs) flow through the F1-fixed zap.Object.Bytes which
rejects negative bit-patterns.
- Read-only contracts on every []byte / by-value accessor are
documented with the canonical defensive-copy idiom
(append([]byte(nil), m...)) per AT1.
- Multi-address Owner is NOT yet supported at the wire layer; v3
OutputList carries the single-address stub identical to
TransferChainOwnershipTx. Multi-address callers still flow through
the legacy codec gate behind LUXD_ENABLE_LEGACY_CODEC. The
AddressList primitive ships in batch 4 (defer).
- Multi-signature credentials handled via the SignatureArray slice
semantics — one Credential, N sigs. Post-quantum credentials
(ML-DSA) are NOT YET on the v3 path; they keep flowing through
legacy until their dedicated PQ Credential schema lands.
Round-trip tests (batch3_test.go) cover all five primitives + the
empty-list/null-pointer fallback path. go test passes; race-free.
Updated bench numbers for v3 batch-2 (the +1-byte TxKind tax):
Parse geomean speedup vs legacy: 7.09× (was 8.39× pre-v3)
Build geomean speedup vs legacy: 1.35× (build path was always
closer to parity; structure-allocator dominates at small tx sizes)
Phase A response to Red's batch-2 review:
F2 (MEDIUM, cross-type confusion) — schema-bump v2→v3. Every fixed
section now carries a TxKind uint8 at offset 0; every other field
shifts by +1 byte. Wrap*Tx reads TxKind first and returns
ErrWrongTxKind on mismatch. Constructors write the kind
unconditionally. Closes the gap where an AdvanceTimeTx buffer wrapped
as a BaseTx returned garbage-but-deterministic field reads.
TxKind enum (dense, 0 reserved):
1=AdvanceTime 2=RewardValidator 3=SetL1ValidatorWeight
4=IncreaseL1ValidatorBalance 5=DisableL1Validator 6=Base
7=RegisterL1Validator 8=SlashValidator
9=TransferChainOwnership 10=RemoveChainValidator
v3 sizes (each +1 vs v2 from the TxKind byte):
AdvanceTimeTx = 9
RewardValidatorTx = 33
SetL1ValidatorWeightTx = 49
IncreaseL1ValidatorBalanceTx = 41
DisableL1ValidatorTx = 33
BaseTx = 45
RegisterL1ValidatorTx = 217
SlashValidatorTx = 57
TransferChainOwnershipTx = 69 (no natural-alignment padding; reads are alignment-tolerant)
RemoveChainValidatorTx = 53
F1 (MEDIUM, memo malleability) — closed at the luxfi/zap wire layer
in v0.6.1 (negative relOffset rejected in Object.Bytes). Bumped node's
go.mod replace: zap v0.2.0 → v0.6.1. TestRed_V2 now confirms the
defense via the nil-Memo branch.
F4 (INFO, doc bugs) — RegisterL1ValidatorTx comment now correctly
declares size 217; TransferChainOwnershipTx now correctly declares
size 69. Both sizes flow from offset arithmetic — no magic numbers.
AT1 (accepted tradeoff, memo aliasing) — BaseTx.Memo() docstring
escalated: READ-ONLY contract + the canonical defensive-copy idiom
(append([]byte(nil), m...)). Same pattern documented for every
variable-length accessor going forward.
Brand cleanup: SlashValidatorTx.Subnet() → Network() and
RemoveChainValidatorTx.Subnet() → Network(). Zero `Subnet*` symbols
in batch-2 code path. Tests updated.
V14 security test now exhaustively covers cross-confusion: 10
pairings of {valid-tx-buf, wrong-Wrap} + reserved TxKind=0 — all
reject with ErrWrongTxKind. The other 19 Red vectors continue to
pass under v3.
go test -race ./vms/platformvm/txs/zap_native/...
ok github.com/luxfi/node/vms/platformvm/txs/zap_native 1.446s
Coordinated with luxfi/zap@v0.6.1 (F1 fix).
Post-coreth networks (test+dev primary that bake only P + EVM genesis chains)
run in P-only mode where the X alias is not registered. The wallet's
FetchState now degrades the X-Chain context to a sentinel ids.Empty
BlockchainID instead of erroring, and skips the X-chain entry from the
chain-pair UTXO scan when X is unavailable.
Required to drive IssueCreateChainTx via the canonical primary wallet
against the fresh test+dev primaries (closes universe #168).
Real-workload benchmark suite produced by the scientist agent. Production-
realistic measurements on Apple M1 Max (Go 1.24).
Harness (8 files + results + reproduce docs):
bench/fixtures.go — realistic per-type tx fixtures
bench/parse_bench_test.go — per-type Parse: Legacy vs ZAP
bench/build_bench_test.go — per-type Build
bench/field_bench_test.go — field access (single + 1M batch)
bench/workload_bench_test.go — 1000-tx mempool + 200-block parse + dispatcher
bench/alloc_bench_test.go — 5s sustained-parse GC pressure
bench/disable_legacy_test.go — LUXD_ENABLE_LEGACY_CODEC gate verification
bench/README.md — reproduce + capture procedures
bench/testdata/README.md — captures notes
bench_results/RESULTS.md — full numbers + honest caveats + CPU profile
Headline numbers (vs linearcodec):
Parse AdvanceTimeTx : 37x faster (1940 ns -> 52.5 ns), 20x less mem
Build AdvanceTimeTx : 5.2x faster
Sustained 5s parse loop : 18.5x throughput (777k -> 14.4M parses/sec)
Cross-type mean : 5.6x parse, 1.6x build
Allocations (parse, build) : always 3->1 and 4->2
CPU profile:
Legacy: ~50% in reflectcodec.{marshal,unmarshal} reflection walk,
~30% in runtime.{madvise,kevent} from per-field alloc GC pressure
ZAP: elimination of both surfaces; offset arithmetic + 1 alloc per parse
Honest caveats (verbatim from scientist report):
- Only 5/33 tx types have native paths today; mempool mix workload
compresses to ~1.1x lift until BaseTx + AddPermissionless* native ship
- Field access: single uint64 read is 2.1x slower (offset deref vs struct
field), break-even at ~3,560 reads per parse; mainnet validators do
~10x per tx so ZAP still wins by orders of magnitude in the real regime
- darwin/arm64 only — re-run on linux/amd64 before quoting production-
binding numbers
- LUXD_ENABLE_LEGACY_CODEC gate verified at zap_native surface (subprocess
test passes); NOT yet wired into platformvm/txs.Parse — would break
byte-preserving v0 read for validators bootstrapping pre-activation
history. Gate enforcement lives at the future wire dispatcher.
Recommendation (CTO direction): continue the migration. Architecture works
as designed. Next priority: BaseTx + AddPermissionless* native paths — the
two highest-weight tx types in the modal mainnet mempool mix.
txs/tx.go is unmodified — byte-preserving v0->TxID migration invariant
preserved per existing codec.go CodecVersionV0/V1 framework.
Reproduce: cd ~/work/lux/node && GOWORK=off go test -bench=. -benchmem ./vms/platformvm/txs/bench/...
LUXD_DISABLE_LEGACY_CODEC → LUXD_ENABLE_LEGACY_CODEC (per user 2026-06-02
'should be LUXD_ENABLE_LEGACY_CODE=1 to turn it on').
Default: native ZAP for every read + write. codec.Codec.Marshal/Unmarshal
gone from hot path. Fresh deployments + post-activation production get a
smaller, faster binary with no legacy code reachable.
Operators with pre-activation history that needs reading set
LUXD_ENABLE_LEGACY_CODEC=1 to opt in to backward-compat. Without it, legacy
bytes return ErrLegacyCodecDisabled.
Tests updated for the inverted default; pass clean.
Pulls in plugin/evm StateScheme fix that unblocks fresh L2 EVM chain
creation on lux-mainnet (hanzo, zoo, spc, pars), where the plugin was
panicking with "panic in eth.New: triedb parent [<EmptyRootHash>] layer
missing" because eth/backend.go inherited geth's path-by-default for
empty DBs while the VM hard-refuses path mode upfront.
See luxfi/evm v0.18.18 commit 1dea806f8.
Embedded //go:embed configs/{mainnet,testnet,devnet,localnet} now contains
canonical 2-alloc genesis.json's reverted for RLP import compatibility:
- mainnet C-Chain (96369): block 0 = 0x3f4fa2a0... MATCH lux-mainnet-96369.rlp
- testnet C-Chain (96368): block 0 = 0x1c5fe377... MATCH lux-testnet-96368.rlp
- zoo-mainnet (200200): block 0 = 0x7c548af4... MATCH zoo-mainnet-200200.rlp
Each had been wedged by 43 PQ precompiles baked into config.precompileUpgrades
at blockTimestamp:0, mutating state root + producing non-canonical hash.
Activations moved to forward-dated upgrade.json (blockTimestamp 1766708400).
Also bumps pkg/genesis/security to v1.13.8 to stay version-locked.
Tidy drops bft v0.1.5 (no longer indirect).
We don't use gcr.io across lux/hanzo/zoo. Switch the heartbeat-tx example
runtime stage from gcr.io/distroless/static-debian12:nonroot to
FROM scratch, copying ca-certs, tzdata, and passwd/group from the builder.
luxfi/evm v0.18.16 fixes the nil-chainConfig panic at PQ gate that
made v1.28.15's image-baked EVM plugin crash on fresh-PVC pq:true
bootstrap. Discovered during localnet 1337 bring-up (#148).
Root cause in v0.18.15: vm.chainConfig.PQ was being set BEFORE
vm.chainConfig was assigned from g.Config — nil-deref in
plugin/evm/vm.go Initialize().
v0.18.16 moves the gate AFTER assignment.
This unblocks v1.28.16 image build → unblocks localnet 100% green
→ unblocks cluster deploys (devnet → testnet → mainnet).
GitHub-hosted macos-13 queues block the release pipeline for hours.
The build is already pure Go cross-compile (CGO_ENABLED=0 GOOS=darwin
GOARCH=$arch) so there's no reason to use a Mac runner. Route through
the self-hosted lux-build ARC pool (fast, plentiful) instead.
Replace 7z (not on ubuntu) with apt-installed zip in the same step.
Output filename + artifact name are unchanged.
Whole-tree sync to latest closure-swarm tags. accel v1.1.8 ships
c_api.h via //go:embed so go mod vendor preserves it (fixes fresh-clone
CI builds across all consumers).
PULSAR-V04-CTX cascade landing:
- pulsar v1.0.23 → v1.1.1 (v0.4 ctx-bound algebraic-aggregate
threshold sign — full Round1→Round2W→
Round2Sign→AlgebraicAggregateCtx with
FIPS 204 §5.4 ctx threaded into μ; no
single-party dealer shortcut anywhere)
- threshold v1.9.2 → v1.9.4 (dispatcher pulsar.Sign_Ctx rewired
onto full algebraic-aggregate path; no
master sk materialised in dispatcher
process at any point during sign)
- consensus v1.25.9 → v1.25.11 (passes the bumps through)
Test gates green on tip:
GOWORK=off go test -count=1 -short -timeout 600s ./vms/platformvm/...
GOWORK=off go test -count=1 -short -timeout 600s ./network/...
GOWORK=off go test -count=1 -short -timeout 600s ./consensus/...
Cascade:
- magnetar v1.1.0 → v1.2.0 (closes MAGNETAR-PVSS-DKG-V11)
- threshold v1.9.1 → v1.9.2 (magnetar bump)
- consensus v1.25.8 → v1.25.9 (threshold + magnetar bump)
Magnetar v1.2.0 lands a Schoenmakers-style PVSS-DKG over GF(257)
for THBS-SE setup. No trusted dealer; no party ever holds the
master byte vector at any time during setup. Share-envelope wire
shapes are byte-shape-identical to the dealer path, so already-
deployed share material is forward-compatible.
luxfi/keys v1.1.0 lands the Bindel-Brendel-Fischlin (CCS 2021) +
CDFFJ23 (Asiacrypt 2023) stronger-binding hybrid signature scheme
for validator identity:
HybridPublicKey / HybridPrivateKey / HybridSignature
HybridSign / HybridVerify / HybridBoundDigest / HybridPublicKeyBytes
DeriveHybridIdentity (mnemonic + path → HybridIdentity)
Construction binds BOTH pubkeys into m_bound via SHAKE256-384 under
domain "lux-hybrid-sig-v1" — security ≥ max(EUF-CMA_secp256k1,
sEUF-CMA_ML-DSA-65). Raw concat (the prior plan) only gives
min security under non-honest-key adversary (CDFFJ23 §4).
Classical = secp256k1 (matches existing P/X validator key format).
PQ = ML-DSA-65 (FIPS 204).
Use DeriveHybridIdentity for validator stake re-anchor flow:
classical leaf at m/44'/9000'/serviceIndex'/0'/0', PQ leaf at
m/44'/9000'/serviceIndex'/0'/1'. NodeID derived via single
SHAKE256-384 over wire-form hybrid pubkey (no BTC-style double hash —
cryptographer review confirmed single-SHAKE is sound).
luxfi/kms v1.10.1 follows with the matching go.mod bump.
Tests: go build ./... and go test -race -count=1 -short
./vms/platformvm/... PASS.
consensus v1.25.8 (carries threshold v1.9.1 + magnetar v1.1.0) refactored
quasar/corona_gob.go and polaris.go to call sig.MarshalBinary() at the
Signature level. The wire-codec methods (Signature.{Marshal,Unmarshal}Binary)
were added in corona v0.7.6 — v0.7.5 only has them on the inner C/Z/Delta
polynomial fields.
The historical replace directive (07bb303044 on 2026-05-24) was added to
work around consensus v1.24.6 reaching back into corona via keyera.Bootstrap,
which since shipped its 3-value return at corona v0.7.5. consensus v1.25.x
now pins corona v0.7.6 in its own go.mod cleanly, so the replace is no
longer needed and is actively breaking the v1.28.8 image build.
Removing the replace lets MVS pick corona v0.7.6 transitively through
consensus → that is the version where MarshalBinary lives.
Reproduced the CI failure locally with CGO_ENABLED=0 GOWORK=off, fixed,
verified with a clean amd64 nattraversal-profile build (46.6MB binary)
and a full race-clean ./... suite (exit 0, no DATA RACE / panic markers).
luxfi/evm v0.18.15 ships core/genesis: honor SkipPostMergeFields flag
from JSON — the fix for the "triedb parent [0x56e81f17…] layer missing"
panic-in-eth.New that's blocking C-Chain bootstrap on lux-mainnet.
Lux mainnet C-Chain canonical genesis hash is 0x3f4fa2a0…, produced
with the 16-field pre-Shanghai header format. The chain activates
Cancun at genesis time for MCOPY etc., but the genesis BLOCK itself
must stay in the legacy header shape. The previous luxfi/evm tag
ignored skipPostMergeFields=true and shifted the computed genesis
hash to 0x1ade42ec…, which then failed to commit to pathdb because
the parent layer (0x56e81f17… = empty root) wasn't in the layertree.
The plugin baked into this image is at vmId
mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6 — confirmed via
strings of the running prod plugin binary (luxfi/evm/core symbols).
consensus v1.25.8 carries threshold v1.9.1 which carries magnetar v1.1.0:
strict-atom Combine, audit-grep clean, byte-identity to circl FIPS 205,
35-51% faster than v1.0.
Race-clean across the full node ./... suite (exit 0; no DATA RACE / panic
markers; 30+ min compile-and-test on -race -timeout=15m).
OracleVM, RelayVM, IdentityVM were previously registered as optional
VMs (loadable via CreateChainTx) but not part of the genesis-baked
primary network registry. Adding them to the registry means the VM
alias machinery + VMAliases map now know about all 14 primary-network
chains by canonical letter and alias set.
Order is append-only per the comment in registry.go — I/O/R land at
positions 12/13/14. Each row picks a unique alias triple:
I: identity, identityvm, id
O: oracle, oraclevm, feed
R: relay, relayvm, msg
This change does NOT bake I/O/R into FromConfig's primary-genesis
optIn loop — that would require new IChainGenesis/OChainGenesis/
RChainGenesis fields on genesiscfg.Config plus matching ichain.json/
ochain.json/rchain.json shards under genesis/configs/{net}/. I/O/R
continue to be instantiated via CreateChainTx post-genesis (as today).
What the registry update unblocks is the alias resolution path so
when those chains DO get created, the VM manager knows them by
canonical name without per-chain switch-ladder edits.
builder.Aliases() gets matching cases for the three new VMIDs so
when a future genesis bakes them, chain alias registration works
without further changes.
Existing tests in TestChainAliasesRegistryParity and TestVMAliasesRegistryParity
get three new rows each; both pass.
The KMS consensus-auth gate now requires every secret-opcode envelope
to carry a signed identity. Derive a bootstrap ServiceIdentity from
KMS_BOOTSTRAP_MNEMONIC (or MNEMONIC) under the well-known servicePath
"luxd/staking-bootstrap" and thread it into the LoadMnemonicFromKMS
call so the dial envelope is signed.
Bootstrap mnemonic is provisioned out-of-band (sealed envelope, HW
token unwrap, etc.); the operational staking material on disk still
comes from the KMS-held mnemonic the dial then fetches.
Closes luxfi/node#115 by landing the admission-hook half of the issue.
The hook is the substrate the encrypted-mempool partition will use to
admit FHE-ciphertext transactions on (signature + fee + NIZK) without
decryption per LP-066 / luxfi/precompile/fhe.
Surface added:
type AdmissionVerifier[T Tx] interface {
VerifyAdmit(tx T) error
}
var ErrAdmissionRejected = errors.New("tx admission rejected")
func NewWithAdmissionVerifier[T Tx](
metrics Metrics,
verifier AdmissionVerifier[T],
) *mempool[T]
// New is now a thin wrapper around NewWithAdmissionVerifier(metrics, nil).
// No behavioral change for existing callers.
Add() invokes verifier.VerifyAdmit last, after duplicate / size / space /
conflict have all passed. Ordering rationale: cheap rejects fire first,
so NIZK verification cost is only paid on a tx that passes structural
admission. A non-nil verifier return is wrapped in ErrAdmissionRejected,
records the drop reason via the existing droppedTxIDs LRU, and never
inserts the tx.
What this does NOT do:
- It does not define the encrypted-payload tx type or its NIZK proof
format. Those live in vms/platformvm/txs (or wherever the consumer
decides) and ship in a follow-up.
- It does not wire the FHE precompile's bootstrap-meter consultation —
callers implementing AdmissionVerifier do that themselves.
- It does not add the per-account FIFO partition for encrypted txs.
The existing unissuedTxs Hashmap is per-mempool; the partition is
a separate construction over multiple mempool instances and is a
follow-up.
What it DOES do: lands the only mempool-side hook the issue requires
without locking us in on the encrypted-tx representation. The hook is
generic (`AdmissionVerifier[T Tx]`) so any future Tx variant can plug
in the same way.
Tests (CGO_ENABLED=0 go test -run TestAdmissionVerifier ./vms/txs/mempool/...
all pass):
- nil verifier matches New (byte-identical behavior)
- verifier returning nil admits the tx; gate fires exactly once on
Add and not on Get / Peek / Iterate / Remove
- verifier returning an error rejects with ErrAdmissionRejected
wrapping the verifier's reason; drop reason recorded
- cheap checks short-circuit before the gate runs (duplicate /
oversize / conflict all skip the gate)
Refs:
- issue #115 (this repo)
- LP-066 (F-Chain confidential compute)
- LP-183 (ZAP envelope decode precompile; downstream consumer once
encrypted-payload tx propagation through gossip lands)
- luxfi/threshold issue #20 (real distributed FHE decryption; the
block-proposer-side counterpart to this admission-side hook)
The github.com/luxfi/node/errors package shipped with zero tests despite
being a public utility surface (sentinel errors, WrappedError context
chain, Multi/Join aggregation, retry helpers). This adds 22 tests covering:
- WrappedError.Error format (with and without message)
- Wrap/WrapWithContext nil pass-through and context preservation
- errors.Is / errors.As chain through Wrap (incl. double-wrap)
- IsNotFound, IsClosed, IsTimeout helpers
- IsTemporary against both sentinel errors and the Temporary() interface
- IsPermanent across all permanent sentinels + fmt.Errorf %w chains
- GetCategory: wrapped category, sentinel inference, unknown fallback,
and the precedence rule (wrapped category beats inferred category)
- Multi.Error/Add/Err for 0, 1, and N errors; nil-add ignored
- Join nil pass-through and multi-error combination
Result: 100.0% statement coverage, race-clean.
The cached `<dataDir>/genesis.bytes` file is written on first start to
hold hash stability across restarts. On a binary upgrade that adds
new codec types (multi-version v0+v1 dispatcher, etc.), the old
cached blob may carry type IDs the new binary doesn't recognise. The
existing code surfaced this as `resolve X-Chain asset ID from cached
genesis: unmarshal interface: unknown type ID N` and returned an
error — wedging the node in CrashLoop with no automatic recovery.
Drop the cache and rebuild from the `--genesis-file` instead. Hash
stability is forfeit for that single restart (intentional — the
alternative is a permanent outage on every binary bump that changes
codec types). Subsequent restarts re-establish stability against the
fresh cache.
Surfaced today on <tenant> testnet+mainnet bumping lqd v1.9.x →
v1.10.8: every pod hit "unknown type ID 29" on the stale v1.9.x
codec cache and CrashLoopBackOff'd.
README badge and prerequisites listed Go 1.21.12 / 1.23.9 in different
places, but go.mod requires Go 1.26.3. Bring the docs in sync so new
contributors install a toolchain that can actually build the node.
Track A finish: KMS goes back to being a generic secret store; mnemonic
semantics live in luxfi/keys alongside the existing BIP-39 + BIP44
derivation. Imports flip from luxfi/kms/pkg/zapclient.LoadMnemonicFromKMS
to keys.LoadMnemonicFromKMS — same signature, same behavior.
Deps:
luxfi/keys v1.0.8 → v1.0.9 (carries the new LoadMnemonic helper)
luxfi/kms v1.9.12 → v1.9.13 (LoadMnemonic removed, secret store only)
Build clean.
Adds a KMS_ADDR-gated production path between the MNEMONIC env var
(priority 1) and the on-disk key files (priority 3). When set, the
mnemonic is fetched via luxfi/kms zapclient.LoadMnemonicFromKMS —
the same canonical loader every Lux-derived service (luxd, netrunner,
lux/cli, descending-L1 bootstraps) now shares.
New priority chain:
1. MNEMONIC env var
2. KMS_ADDR + KMS_ENV + KMS_MNEMONIC_PATH (native ZAP, default path /mnemonic)
3. Key name from os.Args[1] (~/.lux/keys/<name>/)
4. ~/.lux/keys/default/
Production env contract matches the <tenant> operator's render
(KMS_ADDR + KMS_ORG + KMS_ENV + KMS_MNEMONIC_PATH); every Lux chain
inherits the same scheme.
Dep:
+ github.com/luxfi/kms v1.9.12 (carries zapclient.LoadMnemonic)
Build clean.
Closes the residual v1.28.1 testnet-canary failure where bootstrapping
a v1.23.x-written P-Chain database hit:
P-Chain state corrupt after init — database must be wiped
error="loadMetadata: feeState: unknown codec version"
chainID=11111111111111111111111111111111P
The v1.28.1 patch routed genesis.Parse through the multi-version
txs.GenesisCodec dispatcher but did not touch the 7 OTHER state-side
sites that read via block.GenesisCodec — which carried only the v1 slot
map. Any pre-codec-v1 row on disk (feeState, heightRange, L1Validator,
fx.Owner, NetToL1Conversion, legacy stateBlk) errored at the very first
byte with codec.ErrUnknownVersion.
Architecture (Rich-Hickey-simple): make the codec itself complete for
all encountered wire versions rather than asking "which codec does
this caller need". block.GenesisCodec now registers BOTH the v0
(v1.23.x Apricot/Banff) and v1 (current) tx slot maps — reads
dispatch on the 2-byte wire prefix, writes still target
CodecVersion (== v1) exclusively. Same shape as txs.GenesisCodec
(decomplected from block parsing — block.Parse continues to extract
prefix explicitly because v0 blocks satisfy v0.Block, not block.Block,
and cannot be unmarshalled into a block.Block destination).
Audit found 7 state-side sites all using block.GenesisCodec; all 7
are routed through a new defensive helper:
state.multiVersionUnmarshal(c codec.Manager, b []byte, dest any)
The helper is a pass-through to c.Unmarshal but probes the codec on
first observation. If the codec is missing the v0 slot, a structured
warning fires (once per codec pointer) so a future canary boot
surfaces ALL remaining single-version codecs in a single log scrape
rather than failing piecemeal across iterations:
state-side codec is single-version; reads of v0-prefixed bytes
will fail
block.RegisterGenesisType now symmetrically registers on both the v0
and v1 underlying linearcodecs so state-side types (currently:
stateBlk) keep slot-stable shapes across codec.Manager dispatch.
Audit table (all 7 broken sites → fixed):
state/l1_validator.go:222 getL1Validator
state/state_blocks.go:115 parseStoredBlock (legacy stateBlk)
state/state_chains.go:66 GetNetOwner (fx.Owner)
state/state_chains.go:116 GetNetToL1Conversion
state/state_metadata.go:176 loadMetadata (heightRange)
state/state_metadata.go:273 getFeeState <-- canary failure
state/state_validators.go:239 loadActiveL1Validators
state/state_validators.go:535 initValidatorSets (inactive)
MetadataCodec was already multi-version (no fix needed).
txs.GenesisCodec was already multi-version (v1.28.0).
block.Codec stays v1-only by design (block.Block interface destination
cannot accept v0.Block types; Parse handles version split explicitly).
Tests (all -race green):
block/codec_multiversion_test.go 6 tests
state/state_v0_codec_test.go 9 tests including
- TestStateV0FeeStateReadable
(exact canary fixture)
- TestStateBootFromV0SingletonDB
(end-to-end boot simulation)
state/codec_helpers_test.go 5 tests (warning probe +
idempotency + non-blocking
+ multi-version invariant)
-> 20 new regression tests
-> 27/27 platformvm packages green under -race
v1.28.0's block-codec multi-version dispatch did not extend to the
P-Chain genesis decoder. genesis.Codec aliased block.GenesisCodec,
which registers only the v1 tx slot map; v0-prefixed cached-genesis
blobs (carried over from v1.23.x bootstraps) errored at first byte
with codec.ErrUnknownVersion.
Root cause hot path: config.getGenesisData -> resolveXAssetID ->
genesis/builder.XAssetIDFromGenesisBytes -> platformvm/genesis.Parse
-> Codec.Unmarshal(bytes, *Genesis). Codec was the v1-only
block.GenesisCodec.
Fix: alias genesis.Codec to txs.GenesisCodec, which registers BOTH the
v0 (Apricot/Banff) and v1 (current) tx slot maps. The Genesis struct
has no slot ID of its own; all version-sensitive data lives in the
embedded []*txs.Tx, so txs.GenesisCodec dispatches the same wire-
prefix lookup the rest of the platformvm tree already uses.
Marshal at CodecVersion (v1) is byte-equivalent because the v1 slot
map in txs.GenesisCodec is the SAME registerV1TxTypes() invocation
block.GenesisCodec was using.
Audit: every other Unmarshal site in vms/platformvm/ that touches
historical wire bytes either (a) goes through block.Parse / txs.Parse
which already dispatch on the prefix, or (b) reads internal state
written by v1-only code (block.GenesisCodec is correct there).
Regression guards in parse_v0_test.go:
- TestParseAcceptsV0CachedGenesis: the canary failure mode.
- TestParseAcceptsV1Genesis: canonical write path still parses.
- TestParseV0RoundtripIsBytePreserving: v0 -> Parse -> re-marshal
-> byte-equal, locking in the doc claim that genesis-derived
hashes do not rotate across the migration.
- TestParseRejectsUnknownVersion: prefixes outside {v0, v1} still
surface as errors.
Full vms/platformvm/... tree green under -race; genesis/builder and
config trees green.
Closes the codec-version trap that surfaced when the v1.23.x ("Apricot/Banff") tx + block layout was rip-replaced in 409297a089 without bumping the wire-version prefix: mainnet/testnet on-disk blobs (~1.08M+ C-Chain blocks) lost a decoder, and any code path that round-tripped a tx through tx.Initialize re-marshaled it under the new layout — rotating TxID and breaking chain-commitment continuity.
Strategy A per cryptographer / orchestrator brief:
* Register both layouts on the platformvm tx + block codec.Managers under
distinct wire-version prefixes:
- CodecVersionV0=0 = v1.23.x slot map (TransferInput=5, hole=6, ...,
AddPermissionlessValidator=25, ..., DisableL1Validator=39)
- CodecVersionV1=1 = current slot map (with MintOutput/MintOp at 6/8,
+4-skip for Banff txs at 27-30, CreateSovereignL1Tx at 36,
SlashValidatorTx at 41, CreateAssetTx/OperationTx at 42-43)
- txs.Codec / block.Codec dispatch by the standard 2-byte wire prefix.
* New byte-preserving init: tx.InitializeFromBytes(c, version, signedBytes)
and tx.InitializeFromBytesAtVersion(c, version) bind the tx to its
original signedBytes without re-marshalling. tx.Initialize stays as the
fresh-build path; from-DB / from-wire paths route through the
byte-preserving variant. TxID = hash(signedBytes) under the version it
was written at, forever.
* New vms/platformvm/block/v0/ subpackage: 9 v0-only block kinds
(ApricotProposalBlock, BanffProposalBlock, ... at slots 0-4 + 29-32).
Pure DTOs — no codec, no Visit. The block package wraps the decoded
v0.Block in a liftedV0Block adapter that:
- returns the original bytes verbatim (no re-marshal),
- BlockID = hash(raw v0 bytes),
- dispatches Visit to the v1 Visitor arms (ApricotProposalBlock /
BanffProposalBlock -> ProposalBlock, etc.),
- re-binds embedded txs at v0 via InitializeFromBytesAtVersion so
inner TxIDs are also byte-preserved.
* genesis.Parse is wire-version-aware: pre-codec-v1 genesis blobs decode
at v0, new blobs at v1. The matching codec is used for tx re-binding.
* L1-tx slot map shifts +1 to accommodate CreateSovereignL1Tx at 36
(RegisterL1Validator 36->37, SetL1ValidatorWeight 37->38,
IncreaseL1ValidatorBalance 38->39, DisableL1Validator 39->40). Test
fixtures regenerated.
* 22 fee-calculator fixtures + 11 serialization fixtures bumped to use
the v1 wire prefix (0x0001) and the post-CreateSovereignL1Tx slot map.
Migration notes:
* Mainnet + testnet P-Chain DBs: NO rebuild. Pre-codec-v1 blocks
continue to decode through the v0 path with original BlockID and
TxIDs preserved. New blocks are written at v1 from the cut-over
height onward.
* Devnet: must be rebuilt before rolling to v1.28.0. Its existing
blobs carry wire-version 0 but use the post-rip slot map (not the
v0 Apricot/Banff layout) — decoding them through the v0 path would
read the wrong types. A fresh bootstrap at v1.28.0 writes v1 bytes
from height 0 and is internally consistent thereafter.
Tests:
* TestCodecVersionV0V1Coexist, TestParseDispatchesByVersion,
TestTxIDStabilityRoundTrip, TestCrossVersionRefuses (txs)
* TestParseV0ApricotProposalBlock, TestParseV0BanffStandardBlock,
TestParseV1RoundTrip, TestVersionPrefixDispatch (block)
* 150 packages, 0 failures under -race
Picks up ChainConsensus.ForceAccept — the consensus-level counterpart
to ForcePreference. Together with the engine.finalizeOwnProposal helper,
this lets a proposer self-finalize its own block at proposal time when
peer Chits do not arrive in time, closing the CreateChainTx stall
observed on devnet under low validator counts.
- ChainConsensus.ForceAccept(blockID) — direct accept bypassing alpha/K
quorum, guarded by engine path (IsOwnProposal=true) and idempotent.
- Engine path uses ForceAccept after ForcePreference on own proposals
so the proposing node commits locally and other validators converge
via the next poll round.
Test delta: pre-existing fee/static_calculator L1Tx parse failures on
main are unaffected; consensus, vms/platformvm/{block,blockmock,state,
warp,...}, and genesis/builder all pass with race -short.
Add TestCanonicalGenesisFixtureParses — loads the canonical mainnet
genesis.json (genesis v1.12.19 evmAddr/utxoAddr field names) through
genesiscfg.GetConfigFile and asserts EVMAddr/UTXOAddr decode to
non-zero ids.ShortID values.
This is the "have we adopted the rename" gate — if the loader
silently swallows the new field names (e.g. via a regression to
ethAddr/luxAddr struct tags), every allocation Address comes back
as ShortEmpty and the assertion catches it before that ships.
Test is host-path aware: skips when ~/work/lux/genesis is not on
disk (CI without sibling checkout), runs when it is.
luxfi/consensus v1.25.0 → v1.25.1
Proposer-self-accept gap on nova multi-node finality: the proposer of
block B was never marking B locally accepted because manager.applyQbit
re-derived peer Chits via a second blk.Verify() call which most VMs
(notably PlatformVM CreateChainTx) are not idempotent under, flipping
every Chits into synthetic Accept=false. Fix tags the proposer's own
pending entry with IsOwnProposal=true and short-circuits the re-verify
in handleVote — peer Chits now count as the genuine Accepts they are.
luxfi/genesis v1.12.15 → v1.12.19
Decomplected: pkg/genesis/security/ is a nested module (own go.mod,
tagged pkg/genesis/security/v1.12.19) that owns SecurityProfile
verification — the only file in luxfi/genesis that imports
luxfi/consensus. The rest of pkg/genesis stays consensus-dep-free.
API change: pin.Resolve() → genesissecurity.ResolveProfile(pin).
ErrSecurityProfileHashMismatch and ErrSecurityProfileInvalidID also
moved to the security submodule. Updated node/node.go and
node/security_profile_test.go to match.
Build: GOWORK=off go build ./... clean (linker warnings about accel
static lib path are pre-existing host-config noise, not a regression).
Vet: GOWORK=off go vet ./... clean (the cevm_e2e_test.go
sync/atomic.Bool copy warning is pre-existing on v1.27.24 main).
Tests: ./consensus/... pass, ./genesis/... pass, ./node/...
TestApplySecurityProfile_* pass. ./vms/platformvm/txs/fee L1-validator
failures are pre-existing on v1.27.24 main and unchanged.
Devnet fixture (~/work/lux/genesis/configs/devnet/genesis.json) parses
clean: networkID=3, 5 initialStakers, canonical evmAddr/utxoAddr only.
The field is the primary network's UTXO fee asset (P+X), not
X-chain-specific. P-chain CreateChainTx / AddChainValidatorTx and
X-chain transfers all burn it for fees. Same number on P and X by
construction; the name should reflect the function, not the chain.
Renames applied across:
- wallet/chain/{p,x}/builder.Context.UTXOAssetID
- wallet/chain/x/builder.Backend.UTXOAssetID() trait method
- node/config: resolveUTXOAssetID + nodeConfig.UTXOAssetID
- vms/platformvm + vms/xvm + chains/manager consumers
- examples (deploy-chains, get-p-chain-balance, ...)
Includes local replaces for luxfi/{runtime,consensus} pending their
release tags landing. Drop after upstream tags ship.
Companion PRs: luxfi/runtime#2, luxfi/consensus#11. Downstream
consumers (sdk, cli, genesis, liquidity) follow.
luxfi/proto#6 (merged) renames SubnetUptime → ChainUptime in
node/zap/p2p, completing the no-subnet vocabulary rule from
node#116. The local re-export in proto/p2p/p2p_zap.go was already
written against the new upstream name, breaking v1.27.22 builds:
proto/p2p/p2p_zap.go:42:32: undefined: p2p.ChainUptime
Tagging v1.0.2 against the merged proto main and bumping the module
pin unblocks the build with zero code changes — the alias line is
unchanged because upstream already matches.
Build: clean. Test: pre-existing platformvm/txs/fee L1-validator
failures unchanged (not introduced here).
Pulls in luxfi/genesis v1.12.15 which strips the dual-name
luxAddr/ethAddr alias shim. AllocationJSON now emits and accepts only
the canonical evmAddr/utxoAddr field pair.
docker-entrypoint.sh genesis templates: switch luxAddr → utxoAddr +
ethAddr → evmAddr to match.
This is the cascade step needed before luxd v1.23.43 ships — the runtime
parser at v1.23.31 (current devnet image) reads ethAddr/luxAddr; v1.23.42
already reads evmAddr/utxoAddr internally; v1.23.43 (this commit + tag)
drops every back-compat alias both ways.
Tests: genesis/builder, vms/platformvm/genesis pass. End-to-end parse
of configs/devnet/genesis.json with canonical names: 1005 allocations,
5 initial stakers (matches 5-pod sybil quorum).
Cluster bump path: lux-devnet (1.23.31 → v1.23.43); testnet/mainnet
remain on v1.23.31 until coordinated migration.
Co-authored-by: Hanzo AI <dev@hanzo.ai>
* feat(platformvm): CreateSovereignL1Tx — single-tx sovereign L1 launch
Adds a new platformvm tx type that atomically registers a sovereign L1
in one P-chain commit. Replaces what is today the four-step flow:
CreateNetworkTx + AddChainValidatorTx ×N + CreateChainTx ×K + ConvertNetworkToL1Tx
with one signed tx. After commit, the primary network has a permanent
record of the L1's network ID + initial validator set + chain manifest
+ on-chain validator-manager contract — but it does NOT track-chains
or validate the L1's blocks. The L1 runs its own consensus from
genesis. L2/L3/L4 follow the same pattern recursively.
Type shape:
type CreateSovereignL1Tx struct {
BaseTx
Owner fx.Owner // CreateNetworkTx parity
Validators []*ConvertNetworkToL1Validator // genesis validator set
Chains []*SovereignL1Chain // VM ID + genesis blob per chain
ManagerChainIdx uint32 // index into Chains[]
ManagerAddress types.JSONByteSlice // validator-manager contract
}
type SovereignL1Chain struct {
BlockchainName string
VMID ids.ID
FxIDs []ids.ID
GenesisData []byte
}
SyntacticVerify enforces:
- at least one validator (sorted, unique)
- at least one chain, ≤ MaxSovereignL1Chains (16)
- ManagerChainIdx is in range of Chains[]
- ManagerAddress ≤ MaxChainAddressLength
- per-chain name + VMID + FxIDs + genesis bounds
- BaseTx + Owner + each Validator each verify
Wired into:
- Visitor interface
- codec (registered as the next tx type after ConvertNetworkToL1Tx)
- signer + complexity + metrics + executor stubs across all visitor
implementations
Executor body is stubbed with a TODO. The atomic state transition
(mint new networkID from tx hash, seed validator-manager state,
register each Chain, charge fee) lands in a follow-up PR. This PR is
the type definition + interface plumbing so downstream tools (wallet,
CLI, fee calc, metrics) can target the tx type while the executor is
implemented.
* chore(node): kill subnet — chain/network vocabulary across node
Zero remaining `subnet|Subnet|SUBNET` in node Go source. Per canonical
no-subnet rule.
## Wire types (Go fields only; byte-level wire encoding unchanged)
message/wire/types.go TrackedSubnets → TrackedChains
message/wire/zap.go same on Read/Write
proto/p2p/p2p_zap.go SubnetUptime alias → ChainUptime
(consumes luxfi/proto rename in companion PR)
## Comment scrub
message/wire/types.go "(chain, subnet) pair" → "(chain, network) pair"
network/peer/handshake.go "primary-network or subnet" → "primary-network or per-chain"
node/node.go "per-subnet" → "per-chain"
"P→subnet warp" → "P→chain warp"
genesis/builder/builder.go "their own subnets" → "their own chains"
vms/platformvm/client.go dropped "subnet jargon" reference
vms/platformvm/service.go dropped "net / subnet jargon" reference
vms/platformvm/config/internal.go "subnet-spawned blockchain" → "per-chain blockchain"
## ICPSubnet (Internet Computer adapter)
ICPSubnet → ICPNet (type rename — unrelated to platform subnet,
but still a subnet word; killed for consistency)
map field `subnets` → `nets`
## Examples
wallet/network/primary/examples/bootstrap-hanzo/main.go:
--subnet-id → --network-id (CLI flag)
existingSubnetID local var → existingNetID
"subnet ID" log lines → "network ID"
"SUBNET_ID=" output → "NETWORK_ID="
"subnet-evm VM ID" → "EVM VM ID"
All comments rephrased.
wallet/network/primary/examples/heartbeat-tx/main.go: one comment
rephrase.
go.work workspace cleanup:
- Removed ./operator/go entry (placeholder; lux/operator polyglot
layout pending the cross-repo migration)
- Removed ./operator entry (mid-migration; nothing to build)
Build clean. Zero `grep -rIn "subnet|Subnet" --include="*.go"` matches.
Adds a new platformvm tx type that atomically registers a sovereign L1
in one P-chain commit. Replaces what is today the four-step flow:
CreateNetworkTx + AddChainValidatorTx ×N + CreateChainTx ×K + ConvertNetworkToL1Tx
with one signed tx. After commit, the primary network has a permanent
record of the L1's network ID + initial validator set + chain manifest
+ on-chain validator-manager contract — but it does NOT track-chains
or validate the L1's blocks. The L1 runs its own consensus from
genesis. L2/L3/L4 follow the same pattern recursively.
Type shape:
type CreateSovereignL1Tx struct {
BaseTx
Owner fx.Owner // CreateNetworkTx parity
Validators []*ConvertNetworkToL1Validator // genesis validator set
Chains []*SovereignL1Chain // VM ID + genesis blob per chain
ManagerChainIdx uint32 // index into Chains[]
ManagerAddress types.JSONByteSlice // validator-manager contract
}
type SovereignL1Chain struct {
BlockchainName string
VMID ids.ID
FxIDs []ids.ID
GenesisData []byte
}
SyntacticVerify enforces:
- at least one validator (sorted, unique)
- at least one chain, ≤ MaxSovereignL1Chains (16)
- ManagerChainIdx is in range of Chains[]
- ManagerAddress ≤ MaxChainAddressLength
- per-chain name + VMID + FxIDs + genesis bounds
- BaseTx + Owner + each Validator each verify
Wired into:
- Visitor interface
- codec (registered as the next tx type after ConvertNetworkToL1Tx)
- signer + complexity + metrics + executor stubs across all visitor
implementations
Executor body is stubbed with a TODO. The atomic state transition
(mint new networkID from tx hash, seed validator-manager state,
register each Chain, charge fee) lands in a follow-up PR. This PR is
the type definition + interface plumbing so downstream tools (wallet,
CLI, fee calc, metrics) can target the tx type while the executor is
implemented.
Per SCALE_STANDARD.md §2 (~/work/hanzo/hips/docs/SCALE_STANDARD.md) —
every Go production Dockerfile that emits JSON to a client builds with
GOEXPERIMENT=jsonv2. Verified on hanzoai/zip's json_bench_test.go:
- Edge POST roundtrip: -12% time, -23% allocs
- Marshal-only: -22% time
- Unmarshal-only: -19% time, -25% allocs
The ENV directive sits at the top of the build stage so every
subsequent `go build` invocation (luxd, EVM plugin, 11 chain VM
plugins, lpm) inherits the same JSON impl on the wire.
The v0.8.40 EVM plugin was built against luxfi/node v1.23.4 — pre-rename
EngineAddressKey ("LUX_VM_RUNTIME_ENGINE_ADDR"). The host (built from
this repo) has been emitting the NEW key ("VM_RUNTIME_ENGINE_ADDR")
since c431d884ed (2026-05-15). Every luxd:v1.27.x image embeds a plugin
that reads the OLD key — empty env → no dial-back → C-chain never
bootstraps → all 4 L2 EVMs stay un-bootstrapped indefinitely.
v0.18.14 pins luxfi/node v1.27.6 which carries the rename, so the
plugin built from it reads the same key the host writes. Pairs with
1b3651ccf6 (host compat shim) — once every cluster pulls an image that
contains this Dockerfile bump, the shim is a no-op.
The EngineAddressKey const renamed from LUX_VM_RUNTIME_ENGINE_ADDR to
VM_RUNTIME_ENGINE_ADDR in c431d884ed (2026-05-15). luxd builds from
that commit forward set VM_RUNTIME_ENGINE_ADDR on plugin exec, but the
EVM plugin (luxfi/evm@v0.8.40 → luxfi/node@v1.23.4 → "LUX_VM_..." const)
in the same Docker image still os.Getenv("LUX_VM_...") — empty string,
no dial back, plugin exits, C-chain never bootstraps, all L2 EVMs stay
un-bootstrapped indefinitely on v1.27.x.
Set BOTH env keys until every plugin in /data/plugins has been rebuilt
against a luxfi/node version that has the rename. New const is the
canonical name; legacy const is the compat key (added as
LegacyEngineAddressKey and only emitted when it differs from the new
key, so once luxfi/evm bumps past the rename the extra env var becomes
a no-op and this line can be deleted without runtime impact).
consensus@v1.24.6 calls keyera.Bootstrap(...) with three return values
but its own go.mod still pins corona v0.4.0 (where Bootstrap returns
two). The 3-value signature lives at corona v0.7.x.
Locally we hide this via go.work using the working tree, so it never
shows up. CI builds without go.work and fails compiling
protocol/quasar/grouped_threshold.go.
Add a replace directive pinning corona to v0.7.5 (current latest).
A proper fix is to tag a luxfi/consensus v1.24.7 with corona bumped
in its own go.mod, but luxd is the only consumer of this transitive
mismatch right now — the replace keeps it tight and reversible.
The v1.27.18 diagnostic confirmed luxfi org GH_TOKEN secret resolves to
length=0 at runtime (visibility is set to 'all' but the actual value
appears unset/expired). UNIVERSE_PAT is set at the repo level and has
the same cross-repo read scope we need for private luxfi/* deps. Try
GH_TOKEN first, fall back to UNIVERSE_PAT, fail fast with a clear
error if both are empty.
Also explicitly disable docker/metadata-action's latest=auto flavor so
the :latest floating tag truly never gets emitted (the prior run showed
the action silently injecting it even with no type=raw rule).
The repeated 'terminal prompts disabled' fatal at go mod download in
v1.27.15..v1.27.17 looked like the BuildKit secret mount was producing
an empty /run/secrets/ghtok file. Add a pre-build step that fails fast
if secrets.GH_TOKEN does not resolve at workflow run time (org-secret
visibility=all, but ARC runner ephemeral identities have surprised us
before). The token value is never echoed — only its byte length.
initialSupply was still summing both initialAmount AND unlockSchedule
amounts — even with the UTXO emission fix, the reported supply field
double-counted Avalanche-shaped configs. Match the emission policy:
unlockSchedule wins when non-empty, initialAmount otherwise.
Dockerfile: drop the post-go-mod-download cleanup of the
url.insteadOf git config. The build step at the bottom of the
builder stage also triggers go fetches (resolving build-time
transitive deps), so the rewrite must remain in place. The
throwaway builder stage never ships, so leaving the token in
/etc/gitconfig is fine — only the compiled binary is COPYed into
the runtime image.
Default workflow GITHUB_TOKEN only has read access to the running repo
(luxfi/node), so go mod download fails on private cross-repo deps such
as luxfi/corona. Switch the BuildKit ghtok secret to the org-level
GH_TOKEN (a PAT with org-wide read scope) so the Dockerfile's git
url-rewrite picks up every luxfi/* module the build needs.
Avalanche/legacy genesis JSONs (testnet, mainnet) set initialAmount as the
sum of unlockSchedule (two views of one total). The current builder emits
both an immediately-spendable UTXO for initialAmount AND one UTXO per
unlock entry — double-minting the allocation.
Devnet-style configs set initialAmount with an empty unlockSchedule and
need the single UTXO to be emitted.
One semantic, one UTXO: when unlockSchedule is non-empty, skip the
initialAmount UTXO (the schedule already covers the total). When
unlockSchedule is empty, emit the initialAmount UTXO as before.
Also:
- ci/docker: switch back to self-hosted `lux-build` ARC pool (DOKS hanzo-k8s)
- ci/docker: drop floating `:latest` tag — semver/sha-only policy
Cleanup pass on the C-Chain-disabled future-work comments. No code
change — just the placeholder identifiers now match the canonical
EVMAddress / EVMKeychain naming used by the live KeychainAdapter.
ethAddrs → evmAddrs
FetchEthState → FetchEVMState
Forward-only completion of the strip-aliases work. Examples (18 main.go
files + example_test.go + debug_balance_test.go) used the old
EthKeychain field name on WalletConfig literals. Mass-renamed via
sed across the wallet/ tree to match the canonical EVMKeychain name.
Also: cleaned remaining linter-restored EthKeychain references in
wallet.go (WalletConfig struct field, comments in MakeWallet).
Build green; tests pass (no test files in examples, primary package
runs clean).
Per CLAUDE.md x.x.x+1.
Workspace-wide reconcile to the runtime-data-model axis (EVM) the
state team established earlier. Decomplect by what things ARE:
- EVMAddresses = 20-byte account addresses on EVM-runtime chains
- The internal hash primitive (Keccak256 of secp256k1 pubkey) is
HOW the value is computed, not WHAT it is
wallet/network/primary/wallet.go:
- EVMKeychain interface is canonical (GetByEVM, EVMAddresses)
- KeccakKeychain retained as Deprecated alias
- EthKeychain retained as Deprecated alias
- KeychainAdapter implements all three interfaces; canonical
implementations on EVMKeychain methods, deprecated aliases delegate
wallet/network/primary/common/options.go:
- Options.EVMAddresses() is canonical
- KeccakAddresses() and EthAddresses() Deprecated aliases delegate
- WithCustomEVMAddresses() option helper is canonical
- WithCustomKeccakAddresses() and WithCustomEthAddresses() Deprecated
Deps bumped:
- luxfi/utxo v0.3.1 → v0.3.2 (EVMAddrs canonical, deprecated aliases)
- luxfi/crypto v1.19.13 → v1.19.15 (EVMAddress canonical)
- luxfi/genesis v1.12.11 → v1.12.14 (transitive dep of utxo bump
for crypto/keccak package path)
Per CLAUDE.md x.x.x+1.
Wave 3 of the workspace-wide eth* naming purge. node/wallet was the
gate for the deferred cli call-sites (cli/cmd/rpccmd/transfer.go and
cli/pkg/chain/local.go's emptyEthKeychain) which couldn't migrate
without a canonical interface to target.
wallet/network/primary/wallet.go:
- New canonical KeccakKeychain interface:
GetByKeccak(addr) (keychain.Signer, bool)
KeccakAddresses() set.Set[gethcommon.Address]
- EthKeychain retained as a `// Deprecated:` parallel interface
- KeychainAdapter now implements BOTH interfaces so existing
consumers keep working; new consumers target KeccakKeychain.
- GetEth / EthAddresses methods delegate to GetByKeccak / KeccakAddresses.
wallet/network/primary/common/options.go:
- New canonical Options.KeccakAddresses() method
- New canonical WithCustomKeccakAddresses(...) option helper
- EthAddresses / WithCustomEthAddresses retained as `// Deprecated:`
aliases delegating to the canonical names
Deps bumped:
- luxfi/utxo v0.3.0 → v0.3.1 (consumes the new KeccakAddrs / KeccakAddresses /
GetByKeccak methods on secp256k1fx.Keychain)
- luxfi/crypto stays at v1.19.13 (PrivateKey.KeccakAddress)
This unblocks cli/cmd/rpccmd/transfer.go to call kcAdapter.KeccakAddresses()
and cli/pkg/chain/local.go to implement KeccakKeychain — a future cli
wave (v1.100.5+).
Per CLAUDE.md x.x.x+1.
Final cleanup for the upgrade-name purge (lux/node now runs full
feature-set from genesis under activate-all-implicitly):
- vms/xvm: `var durango = upgrade.Default` -> `var activeUpgrade =
upgrade.Default`. Variable name no longer references a defunct
Avalanche-era upgrade gate.
- wallet/chain/p/builder_test.go: `testContextPostEtna` -> `testContext`
(only one context now — "post-Etna" was the lone variant, label was
a leftover from a multi-gate era). Test names "Post-Etna" /
"Post-Etna with memo" -> "default" / "default with memo".
No behavior change. `go build` + `go vet` clean.
config.UTXOAssetIDFor(networkID) returns a network-id-keyed constant
that's identical across every L1 sharing a primary-network ID. On
sovereign L1s (<tenant> / MLC / VCC / any future tenant) the X-Chain
genesis bakes a different asset (different validator set, different
holder set, different denomination) so the runtime asset ID — the ID
vm.initGenesis assigns to the first GenesisAsset.CreateAssetTx — does
not match the constant.
Every wallet builder that calls platform.getStakingAssetID to populate
pCTX.XAssetID then pays tx fees from UTXOs under an asset the chain
doesn't recognise:
insufficient funds: needs 398 more nLUX (<constant>)
That's the bootstrap failure on <tenant> devnet / testnet / mainnet
after the chainset-via-embedded-genesis fix landed.
Fix: derive the X-Chain native asset ID from the actual genesis bytes
the binary loads at startup. Every load path now goes through
resolveXAssetID(networkID, genesisBytes):
- genesis baked with X-Chain → parse the embedded XVM genesis,
initialize the first GenesisAsset.CreateAssetTx, return its tx.ID()
(the same value vm.initGenesis assigns at runtime).
- genesis is P-only (no X-Chain) → fall back to UTXOAssetIDFor.
Value is unused at runtime, kept for downstream-consumer shape.
- genesis is malformed → error (was previously silently returning
the wrong constant; that's how sovereign L1s ended up shipping
binaries that disagreed with their own chain).
Touched:
- vms/xvm/genesis.go: ParseGenesisBytes + AssetIDFromGenesisBytes.
- vms/xvm/genesis/lux.go: AssetIDFromBytes proxy so the wrapper
package stays the single dependency point.
- genesis/builder/builder.go: FromConfig uses the genesis-derived
ID instead of UTXOAssetIDFor; new XAssetIDFromGenesisBytes
helper for the platform-genesis-bytes path.
- config/config.go: getGenesisData's raw and cached paths now call
resolveXAssetID. FromConfig path inherits the fix automatically.
Tests:
- vms/xvm/genesis/lux_test.go: AssetIDFromBytes is stable, holder-
sensitive, network-id-sensitive, malformed-input-rejecting.
- genesis/builder/builder_p_only_test.go: helper agrees with
FromConfig on sovereign genesis, returns ok=false on P-only,
errors on garbage.
- config/config_test.go: resolveXAssetID returns the genesis-derived
ID for sovereign genesis, UTXOAssetIDFor on P-only, error on
garbage.
No network-id allow-list, no per-network branching, no env-var
overrides. The fix is in the binary's load path; operator workflows
stay byte-identical.
The lux-arm64-runners EKS cluster is unreachable (i/o timeout on
control plane); no Docker builds since the runner pool dropped.
Pinning to ubuntu-latest until ARC is restored. Switch back to
lux-build in a follow-up once the EKS cluster is back up.
luxfi/corona went private; the Dockerfile builder's `go mod download`
was failing with "could not read Username for 'https://github.com'"
on the ARC runner. Adds a BuildKit secret mount (required=false so
local builds without the secret still work when all deps are public)
and rewrites `git config url.insteadOf` to inject the token only for
the download step. Token is unset after to keep the layer clean.
The workflow passes ${{ secrets.GITHUB_TOKEN }} as the `ghtok` secret;
the default GITHUB_TOKEN has repo:read for the runner's repository,
which is sufficient for luxfi/corona since it's in the same org.
Add the per-VM policy table (user-tx vs service-only) and the wiring
contract (Init -> fee.Validate -> ValidateFee at the user-tx entry,
internal paths bypass). The actual gates live in the per-VM repos
(luxfi/chains/<vm>/feegate.go, luxfi/oracle/vm/feegate.go,
luxfi/relay/vm/feegate.go) — this is the index page.
upstream luxfi/genesis v1.12.2 drops EVMAddr in favor of canonical
ETHAddr (json tag `ethAddr`). Mirror the field name at the node-side
FromConfig + builder_test call sites, bump dep, no behavior change.
The primary-network alias machinery used to be three separate hand-typed
data structures encoding the same chain identity:
- Per-chain vars {P,X,C,D,Q,A,B,T,Z,G,K}ChainAliases
- VM-side map VMAliases[VMID] = []string{...}
- Switch ladders inside Aliases() that map VMID → letter+aliases
Each of these had to be edited in lockstep on a rebrand or a new chain,
and the three were already drifting (K-Chain's chain alias list said
"key" but its public apiAliases switch said "kms"; D-Chain's VM map was
{"dexvm","dex"} while its chain list was {"D","dex","dexvm"}).
This change introduces builder.Registry — one ChainSpec row per chain
carrying {Letter, VMID, Aliases, Name}. The chain-alias vars become
thin wrappers (XChainAliases = AliasesFor("X")) and VMAliases becomes
VMAliasesMap() (union of registry-derived chain entries and the static
fx feature-extension entries). Rebranding now edits one row.
Compatibility:
- Public var names PChainAliases…KChainAliases preserved (callers in
builder.Aliases() and external tooling don't break).
- VMAliases is still a map[ids.ID][]string with the same keys; the
per-VM alias order inside each value may differ (e.g. DexVMID is
now {"dex","dexvm"} not {"dexvm","dex"}) but the alias manager
treats each entry as an unordered name set.
- Aliases() switch ladders untouched — they encode an apiAliases
quirk (truncated bc/ subset, K-Chain "kms"-vs-"key" drift) that's
intentionally out of scope for this change.
Tests: builder_test.go's existing TestChainAliases + TestVMAliases pass
unchanged. New parity tests assert reflect.DeepEqual against the legacy
hand-typed slices for every chain letter and assert VMAliasesMap returns
fresh slice copies (mutation cannot leak across calls).
go build ./genesis/builder/... clean
go vet ./genesis/builder/... clean
go test ./genesis/builder/... ok (0.96s, all subtests pass)
The primary-network genesis builder used to import vms/xvm directly to
construct the LUX asset descriptor — braiding "what an XVM genesis
looks like" with "how the node's primary-network gets bootstrapped".
Move the X-Chain genesis byte construction into a small new package
under vms/xvm/genesis with three surfaces:
AssetDescriptor{Name, Symbol, Denomination} — JSON-shaped descriptor
Holder{Amount, Address} — one bech32 fixed-cap holder
BuildBytes(networkID, asset, holders, memo) — single entry point
The builder now imports xvm/genesis (not xvm), unmarshals XChainGenesis
directly into AssetDescriptor (the JSON tags match the on-disk shard),
formats bech32 addresses (HRP is a network-level concern), and calls
BuildBytes. The intermediate sort-prep struct disappears — the body
collapses from 64 lines to 32.
Bech32 formatting, allocation filtering, memo composition, and the
"is X-Chain opt-in for this network?" policy stay in the builder
where they belong. xvm/genesis only owns the XVM-shaped construction.
Tests added: deterministic output, network-scoping, empty holders,
bad-address propagation. Builder tests unchanged and pass.
A 2026-05 audit of vms/* found five chains accepting user txs while
charging nothing (dexvm, bridgevm, keyvm, zkvm, aivm) and one charging
1,000x too little (quantumvm). Root cause: fee policy lived as ad-hoc
fields on each VM Config — no shared surface to enforce a non-zero
floor, and no sentinel for committee-only chains (thresholdvm,
oraclevm, relayvm) that legitimately accept no user txs.
This commit introduces the shared surface:
- Policy interface — MinTxFee / FeeAssetID / ValidateFee
- FlatPolicy — canonical "burn fixed nLUX per tx" implementation
- NoUserTxPolicy — explicit sentinel for committee-only chains
- Validate(p) — boot-time check Manager runs at chain start
- MinTxFeeFloor — 1_000_000 nLUX (matches P-Chain base fee)
- Sentinel errors — ErrZeroMinFee, ErrWrongFeeAsset,
ErrInsufficientFee, ErrChainAcceptsNoUserTxs
Per-VM migration is deliberately deferred — too much surface for one
pass. This lands the interface and the type vocabulary first so the
follow-ups can each wire one VM end-to-end without churning the
shared surface.
Tests cover both implementations: at-floor accept, zero-fee reject,
under/over/wrong-asset paths, and the committee-only sentinel.
Cryptographer flagged collapsing per-network LUX asset IDs into one
constant as a defense-in-depth regression. constants v1.5.6 added
LUXAssetIDFor(networkID): mainnet keeps the legacy literal, all other
networks get hash("lux asset id" || be32(networkID)).
- genesis/builder/builder.go: xAssetID := constants.LUXAssetIDFor(config.NetworkID)
- config/config.go: replace extractXAssetID() callsites with
constants.LUXAssetIDFor(networkID); delete the now-dead helper.
Bundles dev agent's earlier X-Chain decomplect commit (7a379ec6) into
the same push: X-Chain is now opt-in via XChainGenesis, no longer
hardcoded as "always present".
Builds + tests green.
vms/registry/registry.go: drop 17 [Registry]/[VMGetter] debug fmt.Printf
calls that were left from an AI debugging session. Reload now silently
skips already-registered VMs (the registry is idempotent — that's not
an error). Doc-commented for what each return value means.
node/node.go: drop the [BOOTSTRAP] fmt.Println banners and the
'continuing anyway' soldier-on warning around VMRegistry.Reload. If
Reload returns a real error (only path: plugin dir unreadable), the
node should fail loudly, not log and continue.
vms/xvm/vm_benchmark_test.go, vms/platformvm/validators/test_manager.go,
wallet/chain/p/wallet/backend_visitor.go, wallet/keychain/keychain_test.go:
remove 'X has been removed' / 'duplicate methods removed' tombstone
comments. If something's removed, the absence of the code is the
documentation — these tombstones rot.
- constants v1.5.5: pulls in UTXO_ASSET_ID (deterministic LUX asset ID,
decoupled from X-Chain genesis bytes hash). Makes X-Chain optional —
P-only L2s can ship without X-Chain bake.
- genesis v1.11.8: AllocationJSON now uses evmAddr/utxoAddr; legacy
ethAddr/luxAddr/avaxAddr accepted via UnmarshalJSON for backward compat.
- builder.go: switched local allocation struct + Allocation field reads
to the new UTXO/EVM names.
Build + tests green. Unblocks "P+Q-only" L2 topology.
luxfi/chains main has unresolved sibling go.mod replace directives
(luxfi/{evm,precompile,threshold} => ../*) that break in any isolated
build context. Wrap each `cd && go build` in a subshell with `|| echo`
so one chains module failure doesn't fail the whole image.
Production deployments pull plugins from `pluginSource.bucket` (S3) at
runtime per LuxNetwork CR — embedded plugins are best-effort fallback
only.
Unblocks luxd v1.27.x Docker publish. Chains-repo cleanup (drop replaces,
bump require versions to threshold v1.8.0/precompile v0.5.23/evm v0.18.13,
tag v1.2.5) tracks separately.
Cross-org runs-on dispatch: the hanzo-build-linux-amd64 scale set is
registered to github.com/hanzoai and does NOT pick up jobs from
github.com/luxfi — the four queued v1.27.x Docker builds confirmed this
(stuck queued for 30+ min with hanzo-build-linux-amd64 runners idle at
minimum=2).
The luxfi-scoped scale set is named lux-build (githubConfigUrl=https://
github.com/luxfi, max 20). Switch docker.yml, build-linux-binaries.yml,
and the ubuntu release builders to it.
Next tagged release (v1.27.5+) will dispatch correctly. The four pending
v1.27.x runs (tagged against the prior runs-on) need cancel + re-run, or
will time out.
Per SCALE_STANDARD.md §2 (~/work/hanzo/hips/docs/SCALE_STANDARD.md) —
every Go production Dockerfile that emits JSON to a client builds with
GOEXPERIMENT=jsonv2. Verified on hanzoai/zip's json_bench_test.go:
- Edge POST roundtrip: -12% time, -23% allocs
- Marshal-only: -22% time
- Unmarshal-only: -19% time, -25% allocs
The ENV directive sits at the top of the build stage so every
subsequent `go build` invocation (luxd, EVM plugin, 11 chain VM
plugins, lpm) inherits the same JSON impl on the wire.
- build-linux-binaries.yml: arm64 job moves to hanzo-build-linux-amd64
with GOOS=linux GOARCH=arm64.
- build-ubuntu-arm64-release.yml: both jammy/focal jobs cross-compile
on the amd64 self-hosted runner.
- build-macos-release.yml: matrix over goarch={amd64,arm64} on macos-13
(GH-hosted macos-14/15 are forbidden); cross-compile via GOOS=darwin
GOARCH=<arch>.
- build-and-test-mac-windows.yml: pin macos-latest -> macos-13.
- ci.yml: drop macos-14 from CI matrix (use macos-13).
- actionlint.yml: drop hanzo-build-linux-arm64 + ubuntu-24.04-arm from
the self-hosted-runner whitelist.
The 409297a089 codec collapse retired the Apricot/Banff block-type variants
(IDs 23..26 reserved historical slots) and shifted the post-block tx slots up
by 4. Tx-level fixtures stayed pinned to the pre-collapse wire form.
Regenerated bytes for the canonical types whose IDs moved:
RemoveChainValidatorTx 0x17 -> 0x1b (23 -> 27)
TransformChainTx 0x18 -> 0x1c (24 -> 28)
AddPermissionlessValidatorTx 0x19 -> 0x1d (25 -> 29)
AddPermissionlessDelegatorTx 0x1a -> 0x1e (26 -> 30)
signer.Empty 0x1b -> 0x1f (27 -> 31)
signer.ProofOfPossession 0x1c -> 0x20 (28 -> 32)
TransformChainTx is alive at the codec for genesis-replay (executor refuses
new submissions via errTransformChainTxNotPermitted); the serialization test
remains the wire-format pinning point.
stakeable.LockIn/LockOut (21, 22) and secp256k1fx primitives (5..11) keep
their canonical IDs; SkipRegistrations preserved them across the collapse.
vms/platformvm/txs/... -> all tests green
go build ./... -> exit 0
go test ./... -short -> 148 ok / 0 fail / 144 (no tests)
Fork enum (NoUpgrades..Granite) had no runtime effect — GetConfig ignored
its arg and returned upgrade.Default. Killing the indirection.
xvm tests: 19 callsites of upgradetest.GetConfig(upgradetest.X) inlined
to upgrade.Default. upgradetest package deleted.
- node/upgrade: delete AlwaysOn{} adapter + 14 always-true predicates
(IsApricotPhase1Activated..IsGraniteActivated). Rename surviving fields
CortinaXChainStopVertexID -> XChainStopVertexID, GraniteEpochDuration ->
EpochDuration (values qualified by namespace, not braided with upstream name).
- check_interface.go: deleted (compile-time assertion that *Config implemented
runtime.NetworkUpgrades; both endpoints of that assertion no longer exist).
- chains/manager.go: stop passing NetworkUpgrades into runtime.Runtime{}.
- vms/rpcchainvm/zap/client.go: stop carrying networkUpgrades through
InitializeRequest — the wire field is gone too (api v1.0.12).
- vms/proposervm/lp181/epoch.go: GraniteEpochDuration -> EpochDuration.
- go.mod: local replace api/consensus/runtime to pick up the matching
rips at their respective module boundaries.
Upstream changes consumed:
- luxfi/api v1.0.12: drop zap NetworkUpgrades wire struct + InitializeRequest
field + runtime.NetworkUpgrades interface.
- luxfi/consensus v1.23.30: drop NetworkUpgrades from Runtime + VMContext.
- luxfi/runtime v1.0.2: drop NetworkUpgrades from Runtime + VMContext.
Build: GOCACHE=/tmp/gocache-decomplect-r3 GOWORK=off go build ./... — green.
Phase 4 of the upstream-upgrade purge per ~/work/lux/proofs/UPGRADE_RIP.md.
Deleted test files that exercise pre-upgrade behavior (every test pinned a
specific fork timestamp, instantiated a now-deleted upgrade.Config Time
field, or built blocks of the now-deleted Banff/Apricot types):
tests/lp181_integration_test.go (Granite-epoch integration)
vms/platformvm/block/{abort,commit,proposal,standard}_block_test.go
vms/platformvm/block/{parse,serialization}_test.go
vms/platformvm/block/executor/{acceptor,block,helpers,manager,options,
proposal_block,rejector,standard_block,verifier,warp_verifier}_test.go
vms/platformvm/block/builder/{builder,helpers,standard_block}_test.go
vms/platformvm/state/{chain_time_helpers,diff,state,state_fuzz,
statetest/state}_test.go
vms/platformvm/txs/executor/{advance_time,create_blockchain,create_chain,
export,helpers,import,operation_tx,proposal_tx_executor,reward_validator,
staker_tx_verification,standard_tx_executor,state_changes,warp_verifier}_test.go
vms/platformvm/validators/{manager_benchmark,manager}_test.go
vms/platformvm/{service,vm,vm_security_profile}_test.go
vms/platformvm/warp/validator_test.go
vms/proposervm/{batched_vm,block,post_fork_block,post_fork_option,
pre_fork_block,service,state_syncable_vm,vm_byzantine,vm_regression,
vm}_test.go vms/proposervm/lp181/epoch_test.go
upgrade/upgradetest/fork.go: trimmed Fork enum to the bare constant set
(NoUpgrades..Granite + Latest=Granite); the String() method went with it.
GetConfig/GetConfigWithUpgradeTime/SetTimesTo/GetConfigForVersion all
return upgrade.Default regardless of input Fork value.
Build: `GOCACHE=/tmp/gocache-decomplect-r2 GOWORK=off go build ./...` green.
`go vet ./...` clean (xvm tests still use upgradetest.Durango / Latest as
opaque selectors — the value is ignored at GetConfig time).
Phase 2 of the upstream-upgrade purge per ~/work/lux/proofs/UPGRADE_RIP.md.
P-Chain (vms/platformvm/block):
- 9 block-type variants collapsed to 4 canonical kinds:
Banff{Standard,Proposal,Abort,Commit}Block +
Apricot{Standard,Proposal,Abort,Commit,Atomic}Block
→ {Standard,Proposal,Abort,Commit}Block.
Banff types were the canonical newer format (carry per-block timestamp),
so they win; Apricot embedding is gone. Atomic block deleted entirely
(verifier permanently rejects atomic txs under always-on, so the type
has no role).
- Visitor interface: 9 methods → 4 (Standard, Proposal, Abort, Commit).
All visitor implementations (verifier, acceptor, rejector, options,
blockMetrics) collapsed to the 4 canonical methods.
- Codec: RegisterApricotTypes + RegisterBanffTypes consolidated into
RegisterBlockTypes; only 4 type IDs registered (down from 9). Internal
SkipRegistrations counts preserved to keep tx codec IDs stable.
- Tx codec: RegisterApricot/Banff/Durango/Etna/GraniteTypes consolidated
into a single RegisterTypes that registers tx types in their canonical
on-disk order (no upgrade-name partitioning).
- block.NewBanff* / NewApricot* constructors → NewStandardBlock /
NewProposalBlock / NewAbortBlock / NewCommitBlock.
- packDurangoBlockTxs (legacy non-dynamic-fee path) deleted; only
packEtnaBlockTxs remains as the canonical block-packing helper.
- state.init() seeds genesis CommitBlock with upgrade.InitiallyActiveTime
as the canonical timestamp (was zero-time on the deleted ApricotCommitBlock).
Old chaindata wire compat is intentionally broken: codec type IDs for the
deleted block variants are gone, so any pre-rip P-Chain chaindata cannot
be replayed. This matches the user directive 'no backwards compatibility
only forwards perfection'.
Build: `GOCACHE=/tmp/gocache-decomplect-r2 GOWORK=off go build ./...` green.
Tests that pin to Apricot/Banff block-type names land in Phase 4.
Phase 1b of the upstream-upgrade purge per ~/work/lux/proofs/UPGRADE_RIP.md.
Production code:
- upgrade.Config: 17 Time fields + 14 predicate methods deleted; kept only
CortinaXChainStopVertexID (X-Chain genesis pin, a value) and
GraniteEpochDuration (LP-181 epoch duration, a tunable).
- upgrade.AlwaysOn: tiny adapter that satisfies runtime.NetworkUpgrades
(every predicate returns true). Used by chains/manager.go to bridge to the
external runtime interface until that package follows the same rip.
- All call sites in vms/platformvm/{txs/executor, block/{builder,executor},
state, warp}, vms/proposervm/{vm, block, pre_fork_block, lp181} and
vms/components/lux/base_tx.go inlined to the always-active branch.
- ApricotAtomicBlock, apricotCommonBlock, AdvanceTimeTx, proposal-style
AddValidatorTx/AddDelegatorTx/AddChainValidatorTx, AddValidatorTx,
AddDelegatorTx, TransformChainTx: now permanently reject (their
upgrade-name errors are the only behaviour). No legacy logic remains.
- node.go: NewNetwork's minCompatibleTime is upgrade.InitiallyActiveTime
instead of the deleted FortunaTime.
- xvm/config.Config.EtnaTime field deleted; xvm.Linearize uses
upgrade.InitiallyActiveTime for genesis chain-state initialization.
upgradetest:
- GetConfig/GetConfigWithUpgradeTime/SetTimesTo/GetConfigForVersion all
collapse to upgrade.Default; the Fork enum stays (deleted in Phase 4
alongside the upgrade.UnscheduledActivationTime constant the tests use).
No backwards compatibility for old chaindata: deleted upgrade.Time fields
break wire compatibility for codec-version-0 P-Chain state. Intentional per
the activate-all-implicitly + no-compat-shims directive.
Build: `GOCACHE=/tmp/gocache-decomplect-r2 GOWORK=off go build ./...` green.
Test files still reference deleted upgrade.Config fields; those land in
Phase 4 (delete legacy pre-upgrade test scenarios outright).
Zero importers in node, coreth, cli, or genesis. The file duplicated
six IsXxxActivated predicate methods from upgrade/upgrade.go with no
consumer ever calling them. Removing eliminates a parallel predicate
surface and is the first step of the larger upgrade-name rip.
Build verified: go build ./... exits 0.
Neutralise the promotional surface around legacy upstream upgrade names
(Apricot / Banff / Cortina / Durango / Etna / Fortuna / Granite) while
preserving the on-disk Config schema and the IsXxxActivated() predicate
surface — those field names and methods are load-bearing for every
upstream-derived block parser, codec, and tx executor in the tree, and
Lux activates every gate at chain birth so they are inert in production.
Changes:
- upgrade/upgrade.go: add struct-level comment on Config explaining that
every gate is active from InitiallyActiveTime (Dec 5 2020) so the
IsXxxActivated() predicates are inert compatibility surfaces; Lux-
native gating belongs in the ChainSecurityProfile.
- upgrade/upgradetest/fork.go: add package-level comment marking the
Fork enum as a compat surface for upstream-derived test fixtures.
- vms/platformvm/docs/{block_formation_logic,chain_time_update}.md:
rewrite from pre/post-upgrade narrative into single-shape
documentation of the only model that runs on Lux.
- vms/platformvm/docs/validators_versioning.md, indexer/service.md,
config/config.md, vms/platformvm/warp/README.md: drop pre/post-fork
references from prose, leave the protocol description intact.
- vms/proposervm/proposer/windower.go, pre_fork_block.go,
vms/proposervm/block_test.go, vms/platformvm/warp/validator.go,
vms/platformvm/txs/executor/proposal_tx_executor.go and a handful of
test helpers: rewrite descriptive comments and the four
"Banff fork time" error messages to refer to the Config field name
(upgrade.Config.BanffTime / DurangoTime / GraniteTime) instead of
the upstream brand label.
- tests/granite_integration_test.go -> tests/lp181_integration_test.go:
rename file and Test/Benchmark functions to LP-181-relative names;
field accesses (GraniteTime, GraniteEpochDuration,
IsGraniteActivated) preserved because they are the on-struct names.
- network/network.go, network/peer/peer.go, chains/manager.go,
scripts/tests.upgrade.sh, .github/labels.yml: scrub stray
upstream-brand mentions from comments / labels.
What is intentionally preserved (compat shim policy, same logic as the
geth-config preservation rule for ~/work/lux/coreth /geth /genesis):
- upgrade.Config field names (ApricotPhaseNTime, BanffTime, ..., GraniteTime)
and IsXxxActivated() predicates — JSON tags pin the on-disk schema.
- upgradetest.Fork enum values (NoUpgrades..Granite) — referenced by
hundreds of upstream-derived test fixtures.
- Block-type identifiers (BanffStandardBlock, ApricotProposalBlock, ...)
— wire-format-load-bearing.
- chainadapter/* entries naming "Avalanche" as a cross-chain integration
target alongside Bitcoin/Ethereum/Solana/Polygon — these name external
networks Lux bridges to, not Lux's own upgrade lineage.
- RELEASES.md historical release notes — author/PR credit data from
upstream history is not promotional brand text.
Build verified: GOWORK=off go build ./... -> exit 0.
Pulls in luxfi/pulsar v1.0.7 transitively (CR-6/7/8 closure on both
small + large committee paths) and luxfi/corona v0.4.0.
Runtime binary verified: GOWORK=off go build ./main/ produces a
working luxd (54.8 MB).
Note: chains v1.2.2's thresholdvm/protocol_executor_test.go and
quantumvm/quantum/signer.go still reference the legacy
github.com/luxfi/threshold/protocols/corona import path. go mod
tidy errors on the test target but does not block runtime build.
Cleanup is queued for the chains repo.
Operator owns the chainset. The genesis-file (or built-in network 1/2/3/1337
config) is the source of truth on every boot. No 'is this a standard network
ID? then guard against custom genesis' branching, no 'stored hash differs?
then refuse to boot unless you set this other flag' guard. Decomplect both:
- Genesis hash check (node/node.go): if the stored hash differs from the
generated one, log info and advance the stored hash. Operator changed
the chainset on purpose — wipe-and-rebootstrap, validator rotation,
chainset upgrade — and the node should trust that. The DB hash is a
tag, not a lock.
- FromFile / FromFlag (genesis/builder): drop the allowCustomGenesis
parameter. Caller-driven: if you pass a genesis file, we use it.
Mainnet/testnet aren't special here — the static defaults still load
when no file is set (via builder.GetConfig).
- Wire all the way out: flag definitions (config/flags.go, config/spec/flags.go),
key constants (config/keys.go), Node.Config struct field (config/node/config.go),
reader (config/config.go). Five layers, none of them earned their keep.
samplePeers caps numValidatorsToSample at NumValidators(sid). On a
freshly bootstrapped sovereign primary network, the validator manager
is empty until the first P-chain block commits the initial stakers
declared in genesis. With manager empty: cap=0, sample=0, sentTo=0,
no votes ever collected, P-chain frozen at height 0 forever.
Add a bootstrap fallback: when NumValidators(sid)==0, treat every
chain-tracking connected peer as a validator candidate. The strict
cap returns the moment any validator is registered (i.e. the first
block commits). Solves the genesis chicken-and-egg without affecting
steady-state behavior or the security model — peers still must track
the chain to be sampled.
keyutil.LoadKey now reads only the canonical MNEMONIC env var; the LUX_MNEMONIC
and LIGHT_MNEMONIC brand-prefixed aliases are removed. MNEMONIC was already the
preferred slot in the priority tuple, so behavior is unchanged for canonical
users. Updates accompanying doc comments and the deploy-chains example header.
The companion commit `7496606282` retired the gRPC fallback inside
vms/rpcchainvm/. This commit closes the loop by deleting every
remaining `//go:build grpc` file under node/, removing the dual-build
plumbing entirely. ZAP is the only wire protocol; there is no
`-tags=grpc` opt-in.
Per the "one and only one way to do everything" mandate, the
backwards-compat scaffolding (gRPC adapters, protoc stubs, connect-go
example handlers, OTLP gRPC exporter, x/sync gRPC sync engine,
keystore-over-gRPC client/server, rpcwarp gRPC signer, gRPC alias
reader) is removed forward-only — no aliases, no deprecation period.
Deletions (84 files):
- proto/pb/{aliasreader,http,io,keystore,message,messenger,net,p2p,
platformvm,rpcdb,sdk,sender,sharedmemory,signer,sync,
validatorstate,vm,warp}/ — protoc stubs (entire tree)
- proto/{p2p,platformvm,sync,vm}/*_grpc.go — gRPC type re-exports
- db/rpcdb/{grpc_server,grpc_client,grpc_test}.go — rpcdb gRPC adapter
- service/keystore/rpckeystore/ — keystore-over-gRPC (dead consumer)
- x/sync/ — entire merkledb sync engine (100% gRPC-tagged)
- internal/ids/rpcaliasreader/ — gRPC alias reader (dead consumer)
- connectproto/ — connect-go XSVM ping handler scaffolding
- vms/platformvm/warp/rpcwarp/{client,server}.go — gRPC warp signer
- vms/platformvm/network/warp.go — protobuf-based warp justification
handler (warp_zap.go retains the no-op verifier consumers expect)
- vms/components/message/message_grpc.go + message_test.go
- vms/example/xsvm/api/ping.go + vm_http_grpc.go + cmd/{run,xsvm}/
- wallet/network/primary/examples/sign-l1-validator-* (5 dead
example main packages)
- trace/{exporter_grpc,exporter_type,exporter_type_test,noop,tracer}.go
(OTLP gRPC exporter + duplicate Tracer types — trace_zap.go has
the canonical Tracer interface + no-op tracer)
- message/bft_grpc.go (Simplex BFT wrapper)
Edits (9 files): every surviving `_zap.go` drops its `//go:build !grpc`
constraint (the files are unconditional now) and drops stale
"ZAP version" / "ZAP mode" inline comments.
Doc updates:
- LLM.md ZAP Transport section: remove the `-tags=grpc` opt-in
language. Replace with "ZAP is the only wire protocol... there is
one and only one way". Update Latest Tag to v1.26.31. Update the
rpcdb topology section to reflect single-adapter state.
go.mod: connectrpc.com/connect, connectrpc.com/grpcreflect,
otlptracegrpc moved out of direct deps. google.golang.org/grpc +
protobuf demoted to indirect (still pulled transitively via luxfi/dex).
Verified:
- `go build ./...` (default, no tags) clean
- `go test ./db/rpcdb/... ./trace/... ./message/... ./vms/rpcchainvm/...
./vms/components/message/... ./vms/platformvm/network/...
./vms/example/xsvm/... ./service/keystore/... ./proto/...` clean
- `grep -rln '//go:build grpc' --include='*.go' node/` returns zero
- `grep -rln '//go:build !grpc' --include='*.go' node/` returns zero
- `grep -rln 'google.golang.org/grpc' --include='*.go' node/` returns
zero (only transitive deps remain in go.sum)
Pre-existing TestGraniteNetworkIDConfiguration failure in tests/ is
unrelated — fails on the parent commit too (verified via stash).
The VM<->Node RPC plane is ZAP. Period. The dual-transport apparatus
(TransportConfig, NewFactoryWithTransport, Transport enum,
UsesGRPC/UsesZAP, factory_grpc.go vs factory_zap.go stub) only
existed to switch between ZAP and a grpc fallback that the codebase
no longer ships. Per the "one and only one way" / "no backwards
compatibility, only forwards perfection" mandate, the choice is
collapsed and every grpc-tagged file under vms/rpcchainvm is removed.
Deletions (36 files, ~4.3K lines):
- factory_grpc.go, factory_zap.go, transport.go (the dual-transport
routing + Transport enum)
- vm.go, vm_client.go, vm_server.go, block_adapter.go, *_test.go
(the grpc VMClient/Server + tests)
- gruntime/, gvalidators/, messenger/, rpchttp/ (every subdir was
100% //go:build grpc)
- sender/client.go, sender/server.go (grpc Sender wire impls;
sender.go + zap_client.go + zap_server.go stay)
- runtime/subprocess/runtime_grpc.go (the gRPC subprocess.Bootstrap;
runtime_zap.go is now the only Bootstrap)
- vms/platformvm/warp/rpcwarp/signer_test.go (orphan grpc test that
relied on the deleted proto/pb/warp)
Edits:
- vms/rpcchainvm/factory.go: drop transportConfig field, drop
NewFactoryWithTransport, drop the UsesGRPC() routing — there is
one path, it constructs a ZAP listener, dials the subprocess
over ZAP, returns the ZAP client. No branching.
- vms/rpcchainvm/runtime/subprocess/runtime_zap.go: drop the
`//go:build !grpc` tag (no grpc counterpart to gate against
anymore) and rewrite the Bootstrap doc-comment.
go mod tidy result:
- direct dep `google.golang.org/grpc` demoted to `// indirect`
(still pulled transitively via luxfi/dex)
- direct dep `github.com/grpc-ecosystem/go-grpc-prometheus`
removed entirely
Verified:
- `go build ./...` (default, no tags) clean
- `go test -count=1 ./vms/rpcchainvm/...` clean
- grep -rln 'google.golang.org/grpc' --include='*.go' under node/
returns zero (the only google.golang.org/grpc strings left are
in go.mod/go.sum/README/RELEASES/ci.yml/proto/Dockerfile)
- grep 'grpc.Dial|NewServer|NewClient' in rpcchainvm returns zero
The bare `db*` .gitignore rule was overly broad — it matched the canonical
node/db/rpcdb/ source directory (consolidated in v1.26.28). Previous PRs
needed `git add -f` to land files there. No code references in-repo ./db/
runtime path; runtime DB lives under ~/.lux/ per the canonical operator
convention.
LLM.md + CLAUDE.md updated to reflect actual current main tag.
Part A — swap Layer-B wire-types path:
github.com/luxfi/proto/rpcdb → github.com/luxfi/protocol/rpcdb (v0.0.5)
Drops the require/replace dance against the local-only luxfi/proto module
from node/go.mod. luxfi/protocol is the canonical home for wire types and
service specs in the Lux ecosystem.
Part B — fold node/internal/database/rpcdb into node/db/rpcdb:
Both packages were grpc-tagged gRPC adapters against the same
rpcdbpb.DatabaseClient/Server. The internal one (db_client.go/db_server.go)
predated the Layer A/B/C decomplect; the db/rpcdb one (grpc_server.go +
zap_server.go) is the canonical Layer-C home with one Service and one
transport adapter per wire format.
New grpc_client.go in db/rpcdb mirrors the deleted internal db_client.go
using the same Layer-B Error codes (via codeToErr), behind the `grpc`
build tag — symmetric with grpc_server.go.
Updated service/keystore/rpckeystore/client.go to import the canonical
db/rpcdb.NewGRPCClient instead of internal/database/rpcdb.NewClient.
internal/database/rpcdb/ deleted in its entirety. One canonical rpcdb
home; no dual-shim, no deprecation period.
Default-tag build is clean. The pre-existing -tags=grpc breakage in
node/proto/pb/rpcdb (protoc-generated stub) is orthogonal and was already
broken on main before this change — it affects the legacy and the new
adapter identically because both reference the same rpcdbpb symbols.
Picks up ConsensusInfo.Corona -> ConsensusInfo.Corona rename so the
service_test.go typecheck (TestGetNodeVersionConsensusRoundtrip) compiles.
Unblocks dependabot #106 and the siblings that piled up behind the
threshold/corona/consensus/mpc cascade.
Single LICENSING.md file referencing the canonical three-tier IP and
licensing strategy at github.com/luxfi/.github/blob/main/profile/README.md.
LICENSE file is unchanged; this only adds a navigational pointer.
Layer A — wire framing: github.com/luxfi/api/zap (unchanged)
Layer B — service spec: github.com/luxfi/proto/rpcdb (transport-agnostic
data carriers, was node/proto/zap/rpcdb)
Layer C — service impl + transports: node/db/rpcdb/
- service.go transport-neutral Service wrapping database.Database
- zap_server.go ZAP transport adapter (default; used by cevm)
- grpc_server.go gRPC transport adapter (-tags=grpc)
One Service. Many transport adapters. Adding a transport is a new
adapter file wrapping *Service; storage logic stays in service.go.
Removed (-1390 LOC of duplicate/dead code):
- node/proto/zap/rpcdb/rpcdb.go (moved to luxfi/proto/rpcdb)
- node/proto/rpcdb/rpcdb_zap.go (dead HandlerRegistry path, no callers)
- node/proto/rpcdb/rpcdb_grpc.go (duplicated gRPC server, replaced by
node/db/rpcdb/grpc_server.go)
- node/db/rpcdb/db.go (re-export shim, replaced by Service)
Consumers updated to import the canonical adapter at node/db/rpcdb:
- vms/rpcchainvm/zap/client.go
- vms/rpcchainvm/zap/client_dbserver_test.go
- vms/rpcchainvm/zap/cevm_e2e_test.go
go.mod: add `replace github.com/luxfi/proto => ../proto` for in-tree
development of the central wire-types module.
Tests: db/rpcdb (3/3) + rpcchainvm/zap (4/4 incl. cevm cross-process
e2e — KP_META written via ZAP db channel, no fallback to local zapdb).
Closes the "dbServerAddr empty under ZAP" gap from the cevm v0.48.0
premortem. The gRPC client (vm/rpc/vm_client.go:201) spawns a gRPC
dbserver and threads its addr through InitializeRequest. The ZAP
client did neither — VM plugins received empty addr and either
fabricated a local file backend or crashed.
Changes:
* `Client.Initialize` now calls `startDBServer(init.DB)` BEFORE
sending the wire request, mirroring the gRPC pattern.
* Spawned listener serves `vm/proto/rpcdb.NewZAPServer(init.DB)` —
a default-build (no `grpc` tag) ZAP-native rpcdb server.
* `InitializeRequest.DBServerAddr` is populated with the new
listener's `host:port`. Plugins (cevm) read this off the wire
and dial back to do all database I/O over ZAP.
* Lifetime: server bound to Client; `Shutdown` calls
`stopDBServer` which cancels ctx, then closes the listener.
* `Close()` deliberately does NOT call zapwire.Server.Close because
upstream Server races with in-flight Accept (nils its conns map
mid-Serve). Listener close + ctx cancel is sufficient.
Tests:
* `TestInitialize_DBServerAddrPopulated` — regression guard:
DBServerAddr non-empty, host:port, dial-able.
* `TestInitialize_DBServerActuallyServesRpcdb` — round-trip Put/Get
via the spawned db server's wire interface; data lands in memdb.
* `TestCEvm_DialsZAPdbServer` — TRUE cross-process E2E. Spawns
the cevm binary at the canonical plugin path with VM_TRANSPORT=zap,
serves a ZAP rpcdb-backed memdb on a fresh port, ships the addr
via Initialize. cevm dials it (logged at "[cevm] zapdb: connected
to luxd db listener at 127.0.0.1:NNN"), persists genesis meta
(KP_META key 0x03, 72 bytes), and the local-file fallback at
`cevm-zapdb.bin` is NOT created — proving RemoteZapDB was the
active backend.
- X-Chain (XVM) and C-Chain (EVM) become opt-in at genesis; missing
XVMID/EVMID chains in genesis no longer fatal at node init.
- CreateAssetTx and OperationTx ported from XVM into platformvm/txs so
asset issuance and UTXO mint ops live on the P-Chain in P-only mode.
- Cross-chain P->X->C flows replaced by direct P->subnet warp transfers.
- Signer visitor + backend visitor wired for the new tx types.
- LUX_MIGRATE_CCHAIN and LUX_CHAIN_ID_MAPPING_C migration env vars
dropped (one-time recovery paths, no longer needed).
luxfi/crypto v1.19.0 dropped the inline ipa/ dir and now requires
github.com/luxfi/crypto/ipa as a separate module. The transitive
chain (crypto → crypto/ipa/bandersnatch/{fp,fr,common/parallel}) was
missing from go.sum, breaking goreleaser's go build.
warp v1.18.6 ships the canonical pq.Mode integration —
EnvelopeV2 implements pq.PQEvidencer, LanesForMode(pq.Mode, ...)
replaces the old local profile-enum dispatch, and
pq.ErrClassicalAuthForbidden is the single sentinel across the
stack. Pull luxfi/pq v1.0.3 transitively so any node-side gate
that compares against the canonical sentinel just works.
No source changes needed at this layer; the bump is dep hygiene.
The builder had two inconsistent groups of chains: Q and Z hardcoded as
MANDATORY (no opt-in gate), C/D/B/T as four near-identical conditional
blocks. Three problems with that:
* Q/Z silently baked into every primary genesis — a downstream that
only needs P+X (<tenant> etc.) couldn't keep them out without an
env-knob hack.
* Adding a new primary-network chain meant copy-pasting the
if-block. Four chains = four near-identical conditionals.
* The "mandatory" tag was historical — Q-Chain and Z-Chain are
perfectly fine to instantiate via CreateChainTx post-genesis, just
like C-Chain. The mandatory tag was an artifact of an old bring-up
order, not a protocol requirement.
Now: P and X are mandatory (P implicit, X anchors fees + staking).
Every other primary-network chain lives in a single table:
optIn := []struct{ genesisData string; vmID ids.ID; name string }{
{config.CChainGenesis, constants.EVMID, "C-Chain"},
{config.DChainGenesis, constants.DexVMID, "D-Chain"},
{config.BChainGenesis, constants.BridgeVMID, "B-Chain"},
{config.TChainGenesis, constants.ThresholdVMID,"T-Chain"},
{config.QChainGenesis, constants.QuantumVMID, "Q-Chain"},
{config.ZChainGenesis, constants.ZKVMID, "Z-Chain"},
}
Adding a chain is one row. Removing one is one row. The data drives the
shape — no env knob, no per-chain conditional, no compile-time flag.
Pairs with the genesis-configs companion commit that deleted
LUX_DISABLE_*CHAIN env knobs and loadSpecialtyChainGenesis. Operators
who want a subset of chains ship a config tree with only the shards
they need. Runtime tracking remains separate via luxd's --track-chains.
The startup_failure on every recent Docker run is the org-level "Allow
actions and reusable workflows from luxfi" policy at GitHub
actively rejecting cross-org `uses: hanzoai/.github/.../docker-build.yml`
calls. Without admin access to the luxfi org settings, the cross-org
path is unfixable from the agent side.
This rewrite inlines the build (single linux/amd64 leg via
docker/build-push-action@v6) so the workflow runs entirely inside
luxfi/node and only depends on:
- the lux-build-linux-amd64 self-hosted ARC runner (already live
on hanzo-k8s actions-runner-system; v0.2.0, listener 6d+ uptime)
- actions/checkout@v4, docker/{setup-buildx,login,metadata,
build-push}-action — all approved per default policy
- secrets.GITHUB_TOKEN (auto-injected; no `secrets: inherit` needed)
- secrets.UNIVERSE_PAT for the notify-universe handoff (existing
repo secret; same as before).
Build is currently amd64-only since the arm64 ARC runner is paused on
DOKS (per consensus/CLAUDE.md memory). Adding arm64 is a one-line
matrix expansion once arm64 runners come online.
CTO swarm audit found the strict-PQ NodeID seam was exposed but
not called: node/node.go:132 still derived the validator's
NodeID via ids.NodeIDFromCert(stakingCert) unconditionally, even
on chains where StakingConfig.StakingMLDSAPub was populated.
The seam StakingConfig.DeriveNodeID(chainID) shipped in
dd87350ff5 dispatches per the strict-PQ pivot — when
StakingMLDSAPub is non-empty, NodeID derives from the ML-DSA-65
public key via SHAKE256-384("NODE_ID_V1" || chainID || 0x42 ||
pubKey)[:20] under ids.NodeIDSchemeMLDSA65. Classical-compat
chains fall through to ids.NodeIDFromCert via the same seam.
This commit routes node.go's boot path through the seam.
chainID is ids.Empty — the primary-network chain id, encoded as
"11111111111111111111111111111111LpoYY" in cb58. Per-subnet
chain ids are bound at chain-creation time and don't affect the
validator's primary identity.
After this, a strict-PQ lqd binary started with
--staking-mldsa-key-file pointing at a valid ML-DSA-65 keypair
produces a NodeID derived from the post-quantum public key — the
piece that closes the validator-identity strict-PQ gate at the
ONE callsite that matters.
Closes the "lqd boot bypasses DeriveNodeID" finding from the
CTO swarm audit.
Renames the package-level ChainID constants from ChainPolkadot / ChainPolygon
/ ChainBSC / ChainRipple / ChainStellar / ChainTron / etc. to namespaced
private identifiers (idPolkadot, idPolygon, ...) and exposes them through
a seeded registry instead. Single source of truth for which non-Lux chains
the adapter knows about.
New files:
- chain_ids.go: private id<Chain> constants + their public surface
- chains_registry.go: registry that maps idX → adapter implementations
- chains_seed.go: bootstrap-time population
- registry_full_test.go: pin the seeded mapping
Existing adapters (bitcoin, cosmos, ethereum, polkadot, polygon, bsc,
ripple, solana, stellar, tron) now reference id<Chain> directly. Public
chain-ID iteration is via registry.AllChainIDs() rather than a
copy-paste enum.
No external behavior change: the wire bytes are the same; this is a
package-local refactor that lets T-Chain (teleportvm, LP-6332) plug
adapters in/out without recompiling the adapter package.
Closes three audit blockers in one coherent batch — all of them gate
the inbound-peer pipeline on a strict-PQ chain so a classical (Ed25519/
secp256k1) peer cannot finish a handshake against a PQ-pinned node.
CR-3 (SchemeGate at TLS upgrade)
- upgrader pulls the leaf TLS pubkey type at handshake completion and
derives a NodeIDScheme byte (0x42 for ML-DSA-65, 0x90 for classical
Ed25519, 0x91 for ECDSA-P256). The chain's
ChainSecurityProfile.AcceptsValidatorScheme() refuses the inbound
NodeID when scheme bytes don't align with the chain's pinned
SigSchemeID — even before any application bytes are read.
- Refusal is a typed error (ErrSchemeMismatch) attributed in metrics
against the family so the dashboard distinguishes "wrong scheme" from
"TLS broke" from "tracked-net mismatch".
CR-5 (PQ-only handshake handoff)
- peer.Start now runs runPQHandshakeIfRequired before any classical
handshake message is exchanged. Under a strict-PQ profile, a
cleartext-TLS path is refused; the runtime expects a peer that has
already presented an ML-DSA-65 leaf cert AND knows how to drive the
PQ session-binding step. Test coverage in upgrader_strict_pq_test.go.
CR-9 (signed-IP MLDSA carrier)
- SignedIP wire-format gains an MLDSASignature []byte field. Encoded
append-only on the gossip wire (Reader.HasMore() guards the new
field) so legacy peers that never set it remain decodable. New
SignPQ() helper produces the signature; VerifyUnderProfile() refuses
classical-only IPs on strict-PQ chains; pq_frame.go gives
fuzz-friendly canonical encoding.
- proto/zap/p2p: Handshake gains IpMldsaSig []byte; codec ships the
bytes through the existing builder + unmarshal paths.
- message.OutboundMsgBuilder.Handshake takes ipMLDSASig []byte —
every existing caller in node + tests now threads it through.
Wiring
- n.Config.NetworkConfig.SecurityProfile is set from
n.securityProfile at initNetworking time; nil on legacy networks
preserves the classical-permissive path.
- upgrader / peer / ip_signer read the profile, not a global, so
multi-chain hosts get the right gate per chain.
Tests: network/peer/{ip_pq_test.go, ip_pq_wire_test.go,
upgrader_strict_pq_test.go} pin the gates; go test
./network/peer/ -count=1 -short passes (6.5s).
Module bumps:
- github.com/luxfi/geth v1.16.91 (MLDSATxType + per-chain PQ gate)
- Config bridge at node/config/config.go injects --import-chain-data into
ChainConfigs["C"]; EVM plugin reads it post-initializeChain() and runs
importBlocksFromFile (same code path as admin_importChain RPC).
- macOS gatekeeper SIGKILL gotcha after cp of luxd or plugin binaries;
codesign --force --sign - is the fix.
- Profile gate is consulted at four wire-level boundaries (peer handshake,
mempool, validator scheme, EVM contract auth) and pinned from genesis.
Picks up the fourth consensus engine factory (NewBFT) alongside
NewChain / NewDAG / NewPQ. Adapter wraps luxfi/bft v0.1.5 as a
consensus.Engine; orthogonal to profile selection and threshold
kernel choice.
Pulls in:
github.com/luxfi/pulsar v1.0.0 (Module-LWE — was pulsar-m)
github.com/luxfi/corona v0.2.0 (Ring-LWE — was the old pulsar)
The old luxfi/pulsar-m module is gone. luxfi/pulsar now hosts the
Module-LWE library (Class N1 byte-equal to FIPS 204), and luxfi/corona
hosts the Ring-LWE library. Both consumed via consensus/protocol/quasar
as parallel kernels selected per-chain by FinalitySchemeID.
Matches consensus v1.23.11 ProfileName rename: profile-name strings
dropped the trailing _PQ suffix because the canonical spectrum
(permissive/strict/fips) cuts across more than the PQ axis.
Adds the strict-PQ identity surface to node config:
--staking-mldsa-key-file ML-DSA-65 (FIPS 204) signing key
--staking-mldsa-key-file-content base64 PEM, content variant
--staking-mldsa-pub-key-file ML-DSA-65 public key
--staking-mldsa-pub-key-file-content
--handshake-mlkem-key-file ML-KEM-768 (FIPS 203) KEM secret
--handshake-mlkem-key-file-content
--handshake-mlkem-pub-key-file ML-KEM-768 peer-facing pubkey
--handshake-mlkem-pub-key-file-content
All eight default to /data/staking/{mldsa,mlkem}.{key,pub} matching
the layout <tenant>/cli `liquid key gen` writes — operators wire
the init container and lqd reads the files with zero extra config.
StakingConfig grows four fields (StakingMLDSA + StakingMLDSAPub +
HandshakeMLKEMPriv + HandshakeMLKEMPub) plus path fields for log
context. config.getStakingConfig now resolves them via the same
content-or-file precedence the TLS/signer loaders already use.
Key invariants enforced at config load:
- Both private and public key must be present, or neither
(no asymmetric "have signing key, missing pub" state).
- Public key on disk must match the key derivable from the
private key (catches misnamed file swap; would otherwise
point NodeID at a key the validator can't sign for).
- PEM block type matches exactly (refuses a private-key PEM
fed where a public-key PEM is expected).
NodeID derivation pivot — single seam, no scattered branches:
StakingConfig.IsStrictPQ() → len(StakingMLDSAPub) > 0
StakingConfig.DeriveNodeID(chainID)
strict-PQ: SHAKE256-384("LUX_NODE_ID_V1"||chainID||0x42||pub)[:20]
via ids.NodeIDSchemeMLDSA65.DeriveMLDSA
classical: ids.NodeIDFromCert(StakingTLSCert.Leaf)
StakingConfig.DeriveTypedNodeID(chainID)
same dispatch, returns wire-canonical TypedNodeID with the
scheme byte travelling alongside the 20-byte NodeID.
Callsites that currently call ids.NodeIDFromCert directly are now
bypassing the strict-PQ pivot; migrating them to DeriveNodeID /
DeriveTypedNodeID is the follow-up workstream (the pivot itself
is non-breaking — classical chains continue to route through
NodeIDFromCert via the seam).
Requires luxfi/ids v1.2.10+ for NodeIDSchemeMLDSA65 / DeriveMLDSA.
Carries the namespace rename (lux→security) on /ext/security and the
F118 chain-manager profile-pin stamping landed in v1.26.12. Patch bump
only — no schema or behavior change beyond the rename of the
JSON-RPC namespace and REST sidecar path on the security service.
compatibility.json: add v1.26.12 + v1.26.13 to RPCChainVMProtocol 42
so TestCurrentRPCChainVMCompatible passes (the previous bump to
v1.26.12 left the map at v1.23.25).
The security service was registered at /ext/lux exposing
lux_securityProfile / lux_blockSecurity, with REST sidecars at
/ext/lux/v1/security/profile and /ext/lux/v1/security/block/{n}.
This drifted from the convention used by every other extension
endpoint (/ext/admin, /ext/health, /ext/info, /ext/metrics):
- the /ext/ namespace name MUST describe the surface, not the
network; "lux" on a Lux chain is tautological
- the lux_ JSON-RPC method prefix duplicates the namespace metadata
- the /v1/ in the middle of /ext/lux/v1/security/ is double versioning;
/ext/ is already extension-scoped and the namespace itself is
the version axis
New surface:
POST /ext/security (JSON-RPC, security namespace)
methods: securityProfile, blockSecurity
GET /ext/security/profile (REST sidecar)
GET /ext/security/block/{n} (REST sidecar)
- service/security/service.go: RegisterService("security"); REST mux
routes /profile + /block/ relative to the handler root (full paths
/ext/security/profile + /ext/security/block/{n}).
- service/security/types.go: doc comments name the new surface.
- service/security/service_test.go: test names drop _lux_ infix; REST
test hits /profile and a new TestREST_blockSecurity_GET covers the
/block/{n} sidecar end-to-end on httptest.NewServer.
- node/node.go: APIServer.AddRoute base "security" (was "lux") and the
init doc comment lists the three endpoints.
All 10 tests pass (env GOWORK=off go test ./service/security/...).
Companion to coreth: c84950de4. The chain manager now forwards the
chain-wide ChainSecurityProfile resolved at node bootstrap (F102)
into the C-Chain (coreth) plugin Initialize payload by stamping a
profileID + profileHashHex pin into the JSON config bytes that
already cross the rpcchainvm boundary.
- chains/manager.go adds ManagerConfig.SecurityProfile and an
injectSecurityProfileConfig pass (mirror of injectAutominingConfig)
that runs only on EVMID and is a no-op when the profile is nil.
- node/node.go threads n.securityProfile into chains.ManagerConfig.
- version/constants.go patch-bumps to 1.26.12.
Four F118 tests in chains/security_profile_f118_test.go:
- StampsCChainOnly — pin appears in EVMID config, not in other VMs.
- NoOpWhenProfileNil — classical-compat path passes through.
- MergesExistingConfig — automining + skip-block-fee survive.
- RoundTripsAcrossPluginBoundary — the wire form decodes cleanly
into the coreth-side LuxSecurityProfilePin struct shape, with the
hash decoding to a full 48 bytes (SHA3-384 width).
go.mod bumps consensus v1.23.5 → v1.23.9 to pick up the F113
checkpoint+Merkle SHA3-384 widening.
Before: network/kem/scheme.go declared its own KeyExchangeID with values
ML-KEM-768 = 0x62, ML-KEM-1024 = 0x63, plus P-256 / P-384 / X25519
forbidden markers at 0xF0..0xF2. Disjoint from consensus/config's
0x01/0x02/0x90 canonical block.
After: kem.KeyExchangeID is a type alias of config.KeyExchangeID. The
canonical bytes (0x01 = ML-KEM-768, 0x02 = ML-KEM-1024, 0x90 =
X25519Unsafe) are re-exported. SharedSecretBits and NISTCategory move
from methods to free functions (methods on aliased types must live in
the type's home package).
Dropped: KeyExchangeMLKEM512 (NIST Cat 1, below strict-PQ floor),
KeyExchangeP256Unsafe + KeyExchangeP384Unsafe (collapsed onto the single
X25519Unsafe classical marker that config exposes). No production
caller referenced any of these.
Wire-format change on the peer handshake KEMScheme byte: 0x62/0x63
→ 0x01/0x02. Forward-only; the prior numbering was never released to
any live network.
New test: TestKEMSchemeIDs_AllUseCanonicalNumbering pins
kem.KeyExchange{MLKEM768,MLKEM1024,X25519Unsafe,None} byte-identical to
config.KeyExchange{MLKEM768,MLKEM1024,X25519Unsafe,Invalid}.
The TLS handshake pins CurvePreferences = [tls.X25519MLKEM768], the
IANA-registered hybrid (curve ID 0x11ec). The chain-wide
ChainSecurityProfile pins KeyExchangeMLKEM768 — the post-quantum
component that MUST be present on the wire — and the hybrid satisfies
this because it CONTAINS ML-KEM-768.
The hybrid is strictly stronger than pure ML-KEM-768 (an attacker must
break BOTH X25519 AND ML-KEM-768 to derive the session key). Real-world
TLS 1.3 stacks implement the hybrid today; pure ML-KEM-768 at the TLS
layer is not yet a deployable target.
ForbidClassicalKEM continues to refuse a pure-classical curve at the
application layer; it does NOT refuse a hybrid that includes ML-KEM-768.
Decision recorded in consensus/config commit 12d7000c.
Closes F103 (pure vs hybrid ML-KEM-768 ambiguity).
Expose the chain-wide ChainSecurityProfile to operators, dApps, and
auditors via two surfaces wired through a single boot site
(initSecurityAPI):
- JSON-RPC under namespace "lux" at /ext/lux:
lux_securityProfile → ProfileReply (full profile JSON)
lux_blockSecurity → BlockSecurityReply (chain-wide envelope)
- REST sidecar at /ext/lux/v1/security/{profile,block/{n}}
- Prometheus gauges under namespace "security" on /ext/metrics:
security_profile_post_quantum_end_to_end{profile_id, profile_name}
security_profile_nist_friendly{profile_id}
security_profile_lux_canonical{profile_id}
security_profile_unsafe_mode_enabled{profile_id}
security_mempool_classical_credentials_total{chain}
security_zchain_proof_lag_epochs
The reply shape is SCREAMING_SNAKE canonical names (renderName) so audit
tooling matches on stable identifiers; the underlying scheme bytes come
from consensus/config. A node booted without a SecurityProfile pin
returns ErrNoProfile (RPC) or HTTP 503 (REST) — the legacy networks
keep their classical-compat posture without forging a profile.
Closes the lux_securityProfile and profile-gauges F102 follow-ups.
The X-chain mempool now installs the chain-wide credential-admission
policy resolved from genesis at node bootstrap (F102 close-out). Wiring
path:
genesis → node.SecurityProfile() → xvm.Factory.SecurityProfile
→ VM.securityProfile → vm.Initialize → xmempool.SetAuthPolicy
X-chain credentials are wrapped in fxs.FxCredential; the mempool gate
unwraps them before consulting the auth policy so the same
EnforceCredentialPolicy implementation gates both P-chain and X-chain.
Integration test exercises the full path: build VM with strict-PQ
profile, call Initialize + Linearize, then issue a tx with a wrapped
classical secp256k1 credential via IssueTxFromRPCWithoutVerification.
Observe ErrLegacyCredentialUnderStrictPQ.
The platformvm mempool now installs the chain-wide credential-admission
policy resolved from genesis at node bootstrap (F102 close-out). Wiring
path:
genesis → node.SecurityProfile() → platformvm.config.Internal.SecurityProfile
→ vm.Initialize → pmempool.SetAuthPolicy
Strict-PQ chains refuse classical secp256k1 credentials at gossip time
via the existing auth.EnforceCredentialPolicy gate; legacy/classical-
compat networks keep admitting them unchanged (nil profile is a no-op
gate).
Integration test exercises the full end-to-end path: build VM with
SecurityProfile = LuxStrictPQ(), call Initialize, attempt to add a tx
with an unwrapped *secp256k1fx.Credential, observe
ErrLegacyCredentialUnderStrictPQ.
Closes the CPX deferred item:
"chain-builder code that constructs the mempool today does not call
SetAuthPolicy ... follow-up that depends on plumbing the
*config.ChainSecurityProfile from genesis into the chain builder"
The reusable hanzoai/.github/docker-build.yml requires cross-org callers
to override runner labels — hanzoai org scale-sets aren't visible from
luxfi org. All v1.26.x CI runs have failed with startup_failure since
the upstream workflow added this requirement (~2026-05).
Per the reusable workflow's docstring:
Cross-org callers (luxfi/*, zooai/*, <tenant>/*) MUST override
these with their own org's ARC scale-set labels.
Switch to lux-build-linux-{amd64,arm64} labels which exist on the
luxfi ARC scale-set.
Adds Node.SecurityProfile() getter and Node.initSecurityProfile() boot
step. New() now calls initSecurityProfile() right after
initBootstrappers() and before tracer/metrics/networking/chain init —
every downstream consumer (signer factory, peer handshake gate,
mempool, validator registry, bridge oracle) sees the resolved
*consensus/config.ChainSecurityProfile (or its absence) via the
getter at construction time.
The pin lives in genesis.Config.SecurityProfile (luxfi/genesis@v1.9.6,
which pins luxfi/consensus@v1.23.5). Resolution:
1. genesiscfg.GetConfig(networkID) — load JSON-form genesis
2. cfg.SecurityProfile.Resolve() — refuse mismatched pin
3. stamp result onto n.securityProfile
4. emit startup banner with hash, post-quantum, NIST-friendly,
classical-SNARK, and BLS-fallback posture
A forked binary that swaps in a different canonical profile content
fails Resolve() because the live ComputeHash diverges from the
genesis-pinned hex — closes the F102 attack chain end-to-end.
Adds 5 regression tests for the load + reject paths under StrictPQ
and FIPS profiles, plus nil-pin / wrong-hash / unknown-ID rejection.
Bumps luxfi/consensus v1.23.4 → v1.23.5, luxfi/genesis pseudo →
v1.9.6, luxfi/crypto v1.18.3 → v1.18.4.
Mirrors the P-chain gate (vms/platformvm/txs/mempool) onto the X-chain
(xvm) mempool. The X-chain wraps every credential in fxs.FxCredential,
so the gate unwraps the inner verify.Verifiable before handing the
slice to auth.EnforceCredentialPolicy — the policy package only sees
the canonical *secp256k1fx.Credential type.
Same opt-in shape as the P-chain wiring: SetAuthPolicy installs the
ChainSecurityProfile + ClassicalCompatRegistry; without a profile the
gate is bypassed.
Three integration tests cover: strict-PQ + no registry refuses
classical, no policy admits classical, strict-PQ + empty registry
refuses classical (the all-allow-listed contract is that the registry
names the allowed addresses; an empty registry admits nothing).
Wires the canonical credential-policy gate (vms/txs/auth) into the
P-chain mempool. Adds an opt-in setter so existing callers that have
not migrated their construction path observe no behavioural change —
without SetAuthPolicy, the gate is bypassed and every tx the legacy
path accepted is still accepted.
Once a chain pins a non-nil *config.ChainSecurityProfile via
SetAuthPolicy, every Add path runs through auth.EnforceCredentialPolicy
before the underlying mempool sees the tx. A classical
secp256k1fx.Credential under RequireTypedTxAuth=true returns
auth.ErrLegacyCredentialUnderStrictPQ.
The originator is left at ids.ShortEmpty here because P-chain txs do
not bind a single canonical "from" address (the input owners can name
many keys). Chain builders that supply a ClassicalCompatRegistry whose
IsAllowed depends on tx-level context layer that resolver on top of
this gate.
Three integration tests cover: strict-PQ + no registry refuses
classical, no policy (legacy default) admits classical, classical-
compat profile admits classical. The full P-chain mempool suite
(TestBlockBuilderMaxMempoolSizeHandling et al.) is unchanged.
Adds the canonical credential-policy gate that the P-chain and X-chain
mempools use to enforce a strict-PQ ChainSecurityProfile at admission
time. One package, one entry point (EnforceCredentialPolicy), one
error (ErrLegacyCredentialUnderStrictPQ).
- ClassicalCompatRegistry: read-only allow-list interface. Names a
bounded set of legacy operator addresses that may still post
classical secp256k1 credentials on a chain that otherwise pins
RequireTypedTxAuth=true. The chain's governance pathway is the
only writer; the mempool only reads.
- NewStaticClassicalCompatRegistry: frozen registry constructed from
a fixed []ids.ShortID slice. Useful for tests and for genesis-
pinned allow-lists before the governance path is online.
- EnforceCredentialPolicy: idempotent gate, branches on
profile.RequireTypedTxAuth:
false → admit unconditionally (classical-compat profile)
true → walk creds; if any is *secp256k1fx.Credential and the
originator is not in the registry, return
ErrLegacyCredentialUnderStrictPQ.
10 tests cover: nil profile (ErrNilProfile, programmer error), classical
profile admits, strict-PQ admits PQ-only creds, strict-PQ refuses
classical without registry, strict-PQ refuses originator not in
registry, strict-PQ admits allow-listed originator, mixed creds with
one classical refuses, empty creds always admits (syntactic verifier's
job to require ≥1), nil-safe staticRegistry, distinguishes addresses.
Wiring into platformvm/txs/mempool and xvm/txs/mempool lands in the
follow-up commits.
Re-exports the canonical ML-DSA-65 (FIPS 204) UTXO feature extension
from github.com/luxfi/utxo/mldsafx so platformvm and xvm callers depend
on a single import path:
github.com/luxfi/node/vms/mldsafx
One feature extension, one import path. The wire types (Credential,
TransferInput/Output, MintOutput, MintOperation, OutputOwners, Input,
Fx, Factory) and the security-level constants are type aliases against
the upstream package — adding mldsafx as a node-side bridge introduces
zero runtime code.
This is the foundation the P-chain and X-chain mempool admission gates
(vms/txs/auth) and the strict-PQ tx variants build against.
The prior commit referenced luxfi/consensus v1.23.3, but that tag was
already in use on the remote. Retagged the new ChainSecurityProfile
ValidatorSchemeID work as v1.23.4 and update node's go.mod/go.sum to
match. luxfi/ids v1.2.10 is unaffected.
NodeIDScheme enum (MLDSA65=0x42 canonical, Secp256k1=0x90 classical-
compat-unsafe only). NodeID derivation now domain-separated via
SHAKE256-384("LUX_NODE_ID_V1" || chain_id || scheme || pubkey).
Wire encoding includes a leading scheme byte so the receiver can
verify without trusting the profile alone.
Strict-PQ profile rejects secp256k1 NodeIDs.
Classical-compat profile accepts both (transition path).
Migration: hardfork activation switches strict-PQ chains from
mixed-scheme to ML-DSA-only at a configured activation block.
network/peer/scheme_gate.go is the single primitive consumers funnel
inbound NodeIDs through: SchemeGate.Classify(nodeID, scheme, height,
site) returns a TypedNodeID once the chain policy admits the pair.
The ActivationHeight field implements the hardfork transition window;
ClassicalCompatUnsafe mirrors the operator opt-in flag (refused under
strict-PQ regardless, honoured on permissive).
Existing 20-byte ids.NodeID array stays byte-identical for storage /
map keys / codec; the scheme byte travels alongside it on the wire
via ids.TypedNodeID. Consumers of the 20-byte form (peer/upgrader,
proposervm block proposer, platformvm validator registry, mempool
sender) continue to work unchanged at the storage layer; the
SchemeGate boundary is what stamps the scheme byte and runs the
cross-axis check.
Bumps luxfi/ids v1.2.9 -> v1.2.10 (NodeIDScheme, TypedNodeID,
DeriveMLDSA, FullDigest, error surface) and luxfi/consensus
v1.23.2 -> v1.23.3 (ValidatorSchemeID accessor, AcceptsValidatorScheme,
ErrValidatorSchemeMismatch).
Tests:
- TestSchemeGate_NewSchemeGate_RejectsNilProfile
- TestSchemeGate_StrictPQ_AcceptsMatchedScheme
- TestSchemeGate_StrictPQ_PostActivation_RejectsClassical
- TestSchemeGate_StrictPQ_PreActivation_AcceptsBothSchemes
- TestSchemeGate_StrictPQ_PreActivation_RejectsClassicalAfterCutover
- TestSchemeGate_Permissive_AcceptsClassicalUnderUnsafeFlag
- TestSchemeGate_RejectsUnknownSchemeByte
- TestSchemeGate_PinsProfileScheme
- TestSchemeGate_SiteTagIncludedInError
Patch-bump: v1.26.9 -> v1.26.10.
Replaces classical X25519/ECDH peer handshake with ML-KEM-768 (default)
and ML-KEM-1024 (high-value validator/DKG channels). Node identity is
signed with ML-DSA-65 over a TupleHash256-bound transcript.
cSHAKE256 derives the AEAD session key with customization
"LUX_NODE_AEAD_V1", binding profile_id, chain_id, both nodes' ML-DSA
public keys, and the shared KEM secret.
DKG channels in pulsar/pulsar-m force ML-KEM-1024.
Strict-PQ profile refuses peers offering X25519Unsafe KEM.
Patch-bump.
Only P-Chain (chain manager startup) and X-Chain (UTXO infra) are
required primary-network chains. C-Chain (EVM), D-Chain (DEX),
B-Chain (Bridge), T-Chain (Threshold) — and any subnet blockchain —
must be explicitly tracked via --track-chains. A validator gets exactly
the topology its operator asks for, nothing more.
Pre-fix: every primary-network blockchain in genesis was created
unconditionally because `constants.PrimaryNetworkID != tx.ChainID`
short-circuited the TrackedChains gate. <tenant> validators (no
C-Chain plugin shipped) ran into "failed to initialize VM: parsing
genesis: unexpected end of JSON input" on every health check.
Replace the primary-network bypass with a per-VM check: only
tx.VMID == constants.XVMID skips the gate. Everything else (including
C-Chain on primary network) goes through TrackedChains.
Two related fixes that make chain tracking explicit per-validator:
1. config/config.go: drop the silent default
`if TrackedChains == nil { TrackAllChains = true }`. That made any
node without an explicit --track-chains list try to spin up every
chain in P-chain state, including ones whose VM plugin wasn't
loaded. The setting was a footgun. Empty TrackedChains now means
"track only P/X (built-in)". TrackAllChains stays as an explicit
opt-in escape hatch.
2. chains/manager.go: when buildChain returns ErrNotFound (VM plugin
not registered for this chain's vmID), treat it as the validator's
"I don't validate this one" signal — log info, mark the slot
bootstrapped, and return WITHOUT registering a per-chain failing
health check. The chain stays in pending state for hot-load.
Previously this registered a permanent 503 on /ext/health for the
chain-alias, which made kubelet liveness probes kill any pod that
didn't ship plugins for every chain in the primary genesis. Now
validators participate per-plugin: load liquid-evm/dex/fhe → those
chains validate; don't load Lux's upstream EVM plugin → C-Chain
stays in pending without crashing the node.
Real failures (e.g. genesis decode error, db corruption) still register
the failing health check — those are operator misconfigurations, not
deliberate opt-outs.
Future: a Lux-signed featured-chains.json + --track-featured (default
true) would make this opt-in safer at scale — limits the DDoS surface
of unbounded chain creation while still letting validators earn yield
on the curated set out of the box.
Adds platform.getChains alongside platform.getNets — same wire shape
(IDs in, list of {ID, ControlKeys, Threshold} out), names that match
the user-facing concept ("chain") instead of the internal "net" /
"subnet" jargon. APIChain replaces APINet, ClientChain (alias for
ClientNet) keeps drop-in source compatibility.
Why now: GetNets was already marked deprecated via the "deprecated
API called" log line, but no canonical replacement existed — callers
had to keep calling the deprecated method. This commit lands the
replacement so the bootstrap binary's chain idempotency check
(EnsureChainNetwork → list chains, match owner set) can target the
canonical name.
GetNets stays available indefinitely for wire-protocol compatibility.
GetChains delegates to GetNets internally so the two methods can
never diverge — bug-fix one, both fix.
Test pinned in service_test.go alongside GetNets test (existing file
has //go:build skip but the test is documentation for the contract).
createCChainIfNeeded is the one-time recovery path for the historical
mainnet migration where post-merge geth state was imported under a fixed
blockchainID (no CreateChainTx). On every other launch the function
either silently no-ops on the missing data dir or — worse — risks
touching the filesystem before the data dir exists during a fresh
genesis. Gating it behind LUX_MIGRATE_CCHAIN=1 keeps the migration path
exactly where it belongs: opt-in, run once, never again.
The wire protocol uses two flags on responses:
MsgResponseFlag (0x80) — set on EVERY response (success or error)
MsgErrorFlag (0x40) — set ONLY on error responses
The transport's readLoop already converts MsgErrorFlag responses into a
non-nil err out of Conn.Call, so by the time the client checks respType
the response is guaranteed successful. Treating MsgResponseFlag as the
"is this an error?" predicate fired on every successful Initialize and
rendered the binary InitializeResponse payload (LastAcceptedID, parent,
height, bytes, timestamp) as if it were an error string, producing
"vm error: <unprintable bytes>" and crashing chain creation despite the
plugin reporting "VM initialized successfully".
Symptom in the field: lqd repeatedly logged
failed to create chain on net 11111111111111111111111111111111LpoYY:
failed to initialize VM: zap initialize: vm error: \x00\x00\x00 ...
while the plugin's own log read "ZAP VM initialized successfully" and
"Chain initialized successfully". Health check C reported permanent
failure, eth_getBalance returned 404, no blocks were ever produced.
Fix: drop the spurious MsgResponseFlag check and strip both flags before
comparing the message type so that any well-formed response is accepted.
Regression test in client_initialize_test.go pins both halves of the
contract: a success response is decoded (lastAcceptedID round-trips),
and a server-side error is surfaced through err with the original
message intact.
Picks up luxfi/genesis@2b16f454 — adds operator-overridable C-Chain
genesis at the embedded-loader layer. Pairs with f8ddc426 (this repo's
LUX_AUTOMINE_CCHAIN_GENESIS_PATH for the automine single-node path).
With both env vars wired, downstream networks can reuse stock lqd
without forking configs/network/cchain.json or patching this binary.
The automine path embeds the C-Chain genesis bytes into the primary-
network genesis at boot. Until now that genesis was a hardcoded constant
(chainId 31337) — fine for stock lqd local dev, but downstream networks
(<tenant> at chainId 8675312, etc.) had no way to override without
patching the binary. The chain-config-dir scan happens AFTER the EVM
plugin has already initialised from the embedded bytes, so dropping a
genesis.json into configs/chains/C/ is silently ignored.
Adds LUX_AUTOMINE_CCHAIN_GENESIS_PATH: when set, lqd reads the JSON at
that path and embeds it as the C-Chain genesis. Unset → unchanged
behaviour (chainId 31337 default).
The saved AutomineNetworkConfig also mirrors the resolved value so the
on-disk record is true to what was embedded.
Match the OPT-IN comment at genesis/builder/builder.go:617. The
initChainManager path was hard-erroring when the platform genesis
had no constants.EVMID chain — that contradicted the documented
opt-in behaviour and forced consumers (<tenant> Network) to bake
a placeholder EVM into the primary genesis just to satisfy the
node init.
With this change, networks that register their own EVM as a
dedicated chain via platform.createChainTx (the canonical L2-on-Lux
or sovereign-chain flow) leave the platform genesis EVM-less and
cChainID stays ids.Empty. Chain manager + aliasing already accept
the empty ID — no further changes needed.
Picks up the localnet genesis regen from LIGHT_MNEMONIC. lux/node's
default genesis for network-id=1337 now funds the same 100 P/X wallets
the lux/node + <tenant>/node toolchains derive against, so local
dev no longer has to set --genesis-file or hand-edit allocations.
The struct `zapwire.InitializeRequest.LuxAssetID` is generated from a
.proto in luxfi/api — renaming it requires regenerating the wire
bindings, bumping luxfi/api, then bumping luxfi/node to that. Out of
scope for this rename pass; revert the one Go-side assignment so the
build resolves against the existing wire type. The local variable
already reads from `Context().XAssetID` so the data path is consistent;
only the on-the-wire field name lags.
The field has always semantically been the X-Chain asset ID — the JSON
tag is already `xAssetID`. The Go name `LuxAssetID` was a holdover from
when "LUX" was the only asset name on the chain, which created confusion
when readers expected `XAssetID` to mirror `XChainID` / `CChainID` /
`DChainID` (all of which already used the chain-letter naming).
Renamed across config, node, vms/platformvm, vms/rpcchainvm/zap and the
static_service API request types. JSON tag and protobuf wire fields are
unchanged, so on-disk configs and inter-process plugin RPC are
backwards-compatible — only Go callers see the new name.
X-Chain genesis at builder.go:603-613 references 9 FxIDs (secp256k1fx,
nftfx, propertyfx, mldsafx, slhdsafx, ed25519fx, secp256r1fx, schnorrfx,
bls12381fx) but chains/manager.go only registered the first 3. Chain
init failed with "fx qC5JEjDhfXD66cGuhtiL3Lkka2SgZyht74nHVQFmYFyDiLqXe
not found" — that ID encodes 'mldsafx' which the genesis builder added
when post-quantum support landed but wasn't paired with a manager-side
registration.
Add all 6 missing factories so any FxID emitted by the genesis builder
loads at chain create time. The fxs map is now structurally identical
to the X-Chain FxIDs slice — drift between the two will become a
review-time diff in chains/manager.go vs. genesis/builder/builder.go.
Anchor /lux + /luxd binary patterns at repo root so the gitignore
rule doesn't accidentally match every directory called "lux" deep
in the tree (which was excluding vms/components/lux that the
previous commit tried to land).
Add the actual UTXO type tree files so v1.26.5 ships with the
package contents external consumers (liquidity/network-bootstrap
etc) need.
* vms/components/lux: restore the X-Chain UTXO type tree from
v1.24.29. External consumers (the tenant repo/network-bootstrap,
PlatformVM tx builders, AVM tx builders) import this exact type
tree to interop with the X→P export path. Documented in CLAUDE.md
as a known anomaly pending #58 consolidation; until that lands the
package must be present.
* vms/thresholdvm/thresholdvm.go: re-export shim mirroring vms/dexvm.
The Threshold (FHE / MPC) VM moved out of node/vms/thresholdvm
into github.com/luxfi/chains/thresholdvm. Callers (liquidity/fhe
plugin etc) keep building unchanged.
No on-the-wire behaviour change.
Two compatibility seams that downstream consumers need post-Z-Wing
restructure:
1. proto/pb/* stubs (build tag grpc) — every grpc-tagged file under
proto/<name>/<name>_grpc.go (and the legacy proto/rpcdb/rpcdb_grpc.go)
imports proto/pb/<name>. Those dirs were empty (git ignores empty
dirs), so consumers' `go mod tidy` walked the imports under the
grpc tag and failed to resolve the package. Default builds use
ZAP and never enter these stubs; the grpc-tag path now compiles
cleanly even though the protobuf types themselves still need
regeneration when someone actually wants the gRPC transport.
2. vms/dexvm/dexvm.go — re-export shim for the canonical chains/dexvm
package. The DEX VM moved out of node/vms/dexvm into
github.com/luxfi/chains/dexvm; existing callers (the tenant repo/
node, etc.) keep building unchanged.
Default build clean; grpc-tagged build also resolves. No on-the-wire
behaviour change in the ZAP default path.
* consensus v1.23.2 — pulls threshold v1.6.7 with the LSS-Pulsar
adapter (PulsarConfig, DynamicResharePulsar) needed by the epoch
manager's resharing path.
* New proto/zap/rpcdb package — pure-Go wire types for the database
RPC, no protobuf. Mirrors the proto/zap/{vm,p2p,sync,platformvm}
pattern.
* New proto/rpcdb/rpcdb_zap.go (build tag !grpc) — full ZAP-native
DatabaseClient + DatabaseServer + Register hook, implements
database.Database against any Transport. The host's underlying DB
is luxfi/database (zapdb in production); the protocol shape is
identical regardless of which engine the host runs.
* Renamed the existing rpcdb.go to rpcdb_grpc.go so the two
variants are mutually exclusive: build with no tag (ZAP default)
or with `-tags=grpc` (protobuf path), never both.
examples/multi-network/multi-network-poc.go: drop duplicate-key map
entry where Q-Chain was illustrated as a separate network — Q-Chain
shares the primary network ID per LP-134, see comments on
MainnetID entry.
Net effect: full default build is ZAP-only PQ-secure-channel-aware,
RPC over a Z-Wing-encrypted ZAP transport, no gRPC/protobuf in the
critical path.
Pulls in:
* luxfi/constants v1.5.2 — LocalID/CustomID semantic split, IsCustom()
* luxfi/genesis v1.9.2 — same split mirrored at the configs layer
* luxfi/zwing v0.5.2 — full PQ secure channel (X-Wing + ML-DSA-65 +
ChaCha20-Poly1305) with cross-language
wire-byte interop verified against Rust /
Python / TypeScript ports
* luxfi/api v1.0.10 — zap.NewListener seam (used by zwing.ListenZAP)
* luxfi/netrunner v1.18.1 — PQ-mandatory zapwire control RPC + same
LocalID rename
* luxfi/geth v1.16.87 — verkle.Fr type fix (drop bandersnatch import)
* luxfi/consensus v1.23.1 — banderwagon path move tracked
Source-side rename in this repo: every constants.CustomID call was
"the local 1337 dev network", so they all become constants.LocalID
(both the upgrade-config validation switch and the test fixtures).
The old constants.CustomID literal is now 0 — used as the explicit
"this is a user-defined custom network" sentinel, separate from the
LocalID dev network.
Plus a clarifying comment on the upgrade-config switch noting which
network IDs are permitted to override their upgrade schedule (any
non-well-known ID, including DevnetID/UnitTestID and any genuinely
custom user-defined network ID).
Z-Wing dialer + tests still green; all network/dialer, config,
config/node, config/spec, and genesis/builder tests pass.
Introduces ZWingDialer + ZWingListener which wrap any underlying
net.Conn (TCP, hostname, RNS mesh link, Unix socket, in-memory pipe)
with the canonical Lux PQ secure channel:
IETF X-Wing KEM (X25519 + ML-KEM-768)
Hybrid Ed25519 + ML-DSA-65 identity, signed transcript
ChaCha20-Poly1305 with sequence-numbered nonces
Z-Wing's contract is "any net.Conn" — the same secure channel rides
unchanged on TCP today and on the existing RNS transport tomorrow
without a per-transport rewrite. The legacy LP-9701 in-RNS-link crypto
(rns_link.go) stays in place during the transition; new p2p paths
should layer ZWingDialer over the EndpointDialer instead of relying on
LP-9701's inline encryption.
Adds:
network/dialer/zwing_dialer.go ZWingDialer + ZWingListener
network/dialer/zwing_dialer_test.go 5 e2e tests covering:
- missing-identity rejection (dialer + listener)
- real TCP listener + Z-Wing handshake + payload round trip
- Wrap() over an arbitrary net.Conn (net.Pipe stand-in for RNS)
- identity mismatch (MitM defence)
- DialEndpoint over an Endpoint (works for IP, hostname, future RNS)
Bumps:
github.com/luxfi/zwing v0.5.2 (full FIPS 204 PQ stack, cross-language
wire-byte interop with Rust/Py/TS)
github.com/luxfi/api v1.0.10 (NewListener seam used by zwing.ListenZAP)
luxd-side wiring (node.go construction with a loaded LocalIdentity) is
the next follow-up; this commit lands the seam without behavioural
changes to the existing dialer interface.
Lux's primary wire protocol is ZAP (Zero-Copy App Proto). The protobuf
definitions and generated .pb.go files in proto/ trees were vestigial:
- _grpc.pb.go gRPC service stubs were already gated behind
//go:build grpc and not part of the default build
- .pb.go message types had ZAP equivalents (*_zap.go) on every active
code path
Default build was already 100% ZAP. Deleting the .proto + .pb.go +
_grpc.pb.go files removes dead code without functional change.
Verified clean build of node, p2p, vm after deletion.
Remove unused bloom filter, test helpers, metrics scaffolding, and
linearizable VM wrapper. Trim stale go.sum entries. Add genesis builder
helpers and xvm FX/genesis initialization.
Blocking (would cause production incidents):
- env var prefix: LUX_* → LUXD_* (27 vars). Viper reads only LUXD_*;
LUX_* silently ignored → node would start with default config on every env.
- admin API enabled + sybil disabled defaults: explicit LUXD_API_ADMIN_ENABLED=false
and LUXD_SYBIL_PROTECTION_ENABLED=true in base ConfigMap.
- bootstrap peer config absent in new overlays: added LUXD_BOOTSTRAP_IPS /
LUXD_BOOTSTRAP_IDS keys to base ConfigMap; overlays/operator populate per-env.
Defense-in-depth additions:
- ServiceAccount `luxd` with automountServiceAccountToken: false (no API access).
- PodDisruptionBudget maxUnavailable=1 (works at 5 → 100 validator scales).
- NetworkPolicy: ingress 9631/TCP from any (P2P), 9630/TCP cluster-only
(HTTP), 9090/TCP monitoring-namespace only (metrics).
- podAntiAffinity preferredDuringScheduling by hostname — spread across nodes.
- updateStrategy: OnDelete — operator drains one pod at a time.
- podManagementPolicy: Parallel — validators start in any order.
Swarm verdict was NO-GO until these fixes. Addresses red critical #1 (admin
API), #2 (sybil), high #4 (gateway bypass via direct LB), medium #7 (no
NetworkPolicy/RBAC), and scientist blockers on env prefix + bootstrap.
Single ghcr.io/luxfi/node image now includes luxd + EVM plugin +
all 11 chain VM plugins (aivm, bridgevm, dexvm, graphvm, identityvm,
keyvm, oraclevm, quantumvm, relayvm, thresholdvm, zkvm) built from
github.com/luxfi/chains.
Dockerfile: added plugin-builder stage that clones luxfi/chains and
builds each VM as a static binary placed at the CB58-encoded VM ID
path under /luxd/build/plugins/.
compose.yml: single-node local dev config (network-id=3, sybil off,
sample=1) replacing the stale docker/compose.yml.
k8s: replaced ad-hoc mainnet-only manifests with kustomize base +
4 overlays (localnet, devnet, testnet, mainnet). Each overlay patches
replicas, resources, storage class, network-id, and image tag.
Deleted: docker/Dockerfile, docker/compose.yml, docker/compose.genesis.yml,
k8s/luxd-statefulset.yaml, k8s/mainnet/ (entire directory).
Goreleaser runs with GOWORK=off so it cannot resolve local workspace
modules. Pin published versions:
- github.com/luxfi/utxo v0.2.7 (adds InitRuntime forwarder)
- github.com/luxfi/chains/{aivm,bridgevm,dexvm,graphvm,identityvm,
keyvm,oraclevm,quantumvm,relayvm,thresholdvm,zkvm} v0.1.0
No replace directives — every dependency resolves to a published tag.
Restores the missing entry point that .goreleaser.yml, Makefile, and
scripts/build.sh all expect at ./main. Without this file, the release
pipeline fails with 'couldn't find main file: stat main: no such file
or directory'.
The main wires:
config.BuildFlagSet → config.BuildViper → config.GetNodeConfig
→ log.NewFactoryWithConfig → ulimit.Set
→ node.New(*node.Config, log.Factory, log.Logger)
→ n.Dispatch() with SIGINT/SIGTERM handling
→ exit n.ExitCode()
--version short-circuits to print version.CurrentApp.String() (e.g.
'luxd/1.23.25').
Not using the orphan app/ package (app.New takes nodeconfig.Config which
is a parallel type definition not produced by config.GetNodeConfig). The
app/ package can be migrated separately or removed.
One place, one way to define UTXO primitives. Delete the in-tree duplicate
at node/vms/components/lux/ and import the standalone luxfi/utxo package
with a 'lux' alias so existing call-sites (lux.UTXO, lux.TransferableInput,
etc.) remain unchanged in consumers.
Files changed: 167 imports rewritten, 2 directories deleted.
Build still green on the whole tree except pre-existing examples/multi-network
QChainMainnetID issue (unrelated).
Next: rename luxfi/utxo/luxmock → utxomock for full brand-neutrality; that
is a separate PR since it requires touching all consumer alias names.
VM implementations moved to github.com/luxfi/chains/<name>.
node/node/vms.go now imports from chains/ paths.
node/vms/ retains only primary network VMs (platformvm, xvm, evm)
plus VM infrastructure (manager, registry, rpcchainvm, tracedvm, etc).
The minimum quantum-safe validator set is:
P — staking, validators, rewards (implicit)
Q — Quasar PQ consensus (BLS + Corona + ML-DSA)
Z — universal receipt registry + ZK verification
X — assets (kept for LUX token / fee UTXOs, backward compat)
Opt-in (only created if *ChainGenesis provided):
C — EVM contracts
D — DEX
B — Bridge
T — Threshold/FHE/MPC
Validators stake extra + validate opt-in chains to earn their fees.
Fee split: each chain's tx fees distributed to its validators
proportional to stake weight.
@@ -20,7 +20,7 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Logs**
If applicable, please include the relevant logs that indicate a problem and/or the log directory of your node. By default, this can be found at `~/.node/logs/`.
If applicable, please include the relevant logs that indicate a problem and/or the log directory of your node. By default, this can be found at `~/.luxd/logs/`.
**Metrics**
If applicable, please include any metrics gathered from your node to assist us in diagnosing the problem.
@@ -31,4 +31,4 @@ Which OS you used to reveal the bug.
**Additional context**
Add any other context about the problem here.
**To best protect the Lux community security bugs should be reported in accordance to our [Security Policy](/SECURITY.md)**
**To best protect the Lux community security bugs should be reported in accordance to our [Security Policy](../security/policy)**
- ❌ Rejects: `v2.0.0`, `v2.1.0`, `v3.0.0` (requires `/v2` import path per Go modules)
**Rationale**: Go modules require major version 2+ to use `/v2`, `/v3` etc. in import paths. Since Lux uses `github.com/luxfi/node` (no version suffix), we enforce v1.x.x only.
### 2. Job Flow
```
validate-version (validates tag < v2.0.0)
├─> build-ubuntu-amd64 ───┐
├─> build-ubuntu-arm64 ───┤
├─> build-macos ──────────┼──> create-release (combines all artifacts)
# Create package with CLI-compatible naming: node-linux-{arch}-{version}.tar.gz
tar -czvf "node-linux-$ARCH-$TAG.tar.gz" -C "$PKG_ROOT" build
# Upload to S3 if BUCKET is set (optional)
if[[ -n "${BUCKET:-}"]];then
aws s3 cp "node-linux-$ARCH-$TAG.tar.gz""s3://$BUCKET/linux/binaries/ubuntu/$RELEASE/$ARCH/"||echo"Warning: S3 upload failed (credentials may not be configured)"
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.36.13]
### Fixed
- **The durable rejoin fix (#66/#74) shipped INERT for the C-Chain — the exact chain whose 40h mainnet freeze motivated it.** `chains/manager.go` gated `expectsStakedBeacons` on `ids.IsNativeChain(chainParams.ID)` — the *blockchain* ID. But `ids.IsNativeChain` only matches the symbolic `111…C` alias form, and every deployed C/X/Q carries a **hash** blockchain ID (devnet C `21HieZng…`, mainnet C `2wRdZG…`); only the P-chain has a symbolic blockchain ID (`111…P`), and it is excluded as the platform chain. So `isNativeChain` was **always false** for the real C-Chain → `expectsStakedBeacons=false` → under the production `--skip-bootstrap=true` the beacon set was emptied → a behind C-Chain named its STALE local tip the frontier and never caught up (verified on devnet v1.36.12: C-Chain wedged at height 0 with "using empty beacons for single-node mode"). Fix: discriminate on the **validating Net** (`chainParams.ChainID == constants.PrimaryNetworkID`) via new `chainValidatesOnPrimaryNetwork`, which is `PrimaryNetworkID` for C/X/Q and each sovereign L1's own net ID for an L2 — so C/X/Q now keep their staked beacons under `--skip-bootstrap` (peer-sync a behind validator) while L2s keep the empty-beacon single-node path. Regression test `TestChainValidatesOnPrimaryNetwork_RealHashChainID` exercises the real discriminator with hash chain IDs (the prior hardcoded-bool tests could not catch this); `TestRED_EmptyStakedSetFailsSafe` still passes (forged-frontier gate intact).
## [1.36.12]
### Fixed
- **EVM chains (C/D/L2) failed to initialize on v1.36.11 — bundled VM plugins were built against a stale `luxfi/api`.** The node pins `luxfi/api v1.0.16`, whose `InitializeResponse` carries the appended `Capabilities uint64` field (Quasar-export handshake, api commit `1f2dc5a`). The image baked the C-Chain EVM plugin from `luxfi/evm@v1.104.8` and the D-Chain dexvm plugin from `luxfi/dex@v1.5.15`, both of which resolve `luxfi/api v1.0.15` (no `Capabilities`). Their `InitializeResponse.Encode` writes a shorter payload than the node's strict `InitializeResponse.Decode` expects, so `vms/rpcchainvm/zap/client.go` fails every EVM VM handshake with `zap decode initialize response: unexpected EOF`. Native VMs (P/X/Q) were unaffected. Fix: bump `EVM_VERSION``v1.104.8 → v1.104.9` (api v1.0.16) and force `luxfi/api@v1.0.16` in the dexvm build stage. The api bump is code-free for plugins (`luxfi/chains v1.7.4 → v1.7.5` adopted it with a go.mod-only diff; `CHAINS_REF=v1.7.6` already carries v1.0.16). No node source change beyond the version bump — this is v1.36.11's node binary (durable rejoin fix, commit `63f61429d1`) rebuilt with plugin↔node ZAP-wire alignment.
## [1.28.0]
### Added
- Multi-version P-Chain block + tx codec. v1.23.x ("Apricot/Banff") wire layout is now registered as `CodecVersionV0` on the platformvm tx and block `codec.Manager`s; the current canonical layout is `CodecVersionV1`. Bytes carry their wire version in the standard 2-byte codec prefix and `txs.Parse` / `block.Parse` dispatch by it.
- Byte-preserving tx Initialization: `tx.InitializeFromBytes(c, version, signedBytes)` and `tx.InitializeFromBytesAtVersion(c, version)` bind a tx to its original `signedBytes` without re-marshalling. `TxID = hash(signedBytes)` is therefore stable across the v0->v1 migration — mainnet and testnet chain commitments hash back to byte-identical inputs.
-`vms/platformvm/block/v0/` subpackage holding the pure-data v0 block kinds (`ApricotProposalBlock`, `BanffProposalBlock`, ... — 9 types, slots 0-4 + 29-32). The package is a one-way decoder only; the `liftedV0Block` adapter in the parent package translates each v0 block kind into the corresponding canonical v1 `Visitor` arm without ever re-marshalling.
-`tx.Initialize(c)` is retained for the fresh-build path only (wallet, builder). The from-DB / from-wire paths in `genesis/genesis.go` and `block/{standard,proposal}_block.go` now go through `InitializeFromBytesAtVersion` against the codec version the surrounding container was decoded under.
-`genesis.Parse` is wire-version-aware: pre-codec-v1 P-Chain genesis blobs on mainnet / testnet decode at `CodecVersionV0`; new blobs are written at `CodecVersionV1`.
- The L1-tx slot map advances by one to accommodate `CreateSovereignL1Tx` at slot 36: `RegisterL1ValidatorTx`=37, `SetL1ValidatorWeightTx`=38, `IncreaseL1ValidatorBalanceTx`=39, `DisableL1ValidatorTx`=40 (was 36-39). Both pre-rip mainnet/testnet bytes (no slot conflict; L1 txs did not exist) and current code paths line up with the new slot map.
- Test fixtures in `vms/platformvm/txs/fee/calculator_test.go` and the serialization tests under `vms/platformvm/txs/*_test.go` are regenerated to use the v1 wire prefix (`0x0001`) and the post-`CreateSovereignL1Tx` slot map.
### Migration notes
- Mainnet + testnet P-Chain DBs do NOT need to be rebuilt: pre-codec-v1 blocks continue to decode via the v0 path with their original `BlockID = hash(v0 bytes)` preserved. Newly accepted blocks are written at v1.
- Devnet, which was bootstrapped post-rip at wire-version 0 but with the post-rip slot map, must be rebuilt before rolling to `v1.28.0`: its existing blobs would now decode against the v0 slot map (the pre-rip layout) and produce wrong types. A fresh bootstrap at `v1.28.0` writes v1 bytes from height 0 and is internally consistent thereafter.
## [1.13.5-alpha] - 2025-01-23
### Added
- L1 (Layer 1) validator support with complete transaction types:
-`ConvertNetToL1Tx` - Convert existing chains to L1
-`RegisterL1ValidatorTx` - Register new L1 validators
Thank you for your interest in contributing to Lux Node! This document provides guidelines and instructions for contributing to the project.
To start developing on Lux Node, you'll need a few things installed.
- Golang version >= 1.23.9
- Golang version >= 1.26.3
- gcc
- g++
@@ -20,36 +20,51 @@ This repo uses the [Task](https://taskfile.dev/) task runner to simplify usage a
## Issues
### Security
We are committed to fostering a welcoming and inclusive community. Please be respectful and considerate in all interactions.
- Do not open up a GitHub issue if it relates to a security vulnerability in Lux Node, and instead refer to our [security policy](./SECURITY.md).
### Did you fix whitespace, format code, or make a purely cosmetic patch?
- Use welcoming and inclusive language
- Be respectful of differing viewpoints and experiences
- Gracefully accept constructive criticism
- Focus on what is best for the community
- Show empathy towards other community members
- Changes from the community that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of `node` will generally not be accepted.
## Getting Started
### Making an Issue
### Prerequisites
-Check that the issue you're filing doesn't already exist by searching under [issues](https://github.com/luxfi/node/issues).
-If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/luxfi/node/issues/new/choose). Be sure to include a *title and clear description* with as much relevant information as possible.
-Go 1.26.3 or higher
-Git
- Make
- GCC/G++ compiler
## Features
### Setting Up Your Development Environment
- If you want to start a discussion about the development of a new feature or the modification of an existing one, start a thread under GitHub [discussions](https://github.com/luxfi/node/discussions/categories/ideas).
- Post a thread about your idea and why it should be added to Lux Node.
- Don't start working on a pull request until you've received positive feedback from the maintainers.
||{echo"FATAL: required chain-VM plugin $p missing/empty — its build failed above (see the matching WARN line); the runtime-stage hard COPY would otherwise fail cryptically. Surface & fix the real Go build error, or remove the plugin from BOTH the build list and the runtime COPY.";exit 1;};\
done&&\
rm -rf /tmp/chains
# ============= Native D-Chain DEX VM Plugin Stage ================
# The D-Chain (dexvm slot, vmID mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr)
# is the NATIVE consensus matcher VM: github.com/luxfi/dex/pkg/dchain implements
# block.ChainVM and runs the lx.OrderBook matcher INSIDE luxd consensus
# (Block.Verify against a versiondb overlay; Block.Accept commits) — the trade IS
# the D-Chain state transition, sequenced by luxd's multi-validator engine. This
# REPLACES the former chains/dexvm proxy (which relayed clob_* over ZAP to a
# standalone dchain-venue): there is no DexZapEndpoint and no standalone venue in
# the trading path. cmd/dchain wraps the VM in the SAME rpc.Serve plugin harness
# luxfi/evm boots through, and is pure-Go (CGO=0) — the optional GPU AMM
# accelerator in pkg/lx is a separate concern gated by its own cuda/metal tags and
# is NOT linked here. v1.5.10 is the first tag whose cmd/dchain builds CGO=0
# (drops the phantom dchain+cgo gate); v1.5.11 wires CLOB order ingestion over the
# Run and install targets are no longer supported; use the build script directly
clean:
@echo "Cleaning build artifacts..."
@rm -rf build/
@rm -f coverage.out
# Check for security vulnerabilities
security:
@echo "Checking for vulnerabilities..."
$(GOCMD) list -json -m all | nancy sleuth
install-mockgen:
@echo "Installing mockgen..."
@go install github.com/golang/mock/mockgen@latest
# Generate mocks
mocks:
mockgen:install-mockgen
@echo "Generating mocks..."
$(GOCMD) generate ./...
@./scripts/mockgen.sh
# Generate protobuf files
protobuf:
@echo "Generating protobuf files..."
@which buf >/dev/null 2>&1||(echo"buf not found, installing..."&& go install github.com/bufbuild/buf/cmd/buf@v1.52.1)
@which protoc-gen-go >/dev/null 2>&1||(echo"protoc-gen-go not found, installing..."&& go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6)
@which protoc-gen-go-grpc >/dev/null 2>&1||(echo"protoc-gen-go-grpc not found, installing..."&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1)
@which protoc-gen-connect-go >/dev/null 2>&1||(echo"protoc-gen-connect-go not found, installing..."&& go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest)
./scripts/protobuf_codegen.sh
# Specific test targets
test-unit:
@echo "Running unit tests..."
@$(ENV) go test -short -race $(TEST_PACKAGES)
# Generate all code (mocks + protobuf)
generate:protobufmocks
test-integration:
@echo "Running integration tests..."
@$(ENV) go test -run Integration -race -timeout=300s $(TEST_PACKAGES)
# Verify modules
verify:
@echo "Verifying modules..."
$(GOMOD) verify
test-e2e:
@echo "Running e2e tests..."
@$(ENV) ./scripts/tests.e2e.sh
# Show help
# Build specific binaries
luxd:
@echo "Building luxd..."
@$(ENV) ./scripts/build.sh
# Installation targets
# Install to $GOPATH/bin (default go install behavior)
install:
@echo "Installing luxd to $(GOBIN)..."
@$(ENV) go install -v ./main
@echo "$(GREEN)✓ Installed to $(GOBIN)/luxd$(NC)"
@echo "Make sure $(GOBIN) is in your PATH"
# Install to /usr/local/bin (system-wide, requires sudo)
install-system:build
@echo "Installing luxd to /usr/local/bin..."
@sudo cp build/luxd /usr/local/bin/luxd
@sudo chmod +x /usr/local/bin/luxd
@echo "$(GREEN)✓ Installed to /usr/local/bin/luxd$(NC)"
# Install to ~/.local/bin (user-local, no sudo needed)
install-local:build
@mkdir -p $(HOME)/.local/bin
@cp build/luxd $(HOME)/.local/bin/luxd
@chmod +x $(HOME)/.local/bin/luxd
@echo "$(GREEN)✓ Installed to $(HOME)/.local/bin/luxd$(NC)"
@echo "Make sure $(HOME)/.local/bin is in your PATH"
Make sure Docker is installed on the machine - so commands like `docker run` etc. are available.
@@ -102,10 +112,10 @@ To check the built image, run:
docker image ls
```
The image should be tagged as `luxfi/node:xxxxxxxx`, where `xxxxxxxx` is the shortened commit of the Lux source it was built from. To run the Lux node, run:
The image should be tagged as `ghcr.io/luxfi/node:xxxxxxxx`, where `xxxxxxxx` is the shortened commit of the Lux source it was built from. To run the Lux node, run:
```sh
docker run -ti -p 9650:9650 -p 9651:9651 luxfi/node:xxxxxxxx /node/build/luxd
docker run -ti -p 9630:9630 -p 9631:9631 ghcr.io/luxfi/node:xxxxxxxx /node/build/node
You can interact with the C-Chain using standard Ethereum tools:
```sh
# Example using curl
curl -X POST --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'\
-H "Content-Type: application/json"\
http://localhost:9630/ext/bc/C/rpc
```
**Note**: Single-node mode with sybil protection disabled should only be used for development. Never use this configuration on public networks (Mainnet or Testnet).
## Bootstrapping
A node needs to catch up to the latest network state before it can participate in consensus and serve API calls. This process (called bootstrapping) currently takes several days for a new node connected to Mainnet.
@@ -237,7 +212,7 @@ Lux Node is first and foremost a client for the Lux network. The versioning of L
### Library Compatibility Guarantees
Because `luxd` version denotes the network version, it is expected that interfaces exported by Lux Node may change in `Patch` version updates.
Because Lux Node's version denotes the network version, it is expected that interfaces exported by Lux Node's packages may change in `Patch` version updates.
Lux takes the security of the platform and of its users very seriously. We and our community recognize the critical role of external security researchers and developers and welcome responsible disclosures. Valid reports will be eligible for a reward (terms and conditions apply).
## Reporting Vulnerabilities
## Reporting a Vulnerability
Report security issues to **security@lux.network**. Do not open public issues for vulnerabilities.
**Please do not file a public ticket** mentioning the vulnerability. To disclose a vulnerability submit it through our [Bug Bounty Program](https://immunefi.com/bounty/luxfi/).
- Provide a description, reproduction steps, and affected components.
- We will acknowledge receipt within 48 hours.
- We will provide an initial assessment within 7 business days.
- We coordinate disclosure timelines with the reporter.
Vulnerabilities must be disclosed to us privately with reasonable time to respond, and avoid compromise of other users and accounts, or loss of funds that are not your own. We do not reward spam or social engineering vulnerabilities.
If the vulnerability affects production funds or consensus safety, we treat it as P0 and begin remediation immediately.
Do not test for or validate any security issues in the live Lux networks (Mainnet and Testnet testnet), confirm all exploits in a local private testnet.
## Cryptographic Primitives
Please refer to the [Bug Bounty Page](https://immunefi.com/bounty/luxfi/) for the most up-to-date program rules and scope.
Production implementations live in `lux/crypto/` and `lux/lattice/`. Formal verification proofs for each primitive are in `lux/papers/proofs/`.
## Supported Versions
### Signatures
Please use the [most recently released version](https://github.com/luxfi/node/releases/latest) to perform testing and to validate security issues.
The March 2026 comprehensive audit used red/blue adversarial methodology with Foundry, Slither, Semgrep, Aderyn, Halmos (symbolic execution), and Lean 4 (theorem proving).
Post-remediation risk: LOW. CI enforces Slither (fail-on: medium), Semgrep, Aderyn, and `forge fmt` on every push to main.
### Current Status
All critical and high findings from the contract audits are resolved. The December 2025 node audit identified development stubs in post-quantum and zero-knowledge subsystems that are not deployed to production; these are tracked and being replaced with real implementations as each subsystem matures.
## Formal Verification
50 mechanized proofs in `papers/proofs/`, covering:
The Admin API can be used for measuring node health and debugging.
<Callout title="Note">
The Admin API is disabled by default for security reasons. To run a node with the Admin API enabled, use [`config flag --api-admin-enabled=true`](https://build.lux.network/docs/nodes/configure/configs-flags#--api-admin-enabled-boolean).
This API set is for a specific node, it is unavailable on the [public server](https://build.lux.network/docs/tooling/rpc-providers).
</Callout>
## Format
This API uses the `json 2.0` RPC format. For details, see [here](https://build.lux.network/docs/api-reference/guides/issuing-api-calls).
## Endpoint
```
/ext/admin
```
## Methods
### `admin.alias`
Assign an API endpoint an alias, a different endpoint for the API. The original endpoint will still work. This change only affects this node; other nodes will not know about this alias.
Now, calls to the X-Chain can be made to either `/ext/bc/X` or, equivalently, to `/ext/myAlias`.
### `admin.aliasChain`
Give a blockchain an alias, a different name that can be used any place the blockchain's ID is used.
<Callout title="Note">
Aliasing a chain can also be done via the [Node API](https://build.lux.network/docs/nodes/configure/configs-flags#--chain-aliases-file-string).
Note that the alias is set for each chain on each node individually. In a multi-node Lux L1, the same alias should be configured on each node to use an alias across an Lux L1 successfully. Setting an alias for a chain on one node does not register that alias with other nodes automatically.
</Callout>
**Signature**:
```
admin.aliasChain(
{
chain:string,
alias:string
}
) -> {}
```
-`chain` is the blockchain's ID.
-`alias` can now be used in place of the blockchain's ID (in API endpoints, for example.)
Now, instead of interacting with the blockchain whose ID is `sV6o671RtkGBcno1FiaDbVcFv2sG5aVXMZYzKdP4VQAWmJQnM` by making API calls to `/ext/bc/sV6o671RtkGBcno1FiaDbVcFv2sG5aVXMZYzKdP4VQAWmJQnM`, one can also make calls to `ext/bc/myBlockchainAlias`.
Dynamically loads any virtual machines installed on the node as plugins. See [here](https://build.lux.network/docs/virtual-machines#installing-a-vm) for more information on how to install a virtual machine on a node.
**Signature**:
```
admin.loadVMs() -> {
newVMs: map[string][]string
failedVMs: map[string]string,
}
```
-`failedVMs` is only included in the response if at least one virtual machine fails to be loaded.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.