Skip to content

fix(network): bind strict-PQ peer identity to staking ML-DSA key so validators produce blocks#131

Closed
Darkhorse7stars wants to merge 226 commits into
mainfrom
fix/pq-peer-nodeid-block-production
Closed

fix(network): bind strict-PQ peer identity to staking ML-DSA key so validators produce blocks#131
Darkhorse7stars wants to merge 226 commits into
mainfrom
fix/pq-peer-nodeid-block-production

Conversation

@Darkhorse7stars

Copy link
Copy Markdown
Member

Summary

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 (peer/upgrader.goids.NodeIDFromCert). 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 → no block was ever produced (the built-in EVM/C-Chain stays at height 0, RPC serves reads but eth_blockNumber never advances).

This was observed on the Liquidity strict-PQ devnet: 3 validators, all healthy, all BLS-correct, peer mesh formed — but P-chain height stuck at 0.

Root cause — two coupled defects

1. The PQ handshake signed with an ephemeral key, not the staking key.
network.NewNetwork built the handshake identity with peer.NewLocalIdentity(MyNodeID), which generates a fresh ML-DSA keypair per process (see its doc-comment). So the handshake signature proved possession of a throwaway key with no relationship to the staking key that MyNodeID derives from. The wire carried the right NodeID, but nothing bound it to a key the validator set knows. (Corollary: the handshake never actually authenticated the validator identity — a peer could assert any NodeID.)

2. The verified peer NodeID was discarded.
peer.runPQHandshakeIfRequired used HandshakeResult.AEADKey but dropped HandshakeResult.PeerNodeID, leaving p.id on the transport TLS-cert NodeID. Consensus then looked up that TLS NodeID in an ML-DSA-keyed validator set and found nothing.

Fix

  • Thread the node's persistent staking ML-DSA keypair (StakingConfig.StakingMLDSA{,Pub}) onto network.Config (mirrored in node.Node.initNetworking) and build the handshake LocalIdentity from it via the new peer.NewLocalIdentityFromStakingKey. The handshake now signs with the same key that derives MyNodeID.
  • peer.adoptVerifiedPQIdentity (new): after a successful handshake, re-derive the NodeID from the peer's presented ML-DSA key under the node-identity domain (ids.Empty — the exact domain DeriveNodeID uses for MyNodeID) and require it to equal the presented NodeID, then adopt that ML-DSA NodeID as p.id.

This both fixes block production and closes an identity-impersonation gap: a peer can no longer claim a NodeID it cannot derive from the key it proved possession of.

Because peer.Start runs the handshake synchronously before the message-pump goroutines and before network.upgrade adds the peer to connectingPeers/connectedPeers, p.id is already the ML-DSA NodeID by the time any peer-set bookkeeping keys by it — no re-keying race.

Blast radius

Entirely inside the strict-PQ path (SecurityProfile != nil && profileRequiresPQHandshake). Classical / permissive chains skip the PQ handshake and are unaffectedp.id stays the TLS-cert NodeID exactly as before. No wire-format change (same INIT/RESP frames); only which key signs, plus an added local verification.

Rollout / review notes

  • Coordinated upgrade for strict-PQ networks. The binding check rejects the old ephemeral-key handshake, so all nodes on a strict-PQ chain must run this together.
  • Needs a devnet soak (3-validator strict-PQ: confirm P-chain height advances + getCurrentValidators lists all NodeIDs with weight + EVM eth_blockNumber increments) before any production rollout. Drafted for chain-team review; do not blind-merge/deploy to a live network.

Tests

go build ./... clean; go vet clean; go test ./network/peer/... green. Adds network/peer/pq_identity_adopt_test.go covering: bound identity → adopted; unbound/forged NodeID → rejected + identity untouched; nil/empty result → rejected.

Follow-ups (not in this PR)

  • The pre-handshake MyNodeID self-check and AllowConnection in network.upgrade still evaluate the TLS-cert NodeID (best-effort pre-filters; authoritative gating is post-handshake on p.id). Worth migrating to the ML-DSA NodeID for completeness.
  • The PQ handshake runs under peersLock in network.upgrade; a slow peer serializes connection establishment. Pre-existing; orthogonal to this fix.

Hanzo AI added 30 commits April 13, 2026 03:45
The minimum quantum-safe validator set is:
  P — staking, validators, rewards (implicit)
  Q — Quasar PQ consensus (BLS + Ringtail + 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.
Config validation, quorum params, lifecycle, set/get finalized,
BLS signature types, RingtailCoordinator sign/verify paths,
active/inactive validator weight filtering.

Remaining uncovered: GPU/NTT hardware code (requires CGO + GPU),
processFinality integration (requires P-Chain provider), Verify
(requires real BLS/Ringtail key material).
Consistent naming — package matches directory name:
- bvm → bridgevm
- gvm → graphvm
- qvm → quantumvm
- tvm → thresholdvm
- zvm → zkvm

Removed VMs (deduped):
- teleportvm/ — duplicated bridgevm+relayvm+oraclevm code (same MPC, same signing)
- servicenodevm/ — moved to dedicated repo github.com/luxfi/session

vms.go: 11 optional VMs (A/B/D/G/I/K/O/Q/R/T/Z) registered with new package names.
S-Chain (Session) registered separately as standalone plugin.
…vm,oraclevm,quantumvm,relayvm,thresholdvm,zkvm}

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).
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.
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.
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.
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).
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.
… builder

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.
…icKey []byte signature; service/info Consensus surfacing
Lux unifies subnet (validator-set) and chain identity into a single
ChainID — every chain identifies itself by its ChainID, no separate
'subnet identifier' needed.

Wire/interface changes (BREAKING — coordinated rollout):
  - node/message/wire: ChainSubnetPair struct → ChainPingEntry; collapsed
    duplicate SubnetId field; ChainSubnetPairs → ChainIds
  - node/proto/zap/p2p: SubnetID → ChainID
  - node/vms/chainadapter: ICPBlock/ICPSubnet SubnetID → ChainID
  - validators/uptime: Calculator interface params subnetID → chainID
  - consensus/core/router: Connected method param subnetID → chainID
  - p2p/message: outbound_msg_builder var renames

Config/UI changes (cleanup):
  - genesis/pkg/genesis ChainEntry: dropped redundant SubnetID field
  - tui/views/ChainStatus: dropped redundant SubnetID field
  - cli: comment cleanup
  - benchmarks: deploy-subnets var rename
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.
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.
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.
* 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.
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 (~/work/liquidity/
   node, etc.) keep building unchanged.

Default build clean; grpc-tagged build also resolves. No on-the-wire
behaviour change in the ZAP default path.
* vms/components/lux: restore the X-Chain UTXO type tree from
  v1.24.29. External consumers (~/work/liquidity/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.
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.
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.
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.
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.
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 + liquidityio/node toolchains derive against, so local
dev no longer has to set --genesis-file or hand-edit allocations.
Hanzo Dev and others added 30 commits May 31, 2026 01:38
…ent) — byte-preserving TxID/BlockID across migration (#123)

Closes the codec-version trap that surfaced when the v1.23.x ("Apricot/Banff") tx + block layout was rip-replaced in b1e265a 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
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.
…i-version dispatcher

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
…c v1.5.7

Pulls in:
- pulsar canonical wire codec (PULS/PULG) + shared lattice/v7/gpu surface
- threshold protocols/{corona,pulsar} alias surface (no direct primitive imports)
- consensus routed through threshold/protocols alias
- metric noop counter race fix (atomic.Uint64)

Cross-repo audit closed: corona+pulsar+threshold production-ready for
permissionless mainnet. End-to-end TestPulsar_Wire_FIPS204Verifiable
asserts threshold-combined signatures are byte-identical to single-party
FIPS 204 ML-DSA signatures (verified externally via cloudflare/circl).
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 Liquidity 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.
Wire-format codec IDs unchanged. CLI aliases deleted; chain is the
command. No backwards-compat shims, no deprecation comments.
Wire-format codec IDs unchanged. CLI aliases deleted; chain is the
command. No backwards-compat shims, no deprecation comments.
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.
…ashLooping

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 Liquidity 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.
fix(config): invalidate cached genesis on parse failure
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.
…ade)

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).
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 + 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 (455b994 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/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.
…DKG)

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.
…old v1.9.2 → v1.9.4

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/...
…hold v1.9.7 + kms v1.11.2 + pulsar v1.1.2

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).
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.
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).
…ample

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.
…testnet+zoo-mainnet genesis embeds)

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).
…alidators produce blocks

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants