LP-018 specifies (1) the canonical Network CR shape — one Kind that
covers every position in the network tree (L2 hosted, anchored, and
independent) — and (2) the atomic P-Chain transaction
(CreateSovereignL1Tx) that materializes a sovereign L1. The CR is
driven by exactly two data fields: networkID and validators. Mode
is purely derived; there is no parent, no sovereign: bool, no
mode: enum. The atomic tx replaces the legacy four-step spawn
sequence with a single commit-or-revert transaction that binds the
PQ cert profile (LP-217 cert mode; internal v1 codename mapping in
LP-017, historical) at genesis.
The pre-LP-018 design carried four authoring traps:
1. Two CR kinds. L1 and L2 were distinct kinds, requiring separate
manifests and orchestration paths.
2. Implicit-root confusion. A network's primary anchor was implicit
in the upstream config — the validator never re-affirmed which root
it was anchored to, leading to mainnet/testnet/devnet crosstalk in
shared-codebase tenants.
3. parent field divergence. Earlier drafts carried a `parent:
<uint32> field separate from networkID`, so the operator had to
reconcile two pointers that always meant the same thing
(parent == networkID for the network the instance is on).
4. sovereign: bool flag. A redundant flag — validators > 0
already says the same thing in fewer bits.
LP-018 collapses all four. **One CR shape. Two data fields. Mode is
derived.**
Spawning a sovereign L1 previously required submitting four distinct
P-Chain transactions in sequence:
1. CreateNetworkTx — allocate the network ID
2. N times AddPermissionlessValidator — register each validator
3. K times CreateChainTx — register each chain (EVM, DEX, FHE, …)
4. ConvertNetworkToL1Tx — switch from P-Chain-managed to sovereign
Partial failure in any step left the network in a half-spawned state.
Operators had to write idempotent re-run scripts. The atomic primitive
replaces all four with one tx that the executor either fully commits
or fully reverts. The PQ-profile selector binds the Quasar cert mode
(LP-217 — was LP-017 Pulsar/Aurora/Polaris codenames, historical) into
the spawn record at network birth.
apiVersion: lux.cloud/v1
kind: Network
metadata:
name: <instance-name> # e.g. "lux-main", "liquid-main", "hanzo-l2"
spec:
networkID: <uint32> # what network this instance is on / part of.
# Matches luxd's LUX_NETWORK_ID env var.
# {1,2,3,1337} RESERVED for Lux primaries.
# For a primary itself: networkID = its own value.
# For a chain on a primary: networkID = primary's ID.
# For an independent network: networkID = own value.
evmChainID: <uint64> # this instance's EVM chain ID (omit for X/P chains)
validators: <int> # 0 = no own consensus (chains served by existing set)
# N = emit N validator pods that participate in networkID
chains: []ChainRef # opaque CB58 blockchainIDs hosted by this instance
That's the whole shape. Optional fields (validatorTemplate,
indexer, explorer, bridge, bootnode, labels, annotations,
imagePullSecrets) configure how those pods are run — not what mode
the CR is in.
Mode is pure data → behavior:
networkID ∈ {1,2,3,1337} | validators | Mode |networkID == 0 or validators < 0 are invalid; the operator
rejects.
The "Anchored" row intentionally covers both "the primary itself" and
"sovereign L1 anchored to it". From the operator's perspective these
are identical workloads: bond N validators against the Lux primary's
stake registry. Whether those validators ARE the primary's genesis
set is a fact about the genesis blob, not the CR.
Chain-on-chain (L3, L4, …) is not in v1. When a real L3 use case
emerges, add a single optional parentChainID: <uint32> field that
points at the parent chain's evmChainID. Not before. Speculative
abstractions are anti-LP-018.
Promotion is just an edit to the CR: bump validators from 0 to N.
The operator's reconciler sees the diff and issues the appropriate
P-Chain tx (either CreateSovereignL1Tx at spawn, or
ConvertNetworkToL1Tx for an existing L2). One tx type, two diff
paths — no new operator API surface required.
When validators > 0, the on-L1 native-token staking manager owns
validator-set rotation (register / weight churn / end-of-validation /
rewards). The Solidity contract lives at the canonical address
0x0200000000000000000000000000000000000010 on every L1's primary EVM
chain. Source: ~/work/lux/standard/contracts/validators/ —
ValidatorManager.sol (base), PoSValidatorManager.sol (PoS
extension), NativeTokenStakingManager.sol (concrete instance bound
to the L1's native token).
The manager exposes seven core actions:
initializeValidatorSet | Seed initial set from spawn-time ConversionData | Anyone after L1 spawn |initializeValidatorRegistration | Lock stake + send RegisterL1ValidatorMessage Warp tx | New validator |completeValidatorRegistration | Acknowledge P-Chain registration ack | Anyone with Warp ack |initializeEndValidation | Begin un-stake; emit L1ValidatorWeightMessage(0) | Validator owner |completeEndValidation | Burn stake or release after P-Chain ack | Anyone with Warp ack |initializeDelegatorRegistration | Lock delegator stake | Delegator |completeEndDelegation | Release delegator stake + rewards | Delegator |ERC-7201 namespaced storage slots inherit upstream constants
(avalanche-icm.storage.ValidatorManager etc.) verbatim. Renaming the
strings would shift storage layout and break in-place upgrades. The
human-facing Solidity identifiers (events, errors, struct names) use
the Network/L1 vocabulary; the storage slot strings stay as the
upstream library constants for ABI/storage compatibility.
~/work/lux/node/vms/platformvm/txs/create_sovereign_l1_tx.go:48:
type CreateSovereignL1Tx struct {
BaseTx `serialize:"true"`
Owner fx.Owner `serialize:"true" json:"owner"`
Validators []*ConvertNetworkToL1Validator `serialize:"true" json:"validators"`
Chains []*SovereignL1Chain `serialize:"true" json:"chains"`
ManagerChainIdx uint32 `serialize:"true" json:"managerChainIdx"`
ManagerAddress types.JSONByteSlice `serialize:"true" json:"managerAddress"`
PQProfile uint8 `serialize:"true" json:"pqProfile"`
}
PQProfile byte values map to the internal v1 codename enum
(LP-017, historical) — operator-facing names are per LP-217 cert modes:
0 = QuasarPQModePulsar (BLS ‖ Pulsar ‖ ZK) — LP-217 PQ-fast1 = QuasarPQModeAurora (BLS ‖ Pulsar ‖ Corona ‖ ZK) — LP-217 PQ-strict2 = QuasarPQModePolaris (BLS ‖ Pulsar ‖ Corona ‖ Magnetar ‖ ZK) — LP-217 PQ-heavyThe byte values are stable; only the operator-facing name changes.
ZK is profile-orthogonal in LP-217 (attached or absent independently
per chain), so a PQ-fast cert may or may not carry a ZK leg.
A validator whose configured Quasar profile does not match the
spawn-time PQProfile refuses to participate in the L1's first
round; the L1 cannot reach finality until enough validators
reconfigure or the L1 is delisted by Owner-rotation tx.
The per-chain manifest entry (line 86):
type SovereignL1Chain struct {
BlockchainName string `serialize:"true" json:"blockchainName"`
VMID ids.ID `serialize:"true" json:"vmID"`
FxIDs []ids.ID `serialize:"true" json:"fxIDs"`
GenesisData []byte `serialize:"true" json:"genesisData"`
}
The per-validator base struct
(convert_network_to_l1_tx.go:87) carries:
type ConvertNetworkToL1Validator struct {
NodeID types.JSONByteSlice `serialize:"true" json:"nodeID"`
Weight uint64 `serialize:"true" json:"weight"`
RemainingBalanceOwner message.PChainOwner `serialize:"true" json:"remainingBalanceOwner"`
DeactivationOwner message.PChainOwner `serialize:"true" json:"deactivationOwner"`
// Canonical additions:
Start uint64 `serialize:"true" json:"start"`
End uint64 `serialize:"true" json:"end"`
Owner message.PChainOwner `serialize:"true" json:"owner"`
InitialBalance uint64 `serialize:"true" json:"initialBalance"`
DelegationFee uint32 `serialize:"true" json:"delegationFee"`
BLSPublicKey [48]byte `serialize:"true" json:"blsPublicKey"`
BLSPoP [96]byte `serialize:"true" json:"blsPoP"`
}
Start, End, Owner, InitialBalance, DelegationFee follow the
canonical AddPermissionlessValidator shape. BLSPublicKey +
BLSPoP are the proof-of-possession pair for the validator's
BLS-12-381 G1 public key (LP-015), bound at genesis so the L1's
Quasar BLS leg is operational from round 1.
> Either ALL of Validators[] are seeded into the validator-manager
> contract AND ALL of Chains[] are registered AND the sovereign L1
> is elected, OR none of these state changes commit.
The standard-tx executor at
~/work/lux/node/vms/platformvm/txs/executor/standard_tx_executor.go
implements CreateSovereignL1Tx(*CreateSovereignL1Tx) error
(declared on the Visitor interface at
~/work/lux/node/vms/platformvm/txs/visitor.go:34). The executor
must:
1. Verify inputs fund the BaseTx fee + the sum of every
Validators[i].InitialBalance + the chain-create fees.
2. Derive the new network ID from the tx hash. Reject if it collides
with constants.PrimaryNetworkID.
3. Verify the PQProfile byte names a registered Quasar profile.
4. Verify every Validators[i] is well-formed (BLS PoP valid,
NodeID non-empty, Weight non-zero) and verify Validators[] is
sorted-and-unique.
5. Verify every Chains[i] is well-formed (VMID non-empty,
FxIDs sorted-and-unique, GenesisData ≤ MaxGenesisLen,
BlockchainName ≤ MaxNameLen).
6. Verify ManagerChainIdx < len(Chains) and
len(ManagerAddress) ≤ MaxChainAddressLength.
7. Stage every state-write in a single diff (commit-or-revert).
8. On any failure in steps 1-7, return error WITHOUT applying any
state-write. The diff is dropped intact.
The P-Chain tx codec supports both v0 (pre-rip) and v1 (post-rip)
codec versions; CreateSovereignL1Tx registers at the v1 slot only.
A v0-only client cannot parse the tx; this is acceptable because v0
clients were the pre-LP-018 four-step-flow consumers, who do not
issue this tx type. Cross-version state-read codec compatibility
lives at ~/work/lux/node/vms/platformvm/state/codec_helpers.go
(the multiVersionUnmarshal helper that backs the seven state-side
Unmarshal sites). The genesis codec at
~/work/lux/node/vms/platformvm/genesis/codec.go uses the
txs.GenesisCodec (v0+v1 dispatch), so a v0-encoded historical
spawn record remains decodable.
The reconciler (~/work/lux/operator/go/internal/controller/network.go)
dispatches on Spec.HasOwnValidatorSet() — which is just
Spec.Validators > 0. No flag branching, no parent lookup, no
mode enum:
validators == 0 → emit no workloads; the chains listed in this CRare served by the existing validator set on the network identified
by Spec.NetworkID.
validators > 0 → emit a StatefulSet with N validator pods +headless + ClusterIP services. The validators participate in the
network identified by Spec.NetworkID (Anchored vs Independent
depending on whether Spec.NetworkID ∈ {1, 2, 3, 1337}).
Promotion (L2→Sovereign L1) is just bumping validators from 0 to N
in the CR. The operator's diff handler issues
ConvertNetworkToL1Tx against the parent P-Chain, transfers
validator-set authority from the parent's manager to the new on-L1
NativeTokenStakingManager, seeds the new manager from the CR's
spec, and boots the new validator pods. The L1's chains continue
producing blocks throughout — no chain halt.
The atomic tx unlocks a one-command spawn from
~/work/lux/cli/cmd/l1cmd/create.go:
lux chain spawn \
--validators 5 \
--chains evm,dex,fhe \
--pq-mode polaris \
--manager-chain evm \
--manager-address 0x...
The CLI:
1. Resolves the validator set from local keystore or from a
bring-your-own-validators flag.
2. Generates per-chain genesis blobs from named templates.
3. Maps --pq-mode to the PQProfile byte.
4. Submits the CreateSovereignL1Tx and waits for finality.
The canonical map per brand × env is implemented in
~/work/lux/genesis/configs/lp182_chain_id_map.go (function
EVMChainID(family, env) and string-input convenience
EVMChainIDFor(brand, env)):
Operators must NEVER reuse a chainID across brand × env — the EVM
chainID is the EIP-155 replay-protection root, and reuse would allow
cross-network replay attacks. The chainID is bound at network creation
in NetworkSpec.EVMChainID and baked into the EVM genesis blob the
operator passes to CreateSovereignL1Tx.Chains[i].GenesisData, so
validators cannot disagree on chainID — it is consensus-bound from
round 1.
Cross-network calls (L1↔L2↔primary) flow through the Warp precompile
at 0x0200000000000000000000000000000000000005. The receiving network
verifies a BLS-aggregated signature over the source network's validator
set; no destination tracking or per-pair registration required. Warp
message header:
sourceChainID — the L1's blockchain ID (NOT EVM chainID).originSenderAddress — the sending contract's address, or address(0) for P-Chain-origin messages.
payload — opaque bytes; the receiving contract decodes per itsown schema.
Per LP-012, validator BLS signatures move to Pulsar hybrid (classical
BLS-12-381 + Pulsar lattice) over the activation timeline. The
ValidatorManager._initializeValidatorRegistration path verifies
PQ-side BLS proofs of possession alongside the classical leg when the
network's PQProfile is non-zero. A validator without a Pulsar key
cannot register on a PQ-profile L1 — the manager reverts with
InvalidBLSKeyLength for now (full PQ identifier work tracked under
LP-021).
The executor must fail-and-revert in each of these cases. Each case
is a test in ~/work/lux/node/vms/platformvm/txs/executor/:
PQProfile byte unrecognized | No state change |len(Chains) > MaxSovereignL1Chains | No state change |ManagerChainIdx >= len(Chains) | No state change |len(ManagerAddress) > MaxChainAddressLength | No state change |Manager-side revert paths (tested in
~/work/lux/standard/contracts/validators/test/):
InvalidStakeAmount |nodeID registration | NodeAlreadyRegistered |maximumChurnPercentage per period | MaxChurnRateExceeded |minStakeDuration | MinStakeDurationNotPassed |UnauthorizedOwner |InvalidWarpSourceChainID |messageNonce violates the monotonic constraint | InvalidNonce |The earlier draft (parent: <uint32> + networkID: <uint32>) carried
two pointers that, for every realistic configuration, meant the same
thing: "what network is this instance on / part of". The operator
had to reconcile the two fields, validate they agreed where they
overlapped, and document which one was authoritative. By collapsing
to a single networkID that matches LUX_NETWORK_ID semantically,
there is no reconcile step, no validation rule, no docstring
ambiguity. The data carries its own meaning.
validators instead of sovereign: boolA sovereign: bool field communicates the same information as
validators > 0 but with worse downstream ergonomics:
set" without a tri-state. validators is an integer with a clear
zero point.
sovereign: bool would have to be validated against validators (you can't have sovereign: true, validators: 0). The integer
doesn't need that validation rule.
validators to size theStatefulSet. A separate flag is redundant data.
One field, one truth.
A half-spawned L1 is observable on Lux primary: validators are
registered but no chain manifest exists, or chains exist but the
sovereign election did not complete. Pre-LP-018, operators had to
write reconciliation tooling to detect and complete or abort half-
spawned L1s. The atomic primitive eliminates the failure mode.
A node-side runtime profile knob is a misconfiguration trap. LP-018
binds the profile to the network's registration record on Lux
primary; a validator that reads the spawn record knows unambiguously
which Quasar profile it must run. A misconfigured validator
self-detects at first round.
InitialBalanceThe per-validator balance is the L1's seed-fund into the validator's
native account on the L1 (NOT on Lux primary). It funds the
validator's first round's gas without requiring a post-spawn bridge
transfer. Tenants commonly want to seed validators with operating
capital from genesis; LP-018 makes this an inline field, not a
follow-up tx.
MaxSovereignL1Chains = 16The cap (line 104) is set by tx-size considerations: a 16-chain
manifest with the worst-case GenesisData = MaxGenesisLen per chain
is still under the P-Chain mempool's per-tx ceiling. Practical L1s
ship 1-5 chains (EVM + maybe DEX + maybe FHE); the cap is a guard,
not a working limit.
A manual ceremony for L1 spawn (clicking through a CLI wizard,
copying genesis blobs by hand) is error-prone and not auditable. The
operator takes a single CR apply as the source of truth, reconciles
it declaratively, and any cluster observer can `kubectl get network
-o yaml` to see the canonical state. No tribal knowledge required.
ERC-7201 namespaced storage uses a hash of the namespace string as
the slot pointer. The vendored contracts inherit avalanche-icm.storage.*
strings from upstream. Renaming them would break in-place upgrades of
already-deployed managers (the new contract would read from a
different slot, losing all validator state). The human-facing
Solidity identifiers are renamed; the storage strings are not.
LP-018 is the lock-in. The legacy four-step flow (CreateNetworkTx,
AddPermissionlessValidator, CreateChainTx, ConvertNetworkToL1Tx)
remains supported for L1s that began as P-Chain-managed and later
converted. New tenants use CreateSovereignL1Tx from day 1.
Storage-layout integrity under upgrades: ERC-7201 storage slots are
PRESERVED from the upstream constants, so an in-place upgrade from a
pre-LP-018 manager reads the same slots and sees the same validator
state. Renaming the slot strings would silently lose state.
On-chain consumers (subgraphs, indexers, wallets) need no migration.
Prior public proposals from upstream parent-chain ecosystems
("reinventing-subnets" being the well-known reference) introduced a
subnet→L1 conversion path but left the four authoring traps listed
above. LP-018 supersedes that pattern entirely. References to the
upstream pattern by name are out of scope; the historical context is
preserved here for searchability only.
~/work/lux/operator/go/internal/controller/network_test.go:
1. Apply Network CR with networkID: 1, validators: 5.
2. Operator issues one CreateSovereignL1Tx against P-chain.
3. Operator boots 5 validator pods; each becomes ready.
4. Status reports phase: Ready, mode: Anchored,
activeValidators: 5, chainCount: len(spec.chains).
Same file:
1. Apply Network CR with networkID: 1, validators: 0.
2. Operator issues CreateChainTx per entry in spec.chains[].
3. Operator updates the parent's LuxNetwork.spec.tenantNetworks[]
to include the L2's blockchain IDs.
4. Parent's validators add the L2's chains to their --track-chains
on next reload.
5. Status reports phase: Ready, mode: L2, without any per-L2
validator pods.
1. Apply Network CR with networkID: 999001, validators: 5.
2. Operator emits a StatefulSet that runs an independent primary
(no P-chain interaction with Lux mainnet).
3. Status reports mode: Independent, activeValidators: 5.
~/work/lux/operator/go/internal/controller/network_promotion_test.go:
1. Apply Network CR with networkID: 1, validators: 0; observe
phase: Ready, mode: L2.
2. Patch to validators: 5.
3. Operator issues ConvertNetworkToL1Tx.
4. Operator deploys 5 new validator pods.
5. Status reports phase: Promoting → Ready, mode: Anchored.
6. The L1's chains continue producing blocks throughout (no chain
halt).
~/work/lux/operator/go/api/v1/network_mode_test.go exhaustively
covers the (networkID, validators) → mode mapping for every
canonical brand × env combination plus negative cases. The test is
the floor that prevents anyone from re-introducing parent or
sovereign: bool without breaking the suite.
~/work/lux/node/vms/platformvm/txs/create_sovereign_l1_tx.go is
covered by the standard vms/platformvm/txs codec round-trip
suite. The test must:
1. Build a valid CreateSovereignL1Tx with 5 validators, 3 chains,
PQProfile = 2 (Polaris).
2. Marshal to bytes via the v1 codec.
3. Unmarshal from bytes via the v1 codec.
4. Assert struct equality on every field.
5. Re-marshal and assert byte equality with step 2.
~/work/lux/node/vms/platformvm/txs/executor/standard_tx_executor_test.go:
1. Funds an account on P-Chain.
2. Submits a well-formed CreateSovereignL1Tx.
3. Asserts the post-tx state shows: (a) new network ID registered,
(b) all validators seeded into the manager contract, (c) all
chains registered, (d) sovereign election complete.
A negative-test file (canonical location:
~/work/lux/node/vms/platformvm/txs/executor/create_sovereign_l1_failure_test.go)
exercises each of the nine failure cases listed in
§Specification.Failure-mode-tests. Each test asserts NO partial state
visible post-execution.
The vendored test suite at
~/work/lux/standard/contracts/validators/test/ covers:
initializeValidatorRegistration happy path + nine failure modes.forceInitializeEndValidation over-rewards-vs-eligibility branches.~/work/lux/operator/go/api/v1/network_types.go — NetworkSpecCRD with the two-field shape and mode derivation.
~/work/lux/operator/go/internal/controller/network.go — reconciler dispatching on HasOwnValidatorSet().
~/work/lux/operator/go/api/v1/network_mode_test.go — themode-derivation lock-in tests.
~/work/lux/node/vms/platformvm/txs/create_sovereign_l1_tx.go:48 — CreateSovereignL1Tx struct.
~/work/lux/node/vms/platformvm/txs/create_sovereign_l1_tx.go:86 — SovereignL1Chain struct.
~/work/lux/node/vms/platformvm/txs/create_sovereign_l1_tx.go:104 — MaxSovereignL1Chains = 16.
~/work/lux/node/vms/platformvm/txs/create_sovereign_l1_tx.go:107-119— the canonical error set.
~/work/lux/node/vms/platformvm/txs/create_sovereign_l1_tx.go:131 — SyntacticVerify.
~/work/lux/node/vms/platformvm/txs/create_sovereign_l1_tx.go:175 — Visit(visitor Visitor) dispatcher.
~/work/lux/node/vms/platformvm/txs/visitor.go:34 — the Visitorinterface declaration that the executor implements.
~/work/lux/node/vms/platformvm/txs/convert_network_to_l1_tx.go:87 — ConvertNetworkToL1Validator (per-validator base struct).
~/work/lux/cli/cmd/l1cmd/create.go — operator CLI entry point.~/work/lux/operator/rust/src/crd.rs — NetworkSpec mirror withidentical JSON shape.
~/work/lux/operator/rust/src/controllers/network.rs —reconciler.
~/work/lux/standard/contracts/validators/ValidatorManager.sol —base class with the storage layout + Warp messaging.
~/work/lux/standard/contracts/validators/PoSValidatorManager.sol— PoS extension with delegation + uptime + rewards.
~/work/lux/standard/contracts/validators/NativeTokenStakingManager.sol— concrete instance bound to the L1's native token via the
native-minter precompile.
~/work/lux/standard/contracts/validators/ValidatorMessages.sol —Warp message codec.
~/work/lux/genesis/configs/lp182_chain_id_map.go — typed EVMChainID(family, env) + string-input EVMChainIDFor(brand, env).
~/work/lux/genesis/configs/lp182_chain_id_map_test.go — lock-intests for every brand × env cell.
The atomicity contract is the load-bearing security property. A naive
executor that committed each step incrementally would leak half-state
on partial-failure paths; a hostile actor could mint a half-spawned
L1 record and use it to confuse downstream tooling (genesis
verifiers, light clients, bridge contracts) without ever running a
real L1.
The PQ-profile spawn-bind closes a subtle attack: an attacker who
runs N validators with a weaker profile (Pulsar instead of Polaris)
could censor a Polaris-profile L1's rounds by emitting weaker-profile
certs that the network rejects. With the profile bound to the
registration record AND verified by the on-L1 manager, every honest
validator can locally check the cert profile against the spawn-time
pin and refuse to participate if its own config mismatches.
The manager enforces maximumChurnPercentage per
churnPeriodSeconds. A hostile validator that floods the L1 with
registration messages cannot exceed the churn rate — the manager
reverts at _checkAndUpdateChurnTracker. The P-Chain sees only the
Warp messages that the manager actually emits, so the parent
network's mempool is naturally bounded.
The CR shape's surface area is two fields. There are only two ways
to misconfigure it: (a) a non-canonical networkID for an instance
the user thought was a Lux primary; (b) a validators count below 0
or above the operator's per-cluster cap. Both are statically
detectable at CR-apply time by the admission webhook. No silent
modes.
ConvertNetworkToL1Tx is a single atomic state transition on the
parent P-Chain. A partial failure leaves both old (L2-tracking) and
new (L1-validating) states inconsistent only if the executor
silently commits a partial diff — forbidden by the standard tx
executor's commit-or-revert contract (§Atomicity contract).
The atomic spawn does NOT eliminate the social-coordination cost of
bringing N validators online simultaneously. Operators must still
pre-stage validator daemons. The atomicity is at the chain state
level, not at the operator-side rollout level.
Copyright and related rights waived via CC0.