Lux Proposals
← All proposals
LP-0018Final

LP-018: Network CR + Atomic L1 Spawn Primitive

Abstract

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.

Motivation

CR shape — kill the four authoring traps

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.**

Atomic spawn — kill the four-step ceremony

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.

Specification

The canonical Network CR


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-derivation table

Mode is pure data → behavior:

| networkID ∈ {1,2,3,1337} | validators | Mode |
|---|---|---|
| YES | 0 | L2 — chains served by the Lux primary's existing set |
| YES | > 0 | Anchored — N validators participate in the Lux primary (covers both "the primary itself" and "sovereign L1 anchored to it") |
| NO | > 0 | Independent — own primary, fully independent of Lux |

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.

Recursion (L3+) deferred

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.

Why no separate ConvertNetworkToSovereign primitive

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.

Validator-manager pattern

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:

| Action | Purpose | Caller |
|---|---|---|
| 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.

Atomic spawn transaction

~/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:

The 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.

Atomicity contract

> 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.

Multi-version codec compatibility

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.

Operator-driven lifecycle

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:

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.

Operator CLI surface

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.

Per-network EVM chainID per env

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)):

| Brand | Mainnet (networkID=1) | Testnet (networkID=2) | Devnet (networkID=3) | Localnet (networkID=1337) |
|---|---|---|---|---|
| Lux primary EVM (C-Chain) | 96369 | 96368 | 96370 | 31337 |
| Hanzo L2 | 36963 | 36962 | 36964 | TBD |
| Zoo L2 | 200200 | 200201 | 200202 | TBD |
| SPC L2 | 36911 | 36910 | 36912 | TBD |
| Pars sovereign / independent | 7070 | 494950 | 494951 | TBD |
| Osage | TBD | TBD | TBD | TBD |

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.

Warp interop

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:

PQ-ready churn semantics

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

Failure-mode tests

The executor must fail-and-revert in each of these cases. Each case

is a test in ~/work/lux/node/vms/platformvm/txs/executor/:

| Failure | Expected state |
|---|---|
| Underfunded BaseTx | No state change |
| Derived network ID equals PrimaryNetworkID | No state change |
| PQProfile byte unrecognized | No state change |
| Validators not sorted/unique | No state change |
| Any Validators[i].Verify() fails | No state change |
| len(Chains) > MaxSovereignL1Chains | No state change |
| Any Chains[i].Verify() fails | 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/):

| Failure | Manager response |
|---|---|
| Underweight registration | InvalidStakeAmount |
| Duplicate nodeID registration | NodeAlreadyRegistered |
| Weight churn > maximumChurnPercentage per period | MaxChurnRateExceeded |
| End-validation called before minStakeDuration | MinStakeDurationNotPassed |
| Non-owner ends a PoS validation | UnauthorizedOwner |
| Warp message from wrong source chain | InvalidWarpSourceChainID |
| messageNonce violates the monotonic constraint | InvalidNonce |

Rationale

Why one field instead of two

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.

Why validators instead of sovereign: bool

A sovereign: bool field communicates the same information as

validators > 0 but with worse downstream ergonomics:

One field, one truth.

Why atomicity matters

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.

Why PQ profile binds at spawn

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.

Why per-validator InitialBalance

The 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.

Why MaxSovereignL1Chains = 16

The 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.

Why operator-driven (and not manual P-chain ceremony)

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.

Why the storage-slot strings stay

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.

Backwards Compatibility

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.

Historical context (informational)

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.

Test Cases

Network CR happy path (Anchored / Sovereign L1)

~/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).

Network CR happy path (L2)

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.

Network CR happy path (Independent)

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.

L2 → Anchored promotion

~/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).

Mode-derivation unit table

~/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.

Codec round-trip

~/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.

Executor happy path

~/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.

Executor failure modes

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.

Staking-manager unit tests

The vendored test suite at

~/work/lux/standard/contracts/validators/test/ covers:

Reference Implementation

Go (operator + node)

Rust (tenant operator mirrors)

Solidity (on-L1 staking manager)

Canonical chainID map

Security Considerations

Atomicity is load-bearing

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.

PQ-profile binding closes a cert-substitution attack

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.

Churn-rate bounded by manager, not by P-Chain

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.

Two fields, two values to validate

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.

Promotion atomicity

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

Social-coordination cost

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

Copyright and related rights waived via CC0.