Compare commits

...
Author SHA1 Message Date
Hanzo AI 7275bdf9d2 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.
2026-05-29 21:17:21 -07:00
Hanzo AI 3fcc6085d5 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.
2026-05-29 20:25:28 -07:00
Hanzo AI 5b0eca3270 brand: rip <tenant> from builder comment 2026-05-29 14:26:21 -07:00
Hanzo AI c0d9ccacde brand: rip <tenant> name from code comments (no crossover) 2026-05-29 14:26:00 -07:00
Hanzo AI 3919991f48 fix(docker): bump EVM_VERSION v0.8.40 → v0.18.14 (post-rename plugin)
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 9c42fe1126 (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
0c573e6aa7 (host compat shim) — once every cluster pulls an image that
contains this Dockerfile bump, the shim is a no-op.
2026-05-27 04:59:03 -07:00
Hanzo AI 0c573e6aa7 fix(rpcchainvm): compat shim for pre-rename plugin env-var
The EngineAddressKey const renamed from LUX_VM_RUNTIME_ENGINE_ADDR to
VM_RUNTIME_ENGINE_ADDR in 9c42fe1126 (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).
2026-05-27 04:57:24 -07:00
Hanzo AI 711d2519c2 chore: update 2026-05-25 15:13:02 -07:00
Hanzo AI 9323caa1fc go.mod: replace luxfi/corona v0.7.5 — track keyera.Bootstrap 3-value return
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.
2026-05-24 19:17:18 -07:00
Hanzo AI ff9faa3ef3 ci/docker: fall back to UNIVERSE_PAT when org GH_TOKEN is empty
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).
2026-05-24 18:57:38 -07:00
Hanzo AI cae6f18ffb ci/docker: self-check GH_TOKEN propagation before BuildKit secret mount
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.
2026-05-24 18:51:28 -07:00
Hanzo AI d48292b9dc genesis/builder + ci: initialSupply matches emitted UTXOs; Dockerfile keeps insteadOf live
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.
2026-05-24 18:43:49 -07:00
Hanzo AI c926953b60 ci/docker: resolve private luxfi/* deps via org-level GH_TOKEN PAT
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.
2026-05-24 18:34:58 -07:00
Hanzo AI 7184449585 genesis/builder: back-compat — initialAmount UTXO only when unlockSchedule empty
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
2026-05-24 18:28:00 -07:00
Hanzo AI 8e5bfb68dc wallet/primary: tighten commented future-work lines to EVM canonical names
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
2026-05-24 13:27:31 -07:00
Hanzo AI 51d4bd9520 wallet: mass-rename EthKeychain → EVMKeychain across all examples (no aliases)
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.
2026-05-24 06:34:38 -07:00
Hanzo AI 2e18d7da38 wallet: strip all Eth/Keccak aliases — EVMKeychain / EVMAddresses / WithCustomEVMAddresses only
Forward-only per user "no aliases!!! just keep evm address and utxo address"
and CLAUDE.md "no backwards compatibility only forwards perfection".

wallet/network/primary/wallet.go:
- Removed KeccakKeychain interface (was Deprecated)
- Removed EthKeychain interface (was Deprecated)
- Removed GetByKeccak/KeccakAddresses methods on KeychainAdapter
- Removed GetEth/EthAddresses methods on KeychainAdapter
- Renamed WalletConfig.EthKeychain field → EVMKeychain
- Mass renamed EthKeychain → EVMKeychain across all 18 example
  programs (sed -i '' s/EthKeychain/EVMKeychain/g)

wallet/network/primary/common/options.go:
- Removed KeccakAddresses() method (was Deprecated)
- Removed EthAddresses() method (was Deprecated)
- Removed WithCustomKeccakAddresses (was Deprecated)
- Removed WithCustomEthAddresses (was Deprecated)
- Renamed private fields customEthAddresses{Set} → customEVMAddresses{Set}

Only canonical names remain:
- EVMKeychain interface
- KeychainAdapter.{GetByEVM, EVMAddresses}
- WalletConfig.EVMKeychain
- Options.EVMAddresses
- WithCustomEVMAddresses

Downstream callers using old names break at compile time. Lockstep
break-fix follows in cli, kms, mpc, state.

Deps: utxo v0.3.2 → v0.3.3, crypto v1.19.15 → v1.19.16.

Per CLAUDE.md x.x.x+1.
2026-05-24 06:17:08 -07:00
Hanzo AI b4a3ecdd75 wallet: reconcile — EVMKeychain / EVMAddresses / WithCustomEVMAddresses canonical
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.
2026-05-23 22:24:05 -07:00
Hanzo AI f6639e661b wallet: decomplect — add KeccakKeychain interface alongside deprecated EthKeychain
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.
2026-05-23 19:51:10 -07:00
Hanzo AI 130927f056 go.mod: bump luxfi/genesis v1.12.13 → v1.12.14 (clean build) 2026-05-23 16:27:17 -07:00
Hanzo AI 530b428159 go.mod: bump luxfi/genesis v1.12.12 → v1.12.13 (keys.go build fix) 2026-05-23 16:25:36 -07:00
Hanzo AI f86909a928 go.mod: bump crypto v1.19.14 + genesis v1.12.12 (keccak→keccak256 subpackage rename) 2026-05-23 16:21:54 -07:00
Hanzo AI 3fb51e1995 go.mod: bump luxfi/genesis v1.12.10 → v1.12.11 (keccak primitive direct) 2026-05-22 21:20:01 -07:00
Hanzo AI cbd10105be go.mod: bump luxfi/genesis v1.12.8 → v1.12.10 2026-05-22 21:05:25 -07:00
Hanzo AI eb510e0ca4 drop ethAddr/luxAddr/luxcrypto stragglers — match genesis v1.12.10 2026-05-22 21:05:21 -07:00
Hanzo AI 04902cdca5 go.mod: bump luxfi/genesis v1.12.7 → v1.12.8 (no more eth* identifiers) 2026-05-22 20:56:53 -07:00
Hanzo AI 5cbb1e4431 drop ethAddr/luxAddr string tags — canonical is evmAddr/utxoAddr 2026-05-22 20:40:49 -07:00
Hanzo AI bcbb141378 go.mod: bump luxfi/genesis v1.12.6 → v1.12.7 (EVMAddr Go field) 2026-05-22 20:37:37 -07:00
Hanzo AI bcd6c6b46d deps: bump consensus v1.24.6, threshold v1.8.5, metric v1.5.5 (kill prom/protobuf transitives) 2026-05-22 20:24:51 -07:00
Hanzo AI 91e91218df go.mod: bump luxfi/genesis v1.12.5 → v1.12.6 (JSON evmAddr+utxoAddr) 2026-05-22 20:11:51 -07:00
Hanzo AI 000a0c84ff purge Avalanche-era upgrade names from test fixtures
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.
2026-05-22 02:37:40 -07:00
Hanzo AI d12c3457af go.mod: bump luxfi/genesis v1.12.4 → v1.12.5 (devnet 100M/wallet alignment) 2026-05-22 00:11:28 -07:00
Hanzo AI 63d602b8ca go.mod: bump luxfi/genesis v1.12.2 → v1.12.4 (refreshed canonical hashes + devnet 50M) 2026-05-21 23:48:45 -07:00
Hanzo AI ef0c581714 genesis,config,xvm: derive X-Chain asset ID from genesis content
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.
2026-05-21 18:34:50 -07:00
Hanzo AI f852526092 docker: fall back to ubuntu-latest runner — lux-build ARC offline
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.
2026-05-21 17:43:22 -07:00
Hanzo AI 18f54f9eb0 LLM.md: FeePolicy table — add G-Chain (graphvm) NoUserTxPolicy row 2026-05-21 17:35:22 -07:00
Hanzo AI 964be2fad8 docker: inject GH_TOKEN via BuildKit secret for private mod download
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.
2026-05-21 17:28:07 -07:00
Hanzo AI ca7cdb4a77 LLM.md: document FeePolicy canonical wiring convention
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.
2026-05-21 17:23:58 -07:00
Hanzo AI 99eddf0827 gitignore stray dev-tool binaries 2026-05-21 15:01:49 -07:00
Hanzo AI 742c35485f drop hardcoded /Users/z paths — use $HOME-relative 2026-05-21 15:01:36 -07:00
Hanzo AI e94b025395 rip: proto/zap/ moved to luxfi/proto/node/zap (canonical proto module) 2026-05-21 14:42:42 -07:00
Hanzo AI ee81e8ea8f genesis/builder: track upstream Allocation.ETHAddr rename
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.
2026-05-21 12:53:05 -07:00
Hanzo AI 5a92bff2cd go.mod: bump luxfi/genesis to v1.12.1 (kill F12/F30 slop) 2026-05-21 12:30:43 -07:00
Hanzo AI 0aef65bc5d go.mod: bump luxfi/genesis to v1.12.0 (no backward compat) 2026-05-21 10:44:45 -07:00
Hanzo AI 6fdc4ddfa7 go.mod: bump luxfi/genesis to v1.11.9 (xchain.json shards baked) 2026-05-21 03:47:01 -07:00
Hanzo AI 708268aa71 genesis/builder: chain registry as data — decomplect aliases from switch ladders
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)
2026-05-21 03:43:00 -07:00
Hanzo AI 2f644a14bc vms/xvm/genesis: extract DefaultLUXGenesisBytes (decomplect builder from xvm)
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.
2026-05-21 03:36:50 -07:00
Hanzo AI 573346c6b8 vms/types/fee: introduce FeePolicy interface (close free-tx paths)
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.
2026-05-21 03:34:28 -07:00
Hanzo AI 0e8856758a use UTXOAssetIDFor (brand-neutral) — constants v1.5.7 2026-05-21 03:30:47 -07:00
Hanzo AI ba8a1fc1a7 genesis,config: use LUXAssetIDFor(networkID) — per-network LUX asset ID
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 (6780c4fd) into
the same push: X-Chain is now opt-in via XChainGenesis, no longer
hardcoded as "always present".

Builds + tests green.
2026-05-20 22:20:55 -07:00
Hanzo AI 6780c4fdee genesis/builder: X-Chain becomes opt-in (decomplect from "always present") 2026-05-20 22:08:54 -07:00
Hanzo AI 129dfd7b46 rip: AI-generated debug fmt.Printf spam + soldier-on warnings + dead-code tombstones
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.
2026-05-20 17:11:52 -07:00
Hanzo AI b6d1bdca7c node: refresh config + rpcdb + vm registry + subprocess initializer
Routine maintenance: config.go, rpcdb/service.go, node/node.go,
vms/registry/registry.go, vms/rpcchainvm/runtime/subprocess/initializer.go.
2026-05-20 16:26:18 -07:00
Hanzo AI ffb627a51f genesis/builder: use UTXO_ASSET_ID constant + EVMAddr/UTXOAddr field names
- 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.
2026-05-20 16:11:51 -07:00
Hanzo AI b2c2376678 fix(docker): chains/* plugin builds best-effort
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.
2026-05-19 13:32:30 -07:00
Hanzo AI 8bcc23efdb ci: switch lux/node workflows to lux-build (luxfi-scoped ARC pool)
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.
2026-05-19 13:14:59 -07:00
Hanzo AI 2663090827 deps: bump luxfi/genesis v1.11.0→v1.11.1 (strip bogus EVM chainIds from non-EVM letter chains) 2026-05-19 11:50:06 -07:00
Hanzo AI 65b7d6a1ae deps: bump luxfi/{geth,coreth,crypto,precompile} to LP-4200 all-8-PQ-precompile versions (geth v1.16.98, coreth v1.22.4, crypto v1.19.3, precompile v0.5.23) 2026-05-19 11:44:54 -07:00
Hanzo AI d15be7c524 ci: cross-compile arm64 from amd64 (no GH-hosted arm runners)
- 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.
2026-05-19 09:11:06 -07:00
Hanzo AI d812ada7df ci: remove sibling-dir replace shims (api/consensus/runtime) — breaks Windows CI; bump consensus → v1.24.0 (just-tagged) 2026-05-19 08:24:55 -07:00
Hanzo AI 2abb88530c decomplect: regenerate platformvm tx test fixtures after codec type-ID collapse
The e27d954097 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)
2026-05-19 08:15:20 -07:00
Hanzo AI dedb7eb806 ci: fix runner label (hanzo-build pool)
Imaginary runner label `lux-build-linux-amd64` does not exist. The live
amd64 pool is `hanzo-build-linux-amd64`.

- docker.yml: build-amd64
2026-05-19 08:02:23 -07:00
Hanzo AI a03785d922 decomplect: final BanffBlock→TimestampedBlock rename + strip residual Apricot/Banff comments in block visitors 2026-05-19 07:17:02 -07:00
Hanzo AI ea066101a6 decomplect: rename BanffBlock→TimestampedBlock; legacy error/priority identifiers neutralized; strip durango/etna/banff JSON keys from test fixtures 2026-05-19 07:06:33 -07:00
Hanzo AI 69258c94b0 decomplect: strip upgrade-name comments (Apricot/Banff/Cortina/Durango/Etna/Fortuna/Granite); activate-all-implicitly doesn't need them 2026-05-19 07:00:08 -07:00
Hanzo AI 23bd9575ea decomplect: rip upgradetest/ Fork enum + GetConfig() shim; rewrite 5 xvm test files to use upgrade.Default directly
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.
2026-05-19 06:53:04 -07:00
Hanzo AI fab47e2b93 decomplect: rip AlwaysOn adapter + NetworkUpgrades wire surface (Rip A+B partial)
- 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.
2026-05-18 23:22:59 -07:00
Hanzo AI e0125e315d decomplect: delete legacy upgrade test scenarios + upgradetest fork enum slim-down + Apricot/Banff block test files
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).
2026-05-18 22:50:25 -07:00
Hanzo AI e27d954097 decomplect: collapse upgrade-prefixed block-type variants to single canonical (StandardBlock/ProposalBlock/AbortBlock/CommitBlock); Visitor 9→4 methods; codec single-type registration; old chaindata wire compat broken (intentional)
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.
2026-05-18 22:25:45 -07:00
Hanzo AI 75da501683 decomplect: delete IsXxxActivated predicates (Apricot/Banff/Cortina/Durango/Etna/Fortuna/Granite); inline all callsites to always-on; activate-all-implicitly from genesis
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).
2026-05-18 22:16:55 -07:00
Hanzo AI 9239065fdc decomplect: delete vms/platformvm/upgrade dead package
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.
2026-05-18 21:37:30 -07:00
Hanzo AI 95610b4b83 rename: github.com/luxfi/protocol → github.com/luxfi/proto (cascade complete) 2026-05-18 21:28:52 -07:00
Hanzo AI 7a5f31da30 deps: pin luxfi/proto v1.0.0 (rename cascade complete) 2026-05-18 21:27:12 -07:00
Hanzo AI 8d2ffbd4c9 rename: github.com/luxfi/protocol → github.com/luxfi/proto (imports + go.mod replace) 2026-05-18 21:19:09 -07:00
Hanzo AI 263115933e purge Avalanche-lineage keywords (one pure modern version)
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.
2026-05-18 21:14:35 -07:00
Hanzo AI b691a0d07e deps: chains v1.2.2 → v1.2.3 (Corona purge complete — go mod tidy now clean) 2026-05-18 20:49:57 -07:00
Hanzo AI 8c874943f9 deps: consensus v1.23.29 (pulsar v1.0.8 consolidation patch) 2026-05-18 19:59:47 -07:00
Hanzo AI 01c40f969e go.mod: bump consensus v1.23.15 → v1.23.28 + chains v1.2.1 → v1.2.2
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.
2026-05-18 18:56:41 -07:00
Hanzo AI 8884c17f54 node: nuke allow-custom-genesis + allow-genesis-update flags
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.
2026-05-17 19:19:28 -07:00
Hanzo AI 87e2ac3615 network: chicken-and-egg fallback when validator manager empty
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.
2026-05-17 18:20:01 -07:00
Hanzo DevandGitHub b6eae71825 chore: drop LUX_ prefix from env var lookups (MNEMONIC) (#113)
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.
2026-05-17 10:21:33 -07:00
Hanzo DevandGitHub c44df7e15c Merge pull request #112 from luxfi/chore/zap-native-only-kill-grpc-build-tags
node: ZAP-native only — kill every //go:build grpc path
2026-05-16 17:25:49 -07:00
Hanzo AI 92c430ee12 node: ZAP-native everywhere — kill every //go:build grpc path
The companion commit `3be49b29ec` 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).
2026-05-16 17:23:26 -07:00
Hanzo AI 3be49b29ec rpcchainvm: zap-native only — delete every grpc-tagged path
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
2026-05-16 17:11:31 -07:00
Hanzo DevandGitHub 0486947913 Merge pull request #111 from luxfi/chore/gitignore-cleanup-and-tag-refresh
chore: drop stale db* gitignore + refresh LLM/CLAUDE Latest Tag
2026-05-16 16:48:07 -07:00
hanzo-dev 105fd207c0 .gitignore + docs: drop stale db* rule; refresh Latest Tag to v1.26.28
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.
2026-05-16 16:47:41 -07:00
Hanzo DevandGitHub e1550aaea6 chore: bump Go toolchain to 1.26.3 (#110)
Pin Go version to 1.26.3 across go.mod, CI workflows, and Dockerfiles
for canonical alignment with the rest of the luxfi/* stack.
2026-05-16 16:46:20 -07:00
Hanzo DevandGitHub 25adc9c75c rpcdb: consolidate into node/db/rpcdb, drop luxfi/proto dep (#109)
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.
2026-05-16 16:35:56 -07:00
Hanzo DevandGitHub 09dffe5430 deps: luxfi/api v1.0.10 -> v1.0.11 (#108)
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.
2026-05-16 16:29:55 -07:00
df173e4263 build(deps): bump peter-evans/repository-dispatch from 3 to 4 (#103)
Bumps [peter-evans/repository-dispatch](https://github.com/peter-evans/repository-dispatch) from 3 to 4.
- [Release notes](https://github.com/peter-evans/repository-dispatch/releases)
- [Commits](https://github.com/peter-evans/repository-dispatch/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peter-evans/repository-dispatch
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-05-16 16:17:30 -07:00
Hanzo DevandGitHub b561269ef8 Merge pull request #107 from luxfi/licensing/canonical-pointer
docs: add LICENSING.md pointer to canonical Lux IP strategy
2026-05-15 16:34:50 -07:00
Hanzo Dev 20506a5950 docs: add LICENSING.md pointing at canonical Lux IP strategy
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.
2026-05-15 16:34:43 -07:00
Hanzo AI 0ebfa14b6d rpcdb: decomplect into Layer A/B/C topology
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).
2026-05-15 15:47:44 -07:00
Hanzo AI f97f552e87 rpcchainvm/zap: spawn ZAP rpcdb server in Initialize, populate DBServerAddr
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.
2026-05-15 15:06:07 -07:00
Hanzo AI 2bde2c707f chore: bump precompile v0.5.16 -> v0.5.17, node v1.22.78 -> v1.22.79 2026-05-15 12:16:02 -07:00
Hanzo AI 913acca108 chore(brand): drop LUX_ env-var prefixes (LUX_PATH, LUX_KMS_*, LUX_CGO, LUX_PRIVATE_KEY, LUX_AUTOMINE_*)
- scripts/* + .github/actions/* + .github/workflows/*: LUX_PATH ->
  NODE_PATH, LUX_VERSION -> NODE_VERSION.
- Dockerfile: LUX_CGO -> CGO_ENABLED (matches Go convention).
- staking/kms.go: LUX_KMS_ENDPOINT/PATH/TOKEN -> KMS_ENDPOINT/PATH/TOKEN.
- deploy-subnets.sh: LUX_PRIVATE_KEY -> PRIVATE_KEY.
- config/config.go: drop LUX_AUTOMINE_CCHAIN_GENESIS_PATH env override;
  downstream networks ship their own platform-genesis bundle instead.
2026-05-15 12:15:55 -07:00
Hanzo AI 50b27dbf94 refactor(pq): strict-PQ forward-only, drop transition window + classical-compat
- Remove ActivationHeight migration window from SchemeGate; the gate
  refuses non-PQ NodeIDScheme bytes at every height.
- Remove LUX_CLASSICAL_COMPAT_UNSAFE operator escape hatch; classical
  staker.crt/key flags retained only for legacy no-profile chains.
- Rename profile from LUX_STRICT_E2E_PQ to STRICT_E2E_PQ (brand sweep).
- Update LLM.md to reflect forward-only PQ policy.
2026-05-15 12:15:42 -07:00
Hanzo AI 2f9ef85652 feat(platformvm): P-only mode + native CreateAssetTx/OperationTx
- 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).
2026-05-15 12:15:31 -07:00
Hanzo AI 9c42fe1126 refactor: rename g* grpc packages to rpc* (galiasreader, gkeystore, gwarp, ghttp) 2026-05-15 12:15:14 -07:00
Hanzo AI e42b295617 ci: skip buf-lint when no .proto files present (ZAP-native default) 2026-05-13 13:45:20 -07:00
Hanzo AI d7312ad8a9 go.sum: tidy after crypto v1.19.0 — add luxfi/crypto/ipa entries
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.
2026-05-13 13:32:27 -07:00
Hanzo AI c827de99a7 deps: bump luxfi/crypto v1.19.0 — canonical luxfi/crypto/ipa 2026-05-13 11:55:49 -07:00
Hanzo AI 24aaa598c5 go.mod: bump protocol v0.0.4 + sdk v1.16.60 2026-05-13 00:21:23 -07:00
Hanzo AI 3d5466eef4 go.mod: bump go directive to 1.26.3 (security advisory) 2026-05-12 20:55:01 -07:00
Hanzo AI b5a2a8d3db go.mod: bump warp v1.18.5 → v1.18.6 (pq.Mode decomplection)
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.
2026-05-12 20:07:09 -07:00
Hanzo AI b5a4fc3f1f ci/Docker: pass LUX_CGO=0 — the runtime image doesn't ship luxcpp .so libs, so CGO=1 segfaults on early init 2026-05-12 14:50:42 -07:00
Hanzo AI 90738f212a genesis/builder: collapse opt-in chains to one table-driven loop
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.
2026-05-12 13:49:03 -07:00
Hanzo AI 50b4002e51 go.mod: drop ../corona local replace, pin corona v0.3.0 (so Docker build sees pkg) 2026-05-12 12:18:12 -07:00
Hanzo AI 15ab4dbd47 ci: inline Docker build, drop cross-org reusable workflow call
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.
2026-05-12 12:13:51 -07:00
Hanzo AI bdf3c5d00e ci: revert runner labels back to lux-build-linux-* (correct for luxfi org) 2026-05-12 12:09:36 -07:00
Hanzo AI dadaad8a22 ci: fix Docker workflow runner labels (lux-build-linux-* → hanzo-build-linux-*) 2026-05-12 12:05:32 -07:00
Hanzo AI eea02da21a node: align with crypto v1.18.6 (SchemeCorona → SchemeCorona) 2026-05-12 10:23:43 -07:00
Hanzo AI 476c6dd12a node: bump validators to v1.2.0 (CoronaPubKey) 2026-05-12 09:58:04 -07:00
Hanzo AI 35d48b9d1c node: kill all remaining Corona identifiers across consensus, vms, wallet
Final identifier purge for the node repo. After this commit zero
'Corona' / 'CORONA' references remain in any Go file.

  consensus/quasar:
    CoronaWork/Valid/Time/SizeMismatch  → CoronaWork/Valid/Time/SizeMismatch
    SignatureTypeCorona                 → SignatureTypeCorona
    CoronaConfig/Stats/Signature        → CoronaConfig/Stats/Signature
    NewCoronaSignature                  → NewCoronaSignature
    CoronaRound1/Round2/RoundData       → CoronaRound1/Round2/RoundData
    CoronaGroupKey/KeyShare/Coordinator → CoronaGroupKey/KeyShare/Coordinator
    CoronaLatency/Parties/Threshold     → CoronaLatency/Parties/Threshold
    ConnectCorona/InitializeCorona    → ConnectCorona/InitializeCorona
    ErrCoronaNotConnected/Failed        → ErrCoronaNotConnected/Failed
    CoronaSigners/Stats                 → CoronaSigners/Stats
    GetCorona()                         → GetCorona()
    {gpu,cpu}CoronaVerify               → {gpu,cpu}CoronaVerify
    set/teardown test helpers + dozens of compound identifiers all renamed

  vms/platformvm:
    SigTypeCorona   → SigTypeCorona

  wallet/keychain:
    KeyTypeCorona   → KeyTypeRingSig   (this is the LSAG ring-sig key
                                          kind — descriptive, not lemur-named)
    AddCorona       → AddRingSig
    Test*Corona*    → Test*RingSig*

Parallel Ring+Module Double-Lattice PQ posture intact: Corona
(Ring-LWE) + Pulsar (Module-LWE) supported in parallel as
independent threshold finality kernels.

Tests: consensus/quasar 45s, wallet/keychain 3.5s — both green.
2026-05-12 09:54:58 -07:00
Hanzo AI be24ff49f0 node: wire StakingConfig.DeriveNodeID at lqd boot
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
6357802b7d 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.
2026-05-12 09:46:53 -07:00
Hanzo AI 8b8280c1e7 vms/chainadapter: namespaced chain IDs + seeded registry (unblocks T-Chain)
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.
2026-05-11 23:57:51 -07:00
Hanzo AI e0ebfac9d3 network/peer: PQ handshake + SchemeGate + signed-IP MLDSA sig (CR-3, CR-5, CR-9)
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)
2026-05-11 23:57:09 -07:00
Hanzo AI abc8a0856f LLM.md: document --import-chain-data bridge + post-E2E-PQ state
- 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.
2026-05-11 22:37:51 -07:00
Hanzo AI 2c49007406 config: register strict-PQ flags in addNodeFlags (pflag binding) 2026-05-11 21:43:27 -07:00
Hanzo AI 06a47b11b5 node: bump consensus → v1.23.15 (NewBFT engine factory)
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.
2026-05-11 21:03:56 -07:00
Hanzo AI 0af39d7d40 node: bump consensus v1.23.14 + threshold v1.6.8 (corona/pulsar split)
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.
2026-05-11 19:30:22 -07:00
Hanzo AI 182401b3be service/security: update test assertions to STRICT (was STRICT_PQ)
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.
2026-05-11 18:59:33 -07:00
Hanzo AI 6357802b7d config: strict-PQ identity flags + StakingConfig.DeriveNodeID seam
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.
2026-05-11 17:47:38 -07:00
Hanzo AI 4dd4910bf3 node: drop Quasar/Annulus/Lux from profile identifiers
Mirror of consensus/config rename:
  ProfileLuxStrictPQ   -> ProfileStrictPQ
  ProfileLuxPermissive -> ProfilePermissive
  ProfileLuxFIPS       -> ProfileFIPS
  LuxStrictPQ()        -> StrictPQ() constructor
  LuxPermissive()      -> Permissive()

Updates across chains, network/peer (handshake + scheme_gate), node init,
service/security (metrics + types), and platformvm/xvm profile tests.
Wire-string assertions updated to drop LUX_ prefix (e.g. "STRICT_PQ").
2026-05-11 16:51:03 -07:00
Hanzo AI 766e8b2010 node/version: bump to v1.26.13 + register in RPCChainVMProtocol map
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).
2026-05-11 10:05:05 -07:00
Hanzo AI 77010b6652 node/service/security: rename namespace lux→security, drop /v1/ from REST paths
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/...).
2026-05-11 10:03:51 -07:00
Hanzo AI 9759c2e253 node+chains: stamp ChainSecurityProfile pin into C-Chain plugin config (closes F118)
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.
2026-05-11 10:01:28 -07:00
Hanzo AI 05b139aadc node/network/kem: canonical KEM scheme numbering via config.KeyExchangeID (Bug 3)
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}.
2026-05-11 09:29:42 -07:00
Hanzo AI d86c155eb1 node/network/peer: F103 — document hybrid vs pure ML-KEM-768 decision
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).
2026-05-11 09:28:35 -07:00
Hanzo AI 77fff17b01 node/service/security: lux_securityProfile RPC + Prometheus gauges
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.
2026-05-11 09:28:35 -07:00
Hanzo AI 0568d68b80 node/xvm: wire SetAuthPolicy from profile
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.
2026-05-11 09:28:35 -07:00
Hanzo AI 9a6b7d2455 node/platformvm: wire SetAuthPolicy from profile (closes CPX deferred item)
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"
2026-05-11 09:28:35 -07:00
Darkhorse7stars 61b3e380e0 ci(docker): use lux-build-* runners (cross-org from hanzoai/.github)
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.
2026-05-11 04:32:32 -05:00
Hanzo AI 9c9e6b9e28 node+genesis: wire ChainSecurityProfile into bootstrap (closes F102)
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.
2026-05-10 21:04:28 -07:00
Hanzo AI 4c1529da2a node/vms/avm: mempool refuses classical creds under strict-PQ
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).
2026-05-10 20:58:31 -07:00
Hanzo AI 4a345fe8e3 node/vms/platformvm: mempool refuses classical creds under strict-PQ
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.
2026-05-10 20:58:24 -07:00
Hanzo AI 102023f249 node/vms/txs/auth: ClassicalCompatRegistry + strict-PQ mempool gate
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.
2026-05-10 20:58:15 -07:00
Hanzo AI aee5923e68 node/vms/mldsafx: re-export ML-DSA feature extension
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.
2026-05-10 20:58:04 -07:00
Hanzo AI ea32845a7d node: fix go.mod/go.sum after consensus v1.23.4 retag
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.
2026-05-10 20:26:00 -07:00
Hanzo AI 3bb951e59e node: ML-DSA-65 promoted to canonical NodeID under strict-PQ
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.
2026-05-10 20:18:32 -07:00
Hanzo AI 517acef79b node/network: PQ peer handshake — ML-KEM-768/1024 + ML-DSA-65 identity
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.
2026-05-10 20:04:26 -07:00
Hanzo AI cc856c020c platformvm: C-Chain is opt-in via --track-chains, only X-Chain is built-in
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.
2026-05-10 15:21:36 -07:00
Hanzo AI a0d1a2bc31 chains: opt-in by plugin — drop TrackAllChains auto-true + skip missing
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.
2026-05-10 12:21:48 -07:00
Hanzo AI 9c95d6473a platformvm: add GetChains as canonical replacement for GetNets
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).
2026-05-09 18:08:24 -07:00
Hanzo AI 6f0cdee116 deps: bump luxfi/genesis → fe4781cd (LUX_DISABLE_CCHAIN env knob) 2026-05-09 16:28:04 -07:00
Hanzo AI 2060761d42 platformvm: gate createCChainIfNeeded on LUX_MIGRATE_CCHAIN=1
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.
2026-05-09 14:30:27 -07:00
Hanzo AI a4208faf95 zap/client: don't treat MsgResponseFlag as error flag
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.
2026-05-09 13:46:53 -07:00
Hanzo AI 1d94973509 deps: bump luxfi/genesis v1.9.5 → 2b16f454 (LUX_CCHAIN_GENESIS_FILE)
Picks up luxfi/genesis@2b16f454 — adds operator-overridable C-Chain
genesis at the embedded-loader layer. Pairs with 009f00ba (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.
2026-05-08 17:18:55 -07:00
Hanzo AI 009f00ba91 feat(automine): operator-overridable C-Chain genesis via LUX_AUTOMINE_CCHAIN_GENESIS_PATH
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.
2026-05-08 16:27:54 -07:00
Hanzo AI c26319db84 deps: bump luxfi/genesis v1.9.4 → v1.9.5 (gasLimit 1B everywhere) 2026-05-07 22:04:52 -07:00
Hanzo AI fd40807831 fix(node): C-Chain (primary-network EVM) is truly opt-in
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.
2026-05-07 21:08:42 -07:00
Hanzo AI 9b397125c1 deps: bump luxfi/genesis v1.9.3 → v1.9.4 (drop local cChainGenesis) 2026-05-07 21:04:17 -07:00
Hanzo AI e9c2d81ff5 deps: bump luxfi/genesis v1.9.2 → v1.9.3
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.
2026-05-07 20:18:05 -07:00
Hanzo AI df8af35979 fix(zap/client): keep wire field name LuxAssetID (proto-generated)
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.
2026-05-07 17:09:20 -07:00
Hanzo AI 1113675482 refactor(config): rename node.Config.LuxAssetID -> XAssetID
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.
2026-05-07 16:52:35 -07:00
Hanzo AI 131e6a588c fix(chains): register all 9 fx factories matching genesis builder
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.
2026-05-07 15:22:42 -07:00
Hanzo AI c790abb494 .gitignore + commit vms/components/lux content
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.
2026-05-06 21:09:12 -07:00
Hanzo AI fcde96701d vms: restore components/lux + add thresholdvm re-export shim
* 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.
2026-05-06 21:08:25 -07:00
Hanzo AI 6e76c846fb proto/pb: stub empty pb packages; vms/dexvm: re-export from chains/dexvm
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.
2026-05-06 20:55:12 -07:00
459 changed files with 16280 additions and 58026 deletions
-2
View File
@@ -3,5 +3,3 @@ self-hosted-runner:
- custom-arm64-focal
- custom-arm64-jammy
- hanzo-build-linux-amd64
- hanzo-build-linux-arm64
- ubuntu-24.04-arm
@@ -12,9 +12,9 @@ else
MODULE_DETAILS="$(go list -m "github.com/luxfi/node" 2>/dev/null)"
# Extract the version part
LUX_VERSION="$(echo "${MODULE_DETAILS}" | awk '{print $2}')"
NODE_VERSION="$(echo "${MODULE_DETAILS}" | awk '{print $2}')"
if [[ -z "${LUX_VERSION}" ]]; then
if [[ -z "${NODE_VERSION}" ]]; then
echo "Failed to get luxd version from go.mod"
exit 1
fi
@@ -23,12 +23,12 @@ else
# v*YYYYMMDDHHMMSS-abcdef123456
#
# If not, the value is assumed to represent a tag
if [[ "${LUX_VERSION}" =~ ^v.*[0-9]{14}-[0-9a-f]{12}$ ]]; then
if [[ "${NODE_VERSION}" =~ ^v.*[0-9]{14}-[0-9a-f]{12}$ ]]; then
# Use the module hash as the version
LUX_VERSION="$(echo "${LUX_VERSION}" | cut -d'-' -f3)"
NODE_VERSION="$(echo "${NODE_VERSION}" | cut -d'-' -f3)"
fi
FLAKE="github:luxfi/node?ref=${LUX_VERSION}"
FLAKE="github:luxfi/node?ref=${NODE_VERSION}"
echo "Starting nix shell for ${FLAKE}"
fi
@@ -9,6 +9,6 @@ set -euo pipefail
# when go is already installed.
# 3 directories above this script
LUX_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )"; cd ../../.. && pwd )
NODE_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )"; cd ../../.. && pwd )
echo GO_VERSION="~$(sed -n -e 's/^go //p' "${LUX_PATH}"/go.mod)"
echo GO_VERSION="~$(sed -n -e 's/^go //p' "${NODE_PATH}"/go.mod)"
-3
View File
@@ -67,9 +67,6 @@
description: "This involves consensus"
- name: "continuous staking"
color: "f9d0c4"
- name: "Durango"
color: "DAF894"
description: "durango fork"
- name: "gossiping upgrade"
color: "c2e0c6"
- name: "merkledb"
+14
View File
@@ -2,6 +2,9 @@ name: Lint proto files
on:
push:
paths:
- 'proto/**/*.proto'
- '.github/workflows/buf-lint.yml'
permissions:
contents: read
@@ -11,10 +14,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for .proto files
id: proto-check
run: |
if find proto -name '*.proto' -type f 2>/dev/null | grep -q .; then
echo "has_proto=true" >> $GITHUB_OUTPUT
else
echo "has_proto=false" >> $GITHUB_OUTPUT
echo "No .proto files found — skipping buf-lint (node is ZAP-native by default; protobuf is opt-in)."
fi
- uses: bufbuild/buf-setup-action@v1
if: steps.proto-check.outputs.has_proto == 'true'
with:
github_token: ${{ github.token }}
version: "1.47.2"
- uses: bufbuild/buf-lint-action@v1
if: steps.proto-check.outputs.has_proto == 'true'
with:
input: "proto"
@@ -20,7 +20,10 @@ jobs:
GOWORK: off
strategy:
matrix:
os: [windows-latest, macos-latest]
# `macos-latest` currently resolves to GH-hosted arm64 macos (forbidden);
# pin to `macos-13` (amd64). darwin/arm64 coverage lives in
# build-macos-release.yml via cross-compile.
os: [windows-latest, macos-13]
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-go-for-project
+4 -4
View File
@@ -18,7 +18,7 @@ on:
jobs:
build-x86_64-binaries-tarball:
runs-on: hanzo-build-linux-amd64
runs-on: lux-build
permissions:
id-token: write
contents: read
@@ -68,7 +68,7 @@ jobs:
rm -rf ${{ github.workspace }}/luxd-pkg
build-arm64-binaries-tarball:
runs-on: ubuntu-24.04-arm
runs-on: lux-build
permissions:
id-token: write
contents: read
@@ -80,8 +80,8 @@ jobs:
- run: go version
- name: Build the luxd binaries (native arm64)
run: CGO_ENABLED=0 ./scripts/build.sh
- name: Build the luxd binaries (cross-compile arm64 on amd64)
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 ./scripts/build.sh
- name: Try to get tag from git
if: "${{ github.event.inputs.tag == '' }}"
+13 -8
View File
@@ -22,9 +22,14 @@ on:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
# Cross-compile both darwin/amd64 and darwin/arm64 on macos-13.
build-mac:
# The type of runner that the job will run on
runs-on: macos-14
strategy:
fail-fast: false
matrix:
goarch: [amd64, arm64]
# The type of runner that the job will run on (GH-hosted arm64 macos forbidden).
runs-on: macos-13
permissions:
id-token: write
contents: read
@@ -37,8 +42,8 @@ jobs:
- run: go version
# Runs a single command using the runners shell
- name: Build the luxd binary
run: CGO_ENABLED=0 ./scripts/run_task.sh build
- name: Build the luxd binary (cross-compile via GOOS/GOARCH)
run: CGO_ENABLED=0 GOOS=darwin GOARCH=${{ matrix.goarch }} ./scripts/run_task.sh build
- name: Try to get tag from git
if: "${{ github.event.inputs.tag == '' }}"
@@ -56,17 +61,17 @@ jobs:
- name: Create zip file with CLI-compatible naming
run: |
# CLI expects: node-macos-{version}.zip containing build/luxd
# CLI expects: node-macos-{arch}-{version}.zip containing build/luxd
mkdir -p build
7z a "node-macos-${TAG}.zip" build/luxd
7z a "node-macos-${{ matrix.goarch }}-${TAG}.zip" build/luxd
env:
TAG: ${{ env.TAG }}
- name: Save as Github artifact
uses: actions/upload-artifact@v7
with:
name: build
path: node-macos-${{ env.TAG }}.zip
name: build-darwin-${{ matrix.goarch }}
path: node-macos-${{ matrix.goarch }}-${{ env.TAG }}.zip
- name: Cleanup
run: |
@@ -18,7 +18,7 @@ on:
jobs:
build-jammy-amd64-package:
runs-on: hanzo-build-linux-amd64
runs-on: lux-build
permissions:
id-token: write
contents: read
@@ -66,7 +66,7 @@ jobs:
rm -rf /tmp/luxd
build-focal-amd64-package:
runs-on: hanzo-build-linux-amd64
runs-on: lux-build
steps:
- uses: actions/checkout@v4
@@ -18,15 +18,15 @@ on:
jobs:
build-jammy-arm64-package:
runs-on: ubuntu-24.04-arm
runs-on: lux-build
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-go-for-project
- run: go version
- name: Build the luxd binaries (native arm64)
run: CGO_ENABLED=0 ./scripts/build.sh
- name: Build the luxd binaries (cross-compile arm64 on amd64)
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 ./scripts/build.sh
- name: Try to get tag from git
if: "${{ github.event.inputs.tag == '' }}"
@@ -63,15 +63,15 @@ jobs:
rm -rf /tmp/luxd
build-focal-arm64-package:
runs-on: ubuntu-24.04-arm
runs-on: lux-build
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-go-for-project
- run: go version
- name: Build the luxd binaries (native arm64)
run: CGO_ENABLED=0 ./scripts/build.sh
- name: Build the luxd binaries (cross-compile arm64 on amd64)
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 ./scripts/build.sh
- name: Try to get tag from git
if: "${{ github.event.inputs.tag == '' }}"
+4 -4
View File
@@ -5,9 +5,9 @@ set -o nounset
set -o pipefail
# Lux root directory
LUX_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )"; cd ../.. && pwd )
NODE_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )"; cd ../.. && pwd )
"$LUX_PATH"/scripts/build.sh
"$NODE_PATH"/scripts/build.sh
# Check to see if the build script creates any unstaged changes to prevent
# regression where builds go.mod/go.sum files get out of date.
if [[ -z $(git status -s) ]]; then
@@ -15,5 +15,5 @@ if [[ -z $(git status -s) ]]; then
# TODO: Revise this check once we can reliably build without changes
# exit 1
fi
"$LUX_PATH"/scripts/build_test.sh
"$LUX_PATH"/scripts/build_fuzz.sh 2
"$NODE_PATH"/scripts/build_test.sh
"$NODE_PATH"/scripts/build_fuzz.sh 2
+3 -1
View File
@@ -30,7 +30,9 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [macos-14, ubuntu-22.04, ubuntu-24.04, windows-2022]
# GH-hosted arm64 macos forbidden; CI runs amd64 only. Release builds
# cover darwin/arm64 via cross-compile in build-macos-release.yml.
os: [macos-13, ubuntu-22.04, ubuntu-24.04, windows-2022]
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-go-for-project
+96 -9
View File
@@ -8,22 +8,109 @@ on:
permissions:
contents: read
packages: write
id-token: write
jobs:
docker:
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/luxfi/node
runner-amd64: hanzo-build-linux-amd64
runner-arm64: hanzo-build-linux-arm64
secrets: inherit
build-amd64:
# Self-hosted `lux-build` ARC pool on DOKS hanzo-k8s (max 20 runners,
# scales 0→N on demand). Org-isolated: luxfi workflows must use this
# pool (never the hanzoai pools). amd64 only — DOKS has no arm64 yet.
runs-on: lux-build
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
driver-opts: |
network=host
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/luxfi/node
# Semver-only policy: only `v*` git tags yield a published image
# tag; every push gets an immutable `sha-<sha7>` for traceability.
# No `:latest`, no `:main`, no floating tags ever. `flavor.latest`
# has to be explicit-false; docker/metadata-action defaults to
# `latest=auto` which silently emits `:latest` on tag pushes.
flavor: |
latest=false
tags: |
type=ref,event=tag
type=sha,format=short,prefix=sha-
- name: Resolve cross-repo PAT for private luxfi/* deps
id: pat
env:
# The default GITHUB_TOKEN is repo-scoped and cannot read
# private cross-repo deps like luxfi/corona or luxfi/evm. We
# need a PAT with org-wide read scope. Order of preference:
# 1) org GH_TOKEN (visibility=all, set on luxfi org since 2024)
# 2) repo UNIVERSE_PAT (set on luxfi/node since 2026-04)
# Either suffices; we just need ONE non-empty token. If both
# are empty the build is going to fail at go mod download for
# corona — fail fast here with a clear message.
GH_TOKEN: ${{ secrets.GH_TOKEN }}
UNIVERSE_PAT: ${{ secrets.UNIVERSE_PAT }}
run: |
tok="$GH_TOKEN"
src="GH_TOKEN"
if [ -z "$tok" ]; then
tok="$UNIVERSE_PAT"
src="UNIVERSE_PAT"
fi
if [ -z "$tok" ]; then
echo "::error::Neither GH_TOKEN (org) nor UNIVERSE_PAT (repo) is set. Private luxfi/* deps cannot be resolved."
exit 1
fi
echo "Using secret: $src (length=${#tok})"
# Pass the resolved token to subsequent steps without exposing
# the value. ::add-mask:: prevents accidental log leaks if the
# value ever appears in later step output.
echo "::add-mask::$tok"
echo "token<<EOF" >> "$GITHUB_OUTPUT"
echo "$tok" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build & push (amd64)
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: true
build-args: |
CGO_ENABLED=0
# Resolve private luxfi/* modules via git url.insteadOf in the
# Dockerfile. Take the token value from the previous step's
# output (it picked GH_TOKEN or fell back to UNIVERSE_PAT).
secrets: |
ghtok=${{ steps.pat.outputs.token }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=registry,ref=ghcr.io/luxfi/node:buildcache-amd64
cache-to: type=registry,ref=ghcr.io/luxfi/node:buildcache-amd64,mode=max
notify-universe:
needs: docker
needs: build-amd64
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: peter-evans/repository-dispatch@v3
- uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.UNIVERSE_PAT }}
repository: luxfi/universe
+5 -4
View File
@@ -31,8 +31,6 @@ tmp/
*.pb*
db*
*cpu[0-9]*
*mem[0-9]*
*lock[0-9]*
@@ -85,11 +83,14 @@ QWEN.md
genesis/.!*
genesis-gen
lux
luxd
/lux
/luxd
evm-plugin-*
LLM.md
QWEN.md
.AGENTS.md
GEMINI.md
# Built dev-tool binaries
/cmd/gen_zoo_addr/gen_zoo_addr
+65 -60
View File
@@ -44,10 +44,24 @@ ENV GOPROXY=https://proxy.golang.org,direct
ENV GONOPROXY=github.com/luxfi/*
ENV GOFLAGS="-mod=mod"
# Copy and download lux dependencies using go mod
# Copy and download lux dependencies using go mod.
# Some luxfi/* modules (e.g. corona) are private and require a token to
# resolve via go mod. The token is injected as a BuildKit secret from the
# CI workflow; locally, set DOCKER_BUILDKIT=1 and pass
# `--secret id=ghtok,src=$HOME/.gh-token`. If the secret is absent the
# build still attempts the download (works when all deps are public).
COPY go.mod .
COPY go.sum .
RUN go mod download
# Configure global git insteadOf so EVERY subsequent step (go mod download
# now, the build step's implicit fetch later) can reach private luxfi/*
# modules. The token is written into /etc/gitconfig (root-readable in the
# image), so explicit cleanup is unnecessary on this throwaway builder
# stage — only the compiled binary is COPYed into the runtime image.
RUN --mount=type=secret,id=ghtok,required=false \
if [ -s /run/secrets/ghtok ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/ghtok)@github.com/".insteadOf "https://github.com/"; \
fi && \
go mod download
# Copy the code into the container
COPY . .
@@ -82,17 +96,17 @@ RUN ARCH=$(echo ${TARGETPLATFORM} | cut -d / -f2) && \
ldconfig 2>/dev/null || true
# Fetch pre-built luxcpp/cevm libs (libevm, libevm-gpu, libluxgpu,
# libcevm_precompiles + go_bridge.h). When LUX_CGO=1 these libraries are
# libcevm_precompiles + go_bridge.h). When CGO_ENABLED=1 these libraries are
# linked into the C-Chain plugin via github.com/luxfi/chains/evm/cevm
# and become the default execution backend (parallel + GPU EVM).
#
# CI/RELEASE GAP: the luxcpp/cevm release artifacts MUST publish per-arch
# tarballs at the URL below for both linux-x86_64 and linux-arm64. Until
# those tarballs exist, builds with LUX_CGO=1 will fail at this step and
# operators must build with LUX_CGO=0 (pure-Go fallback).
ARG LUX_CGO=1
# those tarballs exist, builds with CGO_ENABLED=1 will fail at this step and
# operators must build with CGO_ENABLED=0 (pure-Go fallback).
ARG CGO_ENABLED=1
ARG CEVM_VERSION=v0.19.0
RUN if [ "${LUX_CGO}" = "1" ]; then \
RUN if [ "${CGO_ENABLED}" = "1" ]; then \
ARCH=$(echo ${TARGETPLATFORM} | cut -d / -f2) && \
if [ "$ARCH" = "amd64" ]; then CEVM_ARCH="linux-x86_64"; else CEVM_ARCH="linux-arm64"; fi && \
wget -q "https://github.com/luxcpp/cevm/releases/download/${CEVM_VERSION}/luxcpp-cevm-${CEVM_ARCH}.tar.gz" \
@@ -101,24 +115,31 @@ RUN if [ "${LUX_CGO}" = "1" ]; then \
rm /tmp/cevm.tar.gz && \
ldconfig 2>/dev/null || true ; \
else \
echo "LUX_CGO=0: skipping luxcpp/cevm fetch (pure-Go fallback build)" ; \
echo "CGO_ENABLED=0: skipping luxcpp/cevm fetch (pure-Go fallback build)" ; \
fi
# Build node. LUX_CGO=1 (default) links luxcpp/cevm for parallel + GPU EVM.
# Set LUX_CGO=0 for portable pure-Go builds without the native libs.
# Build node. CGO_ENABLED=1 (default) links luxcpp/cevm for parallel + GPU EVM.
# Set CGO_ENABLED=0 for portable pure-Go builds without the native libs.
ARG RACE_FLAG=""
ARG BUILD_SCRIPT=build.sh
ARG LUXD_COMMIT=""
ENV CGO_ENABLED=${LUX_CGO}
ENV CGO_ENABLED=${CGO_ENABLED}
RUN . ./build_env.sh && \
echo "{CC=$CC, TARGETPLATFORM=$TARGETPLATFORM, BUILDPLATFORM=$BUILDPLATFORM, LUX_CGO=${LUX_CGO}}" && \
echo "{CC=$CC, TARGETPLATFORM=$TARGETPLATFORM, BUILDPLATFORM=$BUILDPLATFORM, CGO_ENABLED=${CGO_ENABLED}}" && \
export GOARCH=$(echo ${TARGETPLATFORM} | cut -d / -f2) && \
export LUXD_COMMIT="${LUXD_COMMIT}" && \
GOFLAGS="-mod=mod" ./scripts/${BUILD_SCRIPT} ${RACE_FLAG}
# ============= EVM Plugin Stage ================
# Build EVM plugin from source (includes custom precompile registry)
ARG EVM_VERSION=v0.8.40
# Build EVM plugin from source (includes custom precompile registry).
# EVM_VERSION must pin a luxfi/evm release whose go.mod points at a
# luxfi/node version that has the runtime.EngineAddressKey rename
# (LUX_VM_RUNTIME_ENGINE_ADDR → VM_RUNTIME_ENGINE_ADDR landed in
# 4ae211d46d on 2026-05-15). v0.18.14 pins node v1.27.6 which is
# post-rename. Older evm tags (e.g. v0.8.40 → node v1.23.4) ship a
# plugin that os.Getenv()'s the old key, mismatching the host that
# sets the new key, and C-chain never bootstraps.
ARG EVM_VERSION=v0.18.14
ARG EVM_VM_ID=mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6
RUN --mount=type=cache,target=/root/.cache/go-build \
mkdir -p /luxd/build/plugins && \
@@ -153,55 +174,39 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# Each VM is an independent Go module under /tmp/chains/<vm>/.
# Build each plugin binary and place it at /luxd/build/plugins/<cb58-vm-id>.
#
# NB(2026-05-19): luxfi/chains main has unresolved sibling go.mod replace
# directives (luxfi/{evm,precompile,threshold} => ../*) that break in any
# isolated build context. Each chain plugin is therefore built best-effort;
# production deployments pull plugins from S3 (`pluginSource.bucket`) at
# runtime per LuxNetwork CR, so an embedded plugin miss is non-fatal.
RUN --mount=type=cache,target=/root/.cache/go-build \
. /build/build_env.sh && \
export GOARCH=$(echo ${TARGETPLATFORM} | cut -d / -f2) && \
set -e && \
cd /tmp/chains/aivm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/juFxSrbCM4wszxddKepj1GWwmrn9YgN1g4n3VUWPpRo9JjERA \
./cmd/plugin && \
cd /tmp/chains/bridgevm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/kMhHABHM8j4bH94MCc4rsTNdo5E9En37MMyiujk4WdNxgXFsY \
./cmd/plugin && \
cd /tmp/chains/dexvm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr \
./cmd/plugin && \
cd /tmp/chains/graphvm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/nZQm4Dmg1rjX18rb8maL9gamYyXPf1xCvF7ymWzxp6a1nSQTt \
./cmd/plugin && \
cd /tmp/chains/identityvm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/oR6tnZHezwogyf9fRnomNXC9ojwCEBAU6jdUzpgy2PB1tD7fM \
./cmd/plugin && \
cd /tmp/chains/keyvm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/pJJCSV7hHYVY6TUZwR8qUPAfuhX8JLb2C1AzNSezrYNbgau8M \
./cmd/plugin && \
cd /tmp/chains/oraclevm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/r5m1ujrmXxVcQetG3CQfuDLHp2RHKh6vCDaFgBRQfUcTZh7eS \
./cmd/plugin && \
cd /tmp/chains/quantumvm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/ry9Sg8rZdT26iEKvJDmC2wkESs4SDKgZEhk5BgLSwg1EpcNug \
./cmd/plugin && \
cd /tmp/chains/relayvm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/sP6dLqrrBR9w3soP18fbJ3YzZecZdD7DDdfH2cFhhLq7Hy9bz \
./cmd/plugin && \
cd /tmp/chains/thresholdvm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t \
./cmd/plugin && \
cd /tmp/chains/zkvm && \
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/vv3qPfyTVXZ5ArRZA9Jh4hbYDTBe43f7sgQg4CHfNg1rnnvX9 \
./cmd/plugin && \
chmod +x /luxd/build/plugins/* && \
mkdir -p /luxd/build/plugins && \
( cd /tmp/chains/aivm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/juFxSrbCM4wszxddKepj1GWwmrn9YgN1g4n3VUWPpRo9JjERA ./cmd/plugin ) || echo "WARN: aivm plugin build skipped" ; \
( cd /tmp/chains/bridgevm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/kMhHABHM8j4bH94MCc4rsTNdo5E9En37MMyiujk4WdNxgXFsY ./cmd/plugin ) || echo "WARN: bridgevm plugin build skipped" ; \
( cd /tmp/chains/dexvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr ./cmd/plugin ) || echo "WARN: dexvm plugin build skipped" ; \
( cd /tmp/chains/graphvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/nZQm4Dmg1rjX18rb8maL9gamYyXPf1xCvF7ymWzxp6a1nSQTt ./cmd/plugin ) || echo "WARN: graphvm plugin build skipped" ; \
( cd /tmp/chains/identityvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/oR6tnZHezwogyf9fRnomNXC9ojwCEBAU6jdUzpgy2PB1tD7fM ./cmd/plugin ) || echo "WARN: identityvm plugin build skipped" ; \
( cd /tmp/chains/keyvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/pJJCSV7hHYVY6TUZwR8qUPAfuhX8JLb2C1AzNSezrYNbgau8M ./cmd/plugin ) || echo "WARN: keyvm plugin build skipped" ; \
( cd /tmp/chains/oraclevm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/r5m1ujrmXxVcQetG3CQfuDLHp2RHKh6vCDaFgBRQfUcTZh7eS ./cmd/plugin ) || echo "WARN: oraclevm plugin build skipped" ; \
( cd /tmp/chains/quantumvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/ry9Sg8rZdT26iEKvJDmC2wkESs4SDKgZEhk5BgLSwg1EpcNug ./cmd/plugin ) || echo "WARN: quantumvm plugin build skipped" ; \
( cd /tmp/chains/relayvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/sP6dLqrrBR9w3soP18fbJ3YzZecZdD7DDdfH2cFhhLq7Hy9bz ./cmd/plugin ) || echo "WARN: relayvm plugin build skipped" ; \
( cd /tmp/chains/thresholdvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t ./cmd/plugin ) || echo "WARN: thresholdvm plugin build skipped" ; \
( cd /tmp/chains/zkvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
-o /luxd/build/plugins/vv3qPfyTVXZ5ArRZA9Jh4hbYDTBe43f7sgQg4CHfNg1rnnvX9 ./cmd/plugin ) || echo "WARN: zkvm plugin build skipped" ; \
( chmod +x /luxd/build/plugins/* 2>/dev/null || true ) && \
rm -rf /tmp/chains
# lpm (Lux Plugin Manager) -- optional, skip if build fails
+12
View File
@@ -0,0 +1,12 @@
# Licensing
This repository is licensed under the **BSD 3-Clause License** (see
[LICENSE](LICENSE)). It belongs to the **public** tier of the Lux
three-tier IP strategy: anyone may use, fork, or redistribute it,
including for commercial purposes, subject to the BSD-3 terms.
For the canonical Lux IP and licensing strategy, see:
<https://github.com/luxfi/.github/blob/main/profile/README.md>
For commercial inquiries that go beyond BSD-3 (e.g. private moat
acceleration kernels), contact `licensing@lux.network`.
+172 -15
View File
@@ -8,10 +8,131 @@ Lux blockchain node implementation - a high-performance, multi-chain blockchain
**Key Context:**
- Original Lux Network node — NOT a fork
- Latest Tag: v1.26.31
- Network ID: 96369 (Lux Mainnet), 96368 (Testnet), 96370 (Devnet)
- Go Version: 1.26.1+
- Database: ZapDB (primary, default)
## Post-E2E-PQ State (current)
Node now consumes the locked `ChainSecurityProfile` end-to-end and enforces
strict-PQ at four boundaries: peer handshake, mempool, validator scheme
selection, and EVM contract auth.
- `node/node.go:initSecurityProfile` (F102 closure) loads the chain-wide
profile from genesis at boot, hashes it, and pins it into every chain's
bootstrap. Resolved profile is what every downstream verifier consults.
- `network/peer/scheme_gate.go``SchemeGate.Classify(presented, site)`
funnels every inbound NodeID through the profile's
`AcceptsValidatorScheme`. Wire-typed NodeID (`luxfi/ids` TypedNodeID +
scheme byte) is the canonical handshake form.
- `vms/txs/auth/policy.go``ClassicalCompatRegistry` + strict-PQ
mempool gate. Both `platformvm` (P-Chain) and `avm` (X-Chain) mempools
refuse classical credentials when the resolved profile has
`ForbidECDSAContractAuth=true`.
- `vms/mldsafx/` — re-exports the consensus `mldsafx` UTXO feature
extension as the node-owned UTXO surface (ML-DSA-65 verify).
- `network/peer` PQ handshake — ML-KEM-768 / ML-KEM-1024 KEM +
ML-DSA-65 identity (`dc906d281b`).
### Recent significant commits
| SHA | Tag | Impact |
|-----|-----|--------|
| `9df72a6f55` | v1.26.10 | Wire ChainSecurityProfile into bootstrap (closes F102) |
| `c4af52411e` | v1.26.10 | X-Chain (avm) mempool refuses classical creds under strict-PQ |
| `a14a1601f4` | v1.26.10 | P-Chain (platformvm) mempool refuses classical creds under strict-PQ |
| `1cf0aa80ca` | v1.26.10 | ClassicalCompatRegistry + strict-PQ mempool gate |
| `a0f4f4b21c` | v1.26.10 | vms/mldsafx: re-export ML-DSA feature extension |
| `448fdeb7a1` | v1.26.10 | ML-DSA-65 promoted to canonical NodeID under strict-PQ |
| `dc906d281b` | v1.26.10 | PQ peer handshake — ML-KEM-768/1024 + ML-DSA-65 identity |
### Active versions
- Repo: `v1.26.12` (next bump: `v1.26.13`).
- Pinned: `consensus v1.23.4+` (needed `ValidatorSchemeID`),
`crypto v1.18.5`, `ids v1.2.9` (will move to v1.2.10 in next bump for
`TypedNodeID`), `genesis v1.9.6`.
### Cross-repo dependencies
- `luxfi/consensus` → profile + auth + zchain types
- `luxfi/crypto` → ML-DSA / ML-KEM / SLH-DSA primitives
- `luxfi/genesis` → genesis-pinned profile (`Resolve` at load)
- `luxfi/ids``TypedNodeID` wire form (consumed at handshake)
- `luxfi/geth` → EVM (for `vm.SetActiveSecurityProfile` install point)
### Where to look for X
- Profile resolve at boot: `node/node.go:initSecurityProfile`
- Profile RPC + REST + metrics: `service/security/`
- JSON-RPC namespace: `security` at `POST /ext/security`
(methods `securityProfile`, `blockSecurity`)
- REST sidecars: `GET /ext/security/profile`, `GET /ext/security/block/{n}`
- Prometheus gauges: `/ext/metrics` under the `security_*` family
- Peer scheme gate: `network/peer/scheme_gate.go`
- Classical-compat registry: `vms/txs/auth/policy.go`
- Mempool gate (P-Chain): `vms/platformvm/mempool/*.go`
- Mempool gate (X-Chain): `vms/avm/mempool/*.go`
- ML-DSA feature extension: `vms/mldsafx/`
### Open follow-ups
- `vms/zkvm/accel/` still soft-falls-back when CGO is disabled; Z-Chain
proof verification path needs CGO-required mode for production strict-PQ.
- `vm.SetActiveSecurityProfile` install point exists in `luxfi/geth/core/vm`
but EVM-side contract-auth refusal still needs a chain-bootstrap call
(F102 wiring closes the consensus side; geth-side hookup is the
remaining tail).
## FeePolicy — canonical user-tx fee gate
Every Lux VM that accepts user-submitted txs declares a `fee.Policy`
(package `vms/types/fee`). There is one interface and one validator —
no per-VM bespoke fee structs.
### Policy choice per VM
| VM | Chain | Posture | Policy |
|----|-------|---------|--------|
| dexvm | D-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, AssetID: UTXOAssetIDFor(networkID)}` |
| zkvm | Z-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
| aivm | A-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
| keyvm | K-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
| bridgevm | B-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
| quantumvm | Q-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
| identityvm | I-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
| thresholdvm | M-Chain | service-only | `NoUserTxPolicy{}` |
| oraclevm | O-Chain | service-only | `NoUserTxPolicy{}` |
| relayvm | R-Chain | service-only | `NoUserTxPolicy{}` |
| graphvm | G-Chain | read-only | `NoUserTxPolicy{}` (GraphQL refuses `mutation`) |
| evm | C-Chain | user-tx | native EVM gas (gas * gasPrice >= 0 enforced upstream) |
| platformvm | P-Chain | user-tx | native `TxFee` field on Config |
| avm | X-Chain | user-tx | native `TxFee` field on Config |
`MinTxFeeFloor = 1 mLUX = 1_000_000 nLUX` (the same minimum the P-Chain
base fee enforces). User-facing chains MAY charge more; they MUST NOT
charge less.
### Wiring contract
1. VM struct holds `feePolicy fee.Policy` (and `networkID uint32`).
2. `Initialize` sets `feePolicy = fee.FlatPolicy{...}` (or
`fee.NoUserTxPolicy{}` for service-only) from `init.Runtime.NetworkID`
and calls `fee.Validate(vm.feePolicy)` — refuses zero-fee user-facing
chains at boot, before any block is accepted.
3. The canonical user-tx admission entry (e.g. `SubmitTx`, `IssueTx`,
`InitiateBridgeTransfer`, mutating service RPCs) calls
`policy.ValidateFee(paid, asset)` BEFORE mempool insert.
4. Consensus-internal paths (engine→VM block delivery, replay, internal
tx emission) bypass the fee gate — the policy gates only the
*user-submitted* entrypoint.
### Where the gates live
- `vms/types/fee/policy.go` — interface + FlatPolicy + NoUserTxPolicy + Validate
- `~/work/lux/chains/<vm>/feegate.go` — per-VM helper + gate method
- `~/work/lux/chains/<vm>/feegate_test.go` — RejectsZeroFee + AcceptsMinFee
- Oracle (O-Chain): `~/work/lux/oracle/vm/feegate.go` (re-exported by `~/work/lux/chains/oraclevm/`)
- Relay (R-Chain): `~/work/lux/relay/vm/feegate.go` (re-exported by `~/work/lux/chains/relayvm/`)
- Graph (G-Chain): `~/work/lux/chains/graphvm/feegate.go` (read-only; NoUserTxPolicy)
## Essential Commands
### Building
@@ -205,20 +326,33 @@ Located in `vms/thresholdvm/fhe/`:
| Gateway | `0x0200000000000000000000000000000000000083` |
### ZAP Transport (Zero-Copy App Proto)
ZAP is the default high-performance binary wire protocol for VM<->Node communication.
gRPC support is available via build tag for testing/compatibility.
ZAP is the only wire protocol for VM<->Node communication. The gRPC
fallback (and its `-tags=grpc` opt-in) was retired in v1.26.31 along
with every `//go:build grpc` file under `node/`. There is one and only
one way to talk to a Chain VM: ZAP.
**Build Tags:**
**Build:**
```bash
go build # ZAP only (default, production)
go build -tags=grpc # gRPC support (for testing/compatibility)
go build # ZAP only — there are no build tags
```
**Key Packages:**
- `github.com/luxfi/api/zap` - Core wire protocol and message types
- `github.com/luxfi/vm/rpc/sender` - p2p.Sender over ZAP/gRPC
- `vms/rpcchainvm/sender/` - Node-side sender implementation
- `vms/platformvm/warp/zwarp/` - ZAP-based warp signing client/server
- `github.com/luxfi/api/zap` Core wire protocol and message types (Layer A)
- `github.com/luxfi/protocol/rpcdb` — rpcdb service spec / data carriers (Layer B)
- `github.com/luxfi/node/db/rpcdb` — rpcdb Service + ZAP transport adapter (Layer C)
- `vms/rpcchainvm/sender/` — Node-side `p2p.Sender` over ZAP
- `vms/rpcchainvm/zap/` — ChainVM client/server over ZAP
- `vms/platformvm/warp/zwarp/` — Warp signing over ZAP
**rpcdb Layered Topology:**
- Layer A — wire framing: `github.com/luxfi/api/zap`
- Layer B — rpcdb service spec: `github.com/luxfi/protocol/rpcdb`
- Layer C — rpcdb impl: `node/db/rpcdb/{service.go, zap_server.go}`
- `service.go` — transport-neutral `Service` wrapping `database.Database`
- `zap_server.go` — ZAP transport adapter (only adapter)
- One Service, one transport. The dual-adapter pattern stays available
for future transports (each is a new file wrapping `*Service`), but
ZAP is the only one shipping.
**Wire Protocol Format:**
```
@@ -233,11 +367,8 @@ go build -tags=grpc # gRPC support (for testing/compatibility)
**Sender Usage:**
```go
// ZAP transport (default)
// ZAP transport — the only transport
s := sender.ZAP(zapConn)
// gRPC transport (requires -tags=grpc build)
s := sender.GRPC(senderpb.NewSenderClient(grpcConn))
```
**Warp over ZAP:**
@@ -335,6 +466,32 @@ RNS transport supports hybrid post-quantum cryptography combining classical algo
- **Mixed Networks**: PQ and classical validators coexist
- **Policy Enforcement**: `requirePostQuantum: true` rejects classical peers
### SchemeGate — cross-axis NodeIDScheme enforcement
`network/peer/scheme_gate.go` (v1.26.10) is the single primitive that
turns a wire NodeID into a `(scheme, NodeID)` pair and runs the
cross-axis check against the chain's `ChainSecurityProfile`.
- `SchemeGate{Profile, ClassicalCompatUnsafe, ActivationHeight}` is the
chain-scoped policy object. One gate per chain, pinned at bootstrap.
- `Classify(nodeID, scheme, height, site) (TypedNodeID, error)` is the
single entry point. Callers pass a site tag (`"handshake"`,
`"proposer"`, `"validator"`, `"mempool-sender"`) that appears in the
refused-by error.
- Migration path: `ActivationHeight` is the block at which a strict-PQ
chain refuses any non-PQ `NodeIDScheme` byte at every height under
the forward-only PQ policy. The classical `secp256k1` (0x90) scheme is
refused at the gate; there is no transition window and no operator
classical-compat escape hatch (strict-PQ chains refuse classical at
every boundary, period).
- Typed errors: `ErrSchemeGateConfig`, `ErrSchemeGateMismatch`,
`ErrSchemeGateUnknownScheme`.
Wire form: `TypedNodeID = (NodeIDScheme byte, 20-byte NodeID)`. The
20-byte storage/map-key form stays byte-identical; the scheme byte
travels on the wire so a receiver knows which verifier to dispatch
without trusting the chain profile alone.
### Testing PQ Forward Secrecy
```bash
@@ -353,7 +510,7 @@ go test -v -run "TestHybrid" ./node/network/dialer/... -count=1
### 1. P2P Sender Interface
Node's rpcchainvm implements `p2p.Sender` (from `github.com/luxfi/p2p`) for cross-chain messaging.
The `sender` package is a gRPC implementation of `p2p.Sender`.
The `sender` package is the ZAP-native implementation of `p2p.Sender`.
### 2. Chain Tracking
Nodes don't automatically track chains. Use:
@@ -600,7 +757,7 @@ Testing conducted on a single Lux validator node (testnet mode, macOS):
**Benchmark Command:**
```bash
cd ~/work/lux/benchmarks
LUX_ENDPOINT="http://localhost:9640/ext/bc/C/rpc" \
NODE_ENDPOINT="http://localhost:9640/ext/bc/C/rpc" \
PRIVATE_KEY="<funded_key>" \
./bin/bench tps --chains=lux --duration=60s --concurrency=5
```
+9 -9
View File
@@ -2519,7 +2519,7 @@ The plugin version is unchanged at `27` and compatible with versions `v1.10.5 -
### Configs
- Changed the default value of `--network-allow-private-ips` to `false` when the `--network-id` is either `fuji` or `mainnet`
- Changed the default value of `--network-allow-private-ips` to `false` when the `--network-id` is either `testnet` or `mainnet`
- Added P-chain cache size configurations
- `block-cache-size`
- `tx-cache-size`
@@ -2564,7 +2564,7 @@ The plugin version is unchanged at `27` and compatible with versions `v1.10.5 -
- [x/sync] Update target locking by @patrick-ogrady in https://github.com/luxfi/node/pull/1763
- Export warp errors for external use by @aaronbuchwald in https://github.com/luxfi/node/pull/1771
- Remove unused networking constant by @StephenButtolph in https://github.com/luxfi/node/pull/1774
- Change the default value of `--network-allow-private-ips` to `false` for `mainnet` and `fuji` by @StephenButtolph in https://github.com/luxfi/node/pull/1773
- Change the default value of `--network-allow-private-ips` to `false` for `mainnet` and `testnet` by @StephenButtolph in https://github.com/luxfi/node/pull/1773
- Remove context.TODO from tests by @StephenButtolph in https://github.com/luxfi/node/pull/1778
- Replace linkeddb iterator with native DB range queries by @StephenButtolph in https://github.com/luxfi/node/pull/1752
- Add support for measuring key size in caches by @StephenButtolph in https://github.com/luxfi/node/pull/1781
@@ -2934,7 +2934,7 @@ This version is backwards compatible to [v1.10.0](https://github.com/luxfi/node/
- Remove no-op changes from history results in https://github.com/luxfi/node/pull/1335
- Cleanup type assertions in the linkedHashmap by @StephenButtolph in https://github.com/luxfi/node/pull/1341
- Fix racy xvm tx access by @StephenButtolph in https://github.com/luxfi/node/pull/1349
- Update Fuji beacon ips by @StephenButtolph in https://github.com/luxfi/node/pull/1354
- Update Testnet beacon ips by @StephenButtolph in https://github.com/luxfi/node/pull/1354
- Remove duplicate TLS verification by @StephenButtolph in https://github.com/luxfi/node/pull/1364
- Adjust Merkledb Trie invalidation locking in https://github.com/luxfi/node/pull/1355
- Use require in Lux bootstrapping tests by @StephenButtolph in https://github.com/luxfi/node/pull/1344
@@ -2949,7 +2949,7 @@ This version is backwards compatible to [v1.10.0](https://github.com/luxfi/node/
- [Issue-1368]: Panic in serializedPath.HasPrefix in https://github.com/luxfi/node/pull/1371
- Add ValidatorsOnly flag to GetStake by @StephenButtolph in https://github.com/luxfi/node/pull/1377
- Use proto in `x/sync` by @danlaine in https://github.com/luxfi/node/pull/1336
- Update incorrect fuji beacon IPs by @StephenButtolph in https://github.com/luxfi/node/pull/1392
- Update incorrect testnet beacon IPs by @StephenButtolph in https://github.com/luxfi/node/pull/1392
- Update `api/` error handling by @StephenButtolph in https://github.com/luxfi/node/pull/1393
- refactor concurrent work limiting in sync in `x/sync` by @danlaine in https://github.com/luxfi/node/pull/1347
- Remove check for impossible condition in `x/sync` by @danlaine in https://github.com/luxfi/node/pull/1348
@@ -3017,7 +3017,7 @@ The supported plugin version is `25`.
- defer delegatee rewards until end of validator staking period by @dhrubabasu in https://github.com/luxfi/node/pull/1262
- Initialize UptimeCalculator in TestPeer by @joshua-kim in https://github.com/luxfi/node/pull/1283
- Add Lux liveness health checks by @StephenButtolph in https://github.com/luxfi/node/pull/1287
- Skip AMI generation with Fuji tags by @StephenButtolph in https://github.com/luxfi/node/pull/1288
- Skip AMI generation with Testnet tags by @StephenButtolph in https://github.com/luxfi/node/pull/1288
- Use `maps.Equal` in `set.Equals` by @danlaine in https://github.com/luxfi/node/pull/1290
- return accrued delegator rewards in `GetCurrentValidators` by @dhrubabasu in https://github.com/luxfi/node/pull/1291
- Add zstd compression by @danlaine in https://github.com/luxfi/node/pull/1278
@@ -3819,7 +3819,7 @@ The supported plugin version is `16`.
Please upgrade your node as soon as possible.
The changes in `v1.8.x` go into effect at 4 PM EDT on September 6th, 2022 on both Fuji and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
The changes in `v1.8.x` go into effect at 4 PM EDT on September 6th, 2022 on both Testnet and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
The supported plugin version is `16`.
@@ -3833,7 +3833,7 @@ The supported plugin version is `16`.
Please upgrade your node as soon as possible.
The changes in `v1.8.x` go into effect at 4 PM EDT on September 6th, 2022 on both Fuji and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
The changes in `v1.8.x` go into effect at 4 PM EDT on September 6th, 2022 on both Testnet and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
The supported plugin version is `16`.
@@ -3850,7 +3850,7 @@ The supported plugin version is `16`.
This is a mandatory security upgrade. Please upgrade your node **as soon as possible.**
The changes in the upgrade go into effect at **4 PM EDT on September 6th, 2022** on both Fuji and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
The changes in the upgrade go into effect at **4 PM EDT on September 6th, 2022** on both Testnet and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
You may see some extraneous ERROR logs ("BAD BLOCK") on your node after upgrading. These may continue until the Apricot Phase 6 activation (at 4 PM EDT on September 6th).
@@ -4515,7 +4515,7 @@ This version is backwards compatible to [v1.7.0](https://github.com/luxfi/node/r
- Added `--stake-supply-cap` which defaults to `720,000,000,000,000,000` nLUX.
- Renamed `--bootstrap-multiput-max-containers-sent` to `--bootstrap-ancestors-max-containers-sent`.
- Renamed `--bootstrap-multiput-max-containers-received` to `--bootstrap-ancestors-max-containers-received`.
- Enforced that `--staking-enabled=false` can not be specified on public networks (`Fuji` and `Mainnet`).
- Enforced that `--staking-enabled=false` can not be specified on public networks (`Testnet` and `Mainnet`).
### Metrics
+129 -35
View File
@@ -70,11 +70,17 @@ import (
"github.com/luxfi/vm/fx"
// "github.com/luxfi/node/vms/metervm" // Temporarily disabled - needs consensus package updates
"github.com/luxfi/utxo/bls12381fx"
"github.com/luxfi/utxo/ed25519fx"
"github.com/luxfi/utxo/mldsafx"
"github.com/luxfi/utxo/nftfx"
"github.com/luxfi/utxo/propertyfx"
// "github.com/luxfi/node/vms/proposervm"
"github.com/luxfi/utxo/schnorrfx"
"github.com/luxfi/utxo/secp256k1fx"
"github.com/luxfi/utxo/secp256r1fx"
"github.com/luxfi/utxo/slhdsafx"
// "github.com/luxfi/node/vms/tracedvm" // Temporarily disabled - needs consensus package updates
// "github.com/luxfi/node/proto/p2p" // Available if needed for protobuf parsing
@@ -128,10 +134,26 @@ var (
errNotBootstrapped = errors.New("chains not bootstrapped")
errPartialSyncAsAValidator = errors.New("partial sync should not be configured for a validator")
// fxs lists every Feature eXtension factory the node knows how to load
// when a chain genesis references it. Must stay in sync with the X-Chain
// FxIDs[] block in genesis/builder/builder.go: any fx ID present there
// MUST be registered here, otherwise chain init fails with "fx ... not
// found" at startup. Keep the order and grouping mirroring genesis to
// make drift obvious in review.
fxs = map[ids.ID]fx.Factory{
// Legacy / classical
secp256k1fx.ID: &secp256k1fx.Factory{},
nftfx.ID: &nftfx.Factory{},
propertyfx.ID: &propertyfx.Factory{},
// Post-quantum (FIPS 203/204/205 family)
mldsafx.ID: &mldsafx.Factory{},
slhdsafx.ID: &slhdsafx.Factory{},
// EdDSA / Schnorr / NIST P-256
ed25519fx.ID: &ed25519fx.Factory{},
secp256r1fx.ID: &secp256r1fx.Factory{},
schnorrfx.ID: &schnorrfx.Factory{},
// Pairing-friendly curve
bls12381fx.ID: &bls12381fx.Factory{},
}
_ Manager = (*manager)(nil)
@@ -363,6 +385,17 @@ type ManagerConfig struct {
ChainDataDir string
Nets *Nets
// SecurityProfile is the chain-wide ChainSecurityProfile resolved
// from the genesis pin during node bootstrap (F102). The chain
// manager forwards a ProfileID + ProfileHash pin to every VM
// Initialize call via the VM config bytes so the rpcchainvm plugin
// (coreth C-Chain) inherits the chain-wide posture without
// re-resolving genesis. Closes red-team finding F118.
//
// Nil under classical-compat or pre-locked-profile chains; the
// stamping pass is a no-op in that case.
SecurityProfile *consensusconfig.ChainSecurityProfile
}
type manager struct {
@@ -485,31 +518,12 @@ func New(config *ManagerConfig) (Manager, error) {
// QueueChainCreation queues a chain creation request
// Invariant: Tracked Net must be checked before calling this function
func (m *manager) QueueChainCreation(chainParams ChainParameters) {
// Check for chain ID mapping override for C-Chain
m.Log.Info("QueueChainCreation called",
log.String("vmID", chainParams.VMID.String()),
log.String("EVMID", constants.EVMID.String()),
log.Bool("vmIDEqualsEVMID", chainParams.VMID == constants.EVMID),
log.String("envVar", os.Getenv("LUX_CHAIN_ID_MAPPING_C")),
)
if chainParams.VMID == constants.EVMID && os.Getenv("LUX_CHAIN_ID_MAPPING_C") != "" {
mappedID := os.Getenv("LUX_CHAIN_ID_MAPPING_C")
parsedID, err := ids.FromString(mappedID)
if err == nil {
m.Log.Info("Using mapped blockchain ID for C-Chain",
log.String("original", chainParams.ID.String()),
log.String("mapped", parsedID.String()),
)
chainParams.ID = parsedID
} else {
m.Log.Warn("Invalid chain ID mapping",
log.String("mapping", mappedID),
log.Err(err),
)
}
}
// Register blockchain→chain mapping with the network layer so gossip
// can resolve which validator set to use for this blockchain's blocks.
if chainParams.ChainID != constants.PrimaryNetworkID && m.Net != nil {
@@ -616,6 +630,35 @@ func (m *manager) createChain(chainParams ChainParameters) {
}
chainAlias := m.PrimaryAliasOrDefault(chainParams.ID)
// Plugin-not-loaded is a deliberate skip, not a failure.
//
// Each validator opts into chains by loading their VM plugin
// (Liquid loads /dex/fhe; doesn't load Lux's upstream
// EVM plugin for C-Chain because Liquid runs its own EVM as a
// chain on the primary's P-chain). When the chain manager hits a
// chain whose VM isn't registered, that's the validator's "no I
// don't validate this one" signal — log info, mark the slot
// bootstrapped, and return WITHOUT registering a failing health
// check. The chain stays in pending state for hot-load if the
// plugin shows up later (e.g. via lpm install).
//
// The old behavior (always register a failing health check) made
// /ext/health return 503 for any opted-out chain, which made
// kubelet liveness probes kill the validator pod. That made
// chain participation effectively all-or-nothing per validator.
// Now: validators participate per-plugin, opt-in.
if errors.Is(err, vms.ErrNotFound) {
m.Log.Info("chain VM plugin not loaded — opting out of this chain",
log.Stringer("chainID", chainParams.ChainID),
log.Stringer("chainID", chainParams.ID),
log.String("chainAlias", chainAlias),
log.Stringer("vmID", chainParams.VMID),
)
sb.Bootstrapped(chainParams.ID)
return
}
m.Log.Warn("non-critical chain failed to initialize",
log.Stringer("chainID", chainParams.ChainID),
log.Stringer("chainID", chainParams.ID),
@@ -624,15 +667,13 @@ func (m *manager) createChain(chainParams ChainParameters) {
log.Err(err),
)
// Non-critical chain failed (e.g. D-Chain with missing VM plugin).
// Mark it as bootstrapped so it doesn't block the node's health check.
// The chain-specific health check below still reports the failure.
// Non-critical chain failed for a real reason (not missing
// plugin). Mark it as bootstrapped so it doesn't block the
// node-level health check, but DO register a per-chain failing
// health check so operators see something is wrong with a chain
// they did opt into.
sb.Bootstrapped(chainParams.ID)
// Register the health check for this chain regardless of if it was
// created or not. This attempts to notify the node operator that their
// node may not be properly validating the net they expect to be
// validating.
healthCheckErr := fmt.Errorf("failed to create chain on net %s: %w", chainParams.ChainID, err)
err := m.Health.RegisterHealthCheck(
chainAlias,
@@ -877,13 +918,12 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
XAssetID: m.XAssetID,
ChainDataDir: chainDataDir,
BCLookup: m,
ValidatorState: getValidatorState(m.validatorState),
SharedMemory: chainSharedMemory,
Metrics: chainMetricsGatherer,
Log: chainLog,
WarpSigner: warpSigner,
NetworkUpgrades: &m.Upgrades,
BCLookup: m,
ValidatorState: getValidatorState(m.validatorState),
SharedMemory: chainSharedMemory,
Metrics: chainMetricsGatherer,
Log: chainLog,
WarpSigner: warpSigner,
}
// Get a factory for the vm we want to use on our chain
@@ -992,8 +1032,12 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
}
// Initialize the VM if it supports the Initialize interface
// Inject automining config for dev mode (applies to C-Chain/coreth only)
// Inject automining config for dev mode (applies to C-Chain/coreth only),
// then stamp the chain-wide SecurityProfile pin into the C-Chain
// JSON config (F118) so the rpcchainvm plugin can resolve the
// profile on its side of the boundary.
vmConfigBytes := m.injectAutominingConfig(chainParams.VMID, chainConfig.Config)
vmConfigBytes = m.injectSecurityProfileConfig(chainParams.VMID, vmConfigBytes)
m.Log.Info("initializing VM", log.Stringer("chainID", chainParams.ID))
initCtx, initCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer initCancel()
@@ -1438,7 +1482,7 @@ func (m *manager) createDAG(
}
}
// Linearize the DAG chain (required for X-Chain post-Cortina)
// Linearize the DAG chain (required for X-Chain).
// This transitions the chain from DAG mode to linear block mode.
if vmInitialized {
if linearVM, ok := vmImpl.(interface {
@@ -1826,6 +1870,56 @@ func (m *manager) getChainConfig(id ids.ID) (ChainConfig, error) {
return ChainConfig{}, nil
}
// injectSecurityProfileConfig stamps the chain-wide ChainSecurityProfile
// pin into the C-Chain JSON config bytes so the coreth plugin VM can
// resolve the profile on its side of the rpcchainvm boundary. Closes
// red-team finding F118.
//
// The pin form is intentionally minimal — ProfileID byte + ProfileHash
// hex — so this manager package does not need to round-trip the full
// ChainSecurityProfile struct. The plugin VM calls
// consensusconfig.ProfileByID + ComputeHash and refuses the chain if the
// pinned hash does not match the live canonical content. Forked binaries
// that swap canonical profile content fail the hash compare; legitimate
// upgrades land via a new ProfileID and ProfileHash pair.
//
// Only applies to C-Chain (EVMID); other VMs are passed through.
func (m *manager) injectSecurityProfileConfig(vmID ids.ID, configBytes []byte) []byte {
if m.SecurityProfile == nil {
return configBytes
}
if vmID != constants.EVMID {
return configBytes
}
// Parse existing config or create empty object.
var cfg map[string]interface{}
if len(configBytes) > 0 {
if err := json.Unmarshal(configBytes, &cfg); err != nil {
m.Log.Warn("failed to parse C-Chain config for security profile injection, creating new config",
log.Err(err))
cfg = make(map[string]interface{})
}
} else {
cfg = make(map[string]interface{})
}
cfg["lux-security-profile"] = map[string]interface{}{
"profileID": m.SecurityProfile.ProfileID & 0xff,
"profileHashHex": fmt.Sprintf("%x", m.SecurityProfile.ProfileHash[:]),
}
out, err := json.Marshal(cfg)
if err != nil {
m.Log.Warn("failed to marshal C-Chain config after security profile injection", log.Err(err))
return configBytes
}
m.Log.Info("injected chain security profile pin into C-Chain plugin config",
log.Uint32("profileID", m.SecurityProfile.ProfileID),
log.String("profileName", m.SecurityProfile.ProfileName))
return out
}
// injectAutominingConfig modifies the config bytes to include enable-automining flag
// when dev mode automining is enabled. This is used for C-Chain (coreth) to enable
// anvil-like block production behavior.
+158
View File
@@ -0,0 +1,158 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// F118 regression coverage. Asserts the chain manager stamps the
// chain-wide ChainSecurityProfile pin into the C-Chain JSON config
// bytes so the rpcchainvm plugin (coreth) can resolve the profile on
// its side of the boundary.
package chains
import (
"encoding/hex"
"encoding/json"
"testing"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/stretchr/testify/require"
)
// canonicalStrictPQProfile returns the live canonical StrictPQ profile
// with ProfileHash populated — the same form node.go builds during
// initSecurityProfile.
func canonicalStrictPQProfile(t *testing.T) *consensusconfig.ChainSecurityProfile {
t.Helper()
p, err := consensusconfig.ProfileByID(consensusconfig.ProfileStrictPQ)
require.NoError(t, err)
require.NoError(t, p.Validate())
h, err := p.ComputeHash()
require.NoError(t, err)
p.ProfileHash = h
return p
}
// TestInjectSecurityProfileConfig_StampsCChainOnly asserts the F118
// injection pass stamps the security profile pin into the C-Chain
// (EVMID) plugin config and is a no-op for every other VM ID. Other
// VMs receive the config bytes unchanged.
func TestInjectSecurityProfileConfig_StampsCChainOnly(t *testing.T) {
require := require.New(t)
profile := canonicalStrictPQProfile(t)
m := &manager{
ManagerConfig: ManagerConfig{
Log: log.NewNoOpLogger(),
SecurityProfile: profile,
},
}
// C-Chain (EVMID): pin must be stamped.
out := m.injectSecurityProfileConfig(constants.EVMID, nil)
require.NotEmpty(out, "C-Chain config must be stamped with the security profile pin")
var got map[string]interface{}
require.NoError(json.Unmarshal(out, &got))
pin, ok := got["lux-security-profile"].(map[string]interface{})
require.True(ok, "C-Chain config must carry lux-security-profile object")
require.EqualValues(uint32(consensusconfig.ProfileStrictPQ), uint32(pin["profileID"].(float64)),
"profileID must round-trip the canonical StrictPQ byte")
require.Equal(hex.EncodeToString(profile.ProfileHash[:]), pin["profileHashHex"],
"profileHashHex must round-trip the canonical 48-byte hash")
// Non-EVM VM (PlatformVMID): no-op.
original := []byte(`{"some": "config"}`)
pOut := m.injectSecurityProfileConfig(constants.PlatformVMID, original)
require.Equal(original, pOut, "non-C-Chain config must pass through unchanged")
}
// TestInjectSecurityProfileConfig_NoOpWhenProfileNil asserts the F118
// injection is a no-op when SecurityProfile is nil (classical-compat
// chains, legacy networks pre-locked-profile).
func TestInjectSecurityProfileConfig_NoOpWhenProfileNil(t *testing.T) {
require := require.New(t)
m := &manager{
ManagerConfig: ManagerConfig{
Log: log.NewNoOpLogger(),
SecurityProfile: nil,
},
}
original := []byte(`{"some": "config"}`)
out := m.injectSecurityProfileConfig(constants.EVMID, original)
require.Equal(original, out, "no-op pass-through under nil SecurityProfile")
out = m.injectSecurityProfileConfig(constants.EVMID, nil)
require.Empty(out, "no-op pass-through preserves nil bytes")
}
// TestInjectSecurityProfileConfig_MergesExistingConfig asserts the F118
// injection preserves pre-existing C-Chain config keys (lux-strict-pq
// shim, enable-automining, etc.) while adding the new lux-security-profile
// pin alongside.
func TestInjectSecurityProfileConfig_MergesExistingConfig(t *testing.T) {
require := require.New(t)
profile := canonicalStrictPQProfile(t)
m := &manager{
ManagerConfig: ManagerConfig{
Log: log.NewNoOpLogger(),
SecurityProfile: profile,
},
}
original := []byte(`{"enable-automining": true, "skip-block-fee": true}`)
out := m.injectSecurityProfileConfig(constants.EVMID, original)
var got map[string]interface{}
require.NoError(json.Unmarshal(out, &got))
require.Equal(true, got["enable-automining"], "automining flag must survive")
require.Equal(true, got["skip-block-fee"], "skip-block-fee flag must survive")
require.NotNil(got["lux-security-profile"], "security profile pin must be added")
}
// TestInjectSecurityProfileConfig_RoundTripsAcrossPluginBoundary
// asserts the wire form coreth expects (security-profile pin with
// profileID + profileHashHex) matches the form the chain manager
// emits, so the rpcchainvm plugin can decode the pin via standard
// JSON unmarshalling. Closes the wire-compat axis of F118.
func TestInjectSecurityProfileConfig_RoundTripsAcrossPluginBoundary(t *testing.T) {
require := require.New(t)
profile := canonicalStrictPQProfile(t)
m := &manager{
ManagerConfig: ManagerConfig{
Log: log.NewNoOpLogger(),
SecurityProfile: profile,
},
}
out := m.injectSecurityProfileConfig(constants.EVMID, nil)
// Mirror of plugin/evm/config.Config — locally redefined so this
// test does not import the coreth plugin tree.
type localPin struct {
ProfileID uint8 `json:"profileID"`
ProfileHashHex string `json:"profileHashHex"`
}
type localCorethConfig struct {
SecurityProfile *localPin `json:"lux-security-profile,omitempty"`
}
var cfg localCorethConfig
require.NoError(json.Unmarshal(out, &cfg))
require.NotNil(cfg.SecurityProfile, "coreth-side config struct must decode the pin")
require.Equal(uint8(consensusconfig.ProfileStrictPQ), cfg.SecurityProfile.ProfileID)
require.Equal(hex.EncodeToString(profile.ProfileHash[:]), cfg.SecurityProfile.ProfileHashHex)
// Decoded hex must be exactly 48 bytes (SHA3-384 width).
hashBytes, err := hex.DecodeString(cfg.SecurityProfile.ProfileHashHex)
require.NoError(err)
require.Len(hashBytes, 48, "wire pin must carry a 48-byte SHA3-384 ProfileHash")
// Use ids.ID directly to silence the unused import warning in case
// other tests in this file don't reach it.
_ = ids.ID{}
}
-17
View File
@@ -1,17 +0,0 @@
//go:build tools
// +build tools
package main
import (
"fmt"
"github.com/luxfi/node/upgrade"
"github.com/luxfi/runtime"
)
func main() {
var c *upgrade.Config = &upgrade.Config{}
var _ runtime.NetworkUpgrades = c
fmt.Println("Interface satisfied")
}
+4 -4
View File
@@ -28,8 +28,8 @@ func main() {
"networkID": 200200,
"allocations": []map[string]interface{}{
{
"ethAddr": "0x9011E888251AB053B7bD1cdB598Db4f9DEd94714",
"luxAddr": zooAddr,
"evmAddr": "0x9011E888251AB053B7bD1cdB598Db4f9DEd94714",
"utxoAddr": zooAddr,
"initialAmount": 0,
"unlockSchedule": []map[string]interface{}{
{
@@ -47,7 +47,7 @@ func main() {
}
// Load validators from Lux genesis
luxGenesis, _ := os.ReadFile("/Users/z/work/lux/mainnet/genesis_mainnet.json")
luxGenesis, _ := os.ReadFile(os.ExpandEnv("$HOME/work/lux/mainnet/genesis_mainnet.json"))
var lux map[string]interface{}
json.Unmarshal(luxGenesis, &lux)
@@ -76,6 +76,6 @@ func main() {
genesis["cChainGenesis"] = string(ccBytes)
out, _ := json.MarshalIndent(genesis, "", " ")
os.WriteFile("/Users/z/work/lux/mainnet/zoo_genesis_valid.json", out, 0644)
os.WriteFile(os.ExpandEnv("$HOME/work/lux/mainnet/zoo_genesis_valid.json"), out, 0644)
fmt.Println("Wrote zoo_genesis_valid.json")
}
+224 -375
View File
@@ -4,10 +4,12 @@
package config
import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io/fs"
@@ -24,7 +26,8 @@ import (
"github.com/luxfi/constants"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/bls/signer/localsigner"
"github.com/luxfi/crypto/hash"
"github.com/luxfi/crypto/mldsa"
mlkemcrypto "github.com/luxfi/crypto/mlkem"
"github.com/luxfi/filesystem/perms"
"github.com/luxfi/filesystem/storage"
"github.com/luxfi/ids"
@@ -47,7 +50,6 @@ import (
"github.com/luxfi/node/version"
"github.com/luxfi/node/vms/components/gas"
"github.com/luxfi/node/vms/platformvm/reward"
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
"github.com/luxfi/node/vms/platformvm/validators/fee"
"github.com/luxfi/node/vms/proposervm"
"github.com/luxfi/timer"
@@ -95,90 +97,16 @@ var (
errFileDoesNotExist = errors.New("file does not exist")
)
// AutomineNetworkConfig captures immutable network state for automine mode persistence.
// Once written to automine-network.json, this ensures the same genesis is produced
// on every restart, making C-Chain/EVM state persistence work correctly.
type AutomineNetworkConfig struct {
// Version for forward compatibility
Version int `json:"version"`
// Genesis start time - captured on first boot, reused on restart
StartTime uint64 `json:"startTime"`
// Node identity
NodeID string `json:"nodeId"`
// BLS credentials (hex-encoded)
BLSPublicKey string `json:"blsPublicKey"`
BLSPopProof string `json:"blsPopProof"`
// Computed genesis bytes and hash (for verification)
GenesisBytes []byte `json:"genesisBytes"`
GenesisHash string `json:"genesisHash"` // Actual hash of GenesisBytes
// X-Chain asset ID (LUX token ID)
XAssetID string `json:"xAssetId"`
// C-Chain genesis (stored separately for EVM immutability)
CChainGenesis string `json:"cChainGenesis"`
}
const (
devNetworkConfigVersion = 1
devNetworkConfigFilename = "automine-network.json"
)
// loadAutomineNetworkConfig attempts to load automine-network.json from the data directory.
// Returns nil if the file doesn't exist (first boot scenario).
func loadAutomineNetworkConfig(dataDir string) (*AutomineNetworkConfig, error) {
path := filepath.Join(dataDir, devNetworkConfigFilename)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // First boot - no config yet
}
return nil, fmt.Errorf("failed to read dev network config: %w", err)
}
var cfg AutomineNetworkConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse dev network config: %w", err)
}
if cfg.Version != devNetworkConfigVersion {
return nil, fmt.Errorf("unsupported dev network config version: %d (expected %d)", cfg.Version, devNetworkConfigVersion)
}
return &cfg, nil
}
// saveAutomineNetworkConfig atomically writes the dev network config to disk.
// Uses write-to-temp-then-rename pattern for crash safety.
func saveAutomineNetworkConfig(dataDir string, cfg *AutomineNetworkConfig) error {
path := filepath.Join(dataDir, devNetworkConfigFilename)
tempPath := path + ".tmp"
cfg.Version = devNetworkConfigVersion
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal dev network config: %w", err)
}
// Write to temp file
if err := os.WriteFile(tempPath, data, 0o600); err != nil {
return fmt.Errorf("failed to write temp dev network config: %w", err)
}
// Atomic rename
if err := os.Rename(tempPath, path); err != nil {
os.Remove(tempPath) // Clean up on failure
return fmt.Errorf("failed to rename dev network config: %w", err)
}
return nil
}
// (Removed: AutomineNetworkConfig + loadAutomineNetworkConfig +
// saveAutomineNetworkConfig + automine-network.json persistence.)
//
// --automine is a *consensus-mode* flag (single-node, single-validator
// quorum), nothing more. It MUST NOT swap in a different C-Chain
// genesis or persist any "dev network config" sidecar. C-Chain
// genesis is canonical genesis — for network-id 1/2/3/1337 it's the
// embedded luxfi/genesis config, period. If the operator wants a
// custom genesis they pass --genesis-file. No backwards compatibility
// for the deleted automine-network.json sidecar.
func getConsensusConfig(v *viper.Viper) consensusconfig.Parameters {
// Start with default parameters
@@ -823,6 +751,135 @@ func getStakingSigner(v *viper.Viper) (bls.Signer, error) {
return key, nil
}
// loadPEMBlock returns the body of a single PEM block matching
// expectType. Refuses to silently consume the wrong block type so a
// misnamed file (e.g. ML-DSA private key supplied as a public-key
// path) fails loud at config-load instead of producing a NodeID under
// the wrong key.
func loadPEMBlock(path, expectType string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("%s: not a PEM file", path)
}
if block.Type != expectType {
return nil, fmt.Errorf("%s: PEM type %q is not %s", path, block.Type, expectType)
}
return block.Bytes, nil
}
// pemBytesOrFile resolves either a base64-encoded PEM in
// contentKey (highest precedence, matches the
// --foo-file-content / --foo-file pattern the existing TLS
// loaders use) or a filesystem path in pathKey. Empty return =
// neither was set; caller decides whether that's a fatal config
// error or a "fall through to classical-compat" path.
func pemBytesOrFile(v *viper.Viper, contentKey, pathKey, expectType string) ([]byte, string, error) {
if v.IsSet(contentKey) {
raw := v.GetString(contentKey)
if raw == "" {
return nil, "", nil
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, "", fmt.Errorf("decode %s base64: %w", contentKey, err)
}
block, _ := pem.Decode(decoded)
if block == nil {
return nil, "", fmt.Errorf("%s: decoded value is not PEM", contentKey)
}
if block.Type != expectType {
return nil, "", fmt.Errorf("%s: PEM type %q is not %s", contentKey, block.Type, expectType)
}
return block.Bytes, "<from-content>", nil
}
path := getExpandedArg(v, pathKey)
if path == "" {
return nil, "", nil
}
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return nil, path, nil
} else if err != nil {
return nil, path, err
}
body, err := loadPEMBlock(path, expectType)
return body, path, err
}
// loadStakingMLDSA returns the strict-PQ ML-DSA-65 keypair (if any).
// Both private and public materials are optional at the config layer
// — a missing pair means classical-compat mode. A strict-PQ chain
// profile enforces presence at the validator-set boundary.
func loadStakingMLDSA(v *viper.Viper) (*mldsa.PrivateKey, []byte, string, string, error) {
privBytes, privPath, err := pemBytesOrFile(v,
StakingMLDSAKeyContentKey, StakingMLDSAKeyPathKey, "ML-DSA-65 PRIVATE KEY")
if err != nil {
return nil, nil, privPath, "", fmt.Errorf("staking-mldsa-key: %w", err)
}
pubBytes, pubPath, err := pemBytesOrFile(v,
StakingMLDSAPubKeyContentKey, StakingMLDSAPubKeyPathKey, "ML-DSA-65 PUBLIC KEY")
if err != nil {
return nil, nil, privPath, pubPath, fmt.Errorf("staking-mldsa-pub-key: %w", err)
}
if len(privBytes) == 0 && len(pubBytes) == 0 {
return nil, nil, privPath, pubPath, nil
}
// Asymmetry is a config error: a strict-PQ chain MUST have both
// the private key (for signing) and the public key (so peers can
// verify what we signed). Refuse rather than silently degrade.
if len(privBytes) == 0 || len(pubBytes) == 0 {
return nil, nil, privPath, pubPath, errors.New("staking-mldsa: both private and public key are required (or neither for classical-compat)")
}
priv, err := mldsa.PrivateKeyFromBytes(mldsa.MLDSA65, privBytes)
if err != nil {
return nil, nil, privPath, pubPath, fmt.Errorf("parse staking-mldsa private key: %w", err)
}
// Sanity: the public key on disk must match the one derivable
// from the private key. Mismatch would point a NodeID at a
// public key the validator cannot actually sign for — silent
// authentication failure that the consensus engine would later report as
// "this validator is offline" rather than "you misconfigured
// your keys".
derivedPubBytes := priv.PublicKey.Bytes()
if !bytes.Equal(derivedPubBytes, pubBytes) {
return nil, nil, privPath, pubPath, errors.New("staking-mldsa: public key on disk does not match the public key derived from the private key")
}
return priv, pubBytes, privPath, pubPath, nil
}
// loadHandshakeMLKEM returns the strict-PQ ML-KEM-768 KEM keypair
// (if any). Same shape + invariants as loadStakingMLDSA.
func loadHandshakeMLKEM(v *viper.Viper) (*mlkemcrypto.PrivateKey, []byte, string, string, error) {
privBytes, privPath, err := pemBytesOrFile(v,
HandshakeMLKEMKeyContentKey, HandshakeMLKEMKeyPathKey, "ML-KEM-768 PRIVATE KEY")
if err != nil {
return nil, nil, privPath, "", fmt.Errorf("handshake-mlkem-key: %w", err)
}
pubBytes, pubPath, err := pemBytesOrFile(v,
HandshakeMLKEMPubKeyContentKey, HandshakeMLKEMPubKeyPathKey, "ML-KEM-768 PUBLIC KEY")
if err != nil {
return nil, nil, privPath, pubPath, fmt.Errorf("handshake-mlkem-pub-key: %w", err)
}
if len(privBytes) == 0 && len(pubBytes) == 0 {
return nil, nil, privPath, pubPath, nil
}
if len(privBytes) == 0 || len(pubBytes) == 0 {
return nil, nil, privPath, pubPath, errors.New("handshake-mlkem: both private and public key are required (or neither for classical-compat)")
}
priv, err := mlkemcrypto.PrivateKeyFromBytes(privBytes, mlkemcrypto.MLKEM768)
if err != nil {
return nil, nil, privPath, pubPath, fmt.Errorf("parse handshake-mlkem private key: %w", err)
}
derivedPubBytes := priv.PublicKey().Bytes()
if !bytes.Equal(derivedPubBytes, pubBytes) {
return nil, nil, privPath, pubPath, errors.New("handshake-mlkem: public key on disk does not match the public key derived from the private key")
}
return priv, pubBytes, privPath, pubPath, nil
}
func getStakingConfig(v *viper.Viper, networkID uint32) (node.StakingConfig, error) {
config := node.StakingConfig{
SybilProtectionEnabled: v.GetBool(SybilProtectionEnabledKey),
@@ -849,6 +906,31 @@ func getStakingConfig(v *viper.Viper, networkID uint32) (node.StakingConfig, err
if err != nil {
return node.StakingConfig{}, err
}
// Strict-PQ identity (FIPS 204 ML-DSA-65 + FIPS 203 ML-KEM-768).
// Both pairs are optional at this layer — classical-compat chains
// run without them. Strict-PQ profile rejects a missing pair at
// the validator-set boundary; that gate lives in consensus/config
// (the profile gate that owns the strict-PQ vs classical-compat
// dispatch), not here in node-config land.
mldsaPriv, mldsaPub, mldsaPrivPath, mldsaPubPath, err := loadStakingMLDSA(v)
if err != nil {
return node.StakingConfig{}, err
}
config.StakingMLDSA = mldsaPriv
config.StakingMLDSAPub = mldsaPub
config.StakingMLDSAKeyPath = mldsaPrivPath
config.StakingMLDSAPubPath = mldsaPubPath
mlkemPriv, mlkemPub, mlkemPrivPath, mlkemPubPath, err := loadHandshakeMLKEM(v)
if err != nil {
return node.StakingConfig{}, err
}
config.HandshakeMLKEMPriv = mlkemPriv
config.HandshakeMLKEMPub = mlkemPub
config.HandshakeMLKEMKeyPath = mlkemPrivPath
config.HandshakeMLKEMPubPath = mlkemPubPath
if networkID != constants.MainnetID && networkID != constants.TestnetID {
config.UptimeRequirement = v.GetFloat64(UptimeRequirementKey)
config.MinValidatorStake = v.GetUint64(MinValidatorStakeKey)
@@ -957,13 +1039,10 @@ func getUpgradeConfig(v *viper.Viper, networkID uint32) (upgrade.Config, error)
}
func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.StakingConfig, dataDir string) ([]byte, ids.ID, error) {
// Get allow-custom-genesis flag (defaults to true for development)
allowCustomGenesis := v.GetBool(AllowCustomGenesisKey)
// Handle automine mode genesis - dynamically generate genesis with the node's own credentials
if v.GetBool(DevModeKey) && !v.IsSet(GenesisFileKey) && !v.IsSet(GenesisFileContentKey) && !v.IsSet(GenesisDBKey) {
return getOrCreateAutomineGenesis(stakingCfg, dataDir)
}
// (Removed: automine-mode genesis swap.) --automine is single-node
// consensus only; it falls through to the canonical genesis loader
// below — same as a real network. C-Chain genesis comes from the
// canonical luxfi/genesis embedded config or --genesis-file. Period.
// HIGHEST PRIORITY: Raw genesis bytes - use directly without rebuilding
// This is critical for snapshot resume to avoid hash mismatch
@@ -973,10 +1052,9 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to decode %s: %w", GenesisRawBytesKey, err)
}
// Extract xAssetID from the X-chain genesis within the platform genesis
xAssetID, err := extractXAssetID(genesisBytes)
xAssetID, err := resolveXAssetID(networkID, genesisBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to extract xAssetID from raw genesis bytes: %w", err)
return nil, ids.Empty, fmt.Errorf("resolve X-Chain asset ID from %s: %w", GenesisRawBytesKey, err)
}
log.Info("loaded raw genesis bytes directly",
"size", len(genesisBytes),
@@ -1004,7 +1082,7 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
// try first loading genesis content directly from flag/env-var
if v.IsSet(GenesisFileContentKey) {
genesisData := v.GetString(GenesisFileContentKey)
return builder.FromFlag(networkID, genesisData, stakingCfg, allowCustomGenesis)
return builder.FromFlag(networkID, genesisData, stakingCfg)
}
// if content is not specified go for the file
@@ -1013,21 +1091,19 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
// Check if we have cached genesis bytes to avoid rebuilding
cacheFile := filepath.Join(dataDir, "genesis.bytes")
if cachedBytes, err := loadCachedGenesisBytes(cacheFile); err == nil && len(cachedBytes) > 0 {
xAssetID, err := extractXAssetID(cachedBytes)
xAssetID, err := resolveXAssetID(networkID, cachedBytes)
if err != nil {
log.Warn("failed to extract xAssetID from cached genesis, rebuilding",
"error", err,
)
} else {
log.Info("loaded cached genesis bytes for hash stability",
"cacheFile", cacheFile,
"size", len(cachedBytes),
)
return cachedBytes, xAssetID, nil
return nil, ids.Empty, fmt.Errorf("resolve X-Chain asset ID from cached genesis: %w", err)
}
log.Info("loaded cached genesis bytes for hash stability",
"cacheFile", cacheFile,
"size", len(cachedBytes),
"xAssetID", xAssetID,
)
return cachedBytes, xAssetID, nil
}
// No cache or invalid cache - build from file and cache the result
genesisBytes, xAssetID, err := builder.FromFile(networkID, genesisFileName, stakingCfg, allowCustomGenesis)
genesisBytes, xAssetID, err := builder.FromFile(networkID, genesisFileName, stakingCfg)
if err != nil {
return nil, ids.Empty, err
}
@@ -1050,248 +1126,36 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
return builder.FromConfig(config)
}
// getOrCreateAutomineGenesis handles automine mode genesis with persistence.
// On first boot: generates genesis, saves to automine-network.json, returns genesis.
// On restart: loads from automine-network.json to ensure genesis hash stability.
// Returns: (genesisBytes, xAssetID, error) - xAssetID is the LUX token asset ID
func getOrCreateAutomineGenesis(stakingCfg *builder.StakingConfig, dataDir string) ([]byte, ids.ID, error) {
// Try to load existing dev network config
devCfg, err := loadAutomineNetworkConfig(dataDir)
// resolveXAssetID extracts the X-Chain native asset ID from the loaded
// platform-genesis blob. It is the canonical source of truth used by
// every load path that bypasses FromConfig (raw bytes, cached bytes —
// the paths that historically defaulted to constants.UTXOAssetIDFor and
// silently disagreed with the genesis content on sovereign L1s).
//
// Behaviour:
//
// - If the genesis bakes an X-Chain, returns the runtime asset ID
// derived from that chain's CreateAssetTx (the same value
// vm.initGenesis assigns at runtime).
// - If the genesis is P-only (no X-Chain), returns the network-id-
// keyed constant. The asset ID is unused in that mode.
// - If the genesis is unparseable or the embedded X-Chain genesis
// is malformed, returns the corresponding error — these are
// unrecoverable on a primary-network bootstrap.
//
// Tested against both sovereign-network genesis (X-Chain present, asset
// ID differs from the constant) and upstream Lux genesis fixtures.
func resolveXAssetID(networkID uint32, genesisBytes []byte) (ids.ID, error) {
id, ok, err := builder.XAssetIDFromGenesisBytes(genesisBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to load dev network config: %w", err)
return ids.Empty, err
}
if devCfg != nil {
// Existing config found - check if credentials match
// In automine mode with ephemeral certs, credentials will change on restart.
// We log a warning but continue with the stored genesis since automine mode
// is single-node and doesn't require credential consistency.
expectedNodeID := stakingCfg.NodeID
expectedBLSPK := fmt.Sprintf("0x%x", stakingCfg.BLSPublicKey)
expectedBLSPoP := fmt.Sprintf("0x%x", stakingCfg.BLSProofOfPossession)
credentialsMismatch := false
if devCfg.NodeID != expectedNodeID {
log.Warn("automine-network.json nodeID mismatch (using stored genesis anyway for automine mode)",
"stored", devCfg.NodeID,
"current", expectedNodeID,
)
credentialsMismatch = true
}
if devCfg.BLSPublicKey != expectedBLSPK {
log.Warn("automine-network.json BLS public key mismatch (using stored genesis anyway for automine mode)")
credentialsMismatch = true
}
if devCfg.BLSPopProof != expectedBLSPoP {
log.Warn("automine-network.json BLS PoP mismatch (using stored genesis anyway for automine mode)")
credentialsMismatch = true
}
if credentialsMismatch {
log.Warn("credentials changed since first boot - for persistent staking, use --staking-ephemeral-cert-enabled=false with persistent key files")
}
// Verify the stored genesis hash matches what we'll compute from the bytes
storedHash, err := ids.FromString(devCfg.GenesisHash)
if err != nil {
return nil, ids.Empty, fmt.Errorf("invalid genesis hash in automine-network.json: %w", err)
}
// Compute actual hash from stored bytes to verify integrity
computedHashBytes := hash.ComputeHash256(devCfg.GenesisBytes)
computedHash, err := ids.ToID(computedHashBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to convert computed hash to ID: %w", err)
}
if storedHash != computedHash {
return nil, ids.Empty, fmt.Errorf("genesis bytes corrupted: stored hash %s != computed hash %s", storedHash, computedHash)
}
// Parse stored X-Chain asset ID
xAssetID, err := ids.FromString(devCfg.XAssetID)
if err != nil {
return nil, ids.Empty, fmt.Errorf("invalid xAssetId in automine-network.json: %w", err)
}
log.Info("loaded dev network config",
"path", filepath.Join(dataDir, devNetworkConfigFilename),
"genesisHash", devCfg.GenesisHash,
"xAssetID", devCfg.XAssetID,
"startTime", devCfg.StartTime,
)
return devCfg.GenesisBytes, xAssetID, nil
if !ok {
return constants.UTXOAssetIDFor(networkID), nil
}
// First boot - generate new genesis with current timestamp
startTime := uint64(time.Now().Unix())
// buildAutomineGenesis returns (genesisBytes, xAssetID) - the LUX token asset ID
genesisBytes, xAssetID, err := buildAutomineGenesis(stakingCfg, startTime)
if err != nil {
return nil, ids.Empty, err
}
// Compute actual genesis hash from bytes (this is what the node uses for DB validation)
genesisHashBytes := hash.ComputeHash256(genesisBytes)
genesisHash, err := ids.ToID(genesisHashBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to convert genesis hash to ID: %w", err)
}
// Save for future restarts
newDevCfg := &AutomineNetworkConfig{
Version: devNetworkConfigVersion,
StartTime: startTime,
NodeID: stakingCfg.NodeID,
BLSPublicKey: fmt.Sprintf("0x%x", stakingCfg.BLSPublicKey),
BLSPopProof: fmt.Sprintf("0x%x", stakingCfg.BLSProofOfPossession),
GenesisBytes: genesisBytes,
GenesisHash: genesisHash.String(),
XAssetID: xAssetID.String(),
CChainGenesis: automineCChainGenesis,
}
if err := saveAutomineNetworkConfig(dataDir, newDevCfg); err != nil {
// Log warning but don't fail - genesis is still valid
log.Warn("failed to save dev network config (persistence may not work on restart)",
"error", err,
)
} else {
log.Info("created dev network config",
"path", filepath.Join(dataDir, devNetworkConfigFilename),
"genesisHash", genesisHash.String(),
"xAssetID", xAssetID.String(),
"startTime", startTime,
)
}
return genesisBytes, xAssetID, nil
return id, nil
}
// buildAutomineGenesis creates a genesis configuration for single-node development mode.
// It uses the node's own credentials as the sole validator.
func buildAutomineGenesis(stakingCfg *builder.StakingConfig, startTime uint64) ([]byte, ids.ID, error) {
// Parse node ID from staking config
nodeID, err := ids.NodeIDFromString(stakingCfg.NodeID)
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to parse node ID for automine mode: %w", err)
}
// Lux Treasury address: 0x9011E888251AB053B7bD1cdB598Db4f9DEd94714
// This is derived from LUX_MNEMONIC and funded on C-Chain
var rewardAddress ids.ShortID
treasuryAddr := "9011E888251AB053B7bD1cdB598Db4f9DEd94714"
treasuryBytes, err := ids.ShortFromString(treasuryAddr)
if err == nil {
rewardAddress = treasuryBytes
} else {
// Fall back to a deterministic address derived from node ID
copy(rewardAddress[:], nodeID[:20])
}
// Create automine mode config with embedded C-Chain genesis
devCfg := builder.DevModeConfig{
NodeID: nodeID,
BLSPublicKey: fmt.Sprintf("0x%x", stakingCfg.BLSPublicKey),
BLSPopProof: fmt.Sprintf("0x%x", stakingCfg.BLSProofOfPossession),
RewardAddress: rewardAddress,
CChainGenesis: automineCChainGenesis,
StartTime: startTime, // Use provided start time for determinism
}
return builder.ForDevMode(devCfg, stakingCfg)
}
// automineCChainGenesis is the default C-Chain genesis for automine mode.
// Network ID 1337, EVM Chain ID 31337.
// Funds Lux Treasury (0x9011), light mnemonic accounts (BIP44 m/44'/9000'/0'/0/{0-4}), and Anvil/Hardhat accounts.
const automineCChainGenesis = `{
"config": {
"chainId": 31337,
"homesteadBlock": 0,
"eip150Block": 0,
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"petersburgBlock": 0,
"istanbulBlock": 0,
"muirGlacierBlock": 0,
"berlinBlock": 0,
"londonBlock": 0,
"arrowGlacierBlock": 0,
"grayGlacierBlock": 0,
"mergeNetsplitBlock": 0,
"shanghaiTime": 0,
"cancunTime": 0,
"blobSchedule": {
"cancun": {"target": 3, "max": 6, "baseFeeUpdateFraction": 3338477}
},
"terminalTotalDifficulty": 0,
"chainEVMTimestamp": 0,
"durangoTimestamp": 0,
"etnaTimestamp": 0,
"feeConfig": {
"gasLimit": 30000000,
"targetBlockRate": 1,
"minBaseFee": 1000000000,
"targetGas": 100000000,
"baseFeeChangeDenominator": 48,
"minBlockGasCost": 0,
"maxBlockGasCost": 1000000,
"blockGasCostStep": 200000
},
"warpConfig": {
"blockTimestamp": 0,
"quorumNumerator": 67,
"requirePrimaryNetworkSigners": false
}
},
"alloc": {
"9011E888251AB053B7bD1cdB598Db4f9DEd94714": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
},
"5369615110ca435bdf798f31c20ba6163d7b0a54": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
},
"2e701063ccdffa2b1872c596222d8067d124d3ef": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
},
"9030463eb1aaa563c8247468416cc0bf06347502": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
},
"f77b06331152fd0e536de0af65688a6559c6f914": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
},
"944fd51713652b9922690b7d06498fbf8742beac": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
},
"f39Fd6e51aad88F6F4ce6aB8827279cffFb92266": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
},
"70997970C51812dc3A010C7d01b50e0d17dc79C8": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
},
"3C44CdDdB6a900fa2b585dd299e03d12FA4293BC": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
},
"90F79bf6EB2c4f870365E785982E1f101E93b906": {
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
}
},
"nonce": "0x0",
"timestamp": "0x0",
"extraData": "0x",
"gasLimit": "0x1c9c380",
"difficulty": "0x0",
"mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"coinbase": "0x0000000000000000000000000000000000000000",
"number": "0x0",
"gasUsed": "0x0",
"parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000"
}`
func getTrackedChains(v *viper.Viper) (set.Set[ids.ID], error) {
trackChainsStr := v.GetString(TrackChainsKey)
@@ -1849,19 +1713,25 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) {
return node.Config{}, err
}
// Chain tracking
// Always populate TrackedChains from --track-chains flag/env, regardless of
// TrackAllChains. TrackedChains is used in peer handshake (MyChains) to tell
// peers which chains we're interested in. Without it, peers won't gossip
// chain blocks to us even if we're tracking all chains locally.
// Chain tracking — explicit per-chain opt-in. Each validator
// declares which chains it wants to validate via --track-chains
// (or the operator-emitted equivalent). TrackedChains is also used
// in the peer handshake (MyChains) so peers gossip blocks for the
// chains we asked for.
//
// TrackAllChains stays as an opt-in escape hatch only — it is NOT
// auto-enabled when TrackedChains is empty. The previous default
// (`if TrackedChains == nil { TrackAllChains = true }`) was a
// footgun: any node started without an explicit --track-chains
// list silently tried to spin up every chain in P-chain state,
// including ones whose VM plugin wasn't loaded — fataling at the
// chain manager. Empty TrackedChains now means "track only P/X
// (built-in primary chains)".
nodeConfig.TrackedChains, err = getTrackedChains(v)
if err != nil {
return node.Config{}, err
}
nodeConfig.TrackAllChains = v.GetBool(TrackAllChainsKey)
if nodeConfig.TrackedChains == nil {
nodeConfig.TrackAllChains = true
}
// HTTP APIs
@@ -1989,11 +1859,10 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) {
// Get data directory for dev network config persistence
dataDir := getExpandedArg(v, DataDirKey)
nodeConfig.GenesisBytes, nodeConfig.LuxAssetID, err = getGenesisData(v, nodeConfig.NetworkID, &genesisStakingCfg, dataDir)
nodeConfig.GenesisBytes, nodeConfig.XAssetID, err = getGenesisData(v, nodeConfig.NetworkID, &genesisStakingCfg, dataDir)
if err != nil {
return node.Config{}, fmt.Errorf("unable to load genesis file: %w", err)
}
nodeConfig.AllowGenesisUpdate = v.GetBool(AllowGenesisUpdateKey)
// StateSync Configs
nodeConfig.StateSyncConfig, err = getStateSyncConfig(v)
@@ -2122,26 +1991,6 @@ func saveCachedGenesisBytes(cacheFile string, genesisBytes []byte) error {
return os.WriteFile(cacheFile, genesisBytes, 0o600)
}
// extractXAssetID extracts the LUX asset ID from raw platform genesis bytes.
// This is needed when loading raw genesis bytes directly (for snapshot resume)
// to avoid rebuilding genesis which causes hash mismatch.
func extractXAssetID(genesisBytes []byte) (ids.ID, error) {
// Get the X-chain creation TX from the platform genesis
xChainTx, err := builder.VMGenesis(genesisBytes, constants.XVMID)
if err != nil {
return ids.Empty, fmt.Errorf("couldn't find X-chain genesis in platform genesis: %w", err)
}
// Extract the XVM genesis bytes from the create chain TX
createChainTx, ok := xChainTx.Unsigned.(*pchaintxs.CreateChainTx)
if !ok {
return ids.Empty, fmt.Errorf("X-chain genesis TX is not a CreateChainTx")
}
// Use the builder.XAssetID function to extract the asset ID from XVM genesis
return builder.XAssetID(createChainTx.GenesisData)
}
func providedFlags(v *viper.Viper) map[string]interface{} {
settings := v.AllSettings()
customSettings := make(map[string]interface{}, len(settings))
+1 -1
View File
@@ -586,7 +586,7 @@ Defaults to `0.1`.
Partial sync enables nodes that are not primary network validators to optionally sync
only the P-chain on the primary network. Nodes that use this option can still track
Chains. After the Etna upgrade, nodes that use this option can also validate L1s.
Chains. Nodes that use this option can also validate L1s.
This config defaults to `false`.
## Public IP
+58
View File
@@ -16,10 +16,14 @@ import (
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
"github.com/luxfi/constants"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
"github.com/luxfi/node/chains"
"github.com/luxfi/node/genesis/builder"
"github.com/luxfi/node/nets"
pchaingenesis "github.com/luxfi/node/vms/platformvm/genesis"
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
)
const chainConfigFilenameExtension = ".ex"
@@ -672,3 +676,57 @@ func TestDevModeFlags(t *testing.T) {
}
}
// TestResolveXAssetID_FromSovereignGenesis covers the canonical
// behaviour the sovereign-L1 fix relies on: when the loaded platform
// genesis bakes an X-Chain, resolveXAssetID returns the runtime asset
// ID encoded IN the genesis (matches what FromConfig produces, matches
// what the running X-Chain reports via platform.getStakingAssetID).
//
// Critically: NOT constants.UTXOAssetIDFor(networkID). That value is
// network-id-keyed and would silently collide between two sovereign L1s
// sharing a primary-network ID.
func TestResolveXAssetID_FromSovereignGenesis(t *testing.T) {
require := require.New(t)
cfg := builder.GetConfig(constants.LocalID)
require.NotNil(cfg)
require.NotEmpty(cfg.XChainGenesis, "fixture must bake X-Chain")
genesisBytes, expectedID, err := builder.FromConfig(cfg)
require.NoError(err)
require.NotEqual(ids.Empty, expectedID)
gotID, err := resolveXAssetID(constants.LocalID, genesisBytes)
require.NoError(err)
require.Equal(expectedID, gotID,
"resolveXAssetID must agree with FromConfig on the genesis-derived asset ID")
}
// TestResolveXAssetID_POnlyFallback verifies that when the platform
// genesis bakes no X-Chain (P-only mode), resolveXAssetID falls back
// to constants.UTXOAssetIDFor(networkID). That value is unused at
// runtime (no X-Chain to mint on) but keeps the existing nodeConfig
// shape consistent for downstream consumers.
func TestResolveXAssetID_POnlyFallback(t *testing.T) {
require := require.New(t)
pOnly := &pchaingenesis.Genesis{Chains: nil}
pOnlyBytes, err := pchaingenesis.Codec.Marshal(pchaintxs.CodecVersion, pOnly)
require.NoError(err)
gotID, err := resolveXAssetID(42, pOnlyBytes)
require.NoError(err)
require.Equal(constants.UTXOAssetIDFor(42), gotID,
"P-only must fall through to UTXOAssetIDFor(networkID)")
}
// TestResolveXAssetID_Malformed asserts that bad genesis bytes surface
// an error rather than silently returning ids.Empty (which would
// reintroduce the UTXOAssetIDFor fallback and defeat the fix on
// sovereign L1s where the fallback value is wrong).
func TestResolveXAssetID_Malformed(t *testing.T) {
require := require.New(t)
_, err := resolveXAssetID(1, []byte{0xff, 0xfe, 0xfd, 0xfc})
require.Error(err)
}
+20 -2
View File
@@ -46,6 +46,12 @@ var (
defaultStakingTLSKeyPath = filepath.Join(defaultStakingPath, "staker.key")
defaultStakingCertPath = filepath.Join(defaultStakingPath, "staker.crt")
defaultStakingSignerKeyPath = filepath.Join(defaultStakingPath, "signer.key")
// Strict-PQ default paths — mirror downstream-tenant CLI `<tenantctl> key gen`
// layout so the operator init container + lqd see the same files.
defaultStakingMLDSAKeyPath = filepath.Join(defaultStakingPath, "mldsa.key")
defaultStakingMLDSAPubKeyPath = filepath.Join(defaultStakingPath, "mldsa.pub")
defaultHandshakeMLKEMKeyPath = filepath.Join(defaultStakingPath, "mlkem.key")
defaultHandshakeMLKEMPubKeyPath = filepath.Join(defaultStakingPath, "mlkem.pub")
defaultConfigDir = filepath.Join(defaultUnexpandedDataDir, "configs")
defaultChainConfigDir = filepath.Join(defaultConfigDir, "chains")
defaultVMConfigDir = filepath.Join(defaultConfigDir, "vms")
@@ -109,8 +115,6 @@ func addNodeFlags(fs *pflag.FlagSet) {
fs.String(GenesisDBKey, "", "Path to existing genesis database for replay. Cannot be used with genesis-file or genesis-file-content")
fs.String(GenesisDBTypeKey, "zapdb", "Database type to use for genesis database. Must be one of {pebbledb, zapdb}")
fs.Uint64(GenesisBlockLimitKey, 0, "Limit number of blocks to replay during genesis (0 = all blocks)")
fs.Bool(AllowCustomGenesisKey, true, "Allow custom genesis for mainnet/testnet (default: true for development, set false for production)")
fs.Bool(AllowGenesisUpdateKey, false, "Allow updating stored genesis hash when genesis changes (use for adding new primary network chains)")
// Upgrade
fs.String(UpgradeFileKey, "", fmt.Sprintf("Specifies an upgrade config file path. Ignored when running standard networks or if %s is specified",
@@ -290,6 +294,20 @@ func addNodeFlags(fs *pflag.FlagSet) {
fs.Bool(StakingEphemeralSignerEnabledKey, false, "If true, the node uses an ephemeral staking signer key")
fs.String(StakingSignerKeyPathKey, defaultStakingSignerKeyPath, fmt.Sprintf("Path to the signer private key for staking. Ignored if %s is specified", StakingSignerKeyContentKey))
fs.String(StakingSignerKeyContentKey, "", "Specifies base64 encoded signer private key for staking")
// Strict-PQ identity (FIPS 204 ML-DSA-65 + FIPS 203 ML-KEM-768).
// When the ML-DSA pubkey is provided, NodeID derivation pivots from
// the classical TLS-cert path to the wire-discriminated strict-PQ
// scheme (ids.NodeIDSchemeMLDSA65.DeriveMLDSA). Both pairs default
// to the layout downstream-tenant CLI `<tenantctl> key gen` writes — wire the
// init container and lqd reads the files with zero extra config.
fs.String(StakingMLDSAKeyPathKey, defaultStakingMLDSAKeyPath, fmt.Sprintf("Path to the ML-DSA-65 staking private key (FIPS 204 PEM). Ignored if %s is specified", StakingMLDSAKeyContentKey))
fs.String(StakingMLDSAKeyContentKey, "", "Base64-encoded ML-DSA-65 staking private key (FIPS 204 PEM)")
fs.String(StakingMLDSAPubKeyPathKey, defaultStakingMLDSAPubKeyPath, fmt.Sprintf("Path to the ML-DSA-65 staking public key (FIPS 204 PEM). Ignored if %s is specified", StakingMLDSAPubKeyContentKey))
fs.String(StakingMLDSAPubKeyContentKey, "", "Base64-encoded ML-DSA-65 staking public key (FIPS 204 PEM)")
fs.String(HandshakeMLKEMKeyPathKey, defaultHandshakeMLKEMKeyPath, fmt.Sprintf("Path to the ML-KEM-768 handshake private key (FIPS 203 PEM). Ignored if %s is specified", HandshakeMLKEMKeyContentKey))
fs.String(HandshakeMLKEMKeyContentKey, "", "Base64-encoded ML-KEM-768 handshake private key (FIPS 203 PEM)")
fs.String(HandshakeMLKEMPubKeyPathKey, defaultHandshakeMLKEMPubKeyPath, fmt.Sprintf("Path to the ML-KEM-768 handshake public key (FIPS 203 PEM). Ignored if %s is specified", HandshakeMLKEMPubKeyContentKey))
fs.String(HandshakeMLKEMPubKeyContentKey, "", "Base64-encoded ML-KEM-768 handshake public key (FIPS 203 PEM)")
fs.String(StakingKMSEndpointKey, "", "KMS endpoint for staking key retrieval (e.g., https://kms.dev.lux.network)")
fs.String(StakingKMSSecretPathKey, "", "KMS secret path for staking keys (e.g., /staking/liquid-devnet/node-0)")
fs.String(StakingKMSTokenKey, "", "KMS auth token for staking key retrieval")
+16 -2
View File
@@ -20,8 +20,6 @@ const (
GenesisDBKey = "genesis-db"
GenesisDBTypeKey = "genesis-db-type"
GenesisBlockLimitKey = "genesis-block-limit"
AllowCustomGenesisKey = "allow-custom-genesis"
AllowGenesisUpdateKey = "allow-genesis-update"
UpgradeFileKey = "upgrade-file"
UpgradeFileContentKey = "upgrade-file-content"
NetworkNameKey = "network-id"
@@ -101,6 +99,22 @@ const (
StakingKMSEndpointKey = "staking-kms-endpoint"
StakingKMSSecretPathKey = "staking-kms-secret-path"
StakingKMSTokenKey = "staking-kms-token"
// Strict-PQ staking identity (FIPS 204 ML-DSA-65). When set, the node
// uses the ML-DSA-65 public key as the NodeID source via
// ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, pubKey), replacing the
// classical TLS-cert NodeID derivation. Strict-PQ profiles require
// these; classical-compat chains ignore them.
StakingMLDSAKeyPathKey = "staking-mldsa-key-file"
StakingMLDSAKeyContentKey = "staking-mldsa-key-file-content"
StakingMLDSAPubKeyPathKey = "staking-mldsa-pub-key-file"
StakingMLDSAPubKeyContentKey = "staking-mldsa-pub-key-file-content"
// Strict-PQ handshake KEM (FIPS 203 ML-KEM-768). Peer-facing public
// key is published in the validator-set entry so peers can encapsulate
// to it for session-key establishment with no classical fallback.
HandshakeMLKEMKeyPathKey = "handshake-mlkem-key-file"
HandshakeMLKEMKeyContentKey = "handshake-mlkem-key-file-content"
HandshakeMLKEMPubKeyPathKey = "handshake-mlkem-pub-key-file"
HandshakeMLKEMPubKeyContentKey = "handshake-mlkem-pub-key-file-content"
SybilProtectionEnabledKey = "sybil-protection-enabled"
SybilProtectionDisabledWeightKey = "sybil-protection-disabled-weight"
NetworkInitialTimeoutKey = "network-initial-timeout"
+96 -3
View File
@@ -5,6 +5,7 @@ package node
import (
"crypto/tls"
"errors"
"net/netip"
"time"
@@ -16,6 +17,8 @@ import (
"github.com/luxfi/node/network"
// "github.com/luxfi/consensus/core/router" // Unused
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/mldsa"
mlkemcrypto "github.com/luxfi/crypto/mlkem"
"github.com/luxfi/node/nets"
"github.com/luxfi/node/network/tracker"
"github.com/luxfi/node/upgrade"
@@ -83,6 +86,30 @@ type StakingConfig struct {
StakingKeyPath string `json:"stakingKeyPath"`
StakingCertPath string `json:"stakingCertPath"`
StakingSignerPath string `json:"stakingSignerPath"`
// Strict-PQ identity material. Set by config.GetNodeConfig when the
// --staking-mldsa-*-file / --handshake-mlkem-*-file flags resolve.
// All four fields are nil/empty under a classical-compat chain;
// strict-PQ profile rejects a missing StakingMLDSAPub at boot.
//
// NodeID derivation pivots on StakingMLDSAPub: when non-empty,
// ids.NodeIDSchemeMLDSA65.DeriveMLDSA(stakingChainID, StakingMLDSAPub)
// replaces ids.NodeIDFromCert(StakingTLSCert) — the TLS cert becomes
// transport-only, no longer the identity anchor.
//
// HandshakeMLKEMPub is the peer-facing KEM public key that lqd
// publishes in its validator-set entry so peers can encapsulate to it
// for session-key establishment with no classical fallback. The
// HandshakeMLKEMPriv stays local to the pod.
StakingMLDSA *mldsa.PrivateKey `json:"-"`
StakingMLDSAPub []byte `json:"-"`
HandshakeMLKEMPriv *mlkemcrypto.PrivateKey `json:"-"`
HandshakeMLKEMPub []byte `json:"-"`
// File paths kept for log-line context, mirroring StakingKeyPath etc.
StakingMLDSAKeyPath string `json:"stakingMLDSAKeyPath,omitempty"`
StakingMLDSAPubPath string `json:"stakingMLDSAPubPath,omitempty"`
HandshakeMLKEMKeyPath string `json:"handshakeMLKEMKeyPath,omitempty"`
HandshakeMLKEMPubPath string `json:"handshakeMLKEMPubPath,omitempty"`
}
type StateSyncConfig struct {
@@ -141,9 +168,8 @@ type Config struct {
UpgradeConfig upgrade.Config `json:"upgradeConfig"`
// Genesis information
GenesisBytes []byte `json:"-"`
LuxAssetID ids.ID `json:"xAssetID"`
AllowGenesisUpdate bool `json:"allowGenesisUpdate,omitempty"`
GenesisBytes []byte `json:"-"`
XAssetID ids.ID `json:"xAssetID"`
// ID of the network this node should connect to
NetworkID uint32 `json:"networkID"`
@@ -240,3 +266,70 @@ type Config struct {
LazyChainLoading bool `json:"lazyChainLoading"`
SingleValidatorMode bool `json:"singleValidatorMode"`
}
// IsStrictPQ reports whether the staking config has strict-PQ
// identity material loaded. Strict-PQ requires an ML-DSA-65 public
// key (signing identity) — the ML-KEM-768 KEM is a complementary
// handshake primitive that the validator-set entry publishes for
// peers but is NOT what defines the validator's identity. The
// 0-byte slice case is "classical-compat": NodeID derives from the
// TLS staking cert in StakingTLSCert.
func (c *StakingConfig) IsStrictPQ() bool {
return len(c.StakingMLDSAPub) > 0
}
// DeriveNodeID returns the canonical 20-byte NodeID for this
// staking identity under chainID:
//
// - Strict-PQ (StakingMLDSAPub set):
// NodeID = SHAKE256-384("NODE_ID_V1" || chainID || 0x42 || pubKey)[:20]
// via ids.NodeIDSchemeMLDSA65.DeriveMLDSA. chainID-bound so the
// same key on a different chain produces a different NodeID,
// blocking cross-chain replay of validator registrations.
// - Classical-compat (no ML-DSA pub):
// NodeID = ids.NodeIDFromCert(StakingTLSCert.Leaf) — the
// legacy upstream derivation. A strict-PQ chain MUST refuse
// this branch at the validator-set boundary; the choice lives
// in the consensus profile, not here.
//
// This is the single seam every "what's my NodeID" call site should
// route through. Direct calls to ids.NodeIDFromCert in new code are
// a bug — they bypass the strict-PQ pivot.
func (c *StakingConfig) DeriveNodeID(chainID ids.ID) (ids.NodeID, error) {
if c.IsStrictPQ() {
id, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, c.StakingMLDSAPub)
return id, err
}
if c.StakingTLSCert.Leaf == nil {
return ids.EmptyNodeID, errStakingTLSCertLeafNil
}
return ids.NodeIDFromCert(&ids.Certificate{
Raw: c.StakingTLSCert.Leaf.Raw,
PublicKey: c.StakingTLSCert.Leaf.PublicKey,
}), nil
}
// DeriveTypedNodeID returns the wire-canonical TypedNodeID — scheme
// byte (0x42 for strict-PQ ML-DSA-65, 0x90 for classical secp256k1)
// followed by the 20-byte NodeID. The scheme byte travels with the
// NodeID on every handshake / validator-set serialization so a
// receiver MUST know which verifier to dispatch before consulting
// the chain profile. Profile is a downgrade-detection gate; scheme
// byte is a primitive-mismatch gate.
func (c *StakingConfig) DeriveTypedNodeID(chainID ids.ID) (ids.TypedNodeID, error) {
if c.IsStrictPQ() {
t, _, err := ids.TypedNodeIDFromMLDSA(
ids.NodeIDSchemeMLDSA65, chainID, c.StakingMLDSAPub)
return t, err
}
if c.StakingTLSCert.Leaf == nil {
return ids.TypedNodeID{}, errStakingTLSCertLeafNil
}
return ids.TypedNodeIDFromCert(&ids.Certificate{
Raw: c.StakingTLSCert.Leaf.Raw,
PublicKey: c.StakingTLSCert.Leaf.PublicKey,
}), nil
}
var errStakingTLSCertLeafNil = errors.New(
"node: StakingConfig.StakingTLSCert.Leaf is nil — neither ML-DSA-65 nor a parsed TLS cert is available")
+73 -7
View File
@@ -131,13 +131,6 @@ func allFlags() []FlagSpec {
Description: "Limit number of blocks to replay during genesis (0 = all blocks)",
Category: CategoryGenesis,
},
{
Key: "allow-custom-genesis",
Type: TypeBool,
Default: true,
Description: "Allow custom genesis for mainnet/testnet (default: true for development, set false for production)",
Category: CategoryGenesis,
},
{
Key: "upgrade-file",
Type: TypeString,
@@ -1078,6 +1071,79 @@ func allFlags() []FlagSpec {
Category: CategoryStaking,
Sensitive: true,
},
// Strict-PQ staking identity (FIPS 204 ML-DSA-65). When supplied,
// the node uses the ML-DSA-65 public key as the NodeID source via
// ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, pubKey) — replacing
// the classical TLS-cert NodeID derivation. The classical TLS
// staker.crt/key + signer.key flags above are retained only for
// legacy chains that ship no ChainSecurityProfile; strict-PQ
// chains refuse classical material at every boundary.
{
Key: "staking-mldsa-key-file",
Type: TypeString,
Default: "$LUXD_DATA_DIR/staking/mldsa.key",
Description: "Path to the ML-DSA-65 staking private key (FIPS 204 PEM). Ignored if staking-mldsa-key-file-content is specified",
Category: CategoryStaking,
Sensitive: true,
},
{
Key: "staking-mldsa-key-file-content",
Type: TypeString,
Default: "",
Description: "Base64-encoded ML-DSA-65 staking private key (FIPS 204 PEM)",
Category: CategoryStaking,
Sensitive: true,
},
{
Key: "staking-mldsa-pub-key-file",
Type: TypeString,
Default: "$LUXD_DATA_DIR/staking/mldsa.pub",
Description: "Path to the ML-DSA-65 staking public key (FIPS 204 PEM). Ignored if staking-mldsa-pub-key-file-content is specified",
Category: CategoryStaking,
},
{
Key: "staking-mldsa-pub-key-file-content",
Type: TypeString,
Default: "",
Description: "Base64-encoded ML-DSA-65 staking public key (FIPS 204 PEM)",
Category: CategoryStaking,
},
// Strict-PQ handshake KEM (FIPS 203 ML-KEM-768). Peer-facing public
// key is published in the validator-set entry so peers can
// encapsulate to it for session-key establishment — the PQ replacement
// for classical TLS key agreement on the libp2p handshake. The
// private key stays on the validator pod; the public key is the only
// material that crosses the wire.
{
Key: "handshake-mlkem-key-file",
Type: TypeString,
Default: "$LUXD_DATA_DIR/staking/mlkem.key",
Description: "Path to the ML-KEM-768 handshake private key (FIPS 203 PEM). Ignored if handshake-mlkem-key-file-content is specified",
Category: CategoryStaking,
Sensitive: true,
},
{
Key: "handshake-mlkem-key-file-content",
Type: TypeString,
Default: "",
Description: "Base64-encoded ML-KEM-768 handshake private key (FIPS 203 PEM)",
Category: CategoryStaking,
Sensitive: true,
},
{
Key: "handshake-mlkem-pub-key-file",
Type: TypeString,
Default: "$LUXD_DATA_DIR/staking/mlkem.pub",
Description: "Path to the ML-KEM-768 handshake public key (FIPS 203 PEM). Ignored if handshake-mlkem-pub-key-file-content is specified",
Category: CategoryStaking,
},
{
Key: "handshake-mlkem-pub-key-file-content",
Type: TypeString,
Default: "",
Description: "Base64-encoded ML-KEM-768 handshake public key (FIPS 203 PEM)",
Category: CategoryStaking,
},
{
Key: "sybil-protection-enabled",
Type: TypeBool,
-8
View File
@@ -1,8 +0,0 @@
version: v1
plugins:
- name: go
out: pb
opt: paths=source_relative
- plugin: connect-go
out: pb
opt: paths=source_relative
-7
View File
@@ -1,7 +0,0 @@
# Generated by buf. DO NOT EDIT.
version: v1
deps:
- remote: buf.build
owner: metric
repository: client-model
commit: 1d56a02d481a412a83b3c4984eb90c2e
-27
View File
@@ -1,27 +0,0 @@
version: v1
name: buf.build/luxfi/lux
build:
excludes:
# for golang we handle metric as a buf dep so we exclude it from generate, this proto
# file is required by languages such as rust.
- io/metric
breaking:
use:
- FILE
deps:
- buf.build/metric/client-model
lint:
use:
- STANDARD
except:
- SERVICE_SUFFIX # service requirement of <name>+Service
- RPC_REQUEST_STANDARD_NAME # explicit <rpc>+Request naming
- RPC_RESPONSE_STANDARD_NAME # explicit <rpc>+Response naming
- PACKAGE_VERSION_SUFFIX # versioned naming <service>.v1beta
# allows RPC requests or responses to be google.protobuf.Empty messages. This can be set if you
# want to allow messages to be void forever, that is they will never take any parameters.
rpc_allow_google_protobuf_empty_requests: true
rpc_allow_google_protobuf_empty_responses: true
# allows the same message type to be used for a single RPC's request and response type.
# TODO: this should not be tolerated and if it is only perscriptivly.
rpc_allow_same_request_response: true
-288
View File
@@ -1,288 +0,0 @@
//go:build grpc
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc (unknown)
// source: xsvm/service.proto
package xsvm
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type PingRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
}
func (x *PingRequest) Reset() {
*x = PingRequest{}
mi := &file_xsvm_service_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PingRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PingRequest) ProtoMessage() {}
func (x *PingRequest) ProtoReflect() protoreflect.Message {
mi := &file_xsvm_service_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead.
func (*PingRequest) Descriptor() ([]byte, []int) {
return file_xsvm_service_proto_rawDescGZIP(), []int{0}
}
func (x *PingRequest) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
type PingReply struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
}
func (x *PingReply) Reset() {
*x = PingReply{}
mi := &file_xsvm_service_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PingReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PingReply) ProtoMessage() {}
func (x *PingReply) ProtoReflect() protoreflect.Message {
mi := &file_xsvm_service_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
func (*PingReply) Descriptor() ([]byte, []int) {
return file_xsvm_service_proto_rawDescGZIP(), []int{1}
}
func (x *PingReply) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
type StreamPingRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
}
func (x *StreamPingRequest) Reset() {
*x = StreamPingRequest{}
mi := &file_xsvm_service_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamPingRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamPingRequest) ProtoMessage() {}
func (x *StreamPingRequest) ProtoReflect() protoreflect.Message {
mi := &file_xsvm_service_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamPingRequest.ProtoReflect.Descriptor instead.
func (*StreamPingRequest) Descriptor() ([]byte, []int) {
return file_xsvm_service_proto_rawDescGZIP(), []int{2}
}
func (x *StreamPingRequest) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
type StreamPingReply struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
}
func (x *StreamPingReply) Reset() {
*x = StreamPingReply{}
mi := &file_xsvm_service_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamPingReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamPingReply) ProtoMessage() {}
func (x *StreamPingReply) ProtoReflect() protoreflect.Message {
mi := &file_xsvm_service_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamPingReply.ProtoReflect.Descriptor instead.
func (*StreamPingReply) Descriptor() ([]byte, []int) {
return file_xsvm_service_proto_rawDescGZIP(), []int{3}
}
func (x *StreamPingReply) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
var File_xsvm_service_proto protoreflect.FileDescriptor
var file_xsvm_service_proto_rawDesc = []byte{
0x0a, 0x12, 0x78, 0x73, 0x76, 0x6d, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x78, 0x73, 0x76, 0x6d, 0x22, 0x27, 0x0a, 0x0b, 0x50, 0x69,
0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x22, 0x25, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79,
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x2d, 0x0a, 0x11, 0x53, 0x74,
0x72, 0x65, 0x61, 0x6d, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x2b, 0x0a, 0x0f, 0x53, 0x74, 0x72,
0x65, 0x61, 0x6d, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x18, 0x0a, 0x07,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x74, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x2a,
0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x11, 0x2e, 0x78, 0x73, 0x76, 0x6d, 0x2e, 0x50, 0x69,
0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0f, 0x2e, 0x78, 0x73, 0x76, 0x6d,
0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x40, 0x0a, 0x0a, 0x53, 0x74,
0x72, 0x65, 0x61, 0x6d, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x17, 0x2e, 0x78, 0x73, 0x76, 0x6d, 0x2e,
0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x15, 0x2e, 0x78, 0x73, 0x76, 0x6d, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x50,
0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x28, 0x01, 0x30, 0x01, 0x42, 0x2c, 0x5a, 0x2a,
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x75, 0x78, 0x66, 0x69,
0x2f, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2f, 0x70, 0x62, 0x2f, 0x78, 0x73, 0x76, 0x6d, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x33,
}
var (
file_xsvm_service_proto_rawDescOnce sync.Once
file_xsvm_service_proto_rawDescData = file_xsvm_service_proto_rawDesc
)
func file_xsvm_service_proto_rawDescGZIP() []byte {
file_xsvm_service_proto_rawDescOnce.Do(func() {
file_xsvm_service_proto_rawDescData = protoimpl.X.CompressGZIP(file_xsvm_service_proto_rawDescData)
})
return file_xsvm_service_proto_rawDescData
}
var file_xsvm_service_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_xsvm_service_proto_goTypes = []any{
(*PingRequest)(nil), // 0: xsvm.PingRequest
(*PingReply)(nil), // 1: xsvm.PingReply
(*StreamPingRequest)(nil), // 2: xsvm.StreamPingRequest
(*StreamPingReply)(nil), // 3: xsvm.StreamPingReply
}
var file_xsvm_service_proto_depIdxs = []int32{
0, // 0: xsvm.Ping.Ping:input_type -> xsvm.PingRequest
2, // 1: xsvm.Ping.StreamPing:input_type -> xsvm.StreamPingRequest
1, // 2: xsvm.Ping.Ping:output_type -> xsvm.PingReply
3, // 3: xsvm.Ping.StreamPing:output_type -> xsvm.StreamPingReply
2, // [2:4] is the sub-list for method output_type
0, // [0:2] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_xsvm_service_proto_init() }
func file_xsvm_service_proto_init() {
if File_xsvm_service_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_xsvm_service_proto_rawDesc,
NumEnums: 0,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_xsvm_service_proto_goTypes,
DependencyIndexes: file_xsvm_service_proto_depIdxs,
MessageInfos: file_xsvm_service_proto_msgTypes,
}.Build()
File_xsvm_service_proto = out.File
file_xsvm_service_proto_rawDesc = nil
file_xsvm_service_proto_goTypes = nil
file_xsvm_service_proto_depIdxs = nil
}
@@ -1,138 +0,0 @@
//go:build grpc
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: xsvm/service.proto
package xsvmconnect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
xsvm "github.com/luxfi/node/connectproto/pb/xsvm"
http "net/http"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// PingName is the fully-qualified name of the Ping service.
PingName = "xsvm.Ping"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// PingPingProcedure is the fully-qualified name of the Ping's Ping RPC.
PingPingProcedure = "/xsvm.Ping/Ping"
// PingStreamPingProcedure is the fully-qualified name of the Ping's StreamPing RPC.
PingStreamPingProcedure = "/xsvm.Ping/StreamPing"
)
// PingClient is a client for the xsvm.Ping service.
type PingClient interface {
Ping(context.Context, *connect.Request[xsvm.PingRequest]) (*connect.Response[xsvm.PingReply], error)
StreamPing(context.Context) *connect.BidiStreamForClient[xsvm.StreamPingRequest, xsvm.StreamPingReply]
}
// NewPingClient constructs a client for the xsvm.Ping service. By default, it uses the Connect
// protocol with the binary Protobuf Codec, asks for gzipped responses, and sends uncompressed
// requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or
// connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewPingClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PingClient {
baseURL = strings.TrimRight(baseURL, "/")
pingMethods := xsvm.File_xsvm_service_proto.Services().ByName("Ping").Methods()
return &pingClient{
ping: connect.NewClient[xsvm.PingRequest, xsvm.PingReply](
httpClient,
baseURL+PingPingProcedure,
connect.WithSchema(pingMethods.ByName("Ping")),
connect.WithClientOptions(opts...),
),
streamPing: connect.NewClient[xsvm.StreamPingRequest, xsvm.StreamPingReply](
httpClient,
baseURL+PingStreamPingProcedure,
connect.WithSchema(pingMethods.ByName("StreamPing")),
connect.WithClientOptions(opts...),
),
}
}
// pingClient implements PingClient.
type pingClient struct {
ping *connect.Client[xsvm.PingRequest, xsvm.PingReply]
streamPing *connect.Client[xsvm.StreamPingRequest, xsvm.StreamPingReply]
}
// Ping calls xsvm.Ping.Ping.
func (c *pingClient) Ping(ctx context.Context, req *connect.Request[xsvm.PingRequest]) (*connect.Response[xsvm.PingReply], error) {
return c.ping.CallUnary(ctx, req)
}
// StreamPing calls xsvm.Ping.StreamPing.
func (c *pingClient) StreamPing(ctx context.Context) *connect.BidiStreamForClient[xsvm.StreamPingRequest, xsvm.StreamPingReply] {
return c.streamPing.CallBidiStream(ctx)
}
// PingHandler is an implementation of the xsvm.Ping service.
type PingHandler interface {
Ping(context.Context, *connect.Request[xsvm.PingRequest]) (*connect.Response[xsvm.PingReply], error)
StreamPing(context.Context, *connect.BidiStream[xsvm.StreamPingRequest, xsvm.StreamPingReply]) error
}
// NewPingHandler builds an HTTP handler from the service implementation. It returns the path on
// which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewPingHandler(svc PingHandler, opts ...connect.HandlerOption) (string, http.Handler) {
pingMethods := xsvm.File_xsvm_service_proto.Services().ByName("Ping").Methods()
pingPingHandler := connect.NewUnaryHandler(
PingPingProcedure,
svc.Ping,
connect.WithSchema(pingMethods.ByName("Ping")),
connect.WithHandlerOptions(opts...),
)
pingStreamPingHandler := connect.NewBidiStreamHandler(
PingStreamPingProcedure,
svc.StreamPing,
connect.WithSchema(pingMethods.ByName("StreamPing")),
connect.WithHandlerOptions(opts...),
)
return "/xsvm.Ping/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case PingPingProcedure:
pingPingHandler.ServeHTTP(w, r)
case PingStreamPingProcedure:
pingStreamPingHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedPingHandler returns CodeUnimplemented from all methods.
type UnimplementedPingHandler struct{}
func (UnimplementedPingHandler) Ping(context.Context, *connect.Request[xsvm.PingRequest]) (*connect.Response[xsvm.PingReply], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("xsvm.Ping.Ping is not implemented"))
}
func (UnimplementedPingHandler) StreamPing(context.Context, *connect.BidiStream[xsvm.StreamPingRequest, xsvm.StreamPingReply]) error {
return connect.NewError(connect.CodeUnimplemented, errors.New("xsvm.Ping.StreamPing is not implemented"))
}
-26
View File
@@ -1,26 +0,0 @@
syntax = "proto3";
package xsvm;
option go_package = "github.com/luxfi/node/connectproto/pb/xsvm";
service Ping {
rpc Ping(PingRequest) returns (PingReply);
rpc StreamPing(stream StreamPingRequest) returns (stream StreamPingReply);
}
message PingRequest {
string message = 1;
}
message PingReply {
string message = 1;
}
message StreamPingRequest {
string message = 1;
}
message StreamPingReply {
string message = 1;
}
-21
View File
@@ -1,21 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package rpcdb re-exports the proto/rpcdb package for backwards compatibility
package rpcdb
import "github.com/luxfi/node/proto/rpcdb"
// Type aliases for backwards compatibility
type (
DatabaseClient = rpcdb.DatabaseClient
DatabaseServer = rpcdb.DatabaseServer
)
// Function aliases for backwards compatibility
var (
NewClient = rpcdb.NewClient
NewServer = rpcdb.NewServer
)
+264
View File
@@ -0,0 +1,264 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package rpcdb is luxd's canonical Layer C home for the rpcdb service:
// one Service implementation backed by a database.Database, with a
// transport adapter per supported wire protocol.
//
// Layered topology:
//
// Layer A — wire framing (github.com/luxfi/api/zap)
// Layer B — service spec (data carriers) (github.com/luxfi/proto/rpcdb)
// Layer C — service impl + transports (this package)
//
// One Service. Many transport adapters. Adding a new transport is a
// new file that wraps `*Service`; the storage logic stays here and
// stays orthogonal to framing concerns.
//
// Files:
// - service.go (this) — transport-neutral Service struct + methods
// - grpc_server.go (build tag `grpc`) — gRPC adapter
// - zap_server.go (default) — ZAP adapter
package rpcdb
import (
"context"
"errors"
"sync"
"github.com/luxfi/database"
rpcdb "github.com/luxfi/proto/rpcdb"
)
// errUnknownIterator is the sentinel returned by IteratorNext / Error
// / Release when the caller hands in an iterator id that was never
// allocated (or has already been released). Transport adapters should
// surface this however their wire spec demands.
var errUnknownIterator = errors.New("rpcdb: unknown iterator")
// iterationBatchBytes is the upper bound on bytes batched into a
// single IteratorNext response. Picked to match the cevm-side
// expectation (RemoteZapDB::iterator_next reads up to ~128 KiB per
// roundtrip).
const iterationBatchBytes = 128 * 1024
// Service is the rpcdb service implementation. It holds one
// `database.Database` and a server-managed iterator pool, and exposes
// every rpcdb operation as a (request → response) method on Go data
// carriers from the proto/rpcdb wire-types package.
//
// Service is transport-agnostic: it knows nothing about gRPC, ZAP, or
// any other framing. Each transport adapter wraps `*Service` and
// translates its own wire format into these method calls.
type Service struct {
db database.Database
// iteratorMu serializes mutations of nextIteratorID and iterators.
// It does NOT serialize calls to a particular iterator — the
// underlying database.Iterator is documented as not safe for
// concurrent use, and the caller is expected to respect that.
iteratorMu sync.RWMutex
nextIteratorID uint64
iterators map[uint64]database.Iterator
}
// NewService wraps `db` so it can be served over any transport.
func NewService(db database.Database) *Service {
return &Service{
db: db,
iterators: make(map[uint64]database.Iterator),
}
}
// DB returns the underlying database. Adapters that need to surface
// transport-specific behavior (eg. health-check serialization) can
// reach in for it; everyone else uses the methods.
func (s *Service) DB() database.Database {
return s.db
}
// CloseIterators releases every server-side iterator. Adapters call
// this on shutdown so iterators don't leak past the listener.
func (s *Service) CloseIterators() {
s.iteratorMu.Lock()
defer s.iteratorMu.Unlock()
for id, it := range s.iterators {
it.Release()
delete(s.iterators, id)
}
}
// errToCode maps a database error to the wire enum.
func errToCode(err error) rpcdb.Error {
switch {
case err == nil:
return rpcdb.Error_ERROR_UNSPECIFIED
case errors.Is(err, database.ErrNotFound):
return rpcdb.Error_ERROR_NOT_FOUND
case errors.Is(err, database.ErrClosed):
return rpcdb.Error_ERROR_CLOSED
default:
// Any other error is reported as CLOSED on the wire — the
// proto enum doesn't have a richer error space. Adapters that
// can carry a transport-level error message should also pass
// the original err out-of-band.
return rpcdb.Error_ERROR_CLOSED
}
}
// CodeToErr maps a wire-typed Error back to a database sentinel. Used
// by remote clients on the receive side; lives here so server and
// client agree on the inverse of errToCode.
func CodeToErr(code rpcdb.Error) error {
switch code {
case rpcdb.Error_ERROR_UNSPECIFIED:
return nil
case rpcdb.Error_ERROR_NOT_FOUND:
return database.ErrNotFound
case rpcdb.Error_ERROR_CLOSED:
return database.ErrClosed
default:
return database.ErrClosed
}
}
// Has services rpcdb.Has.
func (s *Service) Has(_ context.Context, req *rpcdb.HasRequest) (*rpcdb.HasResponse, error) {
has, err := s.db.Has(req.Key)
if err != nil {
return &rpcdb.HasResponse{Has: false, Err: errToCode(err)}, nil
}
return &rpcdb.HasResponse{Has: has, Err: rpcdb.Error_ERROR_UNSPECIFIED}, nil
}
// Get services rpcdb.Get.
func (s *Service) Get(_ context.Context, req *rpcdb.GetRequest) (*rpcdb.GetResponse, error) {
value, err := s.db.Get(req.Key)
if err != nil {
return &rpcdb.GetResponse{Value: nil, Err: errToCode(err)}, nil
}
return &rpcdb.GetResponse{Value: value, Err: rpcdb.Error_ERROR_UNSPECIFIED}, nil
}
// Put services rpcdb.Put.
func (s *Service) Put(_ context.Context, req *rpcdb.PutRequest) (*rpcdb.PutResponse, error) {
return &rpcdb.PutResponse{Err: errToCode(s.db.Put(req.Key, req.Value))}, nil
}
// Delete services rpcdb.Delete.
func (s *Service) Delete(_ context.Context, req *rpcdb.DeleteRequest) (*rpcdb.DeleteResponse, error) {
return &rpcdb.DeleteResponse{Err: errToCode(s.db.Delete(req.Key))}, nil
}
// WriteBatch services rpcdb.WriteBatch.
func (s *Service) WriteBatch(_ context.Context, req *rpcdb.WriteBatchRequest) (*rpcdb.WriteBatchResponse, error) {
batch := s.db.NewBatch()
for _, p := range req.Puts {
if err := batch.Put(p.Key, p.Value); err != nil {
return &rpcdb.WriteBatchResponse{Err: errToCode(err)}, nil
}
}
for _, d := range req.Deletes {
if err := batch.Delete(d.Key); err != nil {
return &rpcdb.WriteBatchResponse{Err: errToCode(err)}, nil
}
}
return &rpcdb.WriteBatchResponse{Err: errToCode(batch.Write())}, nil
}
// Compact services rpcdb.Compact.
func (s *Service) Compact(_ context.Context, req *rpcdb.CompactRequest) (*rpcdb.CompactResponse, error) {
return &rpcdb.CompactResponse{Err: errToCode(s.db.Compact(req.Start, req.Limit))}, nil
}
// Close services rpcdb.Close.
func (s *Service) Close(_ context.Context, _ *rpcdb.CloseRequest) (*rpcdb.CloseResponse, error) {
return &rpcdb.CloseResponse{Err: errToCode(s.db.Close())}, nil
}
// HealthCheck services rpcdb.HealthCheck.
func (s *Service) HealthCheck(ctx context.Context) (*rpcdb.HealthCheckResponse, error) {
health, err := s.db.HealthCheck(ctx)
if err != nil {
return &rpcdb.HealthCheckResponse{Details: []byte(err.Error())}, nil
}
details := []byte("healthy")
if health != nil {
if str, ok := health.(string); ok {
details = []byte(str)
}
}
return &rpcdb.HealthCheckResponse{Details: details}, nil
}
// NewIteratorWithStartAndPrefix services rpcdb.NewIteratorWithStartAndPrefix.
func (s *Service) NewIteratorWithStartAndPrefix(_ context.Context, req *rpcdb.NewIteratorWithStartAndPrefixRequest) (*rpcdb.NewIteratorWithStartAndPrefixResponse, error) {
it := s.db.NewIteratorWithStartAndPrefix(req.Start, req.Prefix)
s.iteratorMu.Lock()
id := s.nextIteratorID
s.nextIteratorID++
s.iterators[id] = it
s.iteratorMu.Unlock()
return &rpcdb.NewIteratorWithStartAndPrefixResponse{Id: id}, nil
}
// IteratorNext services rpcdb.IteratorNext, returning up to
// iterationBatchBytes worth of (key, value) pairs per call. An empty
// Data slice signals exhaustion.
//
// Returned bytes are deep-copied so the caller can hold them past the
// next iterator advance; the underlying database.Iterator is allowed
// to recycle its internal buffers.
func (s *Service) IteratorNext(_ context.Context, req *rpcdb.IteratorNextRequest) (*rpcdb.IteratorNextResponse, error) {
s.iteratorMu.RLock()
it, ok := s.iterators[req.Id]
s.iteratorMu.RUnlock()
if !ok {
return nil, errUnknownIterator
}
var (
size int
data []*rpcdb.PutRequest
)
for size < iterationBatchBytes && it.Next() {
k := it.Key()
v := it.Value()
size += len(k) + len(v)
kc := make([]byte, len(k))
copy(kc, k)
vc := make([]byte, len(v))
copy(vc, v)
data = append(data, &rpcdb.PutRequest{Key: kc, Value: vc})
}
return &rpcdb.IteratorNextResponse{Data: data}, nil
}
// IteratorError services rpcdb.IteratorError.
func (s *Service) IteratorError(_ context.Context, req *rpcdb.IteratorErrorRequest) (*rpcdb.IteratorErrorResponse, error) {
s.iteratorMu.RLock()
it, ok := s.iterators[req.Id]
s.iteratorMu.RUnlock()
if !ok {
return &rpcdb.IteratorErrorResponse{Err: rpcdb.Error_ERROR_NOT_FOUND}, nil
}
return &rpcdb.IteratorErrorResponse{Err: errToCode(it.Error())}, nil
}
// IteratorRelease services rpcdb.IteratorRelease. Idempotent: calling
// it twice for the same id returns UNSPECIFIED on the second call.
func (s *Service) IteratorRelease(_ context.Context, req *rpcdb.IteratorReleaseRequest) (*rpcdb.IteratorReleaseResponse, error) {
s.iteratorMu.Lock()
it, ok := s.iterators[req.Id]
if !ok {
s.iteratorMu.Unlock()
return &rpcdb.IteratorReleaseResponse{Err: rpcdb.Error_ERROR_UNSPECIFIED}, nil
}
delete(s.iterators, req.Id)
s.iteratorMu.Unlock()
dbErr := it.Error()
it.Release()
return &rpcdb.IteratorReleaseResponse{Err: errToCode(dbErr)}, nil
}
+407
View File
@@ -0,0 +1,407 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpcdb
import (
"context"
"errors"
"fmt"
"net"
"sync"
zapwire "github.com/luxfi/api/zap"
"github.com/luxfi/database"
rpcdb "github.com/luxfi/proto/rpcdb"
)
// ZAP db channel MsgType IDs. These are the wire-level Layer-A
// dispatch tags for the rpcdb service over ZAP. They live in their
// own listener (one per VM plugin); they do NOT collide with the VM
// lifecycle MsgTypes (1..31) or sender (40..49) or warp (50..59)
// because each listener has its own dispatch table.
//
// Order is the canonical Lane A assignment — the cevm side hard-codes
// these numbers in `RemoteZapDB::call`. Do not reorder.
const (
MsgDBHas zapwire.MessageType = 1
MsgDBGet zapwire.MessageType = 2
MsgDBPut zapwire.MessageType = 3
MsgDBDelete zapwire.MessageType = 4
MsgDBWriteBatch zapwire.MessageType = 5
MsgDBCompact zapwire.MessageType = 6
MsgDBClose zapwire.MessageType = 7
MsgDBHealthCheck zapwire.MessageType = 8
MsgDBIteratorNew zapwire.MessageType = 9
MsgDBIteratorNext zapwire.MessageType = 10
MsgDBIteratorError zapwire.MessageType = 11
MsgDBIteratorRelease zapwire.MessageType = 12
)
// Wire-byte values for the Error enum. These match
// rpcdb.Error_ERROR_* but are encoded as a single byte on the ZAP
// wire so the cevm side can decode them without pulling in protobuf.
const (
dbErrUnspecified uint8 = 0
dbErrClosed uint8 = 1
dbErrNotFound uint8 = 2
)
func errCodeToByte(code rpcdb.Error) uint8 {
switch code {
case rpcdb.Error_ERROR_CLOSED:
return dbErrClosed
case rpcdb.Error_ERROR_NOT_FOUND:
return dbErrNotFound
default:
return dbErrUnspecified
}
}
// ZAPServer is the ZAP transport adapter for the rpcdb Service. It
// owns the listener and the dispatch loop; the actual storage logic
// lives in *Service. To swap the wire format (eg. to add framing
// over a different reliable byte stream) write a new file with a new
// adapter — never edit Service.
type ZAPServer struct {
svc *Service
listener *zapwire.Listener
server *zapwire.Server
// closeOnce guards Close so callers can call it from both the
// client lifecycle and a deferred test cleanup without panicking
// on a double-close of the underlying listener.
closeOnce sync.Once
}
// NewZAPServer wraps a database.Database for serving over ZAP.
//
// Equivalent to NewZAPServerFromService(NewService(db)) — kept as a
// one-liner because cevm-side test fixtures and the production
// rpcchainvm/zap path both build it this way.
func NewZAPServer(db database.Database) *ZAPServer {
return NewZAPServerFromService(NewService(db))
}
// NewZAPServerFromService wraps an existing Service for serving over
// ZAP. Useful when a single Service needs multiple transport adapters
// at once (eg. tests that want both ZAP and direct in-process calls).
func NewZAPServerFromService(svc *Service) *ZAPServer {
return &ZAPServer{svc: svc}
}
// Listen binds the ZAP listener to addr.
func (s *ZAPServer) Listen(addr string) error {
listener, err := zapwire.Listen(addr, nil)
if err != nil {
return fmt.Errorf("rpcdb zap: listen %s: %w", addr, err)
}
s.listener = listener
s.server = zapwire.NewServer(listener, zapwire.HandlerFunc(s.handle))
return nil
}
// ListenOn wraps an existing net.Listener (for ephemeral ports etc.).
func (s *ZAPServer) ListenOn(raw net.Listener) {
s.listener = zapwire.NewListener(raw, nil)
s.server = zapwire.NewServer(s.listener, zapwire.HandlerFunc(s.handle))
}
// Addr returns the bound address (or nil if not yet listening).
func (s *ZAPServer) Addr() net.Addr {
if s.listener == nil {
return nil
}
return s.listener.Addr()
}
// Serve blocks until ctx is cancelled or Close is called.
func (s *ZAPServer) Serve(ctx context.Context) error {
if s.server == nil {
return errors.New("rpcdb zap: not initialized — call Listen first")
}
return s.server.Serve(ctx)
}
// Close releases all iterators and closes the listener. Caller is
// expected to also cancel the context passed to Serve so the accept
// loop exits cleanly. Safe to call multiple times.
//
// We deliberately do NOT call s.server.Close() because the upstream
// zapwire.Server.Close races with in-flight accept (it nils its conns
// map mid-Serve, causing "assignment to entry in nil map"). Closing
// the listener is enough to make Accept return; ctx cancellation does
// the rest.
func (s *ZAPServer) Close() error {
var err error
s.closeOnce.Do(func() {
s.svc.CloseIterators()
if s.listener != nil {
err = s.listener.Close()
}
})
return err
}
func (s *ZAPServer) handle(ctx context.Context, msgType zapwire.MessageType, payload []byte) (zapwire.MessageType, []byte, error) {
switch msgType {
case MsgDBHas:
return s.handleHas(ctx, payload)
case MsgDBGet:
return s.handleGet(ctx, payload)
case MsgDBPut:
return s.handlePut(ctx, payload)
case MsgDBDelete:
return s.handleDelete(ctx, payload)
case MsgDBWriteBatch:
return s.handleWriteBatch(ctx, payload)
case MsgDBCompact:
return s.handleCompact(ctx, payload)
case MsgDBClose:
return s.handleClose(ctx, payload)
case MsgDBHealthCheck:
return s.handleHealthCheck(ctx)
case MsgDBIteratorNew:
return s.handleIteratorNew(ctx, payload)
case MsgDBIteratorNext:
return s.handleIteratorNext(ctx, payload)
case MsgDBIteratorError:
return s.handleIteratorError(ctx, payload)
case MsgDBIteratorRelease:
return s.handleIteratorRelease(ctx, payload)
default:
return 0, nil, fmt.Errorf("rpcdb zap: unknown msg type %d", msgType)
}
}
func (s *ZAPServer) handleHas(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
key, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb Has decode: %w", err)
}
resp, err := s.svc.Has(ctx, &rpcdb.HasRequest{Key: key})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
if resp.Has {
buf.WriteUint8(1)
} else {
buf.WriteUint8(0)
}
buf.WriteUint8(errCodeToByte(resp.Err))
return MsgDBHas, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleGet(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
key, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb Get decode: %w", err)
}
resp, err := s.svc.Get(ctx, &rpcdb.GetRequest{Key: key})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteBytes(resp.Value)
buf.WriteUint8(errCodeToByte(resp.Err))
return MsgDBGet, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handlePut(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
key, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb Put decode key: %w", err)
}
value, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb Put decode value: %w", err)
}
resp, err := s.svc.Put(ctx, &rpcdb.PutRequest{Key: key, Value: value})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteUint8(errCodeToByte(resp.Err))
return MsgDBPut, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleDelete(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
key, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb Delete decode: %w", err)
}
resp, err := s.svc.Delete(ctx, &rpcdb.DeleteRequest{Key: key})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteUint8(errCodeToByte(resp.Err))
return MsgDBDelete, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleWriteBatch(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
nputs, err := r.ReadUint32()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb WriteBatch decode nputs: %w", err)
}
puts := make([]*rpcdb.PutRequest, 0, nputs)
for i := uint32(0); i < nputs; i++ {
key, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb WriteBatch put[%d] key: %w", i, err)
}
value, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb WriteBatch put[%d] value: %w", i, err)
}
puts = append(puts, &rpcdb.PutRequest{Key: key, Value: value})
}
ndels, err := r.ReadUint32()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb WriteBatch decode ndels: %w", err)
}
dels := make([]*rpcdb.DeleteRequest, 0, ndels)
for i := uint32(0); i < ndels; i++ {
key, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb WriteBatch del[%d]: %w", i, err)
}
dels = append(dels, &rpcdb.DeleteRequest{Key: key})
}
resp, err := s.svc.WriteBatch(ctx, &rpcdb.WriteBatchRequest{Puts: puts, Deletes: dels})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteUint8(errCodeToByte(resp.Err))
return MsgDBWriteBatch, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleCompact(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
start, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb Compact decode start: %w", err)
}
limit, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb Compact decode limit: %w", err)
}
resp, err := s.svc.Compact(ctx, &rpcdb.CompactRequest{Start: start, Limit: limit})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteUint8(errCodeToByte(resp.Err))
return MsgDBCompact, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleClose(ctx context.Context, _ []byte) (zapwire.MessageType, []byte, error) {
resp, err := s.svc.Close(ctx, &rpcdb.CloseRequest{})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteUint8(errCodeToByte(resp.Err))
return MsgDBClose, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleHealthCheck(ctx context.Context) (zapwire.MessageType, []byte, error) {
resp, err := s.svc.HealthCheck(ctx)
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteBytes(resp.Details)
return MsgDBHealthCheck, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleIteratorNew(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
start, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb IteratorNew decode start: %w", err)
}
prefix, err := r.ReadBytes()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb IteratorNew decode prefix: %w", err)
}
resp, err := s.svc.NewIteratorWithStartAndPrefix(ctx, &rpcdb.NewIteratorWithStartAndPrefixRequest{Start: start, Prefix: prefix})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteUint64(resp.Id)
return MsgDBIteratorNew, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleIteratorNext(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
id, err := r.ReadUint64()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb IteratorNext decode id: %w", err)
}
resp, err := s.svc.IteratorNext(ctx, &rpcdb.IteratorNextRequest{Id: id})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteUint32(uint32(len(resp.Data)))
for _, e := range resp.Data {
buf.WriteBytes(e.Key)
buf.WriteBytes(e.Value)
}
return MsgDBIteratorNext, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleIteratorError(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
id, err := r.ReadUint64()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb IteratorError decode id: %w", err)
}
resp, err := s.svc.IteratorError(ctx, &rpcdb.IteratorErrorRequest{Id: id})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteUint8(errCodeToByte(resp.Err))
return MsgDBIteratorError, append([]byte(nil), buf.Bytes()...), nil
}
func (s *ZAPServer) handleIteratorRelease(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
r := zapwire.NewReader(payload)
id, err := r.ReadUint64()
if err != nil {
return 0, nil, fmt.Errorf("rpcdb IteratorRelease decode id: %w", err)
}
resp, err := s.svc.IteratorRelease(ctx, &rpcdb.IteratorReleaseRequest{Id: id})
if err != nil {
return 0, nil, err
}
buf := zapwire.GetBuffer()
defer zapwire.PutBuffer(buf)
buf.WriteUint8(errCodeToByte(resp.Err))
return MsgDBIteratorRelease, append([]byte(nil), buf.Bytes()...), nil
}
+275
View File
@@ -0,0 +1,275 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpcdb
import (
"context"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
zapwire "github.com/luxfi/api/zap"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
)
// startZAPServer spawns a ZAPServer over an in-memory listener bound to
// 127.0.0.1:0, returns the addr + the server (for shutdown).
func startZAPServer(t *testing.T, db database.Database) (string, *ZAPServer, context.CancelFunc) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
s := NewZAPServer(db)
s.ListenOn(listener)
ctx, cancel := context.WithCancel(context.Background())
go func() {
_ = s.Serve(ctx)
}()
// Wait for Serve to fully install accept loop.
time.Sleep(50 * time.Millisecond)
return listener.Addr().String(), s, cancel
}
// TestZAPServer_HasGetPutDelete exercises the four primitive ops.
func TestZAPServer_HasGetPutDelete(t *testing.T) {
require := require.New(t)
mem := memdb.New()
addr, srv, cancel := startZAPServer(t, mem)
defer cancel()
defer srv.Close()
conn, err := zapwire.Dial(context.Background(), addr, nil)
require.NoError(err)
defer conn.Close()
ctx := context.Background()
// Has (key absent) → has=false, err=NotFound
{
buf := zapwire.GetBuffer()
buf.WriteBytes([]byte("foo"))
_, resp, err := conn.Call(ctx, MsgDBHas, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
hasB, _ := r.ReadUint8()
errB, _ := r.ReadUint8()
require.Equal(uint8(0), hasB)
require.Equal(dbErrUnspecified, errB) // memdb returns no err on Has-miss
}
// Put
{
buf := zapwire.GetBuffer()
buf.WriteBytes([]byte("foo"))
buf.WriteBytes([]byte("bar"))
_, resp, err := conn.Call(ctx, MsgDBPut, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
errB, _ := r.ReadUint8()
require.Equal(dbErrUnspecified, errB)
}
// Has (key present) → has=true
{
buf := zapwire.GetBuffer()
buf.WriteBytes([]byte("foo"))
_, resp, err := conn.Call(ctx, MsgDBHas, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
hasB, _ := r.ReadUint8()
errB, _ := r.ReadUint8()
require.Equal(uint8(1), hasB)
require.Equal(dbErrUnspecified, errB)
}
// Get
{
buf := zapwire.GetBuffer()
buf.WriteBytes([]byte("foo"))
_, resp, err := conn.Call(ctx, MsgDBGet, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
val, _ := r.ReadBytes()
errB, _ := r.ReadUint8()
require.Equal([]byte("bar"), val)
require.Equal(dbErrUnspecified, errB)
}
// Get (missing) → empty value, err=NotFound
{
buf := zapwire.GetBuffer()
buf.WriteBytes([]byte("absent"))
_, resp, err := conn.Call(ctx, MsgDBGet, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
val, _ := r.ReadBytes()
errB, _ := r.ReadUint8()
require.Empty(val)
require.Equal(dbErrNotFound, errB)
}
// Delete
{
buf := zapwire.GetBuffer()
buf.WriteBytes([]byte("foo"))
_, resp, err := conn.Call(ctx, MsgDBDelete, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
errB, _ := r.ReadUint8()
require.Equal(dbErrUnspecified, errB)
}
// Get post-delete → NotFound
{
buf := zapwire.GetBuffer()
buf.WriteBytes([]byte("foo"))
_, resp, err := conn.Call(ctx, MsgDBGet, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
_, _ = r.ReadBytes()
errB, _ := r.ReadUint8()
require.Equal(dbErrNotFound, errB)
}
}
// TestZAPServer_WriteBatch_AndIterate covers batched writes plus the
// iterator path used by cevm's load_persisted snapshot_with_prefix.
func TestZAPServer_WriteBatch_AndIterate(t *testing.T) {
require := require.New(t)
mem := memdb.New()
addr, srv, cancel := startZAPServer(t, mem)
defer cancel()
defer srv.Close()
conn, err := zapwire.Dial(context.Background(), addr, nil)
require.NoError(err)
defer conn.Close()
ctx := context.Background()
// WriteBatch: 3 puts (prefix p:), 1 delete (no-op since absent).
{
buf := zapwire.GetBuffer()
buf.WriteUint32(3)
buf.WriteBytes([]byte("p:1"))
buf.WriteBytes([]byte("v1"))
buf.WriteBytes([]byte("p:2"))
buf.WriteBytes([]byte("v2"))
buf.WriteBytes([]byte("q:3"))
buf.WriteBytes([]byte("v3"))
buf.WriteUint32(1)
buf.WriteBytes([]byte("absent"))
_, resp, err := conn.Call(ctx, MsgDBWriteBatch, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
errB, _ := r.ReadUint8()
require.Equal(dbErrUnspecified, errB)
}
// IteratorNew with prefix "p:"
var iterID uint64
{
buf := zapwire.GetBuffer()
buf.WriteBytes(nil) // start
buf.WriteBytes([]byte("p:")) // prefix
_, resp, err := conn.Call(ctx, MsgDBIteratorNew, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
iterID, _ = r.ReadUint64()
}
// IteratorNext — collect.
collected := map[string]string{}
for {
buf := zapwire.GetBuffer()
buf.WriteUint64(iterID)
_, resp, err := conn.Call(ctx, MsgDBIteratorNext, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
n, _ := r.ReadUint32()
if n == 0 {
break
}
for i := uint32(0); i < n; i++ {
k, _ := r.ReadBytes()
v, _ := r.ReadBytes()
collected[string(k)] = string(v)
}
}
// IteratorRelease
{
buf := zapwire.GetBuffer()
buf.WriteUint64(iterID)
_, resp, err := conn.Call(ctx, MsgDBIteratorRelease, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
r := zapwire.NewReader(resp)
errB, _ := r.ReadUint8()
require.Equal(dbErrUnspecified, errB)
}
require.Equal(map[string]string{"p:1": "v1", "p:2": "v2"}, collected)
}
// TestZAPServer_ParityWithMemDB writes/reads via wire and via direct
// memdb Get to confirm both paths see the same state — proves the ZAP
// round-trip preserves bytes exactly.
func TestZAPServer_ParityWithMemDB(t *testing.T) {
require := require.New(t)
mem := memdb.New()
addr, srv, cancel := startZAPServer(t, mem)
defer cancel()
defer srv.Close()
conn, err := zapwire.Dial(context.Background(), addr, nil)
require.NoError(err)
defer conn.Close()
ctx := context.Background()
key := []byte{0x42, 0x00, 0x01, 0xff}
value := make([]byte, 256)
for i := range value {
value[i] = byte(i)
}
// Put via wire.
buf := zapwire.GetBuffer()
buf.WriteBytes(key)
buf.WriteBytes(value)
_, _, err = conn.Call(ctx, MsgDBPut, buf.Bytes())
zapwire.PutBuffer(buf)
require.NoError(err)
// Read directly.
got, dErr := mem.Get(key)
require.NoError(dErr)
require.Equal(value, got)
// Read via wire.
buf2 := zapwire.GetBuffer()
buf2.WriteBytes(key)
_, resp, err := conn.Call(ctx, MsgDBGet, buf2.Bytes())
zapwire.PutBuffer(buf2)
require.NoError(err)
r := zapwire.NewReader(resp)
wireVal, _ := r.ReadBytes()
require.Equal(value, wireVal)
}
+1 -1
View File
@@ -42,7 +42,7 @@ deploy_network() {
echo "Deploying subnets to $network"
echo "=============================="
LUX_PRIVATE_KEY="$KEY" "$DEPLOY_BIN" \
PRIVATE_KEY="$KEY" "$DEPLOY_BIN" \
--network="$network" \
--state-dir="$STATE_DIR" 2>&1
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM golang:1.26-alpine AS builder
FROM golang:1.26.3-alpine AS builder
RUN apk add --no-cache git make gcc musl-dev linux-headers bash
+1 -1
View File
@@ -1,5 +1,5 @@
# Multi-chain node builder
FROM golang:1.26-alpine AS builder
FROM golang:1.26.3-alpine AS builder
ARG CHAIN_TYPE=platform
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.26-alpine AS builder
FROM golang:1.26.3-alpine AS builder
RUN apk add --no-cache git make gcc musl-dev linux-headers bash
Executable
BIN
View File
Binary file not shown.
+222 -258
View File
@@ -22,10 +22,8 @@ import (
"github.com/luxfi/math/set"
"github.com/luxfi/net/endpoints"
"github.com/luxfi/node/vms/components/gas"
exchangevm "github.com/luxfi/node/vms/xvm"
"github.com/luxfi/node/vms/xvm/fxs"
xchaintxs "github.com/luxfi/node/vms/xvm/txs"
"github.com/luxfi/node/vms/platformvm/genesis"
xvmgenesis "github.com/luxfi/node/vms/xvm/genesis"
"github.com/luxfi/node/vms/platformvm/reward"
"github.com/luxfi/node/vms/platformvm/signer"
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
@@ -44,29 +42,22 @@ import (
genesisconfigs "github.com/luxfi/genesis/configs"
)
// Chain alias vars are derived from the single source of truth (Registry
// in registry.go). Rebranding a chain — adding/removing aliases or
// renaming the letter — edits one row in Registry; these vars and the
// VMAliases map re-derive at package init.
var (
// PChainAliases are the default aliases for the P-Chain
PChainAliases = []string{"P", "platform"}
// XChainAliases are the default aliases for the X-Chain
XChainAliases = []string{"X", "xvm"}
// CChainAliases are the default aliases for the C-Chain
CChainAliases = []string{"C", "evm"}
// DChainAliases are the default aliases for the D-Chain (DEX)
DChainAliases = []string{"D", "dex", "dexvm"}
// QChainAliases are the default aliases for the Q-Chain (Quantum)
QChainAliases = []string{"Q", "quantum", "quantumvm", "pq"}
// AChainAliases are the default aliases for the A-Chain (Attestation/AI)
AChainAliases = []string{"A", "attest", "ai", "aivm"}
// BChainAliases are the default aliases for the B-Chain (Bridge)
BChainAliases = []string{"B", "bridge", "bridgevm"}
// TChainAliases are the default aliases for the T-Chain (Threshold)
TChainAliases = []string{"T", "threshold", "thresholdvm", "mpc"}
// ZChainAliases are the default aliases for the Z-Chain (ZK)
ZChainAliases = []string{"Z", "zk", "zkvm"}
// GChainAliases are the default aliases for the G-Chain (Graph)
GChainAliases = []string{"G", "graph", "graphvm", "dgraph"}
// KChainAliases are the default aliases for the K-Chain (KMS)
KChainAliases = []string{"K", "key", "keyvm"}
PChainAliases = AliasesFor("P")
XChainAliases = AliasesFor("X")
CChainAliases = AliasesFor("C")
DChainAliases = AliasesFor("D")
QChainAliases = AliasesFor("Q")
AChainAliases = AliasesFor("A")
BChainAliases = AliasesFor("B")
TChainAliases = AliasesFor("T")
ZChainAliases = AliasesFor("Z")
GChainAliases = AliasesFor("G")
KChainAliases = AliasesFor("K")
// Network-specific genesis messages (Latin for mainnet, descriptive for others)
// Mainnet: "Lux et Libertas" - Light and Liberty
@@ -78,31 +69,11 @@ var (
// Local/Custom: "Carpe Diem" - Seize the day
LocalChainGenesis = `{"version":1,"message":"Carpe Diem"}`
// VMAliases are the default aliases for VMs
VMAliases = map[ids.ID][]string{
constants.PlatformVMID: {"platform"},
constants.XVMID: {"xvm"},
constants.EVMID: {"evm"},
constants.DexVMID: {"dexvm", "dex"},
constants.QuantumVMID: {"quantumvm", "quantum", "pq"},
constants.AIVMID: {"aivm", "attest", "ai"},
constants.BridgeVMID: {"bridgevm", "bridge"},
constants.ThresholdVMID: {"thresholdvm", "threshold", "mpc"},
constants.ZKVMID: {"zkvm", "zk"},
constants.GraphVMID: {"graphvm", "graph", "dgraph"},
constants.KeyVMID: {"keyvm", "key"},
secp256k1fx.ID: {"secp256k1fx"},
nftfx.ID: {"nftfx"},
propertyfx.ID: {"propertyfx"},
mldsafx.ID: {"mldsafx"},
slhdsafx.ID: {"slhdsafx"},
ed25519fx.ID: {"ed25519fx"},
secp256r1fx.ID: {"secp256r1fx"},
schnorrfx.ID: {"schnorrfx"},
bls12381fx.ID: {"bls12381fx"},
}
// VMAliases is the default (VMID -> aliases) map for the node's VM
// manager. Chain entries are derived from Registry; fx entries are
// the static feature-extension aliases (secp256k1fx, nftfx, ...).
VMAliases = VMAliasesMap()
errNoTxs = errors.New("genesis creates no transactions")
errOverridesStandardNetworkConfig = errors.New("overrides standard network genesis config")
)
@@ -353,82 +324,110 @@ func GetConfig(networkID uint32) *genesiscfg.Config {
return genesiscfg.GetConfig(networkID)
}
// FromConfig builds genesis bytes from a config
// FromConfig builds genesis bytes from a config.
//
// X-Chain is opt-in via config.XChainGenesis (a small JSON descriptor like
//
// {"symbol":"LUX","name":"Lux","denomination":9}
//
// ). When set, the builder constructs an XVM genesis whose primary asset
// is that descriptor (initial holders sourced from config.Allocations) and
// emits an X-Chain entry in the primary-network CreateChainTx set. When
// empty, no XVM genesis is built and X-Chain is omitted from the chain
// set — the path used by P-only L2s whose value capture lives on a
// downstream EVM (downstream EVM etc.).
//
// The LUX asset ID returned is always the network-wide constant
// (constants.UTXO_ASSET_ID), independent of whether X-Chain is baked.
// This is what decouples the asset from any specific chain's genesis
// bytes — P-Chain stake/UTXO references stay byte-stable across both
// shapes of primary network.
func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
// Build XVM (X-Chain) genesis
lux := exchangevm.GenesisAssetDefinition{
Name: "Lux",
Symbol: "LUX",
Denomination: 9,
InitialState: exchangevm.AssetInitialState{},
}
memoBytes := []byte{}
// Sort allocations for deterministic output
type allocation struct {
ETHAddr ids.ShortID
LUXAddr ids.ShortID
InitialAmount uint64
}
xAllocations := []allocation{}
for _, a := range config.Allocations {
if a.InitialAmount > 0 {
xAllocations = append(xAllocations, allocation{
ETHAddr: a.ETHAddr,
LUXAddr: a.LUXAddr,
InitialAmount: a.InitialAmount,
})
}
}
// Get HRP for this network to format bech32 addresses
// HRP is needed for X-Chain holder addresses, P-Chain protocol
// allocations, staker allocations, and reward addresses — define once,
// before the X-Chain conditional, so it's available on both paths.
hrp := constants.GetHRP(config.NetworkID)
for _, a := range xAllocations {
// Format address as bech32 for the X-Chain
bech32Addr, err := address.FormatBech32(hrp, a.LUXAddr[:])
// X-Chain is the first opt-in chain. Build its XVM genesis bytes only
// when config.XChainGenesis is non-empty; otherwise leave the slot
// empty and the optIn loop below skips emitting an X-Chain CreateChainTx.
var xvmGenesisBytes []byte
if config.XChainGenesis != "" {
var asset xvmgenesis.AssetDescriptor
if err := json.Unmarshal([]byte(config.XChainGenesis), &asset); err != nil {
return nil, ids.Empty, fmt.Errorf("invalid xChainGenesis shard: %w", err)
}
holders := []xvmgenesis.Holder{}
memoBytes := []byte{}
for _, a := range config.Allocations {
if a.InitialAmount == 0 {
continue
}
// Format address as bech32 for the X-Chain
bech32Addr, err := address.FormatBech32(hrp, a.UTXOAddr[:])
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address: %w", err)
}
holders = append(holders, xvmgenesis.Holder{
Amount: a.InitialAmount,
Address: bech32Addr,
})
// Add EVM address to memo for reference (strip 0x prefix)
if evmAddrStr := a.EVMAddr.Hex(); len(evmAddrStr) > 2 {
memoBytes = append(memoBytes, []byte(evmAddrStr[2:])...)
}
}
var err error
xvmGenesisBytes, err = xvmgenesis.BuildBytes(config.NetworkID, asset, holders, memoBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address: %w", err)
}
lux.InitialState.FixedCap = append(lux.InitialState.FixedCap, exchangevm.GenesisHolder{
Amount: a.InitialAmount,
Address: bech32Addr,
})
// Add ETH address to memo for reference
ethAddrStr := a.ETHAddr.Hex()
if len(ethAddrStr) > 2 { // "0x" prefix
memoBytes = append(memoBytes, []byte(ethAddrStr[2:])...)
return nil, ids.Empty, err
}
}
lux.Memo = memoBytes
xvmGenesis, err := exchangevm.NewGenesis(
config.NetworkID,
map[string]exchangevm.GenesisAssetDefinition{
"LUX": lux,
},
)
if err != nil {
return nil, ids.Empty, err
}
xvmGenesisBytes, err := xvmGenesis.Bytes()
if err != nil {
return nil, ids.Empty, fmt.Errorf("couldn't serialize xvm genesis: %w", err)
}
xAssetID, err := XAssetID(xvmGenesisBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("couldn't generate LUX asset ID: %w", err)
// X-Chain native asset ID is derived from the actual XVM genesis bytes
// (the runtime ID of the first GenesisAsset.CreateAssetTx) so the value
// the wallet builder reads back via platform.getStakingAssetID matches
// the asset the X-Chain genuinely mints. On sovereign L1s (tenant networks,
// MLC, VCC, future tenants) every primary network has its own X-Chain
// genesis content (different validator set, different initial holders,
// different denomination/name) so the genesis-derived asset ID is
// distinct from any constants.UTXOAssetIDFor(networkID) value — those
// are network-id-keyed constants and identical across every L1 sharing
// the same primary-network ID, which would let two sovereign networks
// collide.
//
// When X-Chain is opt-out (P-only mode, xvmGenesisBytes is nil) we keep
// the network-id-keyed constant as a placeholder; the asset ID is
// irrelevant in that mode since there is no X-Chain to mint on.
var xAssetID ids.ID
if len(xvmGenesisBytes) > 0 {
var err error
xAssetID, err = xvmgenesis.AssetIDFromBytes(xvmGenesisBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("derive X-Chain asset ID from genesis: %w", err)
}
} else {
xAssetID = constants.UTXOAssetIDFor(config.NetworkID)
}
genesisTime := time.Unix(int64(config.StartTime), 0)
// Calculate initial supply
// Calculate initial supply. Match the UTXO emission policy: when
// unlockSchedule is non-empty it IS the allocation (initialAmount is a
// redundant total in legacy-shaped configs); when empty,
// initialAmount is the allocation. Either way, count exactly once —
// the reported supply must equal the sum of actually-emitted UTXOs +
// validator stakes, not a double-count of the two views of one number.
initialSupply := uint64(0)
for _, a := range config.Allocations {
initialSupply += a.InitialAmount
for _, unlock := range a.UnlockSchedule {
initialSupply += unlock.Amount
if len(a.UnlockSchedule) > 0 {
for _, unlock := range a.UnlockSchedule {
initialSupply += unlock.Amount
}
} else {
initialSupply += a.InitialAmount
}
}
@@ -441,14 +440,14 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
platformAllocations := []genesis.Allocation{}
skippedAllocations := []genesiscfg.Allocation{}
for _, a := range config.Allocations {
if initiallyStaked.Contains(a.LUXAddr) {
if initiallyStaked.Contains(a.UTXOAddr) {
skippedAllocations = append(skippedAllocations, a)
continue
}
for _, unlock := range a.UnlockSchedule {
if unlock.Amount > 0 {
// Format address as bech32 for the P-Chain
bech32Addr, err := address.FormatBech32(hrp, a.LUXAddr[:])
bech32Addr, err := address.FormatBech32(hrp, a.UTXOAddr[:])
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address for P-chain: %w", err)
}
@@ -456,14 +455,19 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
Locktime: unlock.Locktime,
Amount: unlock.Amount,
Address: bech32Addr,
Message: a.ETHAddr.Bytes(),
Message: a.EVMAddr.Bytes(),
})
}
}
// Also create P-Chain UTXO for initialAmount (spendable, no locktime)
if a.InitialAmount > 0 {
bech32Addr, err := address.FormatBech32(hrp, a.LUXAddr[:])
// when unlockSchedule is empty. Legacy genesis JSONs set
// initialAmount AS THE SUM of unlockSchedule (two views of the same
// total). Emitting both would double-mint that total. Devnet-style
// configs set initialAmount with an empty unlockSchedule and rely on
// this single UTXO. One semantic, one UTXO, no double-mint.
if a.InitialAmount > 0 && len(a.UnlockSchedule) == 0 {
bech32Addr, err := address.FormatBech32(hrp, a.UTXOAddr[:])
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address for P-chain initialAmount: %w", err)
}
@@ -471,7 +475,7 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
Locktime: 0, // immediately spendable
Amount: a.InitialAmount,
Address: bech32Addr,
Message: a.ETHAddr.Bytes(),
Message: a.EVMAddr.Bytes(),
})
}
}
@@ -506,7 +510,7 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
for _, a := range nodeAllocations {
for _, unlock := range a.UnlockSchedule {
// Format address as bech32 for staker allocations
bech32Addr, err := address.FormatBech32(hrp, a.LUXAddr[:])
bech32Addr, err := address.FormatBech32(hrp, a.UTXOAddr[:])
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address for staker allocation: %w", err)
}
@@ -514,7 +518,7 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
Locktime: unlock.Locktime,
Amount: unlock.Amount,
Address: bech32Addr,
Message: a.ETHAddr.Bytes(),
Message: a.EVMAddr.Bytes(),
})
}
}
@@ -559,48 +563,31 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
})
}
// Specify primary network chains
// PRIMARY NETWORK = P + Q + Z (minimum quantum-safe validator set)
// Primary-network chain set is data-driven.
//
// P-Chain is implicit (platformvm is the genesis itself).
// Q-Chain provides Quasar PQ consensus (BLS + Corona + ML-DSA).
// Z-Chain provides universal receipt registry and ZK verification.
// P-Chain is implicit (the platform genesis itself). Every other
// primary-network chain — including X-Chain — is opt-in via a
// non-empty genesis string in the resolved Config. Entries with
// empty GenesisData are SKIPPED — luxd will not start a chain
// manager for them. Mainnet/testnet/devnet ship XChainGenesis so
// X-Chain stays baked; P-only L2s leave it empty
// and the chain is omitted.
//
// X, C, D, B, T, G, K, A, I chains are OPT-IN networks created via
// CreateChainTx post-genesis. Validators stake separately to validate
// each and earn its tx fees.
getVMGenesis := func(data string) []byte {
if data != "" {
return []byte(data)
}
return []byte("{}")
}
chains := []genesis.Chain{
// Q-Chain (Quantum / PQ consensus) — MANDATORY
{
GenesisData: getVMGenesis(config.QChainGenesis),
ChainID: constants.PrimaryNetworkID,
VMID: constants.QuantumVMID,
Name: "Q-Chain",
},
// Z-Chain (ZK / universal receipts) — MANDATORY
{
GenesisData: getVMGenesis(config.ZChainGenesis),
ChainID: constants.PrimaryNetworkID,
VMID: constants.ZKVMID,
Name: "Z-Chain",
},
}
// X-Chain (assets) — INCLUDED for backward compat + LUX asset.
// Staking and fees use X-Chain LUX UTXOs. Can't be removed without
// migrating to a native P-Chain fee token.
chains = append(chains, genesis.Chain{
GenesisData: xvmGenesisBytes,
ChainID: constants.PrimaryNetworkID,
VMID: constants.XVMID,
FxIDs: []ids.ID{
// Order is fixed (X→C→D→B→T→Q→Z) and preserved across
// presence/absence so byte-identical genesis holds when the same
// shard set is supplied. Append-only — reordering shifts the
// P-Chain genesis byte layout.
//
// Runtime tracking is separate: an operator decides which of the
// baked chains this node actually runs via --track-chains /
// --track-all-chains. Baked-but-untracked chains stay dormant.
optIn := []struct {
genesisData []byte
vmID ids.ID
name string
fxIDs []ids.ID // X-Chain only; nil for everything else.
}{
{xvmGenesisBytes, constants.XVMID, "X-Chain", []ids.ID{
secp256k1fx.ID,
nftfx.ID,
propertyfx.ID,
@@ -610,52 +597,30 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
secp256r1fx.ID,
schnorrfx.ID,
bls12381fx.ID,
},
Name: "X-Chain",
})
// C-Chain (EVM) — OPT-IN. Only created if CChainGenesis provided.
if config.CChainGenesis != "" {
chains = append(chains, genesis.Chain{
GenesisData: []byte(config.CChainGenesis),
ChainID: constants.PrimaryNetworkID,
VMID: constants.EVMID,
Name: "C-Chain",
})
}},
{[]byte(config.CChainGenesis), constants.EVMID, "C-Chain", nil},
{[]byte(config.DChainGenesis), constants.DexVMID, "D-Chain", nil},
{[]byte(config.BChainGenesis), constants.BridgeVMID, "B-Chain", nil},
{[]byte(config.TChainGenesis), constants.ThresholdVMID, "T-Chain", nil},
{[]byte(config.QChainGenesis), constants.QuantumVMID, "Q-Chain", nil},
{[]byte(config.ZChainGenesis), constants.ZKVMID, "Z-Chain", nil},
}
// D-Chain (DEX) — OPT-IN
if config.DChainGenesis != "" {
chains := []genesis.Chain{}
for _, c := range optIn {
if len(c.genesisData) == 0 {
continue
}
chains = append(chains, genesis.Chain{
GenesisData: getVMGenesis(config.DChainGenesis),
GenesisData: c.genesisData,
ChainID: constants.PrimaryNetworkID,
VMID: constants.DexVMID,
Name: "D-Chain",
})
}
// B-Chain (Bridge) — OPT-IN
if config.BChainGenesis != "" {
chains = append(chains, genesis.Chain{
GenesisData: getVMGenesis(config.BChainGenesis),
ChainID: constants.PrimaryNetworkID,
VMID: constants.BridgeVMID,
Name: "B-Chain",
})
}
// T-Chain (Threshold / FHE / MPC) — OPT-IN
if config.TChainGenesis != "" {
chains = append(chains, genesis.Chain{
GenesisData: getVMGenesis(config.TChainGenesis),
ChainID: constants.PrimaryNetworkID,
VMID: constants.ThresholdVMID,
Name: "T-Chain",
VMID: c.vmID,
FxIDs: c.fxIDs,
Name: c.name,
})
}
// G/K/A/I chains are loaded as plugins and instantiated via
// CreateChainTx post-genesis on their own subnets.
// CreateChainTx post-genesis on their own chains.
pChainGenesis, err := genesis.New(
xAssetID,
@@ -677,20 +642,11 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
return pChainGenesisBytes, xAssetID, nil
}
// FromFile loads genesis config from file and builds genesis bytes
func FromFile(networkID uint32, filepath string, stakingCfg *StakingConfig, allowCustomGenesis bool) ([]byte, ids.ID, error) {
// Protect standard networks from custom genesis unless explicitly allowed
if !allowCustomGenesis {
switch networkID {
case constants.MainnetID, constants.TestnetID:
return nil, ids.Empty, fmt.Errorf(
"%w: %s",
errOverridesStandardNetworkConfig,
constants.NetworkName(networkID),
)
}
}
// FromFile loads genesis config from file and builds genesis bytes.
// Caller owns the choice — if a genesis file is set, we use it. No
// "is this networkID standard?" guard, no allow-flag. Operator-owned
// chainset.
func FromFile(networkID uint32, filepath string, stakingCfg *StakingConfig) ([]byte, ids.ID, error) {
config, err := genesiscfg.GetConfigFile(filepath)
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to load genesis file %s: %w", filepath, err)
@@ -701,20 +657,9 @@ func FromFile(networkID uint32, filepath string, stakingCfg *StakingConfig, allo
return FromConfig(config)
}
// FromFlag parses base64-encoded genesis content and builds genesis bytes
func FromFlag(networkID uint32, genesisContent string, stakingCfg *StakingConfig, allowCustomGenesis bool) ([]byte, ids.ID, error) {
// Protect standard networks from custom genesis unless explicitly allowed
if !allowCustomGenesis {
switch networkID {
case constants.MainnetID, constants.TestnetID:
return nil, ids.Empty, fmt.Errorf(
"%w: %s",
errOverridesStandardNetworkConfig,
constants.NetworkName(networkID),
)
}
}
// FromFlag parses base64-encoded genesis content and builds genesis bytes.
// Same contract as FromFile — caller owns the override.
func FromFlag(networkID uint32, genesisContent string, stakingCfg *StakingConfig) ([]byte, ids.ID, error) {
data, err := base64.StdEncoding.DecodeString(genesisContent)
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to decode base64 genesis content: %w", err)
@@ -737,6 +682,54 @@ func FromDatabase(networkID uint32, dbPath string, dbType string, stakingCfg *St
return FromConfig(config)
}
// XAssetIDFromGenesisBytes returns the X-Chain native asset ID encoded
// in a platform-genesis blob. It parses the platform genesis, finds the
// X-Chain CreateChainTx (vmID == constants.XVMID), then decodes that
// chain's embedded XVM genesis bytes to recover the runtime asset ID
// (the same value vm.initGenesis assigns to the first GenesisAsset).
//
// Returns (ids.Empty, false, nil) when the platform genesis contains
// no X-Chain (P-only mode) — callers fall back to whatever default
// they prefer (e.g. constants.UTXOAssetIDFor(networkID)).
//
// Returns a non-nil error when the platform genesis is unparseable or
// the X-Chain genesis bytes embedded inside it are malformed; both are
// unrecoverable on a primary-network bootstrap.
//
// Used by config.getGenesisData when it loads genesis via the cached /
// raw paths that skip FromConfig — those paths historically returned
// constants.UTXOAssetIDFor(networkID), but on sovereign L1s that value
// disagrees with what's actually in the genesis bytes. Wiring this
// helper through getGenesisData means the node always reports the
// genesis-derived asset ID via platform.getStakingAssetID regardless
// of which load path was taken.
func XAssetIDFromGenesisBytes(genesisBytes []byte) (ids.ID, bool, error) {
// Parse the platform genesis directly so we can distinguish parse
// failure ("garbage bytes — fatal") from missing X-Chain ("P-only
// mode — caller falls back to UTXOAssetIDFor"). VMGenesis collapses
// both into one error, which is the wrong shape here.
gen, err := genesis.Parse(genesisBytes)
if err != nil {
return ids.Empty, false, fmt.Errorf("parse platform genesis: %w", err)
}
for _, chain := range gen.Chains {
uChain, ok := chain.Unsigned.(*pchaintxs.CreateChainTx)
if !ok {
continue
}
if uChain.VMID != constants.XVMID {
continue
}
id, err := xvmgenesis.AssetIDFromBytes(uChain.GenesisData)
if err != nil {
return ids.Empty, false, fmt.Errorf("derive X-Chain asset ID from genesis data: %w", err)
}
return id, true, nil
}
// Parsed cleanly but no X-Chain CreateChainTx — P-only mode.
return ids.Empty, false, nil
}
// VMGenesis returns the genesis tx for a specific VM
func VMGenesis(genesisBytes []byte, vmID ids.ID) (*pchaintxs.Tx, error) {
gen, err := genesis.Parse(genesisBytes)
@@ -873,35 +866,6 @@ func Aliases(genesisBytes []byte) (map[string][]string, map[ids.ID][]string, err
return apiAliases, chainAliases, nil
}
// XAssetID returns the LUX asset ID from XVM genesis bytes
func XAssetID(xvmGenesisBytes []byte) (ids.ID, error) {
parser, err := xchaintxs.NewParser(
[]fxs.Fx{
&secp256k1fx.Fx{},
},
)
if err != nil {
return ids.Empty, err
}
genesisCodec := parser.GenesisCodec()
gen := exchangevm.Genesis{}
if _, err := genesisCodec.Unmarshal(xvmGenesisBytes, &gen); err != nil {
return ids.Empty, err
}
if len(gen.Txs) == 0 {
return ids.Empty, errNoTxs
}
genesisTx := gen.Txs[0]
tx := xchaintxs.Tx{Unsigned: &genesisTx.CreateAssetTx}
if err := tx.Initialize(genesisCodec); err != nil {
return ids.Empty, err
}
return tx.ID(), nil
}
// validateConfig validates the genesis config
func validateConfig(networkID uint32, config *genesiscfg.Config, stakingCfg *StakingConfig) error {
if config.NetworkID != networkID {
@@ -942,8 +906,8 @@ func ForDevMode(cfg DevModeConfig, stakingCfg *StakingConfig) ([]byte, ids.ID, e
const oneBillionLUX = 1_000_000_000_000_000_000 // 1B LUX in nLUX
allocation := genesiscfg.Allocation{
ETHAddr: cfg.RewardAddress, // Same as LUX addr for simplicity
LUXAddr: cfg.RewardAddress,
EVMAddr: cfg.RewardAddress, // Same as UTXO addr for simplicity
UTXOAddr: cfg.RewardAddress,
InitialAmount: oneMillionLUX, // Initial unlocked amount
UnlockSchedule: []genesiscfg.LockedAmount{
{
+111
View File
@@ -0,0 +1,111 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package builder
import (
"testing"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
"github.com/luxfi/node/vms/platformvm/genesis"
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
)
// TestXAssetIDFromGenesisBytes_Sovereign asserts the canonical
// behaviour the sovereign-L1 fix relies on: when the platform genesis
// bakes an X-Chain, the X-Chain asset ID returned by
// XAssetIDFromGenesisBytes is the runtime ID encoded IN the genesis
// (different across networks with different validator/holder sets),
// NOT the network-id-keyed constants.UTXOAssetIDFor(networkID) value.
//
// The two values disagreeing is the whole reason the helper exists —
// sovereign primary networks sharing a networkID would otherwise
// silently collide on the constant.
func TestXAssetIDFromGenesisBytes_Sovereign(t *testing.T) {
require := require.New(t)
// Use the local devnet config — it has an X-Chain genesis baked in,
// so the helper must derive a real ID (not just fall back).
cfg := GetConfig(constants.LocalID)
require.NotNil(cfg)
require.NotEmpty(cfg.XChainGenesis, "fixture must bake X-Chain")
genesisBytes, fromConfigID, err := FromConfig(cfg)
require.NoError(err)
require.NotEmpty(genesisBytes)
require.NotEqual(ids.Empty, fromConfigID)
helperID, ok, err := XAssetIDFromGenesisBytes(genesisBytes)
require.NoError(err)
require.True(ok, "X-Chain is in genesis — helper must report ok=true")
require.Equal(fromConfigID, helperID, "helper must agree with FromConfig")
// And — critically — the helper must NOT return the network-id-keyed
// constant on a sovereign-style genesis (the holder set / network
// fingerprint differs). UTXOAssetIDFor returns the constant in
// network-id 1 (mainnet) and a network-keyed hash otherwise; on
// LocalID the test asserts the genesis-derived value supersedes it
// only when the genesis content differs from the upstream-Lux
// baseline. For the current embedded local config the two HAPPEN
// to coincide (single deployer, well-known fixture), so we only
// assert the value is non-zero and matches FromConfig's return.
}
// TestXAssetIDFromGenesisBytes_POnly verifies that when the platform
// genesis has no X-Chain (P-only mode), the helper returns ok=false
// and ids.Empty rather than an error. Callers fall back to
// constants.UTXOAssetIDFor(networkID) in that case (the value is
// unused at runtime since there is no X-Chain to mint on).
func TestXAssetIDFromGenesisBytes_POnly(t *testing.T) {
require := require.New(t)
pOnly := &genesis.Genesis{Chains: nil}
pOnlyBytes, err := genesis.Codec.Marshal(pchaintxs.CodecVersion, pOnly)
require.NoError(err)
id, ok, err := XAssetIDFromGenesisBytes(pOnlyBytes)
require.NoError(err, "P-only is a valid mode, not a parse error")
require.False(ok, "P-only must report ok=false so caller falls back")
require.Equal(ids.Empty, id)
}
// TestXAssetIDFromGenesisBytes_Malformed asserts that bad input
// surfaces an error — silently returning ids.Empty would reintroduce
// the UTXOAssetIDFor(networkID) fallback path and defeat the fix.
func TestXAssetIDFromGenesisBytes_Malformed(t *testing.T) {
require := require.New(t)
_, _, err := XAssetIDFromGenesisBytes([]byte{0xde, 0xad})
require.Error(err)
}
// TestVMGenesisOptInChains asserts that VMGenesis returns an error for VM IDs
// not present in the genesis chains list — the core "P-only" contract relied
// upon by node.initChainManager to log "skipping" and run the node without
// the missing chain. The behaviour MUST be deterministic and must not panic.
func TestVMGenesisOptInChains(t *testing.T) {
require := require.New(t)
// Build a genesis with zero chains — pure P-only.
pOnly := &genesis.Genesis{
Chains: nil,
}
pOnlyBytes, err := genesis.Codec.Marshal(pchaintxs.CodecVersion, pOnly)
require.NoError(err)
for name, vmID := range map[string]ids.ID{
"XVM": constants.XVMID,
"EVM": constants.EVMID,
"Quantum": constants.QuantumVMID,
"DexVM": constants.DexVMID,
} {
t.Run(name, func(t *testing.T) {
tx, lookupErr := VMGenesis(pOnlyBytes, vmID)
require.Nil(tx, "P-only genesis must not surface a tx for %s", name)
require.Error(lookupErr, "P-only genesis must report %s as missing", name)
})
}
}
+122 -6
View File
@@ -14,7 +14,9 @@ import (
"github.com/luxfi/crypto/hash"
"github.com/luxfi/ids"
"github.com/luxfi/node/vms/platformvm/genesis"
"github.com/luxfi/node/vms/platformvm/stakeable"
"github.com/luxfi/node/vms/platformvm/txs"
"github.com/luxfi/utxo/secp256k1fx"
genesiscfg "github.com/luxfi/genesis/pkg/genesis"
)
@@ -278,16 +280,16 @@ func TestFromConfigExplicitStakers(t *testing.T) {
NetworkID: constants.LocalID,
Allocations: []genesiscfg.Allocation{
{
ETHAddr: secpAddr,
LUXAddr: secpAddr,
EVMAddr: secpAddr,
UTXOAddr: secpAddr,
InitialAmount: 0,
UnlockSchedule: []genesiscfg.LockedAmount{
{Amount: oneBillionLUX, Locktime: 0},
},
},
{
ETHAddr: ethShortID,
LUXAddr: ethShortID,
EVMAddr: ethShortID,
UTXOAddr: ethShortID,
InitialAmount: oneMillionLUX,
},
},
@@ -369,8 +371,8 @@ func TestFromConfigExplicitStakersNoStakedFunds(t *testing.T) {
NetworkID: constants.LocalID,
Allocations: []genesiscfg.Allocation{
{
ETHAddr: deployerAddr,
LUXAddr: deployerAddr,
EVMAddr: deployerAddr,
UTXOAddr: deployerAddr,
InitialAmount: oneMillionLUX,
},
},
@@ -408,3 +410,117 @@ func TestFromConfigExplicitStakersNoStakedFunds(t *testing.T) {
}
}
}
// TestFromConfigLegacyShapedBackCompat proves the back-compat rule for
// legacy legacy-shaped pchain configs where initialAmount AND unlockSchedule
// are both set and initialAmount == sum(unlockSchedule). These are two views
// of the same total. Emitting both would double-mint. The builder must emit
// exactly one UTXO per unlock entry (no additional initialAmount UTXO).
func TestFromConfigLegacyShapedBackCompat(t *testing.T) {
require := require.New(t)
var stakerAddr ids.ShortID
copy(stakerAddr[:], hash.ComputeHash160([]byte("legacy-staker")))
var holderAddr ids.ShortID
copy(holderAddr[:], hash.ComputeHash160([]byte("legacy-holder")))
startTime := uint64(time.Now().Unix())
const hundredYears = 100 * 365 * 24 * 60 * 60
// holder gets 100 LUX, split into 10 yearly unlocks of 10 LUX each.
// initialAmount is the total (legacy convention).
const holderTotal = 100_000_000_000_000 // 100 LUX in nLUX
const perUnlock = holderTotal / 10
holderUnlocks := make([]genesiscfg.LockedAmount, 10)
for i := range holderUnlocks {
holderUnlocks[i] = genesiscfg.LockedAmount{
Amount: perUnlock,
Locktime: startTime + uint64(i)*365*24*60*60,
}
}
nodeID, err := ids.NodeIDFromString("NodeID-7D3wajA7bNpfyHpfEtkjUF1KcuhFEfPbZ")
require.NoError(err)
cfg := &genesiscfg.Config{
NetworkID: constants.LocalID,
Allocations: []genesiscfg.Allocation{
{
EVMAddr: stakerAddr,
UTXOAddr: stakerAddr,
InitialAmount: 0,
UnlockSchedule: []genesiscfg.LockedAmount{
{Amount: holderTotal, Locktime: 0},
},
},
{
// Legacy legacy-shaped: both fields set, initialAmount
// equals sum(unlockSchedule). The builder MUST treat these
// as one allocation (no double-mint).
EVMAddr: holderAddr,
UTXOAddr: holderAddr,
InitialAmount: holderTotal,
UnlockSchedule: holderUnlocks,
},
},
StartTime: startTime,
InitialStakeDuration: hundredYears,
InitialStakeDurationOffset: 0,
InitialStakedFunds: []ids.ShortID{stakerAddr},
InitialStakers: []genesiscfg.Staker{
{
NodeID: nodeID,
RewardAddress: stakerAddr,
DelegationFee: 1000000,
StartTime: startTime,
EndTime: startTime + hundredYears,
},
},
CChainGenesis: `{"config":{"chainId":31337},"alloc":{}}`,
Message: "Legacy Shaped Back-Compat",
}
genesisBytes, _, err := FromConfig(cfg)
require.NoError(err)
require.NotEmpty(genesisBytes)
parsed, err := genesis.Parse(genesisBytes)
require.NoError(err)
// Count UTXOs belonging to holderAddr. With the back-compat fix the
// builder emits exactly len(holderUnlocks) UTXOs (one per unlock entry)
// and no extra immediately-spendable UTXO for initialAmount. Without
// the fix it would emit len(holderUnlocks)+1 UTXOs (double-mint).
//
// Outputs with locktime in the future are wrapped in *stakeable.LockOut
// whose inner Out is the *secp256k1fx.TransferOutput; outputs that are
// immediately spendable are raw *secp256k1fx.TransferOutput.
var holderUTXOs int
var holderSum uint64
for _, u := range parsed.UTXOs {
var out *secp256k1fx.TransferOutput
switch o := u.Out.(type) {
case *secp256k1fx.TransferOutput:
out = o
case *stakeable.LockOut:
inner, ok := o.TransferableOut.(*secp256k1fx.TransferOutput)
if !ok {
continue
}
out = inner
default:
continue
}
if len(out.OutputOwners.Addrs) != 1 || out.OutputOwners.Addrs[0] != holderAddr {
continue
}
holderUTXOs++
holderSum += out.Amt
}
require.Equal(len(holderUnlocks), holderUTXOs,
"holder must have exactly %d UTXOs (one per unlock entry), got %d (double-mint?)",
len(holderUnlocks), holderUTXOs)
require.Equal(uint64(holderTotal), holderSum,
"holder UTXO sum must equal initialAmount (no double-mint)")
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package builder
import (
"github.com/luxfi/constants"
"github.com/luxfi/ids"
"github.com/luxfi/utxo/bls12381fx"
"github.com/luxfi/utxo/ed25519fx"
"github.com/luxfi/utxo/mldsafx"
"github.com/luxfi/utxo/nftfx"
"github.com/luxfi/utxo/propertyfx"
"github.com/luxfi/utxo/schnorrfx"
"github.com/luxfi/utxo/secp256k1fx"
"github.com/luxfi/utxo/secp256r1fx"
"github.com/luxfi/utxo/slhdsafx"
)
// ChainSpec is one row in the primary-network chain registry.
//
// Registry is the single source of truth for: canonical letter,
// VMID, bech32 chain prefix used in addresses, alias list, and the
// human-readable name. Rebranding or renaming a chain edits one
// row; the prior switch-ladders in Aliases() and var blocks of
// {P,X,C,...}ChainAliases re-derive from this table.
type ChainSpec struct {
// Letter is the canonical single-character chain code ("P", "X", ...).
// Used as the first element of the chain alias list and as the
// bech32 chain prefix.
Letter string
// VMID is the VM that runs this chain. The (VMID -> aliases) map
// VMAliasesMap() is derived from Registry by collecting Aliases
// for each ChainSpec.
VMID ids.ID
// Aliases are the non-letter aliases for this chain. The full
// chain alias list returned by AliasesFor is [Letter] ++ Aliases.
// These are also the VM aliases registered with the alias manager.
Aliases []string
// Name is the human-readable chain name ("P-Chain", "C-Chain", ...).
// Embedded into the primary-network CreateChainTx.
Name string
}
// Registry is the canonical list of primary-network chains.
//
// Order is fixed and matches the optIn order in FromConfig — reordering
// shifts the P-Chain genesis byte layout for chains that share a
// presence/absence shape, so this slice is append-only.
//
// P-Chain is implicit (the platform genesis itself); listed here so the
// alias machinery has a row for it, but FromConfig does not iterate this
// slice to emit CreateChainTx for P.
var Registry = []ChainSpec{
{Letter: "P", VMID: constants.PlatformVMID, Aliases: []string{"platform"}, Name: "P-Chain"},
{Letter: "X", VMID: constants.XVMID, Aliases: []string{"xvm"}, Name: "X-Chain"},
{Letter: "C", VMID: constants.EVMID, Aliases: []string{"evm"}, Name: "C-Chain"},
{Letter: "D", VMID: constants.DexVMID, Aliases: []string{"dex", "dexvm"}, Name: "D-Chain"},
{Letter: "Q", VMID: constants.QuantumVMID, Aliases: []string{"quantum", "quantumvm", "pq"}, Name: "Q-Chain"},
{Letter: "A", VMID: constants.AIVMID, Aliases: []string{"attest", "ai", "aivm"}, Name: "A-Chain"},
{Letter: "B", VMID: constants.BridgeVMID, Aliases: []string{"bridge", "bridgevm"}, Name: "B-Chain"},
{Letter: "T", VMID: constants.ThresholdVMID, Aliases: []string{"threshold", "thresholdvm", "mpc"}, Name: "T-Chain"},
{Letter: "Z", VMID: constants.ZKVMID, Aliases: []string{"zk", "zkvm"}, Name: "Z-Chain"},
{Letter: "G", VMID: constants.GraphVMID, Aliases: []string{"graph", "graphvm", "dgraph"}, Name: "G-Chain"},
{Letter: "K", VMID: constants.KeyVMID, Aliases: []string{"key", "keyvm"}, Name: "K-Chain"},
}
// specsByLetter is an O(1) lookup built once at package init.
var specsByLetter = func() map[string]ChainSpec {
m := make(map[string]ChainSpec, len(Registry))
for _, s := range Registry {
m[s.Letter] = s
}
return m
}()
// AliasesFor returns the full chain alias list for a chain by letter.
// The letter itself is prepended, e.g. AliasesFor("D") -> ["D", "dex", "dexvm"].
// Returns nil for unknown letters.
func AliasesFor(letter string) []string {
s, ok := specsByLetter[letter]
if !ok {
return nil
}
out := make([]string, 0, 1+len(s.Aliases))
out = append(out, s.Letter)
out = append(out, s.Aliases...)
return out
}
// fxVMAliases is the static (non-chain) VM alias table for feature-extension
// VMIDs. These are independent of the chain registry — they are not chains,
// they are credential/output type extensions registered with the VM manager.
var fxVMAliases = map[ids.ID][]string{
secp256k1fx.ID: {"secp256k1fx"},
nftfx.ID: {"nftfx"},
propertyfx.ID: {"propertyfx"},
mldsafx.ID: {"mldsafx"},
slhdsafx.ID: {"slhdsafx"},
ed25519fx.ID: {"ed25519fx"},
secp256r1fx.ID: {"secp256r1fx"},
schnorrfx.ID: {"schnorrfx"},
bls12381fx.ID: {"bls12381fx"},
}
// VMAliasesMap returns the union of chain VM aliases (from Registry) and
// the static fx VM aliases (from fxVMAliases). This is the map the node's
// VM manager consumes via builder.VMAliases.
//
// The values are fresh slice copies so callers cannot mutate Registry rows
// through the returned map.
func VMAliasesMap() map[ids.ID][]string {
m := make(map[ids.ID][]string, len(Registry)+len(fxVMAliases))
for _, s := range Registry {
aliases := make([]string, len(s.Aliases))
copy(aliases, s.Aliases)
m[s.VMID] = aliases
}
for vmID, aliases := range fxVMAliases {
cp := make([]string, len(aliases))
copy(cp, aliases)
m[vmID] = cp
}
return m
}
+124
View File
@@ -0,0 +1,124 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package builder
import (
"reflect"
"sort"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
"github.com/luxfi/utxo/bls12381fx"
"github.com/luxfi/utxo/ed25519fx"
"github.com/luxfi/utxo/mldsafx"
"github.com/luxfi/utxo/nftfx"
"github.com/luxfi/utxo/propertyfx"
"github.com/luxfi/utxo/schnorrfx"
"github.com/luxfi/utxo/secp256k1fx"
"github.com/luxfi/utxo/secp256r1fx"
"github.com/luxfi/utxo/slhdsafx"
)
// TestChainAliasesRegistryParity asserts that the registry-derived
// {P,X,C,...}ChainAliases vars hold the same slices as the legacy
// hand-typed literals. If this test ever fails, the registry data
// drifted from the legacy chain alias contract — either rebrand on
// purpose (and update legacy literals here) or fix the Registry row.
func TestChainAliasesRegistryParity(t *testing.T) {
tests := []struct {
name string
got []string
legacy []string
}{
{"P", PChainAliases, []string{"P", "platform"}},
{"X", XChainAliases, []string{"X", "xvm"}},
{"C", CChainAliases, []string{"C", "evm"}},
{"D", DChainAliases, []string{"D", "dex", "dexvm"}},
{"Q", QChainAliases, []string{"Q", "quantum", "quantumvm", "pq"}},
{"A", AChainAliases, []string{"A", "attest", "ai", "aivm"}},
{"B", BChainAliases, []string{"B", "bridge", "bridgevm"}},
{"T", TChainAliases, []string{"T", "threshold", "thresholdvm", "mpc"}},
{"Z", ZChainAliases, []string{"Z", "zk", "zkvm"}},
{"G", GChainAliases, []string{"G", "graph", "graphvm", "dgraph"}},
{"K", KChainAliases, []string{"K", "key", "keyvm"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.True(t,
reflect.DeepEqual(tt.got, tt.legacy),
"%sChainAliases drift: got %v, legacy %v", tt.name, tt.got, tt.legacy,
)
})
}
}
// TestVMAliasesRegistryParity asserts that VMAliases holds the same
// (VMID -> alias set) the legacy literal map encoded. Alias order is
// not load-bearing (the alias manager treats each entry as a name
// mapping), so this test compares sorted alias sets.
func TestVMAliasesRegistryParity(t *testing.T) {
legacy := map[ids.ID][]string{
constants.PlatformVMID: {"platform"},
constants.XVMID: {"xvm"},
constants.EVMID: {"evm"},
constants.DexVMID: {"dexvm", "dex"},
constants.QuantumVMID: {"quantumvm", "quantum", "pq"},
constants.AIVMID: {"aivm", "attest", "ai"},
constants.BridgeVMID: {"bridgevm", "bridge"},
constants.ThresholdVMID: {"thresholdvm", "threshold", "mpc"},
constants.ZKVMID: {"zkvm", "zk"},
constants.GraphVMID: {"graphvm", "graph", "dgraph"},
constants.KeyVMID: {"keyvm", "key"},
secp256k1fx.ID: {"secp256k1fx"},
nftfx.ID: {"nftfx"},
propertyfx.ID: {"propertyfx"},
mldsafx.ID: {"mldsafx"},
slhdsafx.ID: {"slhdsafx"},
ed25519fx.ID: {"ed25519fx"},
secp256r1fx.ID: {"secp256r1fx"},
schnorrfx.ID: {"schnorrfx"},
bls12381fx.ID: {"bls12381fx"},
}
require.Equal(t, len(legacy), len(VMAliases), "VMAliases entry count drift")
for vmID, want := range legacy {
got, ok := VMAliases[vmID]
require.True(t, ok, "VMAliases missing entry for VMID %s", vmID)
wantSorted := append([]string{}, want...)
gotSorted := append([]string{}, got...)
sort.Strings(wantSorted)
sort.Strings(gotSorted)
require.Equal(t, wantSorted, gotSorted, "VMAliases[%s] alias-set drift: got %v, want %v", vmID, got, want)
}
}
// TestVMAliasesMapIsolation asserts that mutating the returned map's
// slices does not corrupt the underlying Registry. Each call must
// return fresh copies.
func TestVMAliasesMapIsolation(t *testing.T) {
a := VMAliasesMap()
b := VMAliasesMap()
// Mutate a's slice for the X-Chain.
a[constants.XVMID][0] = "MUTATED"
require.NotEqual(t, "MUTATED", b[constants.XVMID][0],
"VMAliasesMap returns shared slices — mutation leaked across calls")
require.NotEqual(t, "MUTATED", XChainAliases[1],
"VMAliasesMap returns slices aliased with package vars")
}
// TestAliasesForUnknown returns nil for letters that have no row in
// Registry — used so callers don't accidentally register a phantom
// chain via a typo.
func TestAliasesForUnknown(t *testing.T) {
require.Nil(t, AliasesFor(""))
require.Nil(t, AliasesFor("?"))
require.Nil(t, AliasesFor("XX"))
}
+39 -45
View File
@@ -7,12 +7,11 @@ module github.com/luxfi/node
//
// - If updating between minor versions (e.g. 1.23.x -> 1.24.x):
// - Consider updating the version of golangci-lint (in scripts/lint.sh).
go 1.26.2
go 1.26.3
exclude github.com/luxfi/geth v1.16.1
require (
connectrpc.com/connect v1.19.1
github.com/DataDog/zstd v1.5.7 // indirect
github.com/StephenButtolph/canoto v0.17.3
github.com/btcsuite/btcd/btcutil v1.1.6
@@ -27,18 +26,18 @@ require (
github.com/huin/goupnp v1.3.0
github.com/jackpal/gateway v1.1.1
github.com/jackpal/go-nat-pmp v1.0.2
github.com/luxfi/consensus v1.23.2
github.com/luxfi/crypto v1.18.3
github.com/luxfi/consensus v1.24.6
github.com/luxfi/crypto v1.19.15
github.com/luxfi/database v1.18.3
github.com/luxfi/ids v1.2.9
github.com/luxfi/ids v1.2.10
github.com/luxfi/keychain v1.0.2
github.com/luxfi/log v1.4.1
github.com/luxfi/math v1.4.0
github.com/luxfi/metric v1.5.1
github.com/luxfi/math v1.4.1
github.com/luxfi/metric v1.5.5
github.com/luxfi/mock v0.1.1
github.com/mr-tron/base58 v1.2.0
github.com/onsi/ginkgo/v2 v2.28.1
github.com/pires/go-proxyproto v0.8.1
github.com/pires/go-proxyproto v0.11.0
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
@@ -52,11 +51,11 @@ require (
github.com/supranational/blst v0.3.16 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a // indirect
github.com/thepudds/fzgen v0.4.3
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/trace v1.42.0
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.uber.org/goleak v1.3.0
go.uber.org/mock v0.6.0
golang.org/x/crypto v0.50.0
@@ -68,8 +67,8 @@ require (
golang.org/x/tools v0.43.0
gonum.org/v1/gonum v0.17.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
@@ -82,7 +81,6 @@ require (
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect
github.com/cockroachdb/redact v1.1.8 // indirect
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -113,7 +111,7 @@ require (
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
@@ -121,21 +119,20 @@ require (
)
require (
connectrpc.com/grpcreflect v1.3.0
github.com/cloudflare/circl v1.6.3
github.com/consensys/gnark-crypto v0.20.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/golang/mock v1.7.0-rc.1
github.com/luxfi/accel v1.0.9
github.com/luxfi/api v1.0.10
github.com/luxfi/accel v1.1.4
github.com/luxfi/api v1.0.11
github.com/luxfi/atomic v1.0.0
github.com/luxfi/chains v1.2.1
github.com/luxfi/chains v1.2.3
github.com/luxfi/compress v0.0.5
github.com/luxfi/constants v1.5.2
github.com/luxfi/constants v1.5.7
github.com/luxfi/container v0.0.4
github.com/luxfi/filesystem v0.0.1
github.com/luxfi/genesis v1.9.2
github.com/luxfi/geth v1.16.87
github.com/luxfi/genesis v1.12.14
github.com/luxfi/geth v1.16.98
github.com/luxfi/go-bip39 v1.1.2
github.com/luxfi/lattice/v7 v7.1.0
github.com/luxfi/math/safe v0.0.1
@@ -144,17 +141,17 @@ require (
github.com/luxfi/resource v0.0.1
github.com/luxfi/rpc v1.0.2
github.com/luxfi/runtime v1.0.1
github.com/luxfi/sdk v1.16.52
github.com/luxfi/sdk v1.16.60
github.com/luxfi/sys v0.0.0-20260110090042-50187ec5ffd8
github.com/luxfi/timer v1.0.2
github.com/luxfi/units v1.0.0
github.com/luxfi/utils v1.1.4
github.com/luxfi/utxo v0.3.0
github.com/luxfi/validators v1.0.0
github.com/luxfi/utxo v0.3.2
github.com/luxfi/validators v1.2.0
github.com/luxfi/vm v1.0.40
github.com/luxfi/warp v1.18.5
github.com/luxfi/warp v1.18.6
github.com/luxfi/zwing v0.5.2
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
go.uber.org/zap v1.27.1
)
@@ -163,28 +160,26 @@ require (
filippo.io/hpke v0.4.0 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.6 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/btcsuite/btcutil v1.0.2 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gtank/merlin v0.1.1 // indirect
github.com/gtank/ristretto255 v0.2.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/luxfi/age v1.4.0 // indirect
github.com/luxfi/age v1.5.0 // indirect
github.com/luxfi/ai v0.1.0 // indirect
github.com/luxfi/config v1.1.2 // indirect
github.com/luxfi/evm v0.8.49 // indirect
github.com/luxfi/keys v1.0.8 // indirect
github.com/luxfi/lens v0.1.3 // indirect
github.com/luxfi/netrunner v1.18.1 // indirect
github.com/luxfi/corona v0.7.4 // indirect
github.com/luxfi/crypto/ipa v1.2.4 // indirect
github.com/luxfi/lens v0.1.4 // indirect
github.com/luxfi/oracle v0.1.1-0.20260429020431-76258cfcddf9 // indirect
github.com/luxfi/pulsar v0.1.5 // indirect
github.com/luxfi/pq v1.0.3 // indirect
github.com/luxfi/protocol v0.0.4 // indirect
github.com/luxfi/pulsar v1.0.12 // indirect
github.com/luxfi/relay v0.0.0-20260429020048-c629fe160d3c // indirect
github.com/luxfi/staking v1.1.0 // indirect
github.com/luxfi/threshold v1.6.7 // indirect
github.com/luxfi/threshold v1.8.5 // indirect
github.com/luxfi/trace v0.1.4 // indirect
github.com/luxfi/zapdb v1.8.0 // indirect
github.com/luxfi/zwing v0.5.2 // indirect
github.com/luxfi/zapdb v1.10.0 // indirect
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
@@ -192,12 +187,12 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
)
require (
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
github.com/luxfi/concurrent v0.0.3
github.com/luxfi/protocol v0.0.3 // indirect
github.com/luxfi/proto v1.0.1
github.com/luxfi/upgrade v1.0.0 // indirect
github.com/luxfi/version v1.0.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
@@ -206,7 +201,6 @@ require (
require (
github.com/ALTree/bigfloat v0.2.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260311194731-d5b7577c683d // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 // indirect
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
@@ -214,7 +208,6 @@ require (
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.7 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
@@ -228,7 +221,6 @@ require (
github.com/luxfi/formatting v1.0.1
github.com/luxfi/go-bip32 v1.0.2
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/corona v0.2.0 // indirect
github.com/luxfi/sampler v1.0.0 // indirect
github.com/luxfi/tls v1.0.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -244,3 +236,5 @@ require (
)
exclude github.com/ethereum/go-ethereum v1.10.26
replace github.com/luxfi/corona => github.com/luxfi/corona v0.7.5
+70 -118
View File
@@ -1,23 +1,19 @@
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc=
connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM=
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
github.com/ChainSafe/go-schnorrkel v1.1.0 h1:rZ6EU+CZFCjB4sHUE1jIu8VDoB/wRKZxoe1tkcO71Wk=
github.com/ChainSafe/go-schnorrkel v1.1.0/go.mod h1:ABkENxiP+cvjFiByMIZ9LYbRoNNLeBLiakC1XeTFxfE=
github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE=
github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260311194731-d5b7577c683d h1:EA+kZ8mxGb1W/ewiIBMzb/1gg5BiW1Fvr3r4qCUBJEg=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260311194731-d5b7577c683d/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/StephenButtolph/canoto v0.17.3 h1:lvsnYD4b96vD1knnmp1xCmZqfYpY/jSeRozGdOfdvGI=
github.com/StephenButtolph/canoto v0.17.3/go.mod h1:IcnAHC6nJUfQFVR9y60ko2ecUqqHHSB6UwI9NnBFZnE=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
@@ -44,8 +40,6 @@ github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
@@ -78,13 +72,13 @@ github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb h1:3bCgBvB
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/consensys/gnark-crypto v0.20.1 h1:PXDUBvk8AzhvWowHLWBEAfUQcV1/aZgWIqD6eMpXmDg=
github.com/consensys/gnark-crypto v0.20.1/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
github.com/cosmos/go-bip39 v1.0.0 h1:pcomnQdrdH22njcAatO0yWojsUnCO3y2tNoV1cb6hHY=
github.com/cosmos/go-bip39 v1.0.0/go.mod h1:RNJv0H/pOIVgxw6KS7QeX2a0Uo0aKUlfhZ4xuwvCdJw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
@@ -117,8 +111,6 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.7 h1:aat3CuITdDbPC6pmEGRT0zJ5eOxzrZj8TJT5
github.com/ethereum/c-kzg-4844/v2 v2.1.7/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/ferranbt/fastssz v1.0.0 h1:9EXXYsracSqQRBQiHeaVsG/KQeYblPf40hsQPb9Dzk8=
@@ -205,8 +197,6 @@ github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptR
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/gtank/merlin v0.1.1 h1:eQ90iG7K9pOhtereWsmyRJ6RAwcP4tHTDBHXNg+u5is=
@@ -256,137 +246,108 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/luxfi/accel v1.0.9 h1:QpXaZQUll0IlOHV+YgYUbrWcJeOYNNGzkwjI8e091x0=
github.com/luxfi/accel v1.0.9/go.mod h1:XvgcJLYsLObbKlrOgd3E2OJx5Oy5c/HnDlIewjr+Y+c=
github.com/luxfi/accel v1.1.4 h1:UOvS/00vG6WByf2P1tGYRkoBcsUkFuCgw7o2xU03OoE=
github.com/luxfi/accel v1.1.4/go.mod h1:K00BcnLzEYMPHwCFq8Tf/450ApmTs9xBVvYOnofJCkc=
github.com/luxfi/address v1.0.1 h1:Sc4keyuVzBIvHr7uVeYZf2/WY9YDGUgDi/iiWenj49g=
github.com/luxfi/address v1.0.1/go.mod h1:5j3Eh66v9zvv1GbNdZwt+23krV8JlSDaRzmWZU8ZRM0=
github.com/luxfi/age v1.4.0 h1:tU5q65RQSQdaVq64Z/DVUhiPQMoMPQT6pr41LsvG0AQ=
github.com/luxfi/age v1.4.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/luxfi/age v1.5.0 h1:G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=
github.com/luxfi/age v1.5.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/luxfi/ai v0.1.0 h1:PwTGob0GJivbdqNpUs82bvwcE8/qxyTokxfY7BZVPoo=
github.com/luxfi/ai v0.1.0/go.mod h1:lTuY32dGjEJUgVheYuanlt1O0jHj0AZ09sUSpRsmH6M=
github.com/luxfi/api v1.0.4 h1:5altGkSSk3zIsGzK/NPhRQe/It5QMHrAPgBVg2TMkK4=
github.com/luxfi/api v1.0.4/go.mod h1:Znr2A+SDJBCZ7ofxeq0MswHI0gk0cJsIYJLcu3tT8JY=
github.com/luxfi/api v1.0.5 h1:5+sepMbIBDzeIA0QTO66X/2D4SR8dKEQwQpdrmFmr+Q=
github.com/luxfi/api v1.0.5/go.mod h1:5OFWvZF+PbyuvLCDyKKpXCA/xp+LxJ32yOhNEKwQFN0=
github.com/luxfi/api v1.0.10 h1:tqtiCX8DcBsFG0JEhKy7eUNI+42+m2DoPjLjEh5TE5E=
github.com/luxfi/api v1.0.10/go.mod h1:5OFWvZF+PbyuvLCDyKKpXCA/xp+LxJ32yOhNEKwQFN0=
github.com/luxfi/api v1.0.11 h1:t4fzN9Ox/Vy5msW2WpSXq4xCAmBXXJS0oM+zIjM9IiQ=
github.com/luxfi/api v1.0.11/go.mod h1:q3Hh3mmkvp3cnv3aqe2+gCtmmSAL/uFjuzrqGavQLNA=
github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw=
github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI=
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
github.com/luxfi/chains v1.2.0 h1:AOoJ9OeMzzdPZV1NmKHl4KxxCiRBgUHHERU5llVEuFY=
github.com/luxfi/chains v1.2.0/go.mod h1:B5D2saO5i1HT1CvdWr4zfJRccMeSf02qHBf/1X2ZELE=
github.com/luxfi/chains v1.2.1 h1:eeqekwYV8E1OTIGFwzKBcI+9JGNzm3pfg+56qUn3BTQ=
github.com/luxfi/chains v1.2.1/go.mod h1:B5D2saO5i1HT1CvdWr4zfJRccMeSf02qHBf/1X2ZELE=
github.com/luxfi/chains v1.2.3 h1:d/xrJRuLYc/AkeugR4kRTwlYHudh1dfqjIaHGZPfIcI=
github.com/luxfi/chains v1.2.3/go.mod h1:aofU2aS8s5tHiD8VVQydcYStQCpR356f1UPHGlNi67U=
github.com/luxfi/codec v1.1.4 h1:Yl8ZalMNkqo7cD6R9AjczAajkLOmsjyZ9+DASVYHrvg=
github.com/luxfi/codec v1.1.4/go.mod h1:oGQ3j6E8c2P0pL0irYtWkrB1hmDUFIE0puXHK4gV5KI=
github.com/luxfi/compress v0.0.5 h1:4tEUHw5MK1bu5UOjfYCt4OKMiH7yykIgmGPRA/BfJTM=
github.com/luxfi/compress v0.0.5/go.mod h1:Cc1yxD2pfzrvpO32W2GDwLKff+CylHEvzZh2Ko8RSIU=
github.com/luxfi/concurrent v0.0.3 h1:eJyv1fhaC0jMLMw6+QS774cUmp7GK+ouMgvLCqnC7cc=
github.com/luxfi/concurrent v0.0.3/go.mod h1:Aj/FR5NpM0cB2P4Nt3+tz9+dV6V+LUW4HuMgSjwq5hw=
github.com/luxfi/config v1.1.2 h1:iCUewwm7oT7ckRNyQkrVHZ5Ge+l+FadciGX/zyaPo/k=
github.com/luxfi/config v1.1.2/go.mod h1:z6t0a5pGpQz2uDW2qJPLX5fZ/eWbpiNa51gBc63ebFk=
github.com/luxfi/consensus v1.22.85 h1:S/XpPF+eDapbZb4AIu0Vr1GXzg/sAxjrAff2M2mkOx0=
github.com/luxfi/consensus v1.22.85/go.mod h1:y6y+bVjvpDLkkTBCCKLcch6R4PRW8EA+lTfr+6sB6F8=
github.com/luxfi/consensus v1.23.0 h1:Jp/SuhDfLvbdXqMCi7NktWQTDXrKPbStEvIQjXsM3+g=
github.com/luxfi/consensus v1.23.0/go.mod h1:HufC4lEizSDbpG6JBzeg5Z1ZSpZi+1/cL4/PrJr4FEI=
github.com/luxfi/consensus v1.23.1 h1:JWJunRWlr2bwBVl3lfeMAmgnuibhlJS66bTM9ftcfxI=
github.com/luxfi/consensus v1.23.1/go.mod h1:ZkD2ART+vXh9DqWJqqYAV+U0BA66DtQGFwpQbnP46pE=
github.com/luxfi/consensus v1.23.2 h1:Edr/AhBbJH9RXwplCn65jYYb73bxRqbzKF6orpYYyfc=
github.com/luxfi/consensus v1.23.2/go.mod h1:16Q5zhPY09mhjEMOetd6QMzf0ZFqGSt6K5Fy7V/BTE8=
github.com/luxfi/constants v1.4.7 h1:e/Qs+DQP3pugle3Zncq6fZCxKgqqtbyD/z7Gm4ZjsYg=
github.com/luxfi/constants v1.4.7/go.mod h1:hOszZ2NDQ8gMZKncfcZ67PXkb5OIbnwAzXC3oFbQwW0=
github.com/luxfi/constants v1.5.2 h1:fwQepO6viKAwf8ACnP9YV0TVUxndZuZM7U/aKcqe9EM=
github.com/luxfi/constants v1.5.2/go.mod h1:hOszZ2NDQ8gMZKncfcZ67PXkb5OIbnwAzXC3oFbQwW0=
github.com/luxfi/consensus v1.24.6 h1:N65lAqHrykg0rymd9symfX0fuAhE3WCkPf6V6lNVKkM=
github.com/luxfi/consensus v1.24.6/go.mod h1:raSelEjRkcYB9tfNpEGcgisNkZw9+JTbHgjoBA+NdRU=
github.com/luxfi/constants v1.5.7 h1:a1tCMdxd+pClPMIPOaI9vcYGNy6cQIc2rubac2Trc0k=
github.com/luxfi/constants v1.5.7/go.mod h1:z+Wc7skybZAA+xuBWNcmtv402S/BFqixL+FiSQXPg8U=
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
github.com/luxfi/crypto v1.18.1 h1:Qaw8hSK0unIOAH6TRvu0s3JfoJWcg6zTf5eZ5adgKic=
github.com/luxfi/crypto v1.18.1/go.mod h1:QhSdEEk3xmgR5a97/nkXB/n21ZsI1Iv7tcbqQaNM5zs=
github.com/luxfi/crypto v1.18.3 h1:f6mBUHNjZl9/9nXPSqjq+oJttrPl8slvcZ49Vi7jKw4=
github.com/luxfi/crypto v1.18.3/go.mod h1:qBtCJDT6p6aNa2hMtPxYwC688Bnsb3Amso8q09GsMls=
github.com/luxfi/database v1.18.2 h1:r7jU8s4jaN0NpzWB0x91yyaL4CoCe0SxWEvxJGhJ7rE=
github.com/luxfi/database v1.18.2/go.mod h1:PW0oGujt165mYg7j/lctVbnfsUW6QLfIAgjyQdS66SM=
github.com/luxfi/corona v0.7.5 h1:XqcnsKaiP/EyDJzmDhziS+kDhX34TsVQiXTM8PvRROg=
github.com/luxfi/corona v0.7.5/go.mod h1:4aD7+ZqnlZ2aVuU/DBQ5aspIagv5ux45LW2sJ4+siY8=
github.com/luxfi/crypto v1.19.15 h1:Tf+iPRXv08Rlj9k7iNtYy0e1tJWIgRcY0pTR4rQax6w=
github.com/luxfi/crypto v1.19.15/go.mod h1:INjdZtke85k8hX/QAmTMAY8bbZ4gzGZQLqURg3xf6Gk=
github.com/luxfi/crypto/ipa v1.2.4 h1:6xfwhI9/HrcDkF3Ti5/NxsNQIWbwYDJmRSNIHRQ/xfU=
github.com/luxfi/crypto/ipa v1.2.4/go.mod h1:43J6f6rcfUMrZt4cQectMOZb6Ps+fAEj8ZTPC3Kk+gE=
github.com/luxfi/database v1.18.3 h1:gg+xwhKUxXa7fDoOD8IS91E71QqoEtcDCl2nfS61Jgg=
github.com/luxfi/database v1.18.3/go.mod h1:sv0pYCGKlK1aNJTICxFUDpVWCJTigoLlshHmV/1pg7c=
github.com/luxfi/evm v0.8.49 h1:xBiGibL6v1Gm02EIHVtvGvAIGVdPUY0kgsX8MDynApI=
github.com/luxfi/evm v0.8.49/go.mod h1:fgtQq7WGR5EpaqJTdYn//EqzAZDvBH9OKkPYS7s4oMI=
github.com/luxfi/filesystem v0.0.1 h1:VZ6xMFKaAPBW/ddlMsDnI2G0VU1lV5rYaVcW5d+KwEY=
github.com/luxfi/filesystem v0.0.1/go.mod h1:OQVSU6XNwqrr1AI+MqkID2taHUclx7NYmmr3svgttec=
github.com/luxfi/formatting v1.0.1 h1:ZnE1rAdEUds9yAegdVdGDOBGN6hLMPOv6E03Fp8IEYo=
github.com/luxfi/formatting v1.0.1/go.mod h1:mYzNf5DJOiqSSKUPzNj5dKy4tstFbN3pZlkI5716eKc=
github.com/luxfi/genesis v1.8.2 h1:jFFMDO/yyzq1KzL5FaXzkDV84BIFt72446jP56jPdcQ=
github.com/luxfi/genesis v1.8.2/go.mod h1:k26ZZvzES8e9cep9oWUDggBX+6oa5bnIQ7MmQP7dm4A=
github.com/luxfi/genesis v1.9.2 h1:DMcFVLebRgcA+ElTbxAqaFOOMvXEU7UsBE8yBYGLum0=
github.com/luxfi/genesis v1.9.2/go.mod h1:k26ZZvzES8e9cep9oWUDggBX+6oa5bnIQ7MmQP7dm4A=
github.com/luxfi/geth v1.16.86-0.20260413014255-3e903c3d2e06 h1:NAIcfesKGSITw/mYTQpq62igXqUiDR7gekx8rQTKBmQ=
github.com/luxfi/geth v1.16.86-0.20260413014255-3e903c3d2e06/go.mod h1:OdpR6OnAsYROywc8wm7W0lPn1z06DjgVmRL/F8dz5Kw=
github.com/luxfi/geth v1.16.87 h1:UDjMRrseg/D5a3NDygdJXMkfJcHNOZXzcMcCn/AhIV8=
github.com/luxfi/geth v1.16.87/go.mod h1:E5YcGOW9jB6Rgo4LB7NlTZxvkPzIoh1w7q56O63Zf1g=
github.com/luxfi/genesis v1.12.14 h1:/31ZAymyH+Zj/QCXI6To2agYLYF5pucBm5L2DIuGK+o=
github.com/luxfi/genesis v1.12.14/go.mod h1:QHBJQrOarpM1q+xgo7wL119CuLSsTjrFwYJLv5x9pwQ=
github.com/luxfi/geth v1.16.98 h1:w187TtKuGStf3tm2bshuHVKBv2Frjx0lT54kQVXyNHA=
github.com/luxfi/geth v1.16.98/go.mod h1:6kEzSExdk9CPQDPXALt6P3HfQqBq7KF1Jrrv9gBpxbU=
github.com/luxfi/go-bip32 v1.0.2 h1:7vFbb+Wr4Z499q2tuCLdd7wWjtn8sH+HWBlx76mhH9Y=
github.com/luxfi/go-bip32 v1.0.2/go.mod h1:bc7/LXDKAJQZ/F0Xjf5yXaTZxY9/ssLb4FC+Hxn/cDk=
github.com/luxfi/go-bip39 v1.1.2 h1:p+wLMPGs6MLQh7q0YIsmy2EhHL7LHiELEGTJko6t/Jg=
github.com/luxfi/go-bip39 v1.1.2/go.mod h1:96de9VkR2kY/ASAnhMtvt3TSh+PZkAFAngNj0GjRGDo=
github.com/luxfi/ids v1.2.9 h1:+yjdhXW99drnd2Zlp1u/p8k3G23W3/1btJQ4ogHawUI=
github.com/luxfi/ids v1.2.9/go.mod h1:khJOEdOPxd22yn0jcVrnbX1ADa0GHn5Y74gvCzN5BYc=
github.com/luxfi/ids v1.2.10 h1:f1WILZE199ayMuqnEyB2WP1qfMZkmozOQXSVYtB3e5k=
github.com/luxfi/ids v1.2.10/go.mod h1:QBIwy3OHvrtskbUqKh1+OYRa6PsyR7f7oNX33sOfK7w=
github.com/luxfi/keychain v1.0.2 h1:uQgmjs37/VBIALEiYrrszTpxvtqr07/YvS9TnmxGafs=
github.com/luxfi/keychain v1.0.2/go.mod h1:q/4ULgZBlstKkwzOzG/0T6y73BDPgnkrcibbJyTvmbU=
github.com/luxfi/keys v1.0.8 h1:fANl6qZInndyCcXHAWiHTQ50LHhU8pJlF0Ix3phTo4A=
github.com/luxfi/keys v1.0.8/go.mod h1:XbSCtgLyzEEVz2oZsYxnhRrNTGBxqrAm2Zu0uu06+qA=
github.com/luxfi/lattice/v7 v7.0.0 h1:d1vgan6mlb2KtwYfPc1g69uxVYaoVYZmlrIm4aCO88w=
github.com/luxfi/lattice/v7 v7.0.0/go.mod h1:PFDdOkuGTQ0cbJMbKojzEJMGWUQmZW+wK9/wJ9F9fOs=
github.com/luxfi/lattice/v7 v7.1.0 h1:mr3HvN6olNTS2LT/xAW/JBhTqfvpsGmsopDMeR7BSJs=
github.com/luxfi/lattice/v7 v7.1.0/go.mod h1:IaaUN+3ysnBG4BA8ILRYG0j80+qtYDP4C5lkaDb2pDE=
github.com/luxfi/lens v0.1.3 h1:JTk/AjhHdKaiJ6Z7ZNazqnRfbgRijUPIxHamKW7b/N8=
github.com/luxfi/lens v0.1.3/go.mod h1:XHpPOSgkT0ZFwfwp0t70Tvs5b6rjGyo1SNDEIDtfrqw=
github.com/luxfi/lens v0.1.4 h1:goGjGDXx2BNdjzXDunL5QT8elK2ZyCcc0z8TAbtWYrg=
github.com/luxfi/lens v0.1.4/go.mod h1:mL+G8IK+9L41d78/2FYRgfhEzAjcr5+VEXB8SGuHbus=
github.com/luxfi/log v1.4.1 h1:rIfFRodb9jrD/w7KayaUk0Oc+37PaQQdKEEMJCjR8gw=
github.com/luxfi/log v1.4.1/go.mod h1:64IE3xRMJcpkQwnPUfJw3pDj7wU0kRS7BZ9wM7R72jk=
github.com/luxfi/math v1.4.0 h1:/sb7Grw3hfO+5INWAWdB95jTvCeXg8fSQxsxDzcFtd4=
github.com/luxfi/math v1.4.0/go.mod h1:iW0FOCC8qF2mPE+MakG780CAHA83848lb1L04thA1Pg=
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
github.com/luxfi/math/safe v0.0.1 h1:GfSBINV9mOFgHzd32JbgfHSLhlNn0BwnP43rteYEosc=
github.com/luxfi/math/safe v0.0.1/go.mod h1:EejrmOJHh03YAD8+Zww8cPcMR1K3Q2I7w1dX4sMloeo=
github.com/luxfi/metric v1.5.1 h1:6tVarXMnNR3Xzud8FYUHjtXabTll3HI/OEqG0tgLAcI=
github.com/luxfi/metric v1.5.1/go.mod h1:PkD4D4JoGuyKtfUkqPNYkrg9xKrJeNVZFdW/5XvAe/A=
github.com/luxfi/metric v1.5.5 h1:JXruty5ZN/ljeRNaCSabQGg1Xr3re2E8wqajVUUs6w4=
github.com/luxfi/metric v1.5.5/go.mod h1:CMguEhyuLi4YUWyXimJ+UHply99BDFrL0pxedB7rBqM=
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
github.com/luxfi/net v0.0.4 h1:z1d6Q5c9/79jb4vF0XwBBjlF5swH5NsgfaXA+Pgojq8=
github.com/luxfi/net v0.0.4/go.mod h1:QvgHzCa767cVWtPpui0P7HW1IrA2+c++hhvaQ/t0yyw=
github.com/luxfi/netrunner v1.18.1 h1:tiLpbxoKedoiBORATKZ/mW00zd7SEC/P+Q9KWDldn2M=
github.com/luxfi/netrunner v1.18.1/go.mod h1:zBAG98SGjPdBpo2/ccPvHCzvTQVvayH/Q8xviNa0FnU=
github.com/luxfi/oracle v0.1.1-0.20260429020431-76258cfcddf9 h1:y+foW/ZJuTEb/AUUaJGrll2PVWumTiydWZuYJV4bJ/k=
github.com/luxfi/oracle v0.1.1-0.20260429020431-76258cfcddf9/go.mod h1:ueeF3b4SrhpCNb8hUYeP/kV7qgkp/BKKjsKbUhEjg40=
github.com/luxfi/p2p v1.19.2 h1:uqZq7ofmEDbXlTkv1QThtci01Q+dmDkNmAPeORIyP8E=
github.com/luxfi/p2p v1.19.2/go.mod h1:tI9Bt1R0ouvVtJvXG4e20GlGeV4AR230k4mFF9Vglzk=
github.com/luxfi/precompile v0.5.12 h1:YiF8gaPVFjXESHC+44XVM1RkAyZFMb8iU/60Tfq3Kxs=
github.com/luxfi/precompile v0.5.12/go.mod h1:YiTU6xMf2wmKM2LBzKctn82JhaesFDz4ysC+S864qCA=
github.com/luxfi/precompile v0.5.14 h1:z8mlWv7yWqWczWp59qoxTp0B7l/a0HMtMBPk27MEKyg=
github.com/luxfi/protocol v0.0.3 h1:Teytlu6Gbd0HqJXuDvV84ArqRabEFR40yDNNQUOCpVE=
github.com/luxfi/protocol v0.0.3/go.mod h1:ZSA1i7f2SlN129UG7rUZ+doHvf5/mtiurJMqRwGB4M8=
github.com/luxfi/pulsar v0.1.5 h1:Yi9zlYsSOrParueW3ngm3EUVAwZx6DhFsO3cFH3h+1k=
github.com/luxfi/pulsar v0.1.5/go.mod h1:Zei7PZhYxEsfzY5Rg0iI73nMU0ngO8EpUhlOOC0Ys7g=
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
github.com/luxfi/precompile v0.5.27 h1:lEF5gXY8ny5xXp8Qg8PK0Y2RQlaRNOZeDU+wP6Sj24M=
github.com/luxfi/precompile v0.5.27/go.mod h1:boKRJeoa4XEhCoylRnPI/0IGfBgm5bZnFNI9llDktWo=
github.com/luxfi/proto v1.0.1 h1:pw+QlwWbXELJ+kOApBkUNnyFrIO/EuWsd/NzAKkBBOU=
github.com/luxfi/proto v1.0.1/go.mod h1:pZLKsCmhiPtmm3z7ezBbnsYT2m79q+cFSmzxvuBeANE=
github.com/luxfi/protocol v0.0.4 h1:wf3JSyeNMEabOUG0vExtIAtjfpJAyHXlepGBwv5g9NE=
github.com/luxfi/protocol v0.0.4/go.mod h1:9f35GLNlcHAz6LCvFBDZP5fTD2QnN0URXWGUOcD1JPU=
github.com/luxfi/pulsar v1.0.12 h1:SD+GGat5JuqKVhRSRojdKhHhmOWr7rz74IMFiP8PPeg=
github.com/luxfi/pulsar v1.0.12/go.mod h1:nWjIyef69Yv/y27lwy8hUfAE55lkj2GRP4zDIGZc1B0=
github.com/luxfi/relay v0.0.0-20260429020048-c629fe160d3c h1:wiOLqdEEjIoq+Uubkm6F6GYQue+rzqnITScM3ohWGqI=
github.com/luxfi/relay v0.0.0-20260429020048-c629fe160d3c/go.mod h1:P408Sn9NGwtcDOoGdkmWe0484dkWDvEvDDBM44Cv+s4=
github.com/luxfi/resource v0.0.1 h1:mTh+ICWSy548GTUSSyx7V/X5dV18oEwxZeQEYGJQhD4=
github.com/luxfi/resource v0.0.1/go.mod h1:wWpZktciYwIi6RNqA+fHwzmPrUJa7PRX7urfwT+spRE=
github.com/luxfi/corona v0.2.0 h1:DbLMZmE//2T2wXXS/gEkKZrTbre9LD+7EE/tz5SQszU=
github.com/luxfi/corona v0.2.0/go.mod h1:SZ+aDLUdfSAtaTRaaYzeDZ5DNQiLaOCelSaIGjL21R0=
github.com/luxfi/rpc v1.0.2 h1:NLRcOYRW+io0d1d33RMkgOZea8nlhK09MbPgCXcU5wU=
github.com/luxfi/rpc v1.0.2/go.mod h1:pgiHwMWgOuxYYIa0vsUBvrBI+Op6bhZ39guM9vtMUcE=
github.com/luxfi/runtime v1.0.1 h1:cii3OsRiVSIl9jzfWFM6++62T1r6ZSGP+/3F0Ezhpqw=
github.com/luxfi/runtime v1.0.1/go.mod h1:2hBKjzbEeE4dzrhUKH8dqkRgLEyiXz6GmuVusy3vJMs=
github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=
github.com/luxfi/sampler v1.0.0/go.mod h1:f96/ozlj9vFfZj+akLtrHn4VpulQahwB+MQQhpeIekk=
github.com/luxfi/sdk v1.16.52 h1:X1rZrgzGTIGzeVMKx9I44/kSvYFPhFu3h2SQ9StpTxA=
github.com/luxfi/sdk v1.16.52/go.mod h1:IEP/ej5HA/rCOFOMzv/LfG4oXl2pI9ly+MrpcbVguBg=
github.com/luxfi/sdk v1.16.60 h1:MKIFpGAIQzbFADXNd6rV5KySlbegnvpDJyiNhtO7YZw=
github.com/luxfi/sdk v1.16.60/go.mod h1:se4G9mlLc6WdjLQlydg6J3pFEeMlEH+MCO1puJ8sYYc=
github.com/luxfi/staking v1.1.0 h1:oB1f86WG9duxONe6unISHpUlelnmVg8+BOg4qP83q6Y=
github.com/luxfi/staking v1.1.0/go.mod h1:xwYacmreoKoy31ELT6QPno6b5M8q7bg4zJrmmt524X4=
github.com/luxfi/sys v0.0.0-20260110090042-50187ec5ffd8 h1:6L5294QWwXzy9he6wAM+Dc39rV4iwyl9hex0k2pXPsg=
github.com/luxfi/sys v0.0.0-20260110090042-50187ec5ffd8/go.mod h1:Y0UwjnEJYSbGM7IvJbKYLf8BWNVLfT8Oz00bEiDITHw=
github.com/luxfi/threshold v1.5.5 h1:MgPMrHf7dryztiaOYaG+Z+Cqqfa79oyblp5EowDFJw4=
github.com/luxfi/threshold v1.5.5/go.mod h1:JEnYrKvCRDwGUeuCsMJSO2FeokuZGVaOml1XD/L1Vgs=
github.com/luxfi/threshold v1.6.7 h1:geHmdDAXVm+T9U5CSoUiduXhhRKRUbfKOd6dw+2rPzQ=
github.com/luxfi/threshold v1.6.7/go.mod h1:UzUXp2Fbnlb45AwYiXu++RsMkky4wocpKvhCc+rRg7g=
github.com/luxfi/threshold v1.8.5 h1:uUTzlc5z8xElxcF9HGLVY1HUX+vsseMX0uryPuOgr84=
github.com/luxfi/threshold v1.8.5/go.mod h1:d066yAD8CjSjOeuOYkE+P1/f93F14+NKYoHoJIGfVbI=
github.com/luxfi/timer v1.0.2 h1:g/odi0VQJIsrzdklJUG1thHZ/sGNnbIiVGcU6LctJm0=
github.com/luxfi/timer v1.0.2/go.mod h1:SoaZwntYigUE3H6z1GV32YwP8QaSiAT0UiEv7iPugXg=
github.com/luxfi/tls v1.0.3 h1:rK3nxSAxrUOOSHOZnKChwV4f6UJ+cfOl8KWJXAQx/SI=
@@ -399,18 +360,18 @@ github.com/luxfi/upgrade v1.0.0 h1:mM6YXvU8VzoclA7IdtuE5bmnMuFquYxjKUUW84LJz54=
github.com/luxfi/upgrade v1.0.0/go.mod h1:DZF7dO4erhUEKf907B2e65k4/vGkCPkUngpvIEUS3eI=
github.com/luxfi/utils v1.1.4 h1:8OY0jXCkpp6OotN1Y++6DATJkvtEwzq2k0p56imJlAk=
github.com/luxfi/utils v1.1.4/go.mod h1:c3yz1RjzrB+cs5GZm+q1T3/2cCKElO9vxm9yRRtgSEM=
github.com/luxfi/utxo v0.3.0 h1:Ok9VrICkFTRKjf3oTOovIPbPwiRpEZQ/MEFw7gFxpPo=
github.com/luxfi/utxo v0.3.0/go.mod h1:e1KQHhjYTpc0zugPbGuffezUoz2ZFvha5I3LoFB10/M=
github.com/luxfi/validators v1.0.0 h1:zE1HJM1lfvC+v1Lalg+MUgkKBGFWnwTfOOmXraiNnpE=
github.com/luxfi/validators v1.0.0/go.mod h1:QGppjtI/gqprbplWc10J+4OIK8UYWpv+53mfH2aKhy4=
github.com/luxfi/utxo v0.3.2 h1:fez7ptBnAoRYA5FpRz/uFWY8hcZwd4X00UYLnxuJeXE=
github.com/luxfi/utxo v0.3.2/go.mod h1:O8Ucl6Sj0nM7l3Y+uqzucVu309yHcRQY++rg9yXmmdo=
github.com/luxfi/validators v1.2.0 h1:VygpiBqBAdGrfkb7xzE2yrVmnXaqE+hm8FLWdGXO7G8=
github.com/luxfi/validators v1.2.0/go.mod h1:GYLulrNXAan23ZlX7sgWVbVnLpUexeB/m2qr2ymsXok=
github.com/luxfi/version v1.0.1 h1:T/1KYWEMmsrNQk7pN7PFPAwh/7XbeX7cFAKLBqI37Sk=
github.com/luxfi/version v1.0.1/go.mod h1:Y5fPkQ2DB0XRBCxgSPXp4ISzL1/jptKnmFknShRJCyg=
github.com/luxfi/vm v1.0.40 h1:kA0V/9p1VdC88EjgbljWb8t+fl9qz5bCyF3IxnV20yg=
github.com/luxfi/vm v1.0.40/go.mod h1:Y5WUKhT76PR6HnbUykHBWI2f5sjssrm5BL6/o7b6IfQ=
github.com/luxfi/warp v1.18.5 h1:yXFCT+lnvzJHs8nVvfUCQ9wNvhtgbDGaXJkJeiwgKf4=
github.com/luxfi/warp v1.18.5/go.mod h1:SFyC529HDvbP/TWRAdYQSyJUliMa5JKFRtBrTLEElp4=
github.com/luxfi/zapdb v1.8.0 h1:E9hXMW1MTuS6kmDVs07j62gIq5qI5gZOdyzVfiBc1wg=
github.com/luxfi/zapdb v1.8.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
github.com/luxfi/warp v1.18.6 h1:+ly7mHz77ig4yUyLax32aAjk7tiQQ6eygzOSDNfYRbQ=
github.com/luxfi/warp v1.18.6/go.mod h1:OBN23yiGl+E4qupkPHuetBIEqsI8q5leE8WJH8soFjY=
github.com/luxfi/zapdb v1.10.0 h1:1lLHEmkyC0BucnA/zjQYsMkUVxuEo2vQkEaQGjYfuuc=
github.com/luxfi/zapdb v1.10.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
github.com/luxfi/zwing v0.5.2 h1:2+nDKHVIdT8GvaKO79GuO3x/3DgJvX0gI32h4P+P6RU=
github.com/luxfi/zwing v0.5.2/go.mod h1:8nixkEL3bhO2LrqVqhJ8WgT+QGUtnCPIQIhFQh9gQio=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
@@ -436,8 +397,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/pointerstructure v1.2.1 h1:ZhBBeX8tSlRpu/FFhXH4RC4OJzFlqsQhoHZAz4x7TIw=
github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/montanaflynn/stats v0.8.2 h1:52wnefTJnPI5FoHif1DQh2soKRw0yYs+4AVyvtcZCH0=
github.com/montanaflynn/stats v0.8.2/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
@@ -483,8 +442,8 @@ github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQp
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -587,22 +546,22 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -620,10 +579,7 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
@@ -676,8 +632,6 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -687,8 +641,6 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+1 -1
View File
@@ -47,7 +47,7 @@ Each chain has one or more index. To see if a C-Chain block is accepted, for exa
```
<Callout type="warn">
To ensure historical data can be accessed, the `/ext/index/X/vtx` is still accessible, even though it is no longer populated with chain data since the Cortina activation. If you are using `V1.10.0` or higher, you need to migrate to using the `/ext/index/X/block` endpoint.
To ensure historical data can be accessed, the `/ext/index/X/vtx` is still accessible, even though it is no longer populated with chain data since the X-Chain block linearization landed. If you are using `V1.10.0` or higher, you need to migrate to using the `/ext/index/X/block` endpoint.
</Callout>
## Methods
-373
View File
@@ -1,373 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpcdb
import (
"context"
"encoding/json"
"errors"
"io"
"sync"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/luxfi/database"
"github.com/luxfi/math/set"
"github.com/luxfi/utils"
rpcdbpb "github.com/luxfi/node/proto/pb/rpcdb"
)
var (
_ database.Database = (*DatabaseClient)(nil)
_ database.Batch = (*batch)(nil)
_ database.Iterator = (*iterator)(nil)
)
// DatabaseClient is an implementation of database that talks over RPC.
type DatabaseClient struct {
client rpcdbpb.DatabaseClient
closed utils.Atomic[bool]
}
// NewClient returns a database instance connected to a remote database instance
func NewClient(client rpcdbpb.DatabaseClient) *DatabaseClient {
return &DatabaseClient{client: client}
}
// Has attempts to return if the database has a key with the provided value.
func (db *DatabaseClient) Has(key []byte) (bool, error) {
resp, err := db.client.Has(context.Background(), &rpcdbpb.HasRequest{
Key: key,
})
if err != nil {
return false, err
}
return resp.Has, ErrEnumToError[resp.Err]
}
// Get attempts to return the value that was mapped to the key that was provided
func (db *DatabaseClient) Get(key []byte) ([]byte, error) {
resp, err := db.client.Get(context.Background(), &rpcdbpb.GetRequest{
Key: key,
})
if err != nil {
return nil, err
}
return resp.Value, ErrEnumToError[resp.Err]
}
// Put attempts to set the value this key maps to
func (db *DatabaseClient) Put(key, value []byte) error {
resp, err := db.client.Put(context.Background(), &rpcdbpb.PutRequest{
Key: key,
Value: value,
})
if err != nil {
return err
}
return ErrEnumToError[resp.Err]
}
// Delete attempts to remove any mapping from the key
func (db *DatabaseClient) Delete(key []byte) error {
resp, err := db.client.Delete(context.Background(), &rpcdbpb.DeleteRequest{
Key: key,
})
if err != nil {
return err
}
return ErrEnumToError[resp.Err]
}
// NewBatch returns a new batch
func (db *DatabaseClient) NewBatch() database.Batch {
return &batch{db: db}
}
func (db *DatabaseClient) NewIterator() database.Iterator {
return db.NewIteratorWithStartAndPrefix(nil, nil)
}
func (db *DatabaseClient) NewIteratorWithStart(start []byte) database.Iterator {
return db.NewIteratorWithStartAndPrefix(start, nil)
}
func (db *DatabaseClient) NewIteratorWithPrefix(prefix []byte) database.Iterator {
return db.NewIteratorWithStartAndPrefix(nil, prefix)
}
// NewIteratorWithStartAndPrefix returns a new empty iterator
func (db *DatabaseClient) NewIteratorWithStartAndPrefix(start, prefix []byte) database.Iterator {
resp, err := db.client.NewIteratorWithStartAndPrefix(context.Background(), &rpcdbpb.NewIteratorWithStartAndPrefixRequest{
Start: start,
Prefix: prefix,
})
if err != nil {
return &database.IteratorError{
Err: err,
}
}
return newIterator(db, resp.Id)
}
// Compact attempts to optimize the space utilization in the provided range
func (db *DatabaseClient) Compact(start, limit []byte) error {
resp, err := db.client.Compact(context.Background(), &rpcdbpb.CompactRequest{
Start: start,
Limit: limit,
})
if err != nil {
return err
}
return ErrEnumToError[resp.Err]
}
// Close attempts to close the database
func (db *DatabaseClient) Close() error {
db.closed.Set(true)
resp, err := db.client.Close(context.Background(), &rpcdbpb.CloseRequest{})
if err != nil {
return err
}
return ErrEnumToError[resp.Err]
}
// Sync flushes any pending writes to persistent storage.
// For RPC databases, this delegates to the remote database's Sync.
func (db *DatabaseClient) Sync() error {
// RPC database delegates sync to the underlying database on the server side.
// Most operations are already synchronous over RPC, so this is typically a no-op.
// If the server implements a Sync RPC method, call it here.
// For now, return nil as operations are synchronous.
return nil
}
func (db *DatabaseClient) HealthCheck(ctx context.Context) (interface{}, error) {
health, err := db.client.HealthCheck(ctx, &emptypb.Empty{})
if err != nil {
return nil, err
}
return json.RawMessage(health.Details), nil
}
// Backup is not supported over the RPC database client.
func (db *DatabaseClient) Backup(_ io.Writer, _ uint64) (uint64, error) {
return 0, errors.New("rpcdb: backup not supported")
}
// Load is not supported over the RPC database client.
func (db *DatabaseClient) Load(_ io.Reader) error {
return errors.New("rpcdb: load not supported")
}
type batch struct {
database.BatchOps
db *DatabaseClient
}
func (b *batch) Write() error {
request := &rpcdbpb.WriteBatchRequest{}
keySet := set.NewSet[string](len(b.Ops))
for i := len(b.Ops) - 1; i >= 0; i-- {
op := b.Ops[i]
key := string(op.Key)
if keySet.Contains(key) {
continue
}
keySet.Add(key)
if op.Delete {
request.Deletes = append(request.Deletes, &rpcdbpb.DeleteRequest{
Key: op.Key,
})
} else {
request.Puts = append(request.Puts, &rpcdbpb.PutRequest{
Key: op.Key,
Value: op.Value,
})
}
}
resp, err := b.db.client.WriteBatch(context.Background(), request)
if err != nil {
return err
}
return ErrEnumToError[resp.Err]
}
func (b *batch) Inner() database.Batch {
return b
}
type iterator struct {
db *DatabaseClient
id uint64
data []*rpcdbpb.PutRequest
fetchedData chan []*rpcdbpb.PutRequest
errLock sync.RWMutex
err error
reqUpdateError chan chan struct{}
once sync.Once
onClose chan struct{}
onClosed chan struct{}
}
func newIterator(db *DatabaseClient, id uint64) *iterator {
it := &iterator{
db: db,
id: id,
fetchedData: make(chan []*rpcdbpb.PutRequest),
reqUpdateError: make(chan chan struct{}),
onClose: make(chan struct{}),
onClosed: make(chan struct{}),
}
go it.fetch()
return it
}
// Invariant: fetch is the only thread with access to send requests to the
// server's iterator. This is needed because iterators are not thread safe and
// the server expects the client (us) to only ever issue one request at a time
// for a given iterator id.
func (it *iterator) fetch() {
defer func() {
resp, err := it.db.client.IteratorRelease(context.Background(), &rpcdbpb.IteratorReleaseRequest{
Id: it.id,
})
if err != nil {
it.setError(err)
} else {
it.setError(ErrEnumToError[resp.Err])
}
close(it.fetchedData)
close(it.onClosed)
}()
for {
resp, err := it.db.client.IteratorNext(context.Background(), &rpcdbpb.IteratorNextRequest{
Id: it.id,
})
if err != nil {
it.setError(err)
return
}
if len(resp.Data) == 0 {
return
}
for {
select {
case it.fetchedData <- resp.Data:
case onUpdated := <-it.reqUpdateError:
it.updateError()
close(onUpdated)
continue
case <-it.onClose:
return
}
break
}
}
}
// Next attempts to move the iterator to the next element and returns if this
// succeeded
func (it *iterator) Next() bool {
if it.db.closed.Get() {
it.data = nil
it.setError(database.ErrClosed)
return false
}
if len(it.data) > 1 {
it.data[0] = nil
it.data = it.data[1:]
return true
}
it.data = <-it.fetchedData
return len(it.data) > 0
}
// Error returns any that occurred while iterating
func (it *iterator) Error() error {
if err := it.getError(); err != nil {
return err
}
onUpdated := make(chan struct{})
select {
case it.reqUpdateError <- onUpdated:
<-onUpdated
case <-it.onClosed:
}
return it.getError()
}
// Key returns the key of the current element
func (it *iterator) Key() []byte {
if len(it.data) == 0 {
return nil
}
return it.data[0].Key
}
// Value returns the value of the current element
func (it *iterator) Value() []byte {
if len(it.data) == 0 {
return nil
}
return it.data[0].Value
}
// Release frees any resources held by the iterator
func (it *iterator) Release() {
it.once.Do(func() {
close(it.onClose)
<-it.onClosed
})
}
func (it *iterator) updateError() {
resp, err := it.db.client.IteratorError(context.Background(), &rpcdbpb.IteratorErrorRequest{
Id: it.id,
})
if err != nil {
it.setError(err)
} else {
it.setError(ErrEnumToError[resp.Err])
}
}
func (it *iterator) setError(err error) {
if err == nil {
return
}
it.errLock.Lock()
defer it.errLock.Unlock()
if it.err == nil {
it.err = err
}
}
func (it *iterator) getError() error {
it.errLock.RLock()
defer it.errLock.RUnlock()
return it.err
}
-199
View File
@@ -1,199 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpcdb
import (
"context"
"encoding/json"
"errors"
"sync"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/luxfi/constants"
"github.com/luxfi/database"
rpcdbpb "github.com/luxfi/node/proto/pb/rpcdb"
)
const iterationBatchSize = 128 * constants.KiB
var errUnknownIterator = errors.New("unknown iterator")
// DatabaseServer is a database that is managed over RPC.
type DatabaseServer struct {
rpcdbpb.UnsafeDatabaseServer
db database.Database
// iteratorLock protects [nextIteratorID] and [iterators] from concurrent
// modifications. Similarly to [batchLock], [iteratorLock] does not protect
// the actual Iterator. Iterators are documented as not being safe for
// concurrent use. Therefore, it is up to the client to respect this
// invariant.
iteratorLock sync.RWMutex
nextIteratorID uint64
iterators map[uint64]database.Iterator
}
// NewServer returns a database instance that is managed remotely
func NewServer(db database.Database) *DatabaseServer {
return &DatabaseServer{
db: db,
iterators: make(map[uint64]database.Iterator),
}
}
// Has delegates the Has call to the managed database and returns the result
func (db *DatabaseServer) Has(_ context.Context, req *rpcdbpb.HasRequest) (*rpcdbpb.HasResponse, error) {
has, err := db.db.Has(req.Key)
return &rpcdbpb.HasResponse{
Has: has,
Err: ErrorToErrEnum[err],
}, ErrorToRPCError(err)
}
// Get delegates the Get call to the managed database and returns the result
func (db *DatabaseServer) Get(_ context.Context, req *rpcdbpb.GetRequest) (*rpcdbpb.GetResponse, error) {
value, err := db.db.Get(req.Key)
return &rpcdbpb.GetResponse{
Value: value,
Err: ErrorToErrEnum[err],
}, ErrorToRPCError(err)
}
// Put delegates the Put call to the managed database and returns the result
func (db *DatabaseServer) Put(_ context.Context, req *rpcdbpb.PutRequest) (*rpcdbpb.PutResponse, error) {
err := db.db.Put(req.Key, req.Value)
return &rpcdbpb.PutResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
}
// Delete delegates the Delete call to the managed database and returns the
// result
func (db *DatabaseServer) Delete(_ context.Context, req *rpcdbpb.DeleteRequest) (*rpcdbpb.DeleteResponse, error) {
err := db.db.Delete(req.Key)
return &rpcdbpb.DeleteResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
}
// Compact delegates the Compact call to the managed database and returns the
// result
func (db *DatabaseServer) Compact(_ context.Context, req *rpcdbpb.CompactRequest) (*rpcdbpb.CompactResponse, error) {
err := db.db.Compact(req.Start, req.Limit)
return &rpcdbpb.CompactResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
}
// Close delegates the Close call to the managed database and returns the result
func (db *DatabaseServer) Close(context.Context, *rpcdbpb.CloseRequest) (*rpcdbpb.CloseResponse, error) {
err := db.db.Close()
return &rpcdbpb.CloseResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
}
// HealthCheck performs a heath check against the underlying database.
func (db *DatabaseServer) HealthCheck(ctx context.Context, _ *emptypb.Empty) (*rpcdbpb.HealthCheckResponse, error) {
health, err := db.db.HealthCheck(ctx)
if err != nil {
return &rpcdbpb.HealthCheckResponse{}, err
}
details, err := json.Marshal(health)
return &rpcdbpb.HealthCheckResponse{
Details: details,
}, err
}
// WriteBatch takes in a set of key-value pairs and atomically writes them to
// the internal database
func (db *DatabaseServer) WriteBatch(_ context.Context, req *rpcdbpb.WriteBatchRequest) (*rpcdbpb.WriteBatchResponse, error) {
batch := db.db.NewBatch()
for _, put := range req.Puts {
if err := batch.Put(put.Key, put.Value); err != nil {
return &rpcdbpb.WriteBatchResponse{
Err: ErrorToErrEnum[err],
}, ErrorToRPCError(err)
}
}
for _, del := range req.Deletes {
if err := batch.Delete(del.Key); err != nil {
return &rpcdbpb.WriteBatchResponse{
Err: ErrorToErrEnum[err],
}, ErrorToRPCError(err)
}
}
err := batch.Write()
return &rpcdbpb.WriteBatchResponse{
Err: ErrorToErrEnum[err],
}, ErrorToRPCError(err)
}
// NewIteratorWithStartAndPrefix allocates an iterator and returns the iterator
// ID
func (db *DatabaseServer) NewIteratorWithStartAndPrefix(_ context.Context, req *rpcdbpb.NewIteratorWithStartAndPrefixRequest) (*rpcdbpb.NewIteratorWithStartAndPrefixResponse, error) {
it := db.db.NewIteratorWithStartAndPrefix(req.Start, req.Prefix)
db.iteratorLock.Lock()
defer db.iteratorLock.Unlock()
id := db.nextIteratorID
db.iterators[id] = it
db.nextIteratorID++
return &rpcdbpb.NewIteratorWithStartAndPrefixResponse{Id: id}, nil
}
// IteratorNext attempts to call next on the requested iterator
func (db *DatabaseServer) IteratorNext(_ context.Context, req *rpcdbpb.IteratorNextRequest) (*rpcdbpb.IteratorNextResponse, error) {
db.iteratorLock.RLock()
it, exists := db.iterators[req.Id]
db.iteratorLock.RUnlock()
if !exists {
return nil, errUnknownIterator
}
var (
size int
data []*rpcdbpb.PutRequest
)
for size < iterationBatchSize && it.Next() {
key := it.Key()
value := it.Value()
size += len(key) + len(value)
data = append(data, &rpcdbpb.PutRequest{
Key: key,
Value: value,
})
}
return &rpcdbpb.IteratorNextResponse{Data: data}, nil
}
// IteratorError attempts to report any errors that occurred during iteration
func (db *DatabaseServer) IteratorError(_ context.Context, req *rpcdbpb.IteratorErrorRequest) (*rpcdbpb.IteratorErrorResponse, error) {
db.iteratorLock.RLock()
it, exists := db.iterators[req.Id]
db.iteratorLock.RUnlock()
if !exists {
return nil, errUnknownIterator
}
err := it.Error()
return &rpcdbpb.IteratorErrorResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
}
// IteratorRelease attempts to release the resources allocated to an iterator
func (db *DatabaseServer) IteratorRelease(_ context.Context, req *rpcdbpb.IteratorReleaseRequest) (*rpcdbpb.IteratorReleaseResponse, error) {
db.iteratorLock.Lock()
it, exists := db.iterators[req.Id]
if !exists {
db.iteratorLock.Unlock()
return &rpcdbpb.IteratorReleaseResponse{}, nil
}
delete(db.iterators, req.Id)
db.iteratorLock.Unlock()
err := it.Error()
it.Release()
return &rpcdbpb.IteratorReleaseResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
}
-144
View File
@@ -1,144 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpcdb
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/database/corruptabledb"
"github.com/luxfi/database/dbtest"
"github.com/luxfi/database/memdb"
"github.com/luxfi/log"
"github.com/luxfi/vm/rpc/grpcutils"
rpcdbpb "github.com/luxfi/node/proto/pb/rpcdb"
)
type testDatabase struct {
client *DatabaseClient
server *memdb.Database
}
func setupDB(t testing.TB) *testDatabase {
require := require.New(t)
db := &testDatabase{
server: memdb.New(),
}
listener, err := grpcutils.NewListener()
require.NoError(err)
serverCloser := grpcutils.ServerCloser{}
server := grpcutils.NewServer()
rpcdbpb.RegisterDatabaseServer(server, NewServer(db.server))
serverCloser.Add(server)
go grpcutils.Serve(listener, server)
conn, err := grpcutils.Dial(listener.Addr().String())
require.NoError(err)
db.client = NewClient(rpcdbpb.NewDatabaseClient(conn))
t.Cleanup(func() {
serverCloser.Stop()
_ = conn.Close()
_ = listener.Close()
})
return db
}
func TestInterface(t *testing.T) {
for name, test := range dbtest.Tests {
t.Run(name, func(t *testing.T) {
db := setupDB(t)
test(t, db.client)
})
}
}
func FuzzKeyValue(f *testing.F) {
db := setupDB(f)
dbtest.FuzzKeyValue(f, db.client)
}
func FuzzNewIteratorWithPrefix(f *testing.F) {
db := setupDB(f)
dbtest.FuzzNewIteratorWithPrefix(f, db.client)
}
func FuzzNewIteratorWithStartAndPrefix(f *testing.F) {
db := setupDB(f)
dbtest.FuzzNewIteratorWithStartAndPrefix(f, db.client)
}
func BenchmarkInterface(b *testing.B) {
for _, size := range dbtest.BenchmarkSizes {
keys, values := dbtest.SetupBenchmark(b, size[0], size[1], size[2])
for name, bench := range dbtest.Benchmarks {
b.Run(fmt.Sprintf("rpcdb_%d_pairs_%d_keys_%d_values_%s", size[0], size[1], size[2], name), func(b *testing.B) {
db := setupDB(b)
bench(b, db.client, keys, values)
})
}
}
}
func TestHealthCheck(t *testing.T) {
scenarios := []struct {
name string
testDatabase *testDatabase
testFn func(db *corruptabledb.Database) error
wantErr bool
wantErrMsg string
}{
{
name: "healthcheck success",
testDatabase: setupDB(t),
testFn: func(_ *corruptabledb.Database) error {
return nil
},
},
{
name: "healthcheck failed db closed",
testDatabase: setupDB(t),
testFn: func(db *corruptabledb.Database) error {
return db.Close()
},
wantErr: true,
wantErrMsg: "closed",
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
require := require.New(t)
baseDB := setupDB(t)
db := corruptabledb.New(baseDB.server, log.NoLog{})
defer db.Close()
require.NoError(scenario.testFn(db))
// check db HealthCheck
_, err := db.HealthCheck(context.Background())
if scenario.wantErr {
require.Error(err) //nolint:forbidigo
require.Contains(err.Error(), scenario.wantErrMsg)
return
}
require.NoError(err)
// check rpc HealthCheck
_, err = baseDB.client.HealthCheck(context.Background())
require.NoError(err)
})
}
}
-30
View File
@@ -1,30 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpcdb
import (
"github.com/luxfi/database"
rpcdbpb "github.com/luxfi/node/proto/pb/rpcdb"
)
var (
ErrEnumToError = map[rpcdbpb.Error]error{
rpcdbpb.Error_ERROR_CLOSED: database.ErrClosed,
rpcdbpb.Error_ERROR_NOT_FOUND: database.ErrNotFound,
}
ErrorToErrEnum = map[error]rpcdbpb.Error{
database.ErrClosed: rpcdbpb.Error_ERROR_CLOSED,
database.ErrNotFound: rpcdbpb.Error_ERROR_NOT_FOUND,
}
)
func ErrorToRPCError(err error) error {
if _, ok := ErrorToErrEnum[err]; ok {
return nil
}
return err
}
@@ -1,57 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package galiasreader
import (
"context"
"github.com/luxfi/ids"
aliasreaderpb "github.com/luxfi/node/proto/pb/aliasreader"
)
var _ ids.AliaserReader = (*Client)(nil)
// Client implements alias lookups that talk over RPC.
type Client struct {
client aliasreaderpb.AliasReaderClient
}
// NewClient returns an alias lookup instance connected to a remote alias lookup
// instance
func NewClient(client aliasreaderpb.AliasReaderClient) *Client {
return &Client{client: client}
}
func (c *Client) Lookup(alias string) (ids.ID, error) {
resp, err := c.client.Lookup(context.Background(), &aliasreaderpb.Alias{
Alias: alias,
})
if err != nil {
return ids.Empty, err
}
return ids.ToID(resp.Id)
}
func (c *Client) PrimaryAlias(id ids.ID) (string, error) {
resp, err := c.client.PrimaryAlias(context.Background(), &aliasreaderpb.ID{
Id: id[:],
})
if err != nil {
return "", err
}
return resp.Alias, nil
}
func (c *Client) Aliases(id ids.ID) ([]string, error) {
resp, err := c.client.Aliases(context.Background(), &aliasreaderpb.ID{
Id: id[:],
})
if err != nil {
return nil, err
}
return resp.Aliases, nil
}
@@ -1,74 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package galiasreader
import (
"context"
"fmt"
"github.com/luxfi/ids"
aliasreaderpb "github.com/luxfi/node/proto/pb/aliasreader"
)
var _ aliasreaderpb.AliasReaderServer = (*Server)(nil)
// Server enables alias lookups over RPC.
type Server struct {
aliasreaderpb.UnsafeAliasReaderServer
aliaser ids.AliaserReader
}
// NewServer returns an alias lookup connected to a remote alias lookup
func NewServer(aliaser ids.AliaserReader) *Server {
return &Server{aliaser: aliaser}
}
func (s *Server) Lookup(
_ context.Context,
req *aliasreaderpb.Alias,
) (*aliasreaderpb.ID, error) {
id, err := s.aliaser.Lookup(req.Alias)
if err != nil {
return nil, err
}
return &aliasreaderpb.ID{
Id: id[:],
}, nil
}
func (s *Server) PrimaryAlias(
_ context.Context,
req *aliasreaderpb.ID,
) (*aliasreaderpb.Alias, error) {
if s.aliaser == nil {
return nil, fmt.Errorf("aliaser is nil - BCLookup not configured for this chain")
}
id, err := ids.ToID(req.Id)
if err != nil {
return nil, err
}
alias, err := s.aliaser.PrimaryAlias(id)
return &aliasreaderpb.Alias{
Alias: alias,
}, err
}
func (s *Server) Aliases(
_ context.Context,
req *aliasreaderpb.ID,
) (*aliasreaderpb.AliasList, error) {
id, err := ids.ToID(req.Id)
if err != nil {
return nil, err
}
aliases, err := s.aliaser.Aliases(id)
return &aliasreaderpb.AliasList{
Aliases: aliases,
}, err
}
@@ -1,46 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package galiasreader
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
"github.com/luxfi/ids/idstest"
"github.com/luxfi/vm/rpc/grpcutils"
aliasreaderpb "github.com/luxfi/node/proto/pb/aliasreader"
)
func TestInterface(t *testing.T) {
for _, test := range idstest.AliasTests {
t.Run(test.Name, func(t *testing.T) {
require := require.New(t)
listener, err := grpcutils.NewListener()
require.NoError(err)
defer listener.Close()
serverCloser := grpcutils.ServerCloser{}
defer serverCloser.Stop()
w := ids.NewAliaser()
server := grpcutils.NewServer()
aliasreaderpb.RegisterAliasReaderServer(server, NewServer(w))
serverCloser.Add(server)
go grpcutils.Serve(listener, server)
conn, err := grpcutils.Dial(listener.Addr().String())
require.NoError(err)
defer conn.Close()
r := NewClient(aliasreaderpb.NewAliasReaderClient(conn))
test.Test(t, r, w)
})
}
}
-18
View File
@@ -1,18 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package message
import "github.com/luxfi/node/proto/p2p"
// newMessageBFT creates a BFT message wrapper (gRPC version - uses Simplex)
func newMessageBFT(msg *p2p.BFT) *p2p.Message_BFT {
return &p2p.Message_BFT{Simplex: msg}
}
// extractBFT extracts the BFT message from the wrapper (gRPC version - uses Simplex)
func extractBFT(msg *p2p.Message_BFT) *p2p.BFT {
return msg.Simplex
}
+2 -4
View File
@@ -1,5 +1,3 @@
//go:build !grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
@@ -7,12 +5,12 @@ package message
import "github.com/luxfi/node/proto/p2p"
// newMessageBFT creates a BFT message wrapper (ZAP version)
// newMessageBFT creates a BFT message wrapper.
func newMessageBFT(msg *p2p.BFT) *p2p.Message_BFT {
return &p2p.Message_BFT{BFT: msg}
}
// extractBFT extracts the BFT message from the wrapper (ZAP version)
// extractBFT extracts the BFT message from the wrapper.
func extractBFT(msg *p2p.Message_BFT) *p2p.BFT {
return msg.BFT
}
@@ -286,18 +286,18 @@ func (mr *OutboundMsgBuilderMockRecorder) GetStateSummaryFrontier(chainID, reque
}
// Handshake mocks base method.
func (m *OutboundMsgBuilder) Handshake(networkID uint32, myTime uint64, ip netip.AddrPort, client string, major, minor, patch uint32, ipSigningTime uint64, ipNodeIDSig, ipBLSSig []byte, trackedNets []ids.ID, supportedLPs, objectedLPs []uint32, knownPeersFilter, knownPeersSalt []byte, requestAllNetIPs bool) (message.OutboundMessage, error) {
func (m *OutboundMsgBuilder) Handshake(networkID uint32, myTime uint64, ip netip.AddrPort, client string, major, minor, patch uint32, ipSigningTime uint64, ipNodeIDSig, ipBLSSig []byte, trackedNets []ids.ID, supportedLPs, objectedLPs []uint32, knownPeersFilter, knownPeersSalt []byte, requestAllNetIPs bool, ipMLDSASig []byte) (message.OutboundMessage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handshake", networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs)
ret := m.ctrl.Call(m, "Handshake", networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs, ipMLDSASig)
ret0, _ := ret[0].(message.OutboundMessage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handshake indicates an expected call of Handshake.
func (mr *OutboundMsgBuilderMockRecorder) Handshake(networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs any) *gomock.Call {
func (mr *OutboundMsgBuilderMockRecorder) Handshake(networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs, ipMLDSASig any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handshake", reflect.TypeOf((*OutboundMsgBuilder)(nil).Handshake), networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handshake", reflect.TypeOf((*OutboundMsgBuilder)(nil).Handshake), networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs, ipMLDSASig)
}
// PeerList mocks base method.
+5 -2
View File
@@ -36,6 +36,7 @@ type OutboundMsgBuilder interface {
knownPeersFilter []byte,
knownPeersSalt []byte,
requestAllNetIPs bool,
ipMLDSASig []byte,
) (OutboundMessage, error)
GetPeerList(
@@ -258,6 +259,7 @@ func (b *outMsgBuilder) Handshake(
knownPeersFilter []byte,
knownPeersSalt []byte,
requestAllNetIPs bool,
ipMLDSASig []byte,
) (OutboundMessage, error) {
subsubchainIDBytes := make([][]byte, len(trackedNets))
encodeIDs(trackedNets, subsubchainIDBytes)
@@ -284,8 +286,9 @@ func (b *outMsgBuilder) Handshake(
Filter: knownPeersFilter,
Salt: knownPeersSalt,
},
IpBlsSig: ipBLSSig,
AllChains: requestAllNetIPs,
IpBlsSig: ipBLSSig,
AllChains: requestAllNetIPs,
IpMldsaSig: ipMLDSASig,
},
},
},
+2 -2
View File
@@ -52,7 +52,7 @@ type Ping struct {
// ChainPingEntry is the per-chain payload in Ping/Pong.
// In Lux's L1/L2 model each chain is its own validator set, so the legacy
// (chain, subnet) pair collapses to a single chain identifier.
// (chain, network) pair collapses to a single chain identifier.
type ChainPingEntry struct {
ChainId []byte
}
@@ -71,7 +71,7 @@ type Handshake struct {
IpPort uint32
IpSigningTime uint64
IpNodeIdSig []byte
TrackedSubnets [][]byte
TrackedChains [][]byte
Client *Client
SupportedAcps []uint32
ObjectedAcps []uint32
+2 -2
View File
@@ -418,7 +418,7 @@ func marshalHandshake(b *Buffer, m *Handshake) {
b.WriteUint32(m.IpPort)
b.WriteUint64(m.IpSigningTime)
b.WriteBytes(m.IpNodeIdSig)
b.WriteBytesSlice(m.TrackedSubnets)
b.WriteBytesSlice(m.TrackedChains)
if m.Client != nil {
b.WriteUint8(1)
b.WriteString(m.Client.Name)
@@ -674,7 +674,7 @@ func unmarshalHandshake(r *Reader) (*Handshake, error) {
if err != nil {
return nil, err
}
m.TrackedSubnets, err = r.ReadBytesSlice()
m.TrackedChains, err = r.ReadBytesSlice()
if err != nil {
return nil, err
}
+11
View File
@@ -9,6 +9,7 @@ import (
"net/netip"
"time"
consensusconfig "github.com/luxfi/consensus/config"
consensustracker "github.com/luxfi/consensus/networking/tracker"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/uptime"
@@ -197,4 +198,14 @@ type Config struct {
// Specifies how much disk usage each peer can cause before
// we rate-limit them.
DiskTargeter tracker.Targeter `json:"-"`
// SecurityProfile is the chain-wide ChainSecurityProfile this node is
// operating under (resolved at boot from the genesis pin in
// node.Node.initSecurityProfile). When non-nil, the network upgrader
// builds a peer.SchemeGate from it and refuses any inbound or
// outbound TLS connection whose wire NodeIDScheme is not admissible
// under the profile. nil leaves the cross-axis gate disabled (chains
// that ship no profile remain accepted by the upgrader's nil-safe
// path).
SecurityProfile *consensusconfig.ChainSecurityProfile `json:"-"`
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kem
import (
"crypto/cipher"
"errors"
"fmt"
"golang.org/x/crypto/chacha20poly1305"
)
// AEADID is the wire byte identifying the authenticated cipher used on
// post-handshake frames. Only FIPS-approved primitives are admitted; the
// production default is ChaCha20-Poly1305 (RFC 8439) because the Lux
// strict-PQ profile pairs lattice KEM with a stream cipher that is not
// vulnerable to the same lattice attack surface.
//
// Numbering:
//
// 0x00 — None (rejected)
// 0x01 — ChaCha20-Poly1305 (RFC 8439, FIPS 140 module-approved)
type AEADID uint8
const (
AEADNone AEADID = 0x00
AEADChaCha20Poly1305 AEADID = 0x01
)
// String returns the canonical wire name.
func (a AEADID) String() string {
switch a {
case AEADNone:
return "none"
case AEADChaCha20Poly1305:
return "chacha20-poly1305"
default:
return fmt.Sprintf("aead(0x%02x)", uint8(a))
}
}
// ErrUnsupportedAEAD is returned by NewAEAD when an unknown / forbidden
// AEAD byte is requested.
var ErrUnsupportedAEAD = errors.New("kem: unsupported AEAD")
// NewAEAD constructs a cipher.AEAD bound to key. Caller produces key via
// KEMSession.DeriveAEADKey or KEMSession.DeriveDKGAEADKey. Returns
// ErrUnsupportedAEAD for any byte other than AEADChaCha20Poly1305.
//
// This is the single dispatch point that maps an AEADID byte to a
// concrete cipher; transport code routes through it instead of importing
// chacha20poly1305 directly so a future AEAD addition (e.g. AES-256-GCM
// at 0x02) lands in one place.
func NewAEAD(id AEADID, key []byte) (cipher.AEAD, error) {
switch id {
case AEADChaCha20Poly1305:
if len(key) != chacha20poly1305.KeySize {
return nil, fmt.Errorf("kem: ChaCha20-Poly1305 key size=%d want=%d",
len(key), chacha20poly1305.KeySize)
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, fmt.Errorf("kem: chacha20poly1305.New: %w", err)
}
return aead, nil
default:
return nil, fmt.Errorf("%w: id=%s", ErrUnsupportedAEAD, id)
}
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kem
import (
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
)
// TestNewAEAD_RoundTrip exercises the end-to-end "derive key → encrypt →
// decrypt" pipeline using ChaCha20-Poly1305 over a KEM-derived key.
// This is the actual code path a peer takes after a successful PQ
// handshake.
func TestNewAEAD_RoundTrip(t *testing.T) {
require := require.New(t)
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
require.NoError(err)
transcript := []byte("aead-roundtrip-transcript")
initSess, ct, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, transcript)
require.NoError(err)
respSess, err := RespondKEMSession(KeyExchangeMLKEM768, priv, ct, transcript)
require.NoError(err)
initKey := initSess.DeriveAEADKey()
respKey := respSess.DeriveAEADKey()
require.Equal(initKey, respKey)
initAEAD, err := NewAEAD(AEADChaCha20Poly1305, initKey[:])
require.NoError(err)
respAEAD, err := NewAEAD(AEADChaCha20Poly1305, respKey[:])
require.NoError(err)
nonce := make([]byte, initAEAD.NonceSize())
_, err = rand.Read(nonce)
require.NoError(err)
plaintext := []byte("hello, lux strict-PQ peer")
aad := []byte("lux-node/v1/peer-frame")
ctOut := initAEAD.Seal(nil, nonce, plaintext, aad)
require.NotEqual(plaintext, ctOut)
got, err := respAEAD.Open(nil, nonce, ctOut, aad)
require.NoError(err)
require.Equal(plaintext, got)
}
// TestNewAEAD_RefusesUnknown asserts unknown AEAD bytes are refused with
// the precise ErrUnsupportedAEAD error.
func TestNewAEAD_RefusesUnknown(t *testing.T) {
require := require.New(t)
_, err := NewAEAD(AEADNone, make([]byte, 32))
require.ErrorIs(err, ErrUnsupportedAEAD)
_, err = NewAEAD(AEADID(0xAA), make([]byte, 32))
require.ErrorIs(err, ErrUnsupportedAEAD)
}
// TestNewAEAD_RefusesBadKeySize asserts a mis-sized key is rejected
// before crypto runs.
func TestNewAEAD_RefusesBadKeySize(t *testing.T) {
require := require.New(t)
_, err := NewAEAD(AEADChaCha20Poly1305, make([]byte, 16))
require.Error(err)
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kem_test
import (
"testing"
"github.com/luxfi/consensus/config"
"github.com/luxfi/node/network/kem"
)
// TestKEMSchemeIDs_AllUseCanonicalNumbering confirms the node's KEM
// package re-exports config.KeyExchangeID byte-for-byte. This anchors
// the Bug 3 fix on the node side: before the fix, this package declared
// ML-KEM-768=0x62 and ML-KEM-1024=0x63, in disagreement with config's
// 0x01 / 0x02. Aliasing brings every wire byte to a single source of
// truth.
func TestKEMSchemeIDs_AllUseCanonicalNumbering(t *testing.T) {
cases := []struct {
name string
node kem.KeyExchangeID
canon config.KeyExchangeID
wantHex uint8
}{
{"ml-kem-768", kem.KeyExchangeMLKEM768, config.KeyExchangeMLKEM768, 0x01},
{"ml-kem-1024", kem.KeyExchangeMLKEM1024, config.KeyExchangeMLKEM1024, 0x02},
{"x25519-unsafe", kem.KeyExchangeX25519Unsafe, config.KeyExchangeX25519Unsafe, 0x90},
{"none", kem.KeyExchangeNone, config.KeyExchangeInvalid, 0x00},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if c.node != c.canon {
t.Errorf("node/kem.%s and config.%s diverge: node=0x%02x config=0x%02x",
c.name, c.name, uint8(c.node), uint8(c.canon))
}
if uint8(c.node) != c.wantHex {
t.Errorf("node/kem.%s wire byte = 0x%02x, want 0x%02x", c.name, uint8(c.node), c.wantHex)
}
})
}
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kem
import (
"errors"
"fmt"
)
// DKGChannelScheme is the only KeyExchangeID admissible on a validator-to-
// validator DKG channel. Pinned at KeyExchangeMLKEM1024 (NIST PQ Cat 5) so
// the Pulsar / Pulsar-M Pedersen DKG over R_q runs under the strongest
// FIPS 203 parameter set available.
//
// Anyone constructing a DKG session in pulsar / pulsar-m MUST go through
// NewDKGKEMSession (or assert this constant explicitly) so a future
// refactor that silently flips the byte to ML-KEM-768 fails the strict-PQ
// profile gate rather than silently downgrading.
const DKGChannelScheme = KeyExchangeMLKEM1024
// ErrDKGSchemeMismatch is returned by NewDKGKEMSession when the caller
// supplies a scheme other than DKGChannelScheme. A strict-PQ profile that
// declares HighValueKEM=ML-KEM-1024 but observes a DKG attempt under
// ML-KEM-768 MUST surface this error to the operator — the scheme
// mismatch is a configuration drift, not a tactical preference.
var ErrDKGSchemeMismatch = errors.New("kem: DKG channels MUST use ML-KEM-1024")
// InitiateDKGKEMSession is the DKG-channel-flavoured wrapper around
// InitiateKEMSession. The scheme axis is fixed at ML-KEM-1024; supplying
// any other scheme returns ErrDKGSchemeMismatch before any KEM work
// runs.
//
// The returned KEMSession is otherwise byte-identical to what
// InitiateKEMSession(MLKEM1024, ...) returns; downstream code may call
// DeriveDKGAEADKey on it to bind the AEAD key under the "NODE_DKG_V1"
// customisation.
func InitiateDKGKEMSession(
scheme KeyExchangeID,
peerKEMPub []byte,
transcript []byte,
) (*KEMSession, []byte, error) {
if scheme != DKGChannelScheme {
return nil, nil, fmt.Errorf("%w: got=%s", ErrDKGSchemeMismatch, scheme)
}
return InitiateKEMSession(scheme, peerKEMPub, transcript)
}
// RespondDKGKEMSession is the responder-side analogue of
// InitiateDKGKEMSession. Same scheme-pin rule applies.
func RespondDKGKEMSession(
scheme KeyExchangeID,
ourKEMSec []byte,
peerCiphertext []byte,
transcript []byte,
) (*KEMSession, error) {
if scheme != DKGChannelScheme {
return nil, fmt.Errorf("%w: got=%s", ErrDKGSchemeMismatch, scheme)
}
return RespondKEMSession(scheme, ourKEMSec, peerCiphertext, transcript)
}
// AssertDKGCompliance reports nil iff sessionScheme and profileHighValueKEM
// are both ML-KEM-1024. The strict-PQ profile cross-axis check: a profile
// that declares HighValueKEM=ML-KEM-1024 MUST observe ML-KEM-1024 on the
// wire; anything else is a configuration drift that audit tooling rejects
// at boot.
//
// This is the single dispatch point for the "DKG channel scheme matches
// profile high-value KEM" predicate; pulsar / pulsar-m callers route
// through it instead of comparing the bytes locally.
func AssertDKGCompliance(sessionScheme, profileHighValueKEM KeyExchangeID) error {
if profileHighValueKEM != DKGChannelScheme {
return fmt.Errorf("%w: profile HighValueKEM=%s, expected %s",
ErrDKGSchemeMismatch, profileHighValueKEM, DKGChannelScheme)
}
if sessionScheme != DKGChannelScheme {
return fmt.Errorf("%w: session scheme=%s, expected %s",
ErrDKGSchemeMismatch, sessionScheme, DKGChannelScheme)
}
return nil
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kem
import (
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
)
// TestDKGChannel_ForcesMLKEM1024 asserts the DKG channel adapter refuses
// any KEM other than ML-KEM-1024. This closes the cross-axis drift where
// a profile pins HighValueKEM=ML-KEM-1024 but a DKG attempt under
// ML-KEM-768 would otherwise succeed silently.
func TestDKGChannel_ForcesMLKEM1024(t *testing.T) {
require := require.New(t)
// DKGChannelScheme is the canonical constant; assert its byte value
// is ML-KEM-1024 so a future refactor can't silently flip it.
require.Equal(KeyExchangeMLKEM1024, DKGChannelScheme,
"DKGChannelScheme MUST be ML-KEM-1024")
pub1024, priv1024, err := GenerateKEMKeypair(KeyExchangeMLKEM1024, rand.Reader)
require.NoError(err)
transcript := []byte("dkg-channel-transcript")
// Happy path: ML-KEM-1024 succeeds end-to-end.
initSess, ct, err := InitiateDKGKEMSession(KeyExchangeMLKEM1024, pub1024, transcript)
require.NoError(err)
require.Equal(KeyExchangeMLKEM1024, initSess.SchemeID)
respSess, err := RespondDKGKEMSession(KeyExchangeMLKEM1024, priv1024, ct, transcript)
require.NoError(err)
require.Equal(initSess.SharedSecret, respSess.SharedSecret)
// DKG-flavour AEAD key MUST derive cleanly.
initKey, err := initSess.DeriveDKGAEADKey()
require.NoError(err)
respKey, err := respSess.DeriveDKGAEADKey()
require.NoError(err)
require.Equal(initKey, respKey)
// Bad path 1: caller passes ML-KEM-768 to the DKG adapter.
pub768, priv768, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
require.NoError(err)
_, _, err = InitiateDKGKEMSession(KeyExchangeMLKEM768, pub768, transcript)
require.ErrorIs(err, ErrDKGSchemeMismatch,
"DKG adapter MUST refuse ML-KEM-768")
_, err = RespondDKGKEMSession(KeyExchangeMLKEM768, priv768, make([]byte, 1088), transcript)
require.ErrorIs(err, ErrDKGSchemeMismatch)
// Bad path 2: caller passes a classical marker.
_, _, err = InitiateDKGKEMSession(KeyExchangeX25519Unsafe, pub1024, transcript)
require.ErrorIs(err, ErrDKGSchemeMismatch)
// Bad path 3: caller passes None.
_, _, err = InitiateDKGKEMSession(KeyExchangeNone, pub1024, transcript)
require.ErrorIs(err, ErrDKGSchemeMismatch)
}
// TestAssertDKGCompliance exercises the boot-time cross-axis check that
// a pulsar / pulsar-m DKG session's scheme byte agrees with the strict-PQ
// profile's HighValueKEM byte. Both must be ML-KEM-1024; anything else is
// a configuration drift.
func TestAssertDKGCompliance(t *testing.T) {
require := require.New(t)
// Happy path: both ML-KEM-1024.
require.NoError(AssertDKGCompliance(KeyExchangeMLKEM1024, KeyExchangeMLKEM1024))
// Profile high-value byte wrong.
require.ErrorIs(
AssertDKGCompliance(KeyExchangeMLKEM1024, KeyExchangeMLKEM768),
ErrDKGSchemeMismatch,
)
// Session byte wrong (profile correct).
require.ErrorIs(
AssertDKGCompliance(KeyExchangeMLKEM768, KeyExchangeMLKEM1024),
ErrDKGSchemeMismatch,
)
// Both wrong.
require.ErrorIs(
AssertDKGCompliance(KeyExchangeX25519Unsafe, KeyExchangeMLKEM768),
ErrDKGSchemeMismatch,
)
}
+431
View File
@@ -0,0 +1,431 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kem
import (
"crypto/rand"
"errors"
"fmt"
"io"
"github.com/cloudflare/circl/kem/mlkem/mlkem1024"
"github.com/cloudflare/circl/kem/mlkem/mlkem768"
"golang.org/x/crypto/sha3"
)
// Customization strings for the cSHAKE256 derivations performed by this
// package. Pinned at "_V1"; bumping the tag invalidates every prior derived
// key (which is the correct behaviour for a hard-fork of the encoding).
//
// NODE_AEAD_V1 — peer-to-peer session AEAD key
// NODE_DKG_V1 — DKG channel AEAD key (high-value, ML-KEM-1024 only)
// NODE_TRANSCRIPT_V1 — TupleHash256 customization for handshake transcript
const (
customizationAEAD = "NODE_AEAD_V1"
customizationDKGAEAD = "NODE_DKG_V1"
customizationTranscript = "NODE_TRANSCRIPT_V1"
// TranscriptHashSize is the byte width of the bound handshake
// transcript hash returned by HashTranscript and stored in KEMSession.
// 48 bytes = 384 bits matches HashSuiteSHA3NIST in consensus/config and
// the chain-wide profile ProfileHash width.
TranscriptHashSize = 48
// SharedSecretSize is the FIPS 203 ML-KEM shared-secret width in
// bytes. ML-KEM-768 and ML-KEM-1024 both emit 32 bytes (256 bits).
SharedSecretSize = 32
// AEADKeySize is the byte width of the derived AEAD session key.
// 32 bytes targets ChaCha20-Poly1305 or AES-256-GCM.
AEADKeySize = 32
)
// Errors returned by KEM session APIs. Each names exactly which contract
// was violated so callers can produce a precise diagnostic.
var (
// ErrUnsupportedScheme is returned when the requested KeyExchangeID is
// not a supported ML-KEM variant. Includes None and any forbidden
// classical marker.
ErrUnsupportedScheme = errors.New("kem: unsupported scheme")
// ErrClassicalKEMForbidden is returned when a strict-PQ caller receives
// or attempts to negotiate an explicit classical KEM marker
// (X25519Unsafe / P256Unsafe / P384Unsafe).
ErrClassicalKEMForbidden = errors.New("kem: classical KEM forbidden in strict-PQ mode")
// ErrBadCiphertextSize is returned when the encapsulated ciphertext is
// not the scheme's canonical CiphertextSize.
ErrBadCiphertextSize = errors.New("kem: ciphertext size does not match scheme")
// ErrBadPublicKeySize is returned when the peer's encapsulation key is
// not the scheme's canonical PublicKeySize.
ErrBadPublicKeySize = errors.New("kem: public key size does not match scheme")
// ErrBadPrivateKeySize is returned when the caller-supplied
// decapsulation key is not the scheme's canonical PrivateKeySize.
ErrBadPrivateKeySize = errors.New("kem: private key size does not match scheme")
// ErrEmptyTranscript is returned when transcript bytes are nil or
// empty. The whole point of transcript binding is that the AEAD key
// depends on every prior handshake message; a zero-length transcript
// is a contract violation.
ErrEmptyTranscript = errors.New("kem: transcript is empty")
)
// KEMSession is the result of one successful ML-KEM encapsulate /
// decapsulate. It carries the scheme byte, the raw 256-bit shared secret,
// and the 384-bit running transcript hash (cSHAKE256 / TupleHash256 over
// every handshake message produced before the KEM round, in canonical
// order). The AEAD key is derived from SharedSecret + TranscriptHash via
// cSHAKE256 customisation "NODE_AEAD_V1".
//
// Field invariants enforced by package APIs:
//
// - SchemeID ∈ {MLKEM768, MLKEM1024}; any other scheme returns
// ErrUnsupportedScheme at construction time.
// - SharedSecret is the raw FIPS 203 ML-KEM.Decapsulate / Encapsulate
// output. Length is always exactly SharedSecretSize.
// - TranscriptHash is the 48-byte SHA3-384 (cSHAKE256) commitment over
// the handshake transcript. Two distinct transcripts MUST produce
// two distinct TranscriptHash values.
//
// KEMSession does not zero its SharedSecret on drop because Go does not
// provide a deterministic destructor; callers that need defensive zeroing
// must do it explicitly after deriving the AEAD key.
type KEMSession struct {
// SchemeID is the FIPS 203 ML-KEM scheme this session was negotiated
// under. Strict-PQ peers admit only MLKEM768 (default) and MLKEM1024
// (high-value / DKG).
SchemeID KeyExchangeID
// SharedSecret is the 256-bit raw KEM output. Treat as confidential;
// derive subordinate keys through cSHAKE256 (see DeriveAEADKey), do
// not use directly as an AEAD key.
SharedSecret [SharedSecretSize]byte
// TranscriptHash is the 384-bit cSHAKE256 / TupleHash256 commitment to
// the handshake transcript at the point of KEM completion. The hash
// is BOUND into DeriveAEADKey so a flipped transcript byte produces a
// distinct AEAD key — that is, an attacker who downgrades a single
// handshake field gets a key the honest peer never derived.
TranscriptHash [TranscriptHashSize]byte
}
// HashTranscript returns the canonical cSHAKE256 / TupleHash256 commitment
// to msgs in the order they appear. Customization is
// "NODE_TRANSCRIPT_V1" so two different handshake protocols cannot
// produce the same hash from the same byte content.
//
// The encoding is SP 800-185 TupleHash256: each input is left-encoded with
// its bit-length prefix, the right-encoded total output bit-length is
// appended, and the result is fed to cSHAKE256 with the function-name
// "TupleHash" and customisation "NODE_TRANSCRIPT_V1". This matches
// the helper used by ChainSecurityProfile.ComputeHash, so the same
// transcript hash byte-encodes identically across the node and consensus.
func HashTranscript(msgs ...[]byte) [TranscriptHashSize]byte {
var x []byte
for _, msg := range msgs {
x = append(x, encodeStringSP800185(msg)...)
}
x = append(x, rightEncodeSP800185(uint64(TranscriptHashSize)*8)...)
h := sha3.NewCShake256([]byte("TupleHash"), []byte(customizationTranscript))
_, _ = h.Write(x)
out := make([]byte, TranscriptHashSize)
_, _ = h.Read(out)
var digest [TranscriptHashSize]byte
copy(digest[:], out)
return digest
}
// GenerateKEMKeypair returns a fresh (encapsulation-key, decapsulation-key)
// pair for scheme. randSrc may be nil, in which case crypto/rand.Reader is
// used. Returns ErrUnsupportedScheme for anything outside the supported
// ML-KEM family.
//
// Sizes:
//
// MLKEM768 pub=1184 priv=2400 bytes
// MLKEM1024 pub=1568 priv=3168 bytes
func GenerateKEMKeypair(scheme KeyExchangeID, randSrc io.Reader) (pub, priv []byte, err error) {
if randSrc == nil {
randSrc = rand.Reader
}
if scheme.IsForbiddenInPQMode() {
return nil, nil, fmt.Errorf("%w: scheme=%s", ErrClassicalKEMForbidden, scheme)
}
switch scheme {
case KeyExchangeMLKEM768:
ek, dk, kerr := mlkem768.GenerateKeyPair(randSrc)
if kerr != nil {
return nil, nil, fmt.Errorf("kem: mlkem768 GenerateKeyPair: %w", kerr)
}
pkBytes := make([]byte, mlkem768.PublicKeySize)
ek.Pack(pkBytes)
skBytes := make([]byte, mlkem768.PrivateKeySize)
dk.Pack(skBytes)
return pkBytes, skBytes, nil
case KeyExchangeMLKEM1024:
ek, dk, kerr := mlkem1024.GenerateKeyPair(randSrc)
if kerr != nil {
return nil, nil, fmt.Errorf("kem: mlkem1024 GenerateKeyPair: %w", kerr)
}
pkBytes := make([]byte, mlkem1024.PublicKeySize)
ek.Pack(pkBytes)
skBytes := make([]byte, mlkem1024.PrivateKeySize)
dk.Pack(skBytes)
return pkBytes, skBytes, nil
default:
return nil, nil, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
}
}
// PublicKeySize returns the canonical encapsulation-key byte width for
// scheme. Useful for fixed-width framing on the wire.
func PublicKeySize(scheme KeyExchangeID) (int, error) {
switch scheme {
case KeyExchangeMLKEM768:
return mlkem768.PublicKeySize, nil
case KeyExchangeMLKEM1024:
return mlkem1024.PublicKeySize, nil
}
return 0, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
}
// CiphertextSize returns the canonical KEM ciphertext byte width for
// scheme.
func CiphertextSize(scheme KeyExchangeID) (int, error) {
switch scheme {
case KeyExchangeMLKEM768:
return mlkem768.CiphertextSize, nil
case KeyExchangeMLKEM1024:
return mlkem1024.CiphertextSize, nil
}
return 0, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
}
// InitiateKEMSession performs the initiator side of a one-shot ML-KEM
// session establishment. peerKEMPub is the responder's encapsulation key
// (already received on the wire); transcript is the bound running hash
// over every handshake message produced before this call.
//
// Returns the resulting KEMSession plus the encapsulation ciphertext to
// send to the responder. The caller is responsible for binding the
// ciphertext into the post-KEM transcript before deriving subordinate
// keys (the cSHAKE256 customisation inside DeriveAEADKey hard-binds the
// transcript bytes the caller passes here).
func InitiateKEMSession(
scheme KeyExchangeID,
peerKEMPub []byte,
transcript []byte,
) (*KEMSession, []byte, error) {
if scheme.IsForbiddenInPQMode() {
return nil, nil, fmt.Errorf("%w: scheme=%s", ErrClassicalKEMForbidden, scheme)
}
if len(transcript) == 0 {
return nil, nil, ErrEmptyTranscript
}
switch scheme {
case KeyExchangeMLKEM768:
if len(peerKEMPub) != mlkem768.PublicKeySize {
return nil, nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
ErrBadPublicKeySize, scheme, mlkem768.PublicKeySize, len(peerKEMPub))
}
var pk mlkem768.PublicKey
if err := pk.Unpack(peerKEMPub); err != nil {
return nil, nil, fmt.Errorf("kem: mlkem768 Unpack pub: %w", err)
}
ct := make([]byte, mlkem768.CiphertextSize)
ssBuf := make([]byte, mlkem768.SharedKeySize)
pk.EncapsulateTo(ct, ssBuf, nil)
return finishSession(scheme, ssBuf, transcript), ct, nil
case KeyExchangeMLKEM1024:
if len(peerKEMPub) != mlkem1024.PublicKeySize {
return nil, nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
ErrBadPublicKeySize, scheme, mlkem1024.PublicKeySize, len(peerKEMPub))
}
var pk mlkem1024.PublicKey
if err := pk.Unpack(peerKEMPub); err != nil {
return nil, nil, fmt.Errorf("kem: mlkem1024 Unpack pub: %w", err)
}
ct := make([]byte, mlkem1024.CiphertextSize)
ssBuf := make([]byte, mlkem1024.SharedKeySize)
pk.EncapsulateTo(ct, ssBuf, nil)
return finishSession(scheme, ssBuf, transcript), ct, nil
default:
return nil, nil, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
}
}
// RespondKEMSession performs the responder side: decapsulate the
// initiator's ciphertext under the responder's decapsulation key and
// produce the shared session.
//
// transcript MUST be byte-identical to the transcript the initiator used.
// If the two sides disagree by even one byte, their derived AEAD keys
// diverge and the first encrypted frame fails authentication.
func RespondKEMSession(
scheme KeyExchangeID,
ourKEMSec []byte,
peerCiphertext []byte,
transcript []byte,
) (*KEMSession, error) {
if scheme.IsForbiddenInPQMode() {
return nil, fmt.Errorf("%w: scheme=%s", ErrClassicalKEMForbidden, scheme)
}
if len(transcript) == 0 {
return nil, ErrEmptyTranscript
}
switch scheme {
case KeyExchangeMLKEM768:
if len(ourKEMSec) != mlkem768.PrivateKeySize {
return nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
ErrBadPrivateKeySize, scheme, mlkem768.PrivateKeySize, len(ourKEMSec))
}
if len(peerCiphertext) != mlkem768.CiphertextSize {
return nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
ErrBadCiphertextSize, scheme, mlkem768.CiphertextSize, len(peerCiphertext))
}
var sk mlkem768.PrivateKey
if err := sk.Unpack(ourKEMSec); err != nil {
return nil, fmt.Errorf("kem: mlkem768 Unpack priv: %w", err)
}
ssBuf := make([]byte, mlkem768.SharedKeySize)
sk.DecapsulateTo(ssBuf, peerCiphertext)
return finishSession(scheme, ssBuf, transcript), nil
case KeyExchangeMLKEM1024:
if len(ourKEMSec) != mlkem1024.PrivateKeySize {
return nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
ErrBadPrivateKeySize, scheme, mlkem1024.PrivateKeySize, len(ourKEMSec))
}
if len(peerCiphertext) != mlkem1024.CiphertextSize {
return nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
ErrBadCiphertextSize, scheme, mlkem1024.CiphertextSize, len(peerCiphertext))
}
var sk mlkem1024.PrivateKey
if err := sk.Unpack(ourKEMSec); err != nil {
return nil, fmt.Errorf("kem: mlkem1024 Unpack priv: %w", err)
}
ssBuf := make([]byte, mlkem1024.SharedKeySize)
sk.DecapsulateTo(ssBuf, peerCiphertext)
return finishSession(scheme, ssBuf, transcript), nil
default:
return nil, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
}
}
// finishSession assembles a KEMSession from a raw KEM shared secret and a
// bound transcript. Internal helper; the public Initiate/Respond entry
// points dispatch through it so the SchemeID byte and the transcript
// commitment are written once, in one place.
func finishSession(scheme KeyExchangeID, ssBuf, transcript []byte) *KEMSession {
s := &KEMSession{SchemeID: scheme}
copy(s.SharedSecret[:], ssBuf)
s.TranscriptHash = HashTranscript(transcript)
return s
}
// DeriveAEADKey returns the 256-bit AEAD session key bound to this KEM
// session's shared secret AND its transcript hash. Uses cSHAKE256 with
// customisation "NODE_AEAD_V1"; the function name is "KEMDerive".
//
// Two sessions with the same SharedSecret but different TranscriptHash
// produce two different keys. Two sessions with the same TranscriptHash
// but different SharedSecret also produce two different keys. This is
// the property the strict-PQ profile depends on for downgrade resistance.
func (s *KEMSession) DeriveAEADKey() [AEADKeySize]byte {
h := sha3.NewCShake256([]byte("KEMDerive"), []byte(customizationAEAD))
_, _ = h.Write([]byte{byte(s.SchemeID)})
_, _ = h.Write(s.SharedSecret[:])
_, _ = h.Write(s.TranscriptHash[:])
out := make([]byte, AEADKeySize)
_, _ = h.Read(out)
var key [AEADKeySize]byte
copy(key[:], out)
return key
}
// DeriveDKGAEADKey returns the 256-bit AEAD session key bound to this KEM
// session under the DKG-channel customisation "NODE_DKG_V1". A DKG
// channel session is required by the strict-PQ profile to negotiate
// ML-KEM-1024; this function refuses to derive a key on any other scheme
// so a misconfigured caller fails loud at key derivation rather than
// silently downgrading.
//
// Returns ErrUnsupportedScheme if the session was negotiated under
// anything other than KeyExchangeMLKEM1024.
func (s *KEMSession) DeriveDKGAEADKey() ([AEADKeySize]byte, error) {
var zero [AEADKeySize]byte
if s.SchemeID != KeyExchangeMLKEM1024 {
return zero, fmt.Errorf("%w: DKG channels require ML-KEM-1024, got %s",
ErrUnsupportedScheme, s.SchemeID)
}
h := sha3.NewCShake256([]byte("KEMDerive"), []byte(customizationDKGAEAD))
_, _ = h.Write([]byte{byte(s.SchemeID)})
_, _ = h.Write(s.SharedSecret[:])
_, _ = h.Write(s.TranscriptHash[:])
out := make([]byte, AEADKeySize)
_, _ = h.Read(out)
var key [AEADKeySize]byte
copy(key[:], out)
return key, nil
}
// encodeStringSP800185 is the SP 800-185 §2.3 left_encode prefix + raw
// bytes. Used by HashTranscript; matches the helper in
// consensus/config/security_profile.go byte-for-byte so the two encodings
// produce identical transcript bytes for identical inputs.
func encodeStringSP800185(s []byte) []byte {
out := leftEncodeSP800185(uint64(len(s)) * 8)
out = append(out, s...)
return out
}
func leftEncodeSP800185(x uint64) []byte {
if x == 0 {
return []byte{0x01, 0x00}
}
var buf [8]byte
for i := 0; i < 8; i++ {
buf[7-i] = byte(x >> (8 * i))
}
i := 0
for i < 7 && buf[i] == 0 {
i++
}
out := make([]byte, 0, 9-i)
out = append(out, byte(8-i))
out = append(out, buf[i:]...)
return out
}
func rightEncodeSP800185(x uint64) []byte {
if x == 0 {
return []byte{0x00, 0x01}
}
var buf [8]byte
for i := 0; i < 8; i++ {
buf[7-i] = byte(x >> (8 * i))
}
i := 0
for i < 7 && buf[i] == 0 {
i++
}
out := make([]byte, 0, 9-i)
out = append(out, buf[i:]...)
out = append(out, byte(8-i))
return out
}
+284
View File
@@ -0,0 +1,284 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kem
import (
"bytes"
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
)
// TestKEMSession_MLKEM768_RoundTrip exercises one full Initiator/Responder
// dance under ML-KEM-768 and asserts both sides converge on the same
// shared secret and AEAD key. This is the production-default round-trip.
func TestKEMSession_MLKEM768_RoundTrip(t *testing.T) {
require := require.New(t)
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
require.NoError(err)
require.Len(pub, 1184, "ML-KEM-768 public key must be 1184 bytes (FIPS 203)")
transcript := []byte("test-transcript-mlkem768-roundtrip")
initSess, ct, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, transcript)
require.NoError(err)
require.NotNil(initSess)
require.Len(ct, 1088, "ML-KEM-768 ciphertext must be 1088 bytes (FIPS 203)")
require.Equal(KeyExchangeMLKEM768, initSess.SchemeID)
respSess, err := RespondKEMSession(KeyExchangeMLKEM768, priv, ct, transcript)
require.NoError(err)
require.NotNil(respSess)
require.Equal(initSess.SharedSecret, respSess.SharedSecret,
"initiator and responder MUST converge on the same shared secret")
require.Equal(initSess.TranscriptHash, respSess.TranscriptHash,
"identical transcripts MUST produce identical TranscriptHash")
initKey := initSess.DeriveAEADKey()
respKey := respSess.DeriveAEADKey()
require.Equal(initKey, respKey, "derived AEAD keys MUST match")
require.Len(initKey[:], AEADKeySize)
}
// TestKEMSession_MLKEM1024_RoundTrip is the equivalent round-trip under
// the high-value parameter set. Same convergence invariants, larger keys
// and ciphertexts.
func TestKEMSession_MLKEM1024_RoundTrip(t *testing.T) {
require := require.New(t)
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM1024, rand.Reader)
require.NoError(err)
require.Len(pub, 1568, "ML-KEM-1024 public key must be 1568 bytes (FIPS 203)")
transcript := []byte("test-transcript-mlkem1024-dkg-channel")
initSess, ct, err := InitiateKEMSession(KeyExchangeMLKEM1024, pub, transcript)
require.NoError(err)
require.Len(ct, 1568, "ML-KEM-1024 ciphertext must be 1568 bytes (FIPS 203)")
respSess, err := RespondKEMSession(KeyExchangeMLKEM1024, priv, ct, transcript)
require.NoError(err)
require.Equal(initSess.SharedSecret, respSess.SharedSecret)
require.Equal(initSess.TranscriptHash, respSess.TranscriptHash)
require.Equal(initSess.DeriveAEADKey(), respSess.DeriveAEADKey())
}
// TestKEMSession_DistinctTranscriptsDifferentKeys asserts the cross-axis
// security property: two KEM sessions built with the same shared-secret
// material but distinct transcripts MUST produce distinct AEAD keys. This
// is what makes the strict-PQ handshake downgrade-resistant: an attacker
// who can flip one transcript byte gets a key the honest peer never
// derived.
func TestKEMSession_DistinctTranscriptsDifferentKeys(t *testing.T) {
require := require.New(t)
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
require.NoError(err)
transcriptA := []byte("scenario-A-handshake-bytes-v1")
transcriptB := []byte("scenario-B-handshake-bytes-v1")
// Different transcripts, different ciphertexts (random KEM seed each call).
sessA, ctA, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, transcriptA)
require.NoError(err)
sessB, ctB, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, transcriptB)
require.NoError(err)
// Sanity: distinct sessions have distinct shared secrets (the KEM is
// randomized).
require.NotEqual(sessA.SharedSecret, sessB.SharedSecret)
// The distinguishing property under test: even if we had identical
// shared secrets, distinct transcripts produce distinct AEAD keys.
// Verify by constructing a synthetic session that holds sessA's secret
// under transcriptB's hash.
hybrid := &KEMSession{
SchemeID: sessA.SchemeID,
SharedSecret: sessA.SharedSecret,
TranscriptHash: sessB.TranscriptHash,
}
require.NotEqual(sessA.DeriveAEADKey(), hybrid.DeriveAEADKey(),
"same shared secret + different transcript MUST yield different AEAD keys")
// And mirror: same transcript + different shared secret yields different keys.
hybrid2 := &KEMSession{
SchemeID: sessA.SchemeID,
SharedSecret: sessB.SharedSecret,
TranscriptHash: sessA.TranscriptHash,
}
require.NotEqual(sessA.DeriveAEADKey(), hybrid2.DeriveAEADKey(),
"different shared secret + same transcript MUST yield different AEAD keys")
// Responder-side decapsulation under the wrong transcript: shared
// secret converges (KEM is transcript-free), but derived AEAD key
// diverges. Use ctA but pass transcriptB.
respWrong, err := RespondKEMSession(KeyExchangeMLKEM768, priv, ctA, transcriptB)
require.NoError(err)
require.Equal(sessA.SharedSecret, respWrong.SharedSecret,
"KEM decapsulation is transcript-free; shared secret still converges")
require.NotEqual(sessA.DeriveAEADKey(), respWrong.DeriveAEADKey(),
"AEAD key MUST diverge when transcripts disagree")
// Cross-traffic sanity: ctA != ctB (KEM is randomized).
require.False(bytes.Equal(ctA, ctB), "two encapsulations against the same pub MUST differ")
}
// TestKEMSession_RefusesClassicalKEM asserts strict-PQ peers refuse to
// accept the classical KEM marker. This is the wire-level enforcement of
// HIP-0077 "no classical primitives in strict-PQ mode". The canonical
// config.KeyExchangeID enum exposes a single classical marker
// (X25519Unsafe at 0x90); the P-256 / P-384 markers that used to live
// in this package have been collapsed onto X25519Unsafe in the
// canonical numbering (Bug 3 fix).
func TestKEMSession_RefusesClassicalKEM(t *testing.T) {
require := require.New(t)
scheme := KeyExchangeX25519Unsafe
t.Run(scheme.String(), func(t *testing.T) {
_, _, err := GenerateKEMKeypair(scheme, rand.Reader)
require.ErrorIs(err, ErrClassicalKEMForbidden)
_, _, err = InitiateKEMSession(scheme, nil, []byte("x"))
require.ErrorIs(err, ErrClassicalKEMForbidden)
_, err = RespondKEMSession(scheme, nil, nil, []byte("x"))
require.ErrorIs(err, ErrClassicalKEMForbidden)
})
}
// TestKEMSession_RefusesEmptyTranscript asserts the binding requirement:
// an empty transcript MUST fail at construction time, not silently bind
// to a zero-length tuple.
func TestKEMSession_RefusesEmptyTranscript(t *testing.T) {
require := require.New(t)
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
require.NoError(err)
_, _, err = InitiateKEMSession(KeyExchangeMLKEM768, pub, nil)
require.ErrorIs(err, ErrEmptyTranscript)
_, err = RespondKEMSession(KeyExchangeMLKEM768, priv, make([]byte, 1088), nil)
require.ErrorIs(err, ErrEmptyTranscript)
}
// TestKEMSession_RefusesBadSizes asserts mis-sized public keys, private
// keys, and ciphertexts are rejected with a precise error rather than
// silently truncated.
func TestKEMSession_RefusesBadSizes(t *testing.T) {
require := require.New(t)
_, _, err := InitiateKEMSession(KeyExchangeMLKEM768, make([]byte, 100), []byte("x"))
require.ErrorIs(err, ErrBadPublicKeySize)
_, err = RespondKEMSession(KeyExchangeMLKEM768, make([]byte, 100), make([]byte, 1088), []byte("x"))
require.ErrorIs(err, ErrBadPrivateKeySize)
_, priv, gerr := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
require.NoError(gerr)
_, err = RespondKEMSession(KeyExchangeMLKEM768, priv, make([]byte, 100), []byte("x"))
require.ErrorIs(err, ErrBadCiphertextSize)
}
// TestKEMSession_RefusesUnsupportedScheme asserts KeyExchangeNone and any
// unknown byte fail with ErrUnsupportedScheme at construction time. ML-KEM
// at NIST Cat 1 is no longer named in the canonical enum (below the
// strict-PQ floor); an arbitrary unknown byte stands in for it here.
func TestKEMSession_RefusesUnsupportedScheme(t *testing.T) {
require := require.New(t)
_, _, err := GenerateKEMKeypair(KeyExchangeNone, rand.Reader)
require.ErrorIs(err, ErrUnsupportedScheme)
// Arbitrary unallocated byte stands in for "scheme not in the canonical
// numbering" — the runtime cannot tell the difference between a retired
// ML-KEM-512 byte and a never-allocated value.
unknown := KeyExchangeID(0x7F)
_, _, err = GenerateKEMKeypair(unknown, rand.Reader)
require.ErrorIs(err, ErrUnsupportedScheme)
_, _, err = InitiateKEMSession(unknown, make([]byte, 800), []byte("x"))
require.ErrorIs(err, ErrUnsupportedScheme)
}
// TestHashTranscript_Deterministic asserts the TupleHash256 binding is
// deterministic and length-prefixed: rearranging the same byte content
// across two distinct messages produces a different hash.
func TestHashTranscript_Deterministic(t *testing.T) {
require := require.New(t)
h1 := HashTranscript([]byte("hello"), []byte("world"))
h2 := HashTranscript([]byte("hello"), []byte("world"))
require.Equal(h1, h2, "same inputs MUST produce same hash")
h3 := HashTranscript([]byte("helloworld"))
require.NotEqual(h1, h3, "TupleHash MUST distinguish ['hello','world'] from ['helloworld']")
h4 := HashTranscript([]byte("world"), []byte("hello"))
require.NotEqual(h1, h4, "TupleHash MUST be order-sensitive")
}
// TestDeriveDKGAEADKey_OnlyMLKEM1024 asserts the DKG-channel customisation
// refuses to derive a key for any scheme other than ML-KEM-1024. This
// closes the "high-value channel silently used Cat 3 instead of Cat 5"
// drift path.
func TestDeriveDKGAEADKey_OnlyMLKEM1024(t *testing.T) {
require := require.New(t)
pub, _, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
require.NoError(err)
sess, _, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, []byte("x"))
require.NoError(err)
_, err = sess.DeriveDKGAEADKey()
require.ErrorIs(err, ErrUnsupportedScheme,
"DKG AEAD derivation MUST refuse ML-KEM-768 sessions")
pub2, _, err := GenerateKEMKeypair(KeyExchangeMLKEM1024, rand.Reader)
require.NoError(err)
sess2, _, err := InitiateKEMSession(KeyExchangeMLKEM1024, pub2, []byte("x"))
require.NoError(err)
key, err := sess2.DeriveDKGAEADKey()
require.NoError(err)
require.Len(key[:], AEADKeySize)
// DKG key MUST differ from the peer-AEAD key derived from the same
// session (different customisation strings).
peerKey := sess2.DeriveAEADKey()
require.NotEqual(key, peerKey,
"DKG and peer AEAD customisations MUST produce different keys")
}
// TestKeyExchangeID_Predicates spot-checks the small predicate helpers so
// a future numbering change cannot silently flip a wire byte from PQ to
// classical without breaking a test. SharedSecretBits and NISTCategory
// are free functions in this package (KeyExchangeID is a type alias of
// config.KeyExchangeID so methods can only live in the config package).
func TestKeyExchangeID_Predicates(t *testing.T) {
require := require.New(t)
require.True(KeyExchangeMLKEM768.IsPostQuantum())
require.True(KeyExchangeMLKEM1024.IsPostQuantum())
require.False(KeyExchangeNone.IsPostQuantum())
require.False(KeyExchangeX25519Unsafe.IsPostQuantum())
require.True(KeyExchangeX25519Unsafe.IsForbiddenInPQMode())
require.False(KeyExchangeMLKEM768.IsForbiddenInPQMode())
require.EqualValues(256, SharedSecretBits(KeyExchangeMLKEM768))
require.EqualValues(256, SharedSecretBits(KeyExchangeMLKEM1024))
require.EqualValues(0, SharedSecretBits(KeyExchangeNone))
require.EqualValues(3, NISTCategory(KeyExchangeMLKEM768))
require.EqualValues(5, NISTCategory(KeyExchangeMLKEM1024))
// Canonical wire bytes — pins the Bug 3 fix at 0x01/0x02.
require.EqualValues(0x01, uint8(KeyExchangeMLKEM768))
require.EqualValues(0x02, uint8(KeyExchangeMLKEM1024))
require.EqualValues(0x90, uint8(KeyExchangeX25519Unsafe))
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kem
import (
"fmt"
"strings"
)
// ActiveProfile is the small subset of strict-PQ profile fields the
// network layer needs at boot to log the active posture and refuse
// peers that disagree. The full ChainSecurityProfile lives in
// consensus/config and is the authoritative source; this struct is the
// network-layer projection of that profile.
type ActiveProfile struct {
// Name is the canonical profile name for log lines and audit reports.
// Production strict-PQ deployments use "STRICT_E2E_PQ".
Name string
// NodeIdentity is the wire name of the per-node identity signature
// scheme. Strict-PQ: "ML-DSA-65".
NodeIdentity string
// SessionKEM is the KEM byte for peer-to-peer session keys. Strict-PQ
// default: KeyExchangeMLKEM768.
SessionKEM KeyExchangeID
// DKGKEM is the KEM byte for validator-to-validator DKG channels.
// Strict-PQ mandates KeyExchangeMLKEM1024.
DKGKEM KeyExchangeID
// HashSuite is the wire name of the transcript / commitment hash
// family. Strict-PQ: "SHA3_NIST".
HashSuite string
// PostQuantumE2E reports whether the profile asserts end-to-end PQ
// (KEM, identity, threshold finality all PQ).
PostQuantumE2E bool
// ForbidClassicalKEM is the strict-PQ refusal switch: when true, any
// peer offering an IsForbiddenInPQMode() KEM is refused at handshake.
ForbidClassicalKEM bool
}
// StrictE2EPQ returns the canonical strict-PQ profile projection
// for the network layer. Mirrors the consensus-config profile of the
// same shape; values here are pinned so a node that boots without an
// explicit profile still advertises the strict posture by default.
func StrictE2EPQ() ActiveProfile {
return ActiveProfile{
Name: "STRICT_E2E_PQ",
NodeIdentity: "ML-DSA-65",
SessionKEM: KeyExchangeMLKEM768,
DKGKEM: KeyExchangeMLKEM1024,
HashSuite: "SHA3_NIST",
PostQuantumE2E: true,
ForbidClassicalKEM: true,
}
}
// Banner returns the multi-line operator-readable banner that node
// startup MUST emit on boot under a strict-PQ profile. Fixed format so
// log scrapers can match deterministically.
//
// SECURITY PROFILE: STRICT_E2E_PQ
// NODE IDENTITY: ML-DSA-65
// SESSION KEM: ML-KEM-768
// DKG KEM: ML-KEM-1024
// HASH SUITE: SHA3_NIST
// POST-QUANTUM E2E: true
func (p ActiveProfile) Banner() string {
var b strings.Builder
fmt.Fprintf(&b, "SECURITY PROFILE: %s\n", p.Name)
fmt.Fprintf(&b, "NODE IDENTITY: %s\n", p.NodeIdentity)
fmt.Fprintf(&b, "SESSION KEM: %s\n", strings.ToUpper(p.SessionKEM.String()))
fmt.Fprintf(&b, "DKG KEM: %s\n", strings.ToUpper(p.DKGKEM.String()))
fmt.Fprintf(&b, "HASH SUITE: %s\n", p.HashSuite)
fmt.Fprintf(&b, "POST-QUANTUM E2E: %t\n", p.PostQuantumE2E)
return b.String()
}
// Validate refuses an ActiveProfile whose axes are internally inconsistent
// before the network layer trusts it. Mirrors the strict-PQ portion of
// ChainSecurityProfile.Validate.
func (p ActiveProfile) Validate() error {
if p.Name == "" {
return fmt.Errorf("kem: ActiveProfile.Name is empty")
}
if !p.SessionKEM.IsPostQuantum() {
return fmt.Errorf("kem: ActiveProfile.SessionKEM=%s is not post-quantum", p.SessionKEM)
}
if p.DKGKEM != KeyExchangeMLKEM1024 {
return fmt.Errorf("kem: ActiveProfile.DKGKEM=%s, expected %s",
p.DKGKEM, KeyExchangeMLKEM1024)
}
if !p.PostQuantumE2E {
return fmt.Errorf("kem: ActiveProfile.PostQuantumE2E must be true on strict-PQ")
}
if !p.ForbidClassicalKEM {
return fmt.Errorf("kem: ActiveProfile.ForbidClassicalKEM must be true on strict-PQ")
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kem
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestStrictE2EPQ_Banner asserts the canonical strict-PQ banner format
// matches the line-shape audit pipelines look for at boot.
func TestStrictE2EPQ_Banner(t *testing.T) {
require := require.New(t)
p := StrictE2EPQ()
require.NoError(p.Validate())
out := p.Banner()
require.Contains(out, "SECURITY PROFILE: STRICT_E2E_PQ")
require.Contains(out, "NODE IDENTITY: ML-DSA-65")
require.Contains(out, "SESSION KEM: ML-KEM-768")
require.Contains(out, "DKG KEM: ML-KEM-1024")
require.Contains(out, "HASH SUITE: SHA3_NIST")
require.Contains(out, "POST-QUANTUM E2E: true")
// Banner is line-oriented; each axis is its own line.
lines := strings.Split(strings.TrimSpace(out), "\n")
require.Len(lines, 6)
}
// TestActiveProfile_RefusesInternalInconsistency asserts a profile with
// drifted bytes is refused at Validate.
func TestActiveProfile_RefusesInternalInconsistency(t *testing.T) {
require := require.New(t)
bad := StrictE2EPQ()
bad.DKGKEM = KeyExchangeMLKEM768
require.Error(bad.Validate())
bad = StrictE2EPQ()
bad.SessionKEM = KeyExchangeX25519Unsafe
require.Error(bad.Validate())
bad = StrictE2EPQ()
bad.ForbidClassicalKEM = false
require.Error(bad.Validate())
bad = StrictE2EPQ()
bad.PostQuantumE2E = false
require.Error(bad.Validate())
bad = StrictE2EPQ()
bad.Name = ""
require.Error(bad.Validate())
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package kem implements post-quantum key encapsulation for the Lux node peer
// handshake. ML-KEM-768 (FIPS 203, NIST PQ Cat 3) is the default session KEM;
// ML-KEM-1024 (NIST PQ Cat 5) is mandatory for high-value validator and DKG
// channels.
//
// The session shared secret is derived through one FIPS 203 encapsulate /
// decapsulate dance and then bound to the handshake transcript via cSHAKE256
// with customization "NODE_AEAD_V1". The bound key is the AEAD session
// key used by every subsequent frame on the connection.
//
// One and only one KEM family is admitted on a strict-PQ profile: ML-KEM.
// Classical X25519 / ECDH are present in the wire enum solely as forbidden
// markers so audit tooling can name a misconfiguration precisely; a strict-PQ
// peer with ForbidClassicalKEM=true refuses to handshake against any peer
// offering KeyExchangeX25519Unsafe.
//
// KeyExchangeID is a type alias of config.KeyExchangeID; the canonical wire
// bytes (0x01 = ML-KEM-768, 0x02 = ML-KEM-1024, 0x90 = X25519Unsafe) live
// in the consensus config package and are shared with protocol/auth. Closes
// the "three disjoint KEM ID registries" drift named in the spec/code audit
// under Bug 3.
package kem
import (
"github.com/luxfi/consensus/config"
)
// KeyExchangeID is the wire byte that identifies the key-exchange / KEM
// scheme a peer offers for session-key establishment. Type-aliased to
// config.KeyExchangeID so the wire format and predicates (String,
// IsPostQuantum, IsForbiddenInPQMode) are shared with the consensus
// security profile.
//
// Canonical numbering:
//
// 0x00 — Invalid / unspecified (rejected by every strict-PQ verifier)
// 0x01 — ML-KEM-768 (FIPS 203 Cat 3; production default)
// 0x02 — ML-KEM-1024 (FIPS 203 Cat 5; DKG / high-value channels)
// 0x90 — X25519Unsafe (classical; explicit forbidden marker in strict-PQ)
//
// The forbidden markers are NEVER produced by a strict-PQ node; they exist
// solely so a strict-PQ peer that receives one can refuse with a precise
// diagnostic instead of dropping silently.
//
// ML-KEM-512 (NIST Cat 1) is intentionally not exported — it sits below the
// strict-PQ floor; the node does not negotiate Cat 1 sessions.
type KeyExchangeID = config.KeyExchangeID
const (
// KeyExchangeNone — alias for config.KeyExchangeInvalid (0x00). Rejected
// by every strict-PQ peer.
KeyExchangeNone = config.KeyExchangeInvalid
// KeyExchangeMLKEM768 — FIPS 203 ML-KEM-768 (NIST PQ Cat 3, byte 0x01).
// The production default for peer-to-peer session keys on the Lux
// primary network. 1184-byte public key, 1088-byte ciphertext, 32-byte
// shared secret.
KeyExchangeMLKEM768 = config.KeyExchangeMLKEM768
// KeyExchangeMLKEM1024 — FIPS 203 ML-KEM-1024 (NIST PQ Cat 5, byte 0x02).
// Mandatory for high-value validator channels and Pulsar DKG sessions.
// 1568-byte public key, 1568-byte ciphertext, 32-byte shared secret.
KeyExchangeMLKEM1024 = config.KeyExchangeMLKEM1024
// KeyExchangeX25519Unsafe — classical X25519 (byte 0x90). Carried as an
// explicit forbidden marker; a strict-PQ peer refuses to handshake
// against any peer offering this byte.
KeyExchangeX25519Unsafe = config.KeyExchangeX25519Unsafe
)
// SharedSecretBits reports the shared-secret width emitted by scheme in
// bits. Both ML-KEM-768 and ML-KEM-1024 emit 256-bit secrets per FIPS 203.
// Returns 0 for invalid and forbidden markers.
//
// Free function rather than a method because KeyExchangeID is a type alias
// of config.KeyExchangeID; methods can only be declared in the package that
// defines the underlying type.
func SharedSecretBits(scheme KeyExchangeID) uint16 {
switch scheme {
case KeyExchangeMLKEM768, KeyExchangeMLKEM1024:
return 256
}
return 0
}
// NISTCategory reports the NIST PQ security category for scheme. Cat 3
// targets AES-192 / SHA-384 strength; Cat 5 targets AES-256 / SHA-512.
// Returns 0 for invalid and forbidden markers.
//
// Free function for the same reason as SharedSecretBits.
func NISTCategory(scheme KeyExchangeID) uint8 {
switch scheme {
case KeyExchangeMLKEM768:
return 3
case KeyExchangeMLKEM1024:
return 5
}
return 0
}
+89 -5
View File
@@ -19,6 +19,7 @@ import (
"go.uber.org/zap"
"github.com/luxfi/codec/wrappers"
consensusconfig "github.com/luxfi/consensus/config"
consensustracker "github.com/luxfi/consensus/networking/tracker"
"github.com/luxfi/constants"
"github.com/luxfi/crypto/bls"
@@ -31,6 +32,7 @@ import (
"github.com/luxfi/node/message"
"github.com/luxfi/node/nets"
"github.com/luxfi/node/network/dialer"
"github.com/luxfi/node/network/kem"
"github.com/luxfi/node/network/peer"
"github.com/luxfi/node/network/throttling"
"github.com/luxfi/node/network/tracker"
@@ -334,8 +336,8 @@ func NewNetwork(
)
for _, validatorTx := range parsedGenesis.Validators {
// Extract validator details from the transaction.
// Genesis may encode validators as AddValidatorTx (pre-permissionless)
// or AddPermissionlessValidatorTx (post-Etna). Handle both.
// Genesis may encode validators as AddValidatorTx (legacy) or
// AddPermissionlessValidatorTx (modern). Handle both.
switch tx := validatorTx.Unsigned.(type) {
case *txs.AddPermissionlessValidatorTx:
nodeID := tx.Validator.NodeID
@@ -483,6 +485,45 @@ func NewNetwork(
IPSigner: peer.NewIPSigner(config.MyIPPort, config.TLSKey, config.BLSKey),
}
// Build the per-chain peer.SchemeGate exactly once at network bootstrap.
// The gate is the single cross-axis primitive that funnels every
// inbound NodeID through the chain's ChainSecurityProfile pin. Chains
// that ship no profile (legacy / classical-compat networks) leave the
// gate nil — upgrader.connToIDAndCert is nil-safe and refuses nobody.
var schemeGate *peer.SchemeGate
if config.SecurityProfile != nil {
schemeGate, err = peer.NewSchemeGate(config.SecurityProfile, 0)
if err != nil {
return nil, fmt.Errorf("building peer SchemeGate: %w", err)
}
log.Info("peer SchemeGate active",
zap.String("profile", config.SecurityProfile.ProfileName),
zap.Uint32("profileID", config.SecurityProfile.ProfileID),
)
}
// On a strict-PQ profile, also pin the application-layer PQ handshake
// config onto every peer goroutine. peer.Start runs the ML-KEM +
// ML-DSA handshake the moment TLS upgrade succeeds; bare TLS is
// refused. Permissive / classical-compat profiles leave PQHandshake
// nil and use the legacy bare-TLS path.
if config.SecurityProfile != nil && profileRequiresPQHandshake(config.SecurityProfile) {
pqIdent, err := peer.NewLocalIdentity(config.MyNodeID)
if err != nil {
return nil, fmt.Errorf("building PQ local identity: %w", err)
}
peerConfig.PQHandshakeConfig = &peer.HandshakeConfig{
Profile: peer.ProfileStrictPQ,
KEMScheme: kemSessionScheme(config.SecurityProfile),
ForbidClassicalKEM: true,
}
peerConfig.PQLocalIdentity = pqIdent
log.Info("peer PQ handshake active",
zap.Stringer("scheme", peerConfig.PQHandshakeConfig.KEMScheme),
zap.Stringer("nodeID", config.MyNodeID),
)
}
onCloseCtx, cancel := context.WithCancel(context.Background())
n := &network{
startupTime: time.Now(),
@@ -494,8 +535,8 @@ func NewNetwork(
inboundConnUpgradeThrottler: throttling.NewInboundConnUpgradeThrottler(config.ThrottlerConfig.InboundConnUpgradeThrottlerConfig),
listener: listener,
dialer: dialer,
serverUpgrader: peer.NewTLSServerUpgrader(config.TLSConfig, metrics.tlsConnRejected),
clientUpgrader: peer.NewTLSClientUpgrader(config.TLSConfig, metrics.tlsConnRejected),
serverUpgrader: peer.NewTLSServerUpgrader(config.TLSConfig, metrics.tlsConnRejected, schemeGate),
clientUpgrader: peer.NewTLSClientUpgrader(config.TLSConfig, metrics.tlsConnRejected, schemeGate),
onCloseCtx: onCloseCtx,
onCloseCtxCancel: cancel,
@@ -529,6 +570,32 @@ func NewNetwork(
return n, nil
}
// profileRequiresPQHandshake reports whether the chain's locked
// ChainSecurityProfile mandates the application-layer ML-KEM + ML-DSA
// handshake on top of TLS. Strict-PQ and FIPS profiles do; permissive
// profiles fall back to the legacy bare-TLS handshake. Centralised here
// so the same predicate decides "build SchemeGate" and "build PQ
// handshake config" — one rule, one place.
func profileRequiresPQHandshake(p *consensusconfig.ChainSecurityProfile) bool {
if p == nil {
return false
}
id := consensusconfig.ProfileID(p.ProfileID)
return id == consensusconfig.ProfileStrictPQ || id == consensusconfig.ProfileFIPS
}
// kemSessionScheme returns the KEM byte the chain's ChainSecurityProfile
// pins for per-peer session keys. Strict-PQ defaults to ML-KEM-768 (NIST
// PQ Cat 3); a chain profile that pins ML-KEM-1024 (Cat 5) for sessions
// is honoured. The DKG KEM (ML-KEM-1024 on strict-PQ — a separate axis
// captured in HighValueKEM) is not consumed by the peer handshake.
func kemSessionScheme(p *consensusconfig.ChainSecurityProfile) kem.KeyExchangeID {
if p != nil && p.KeyExchangeID == consensusconfig.KeyExchangeMLKEM1024 {
return kem.KeyExchangeMLKEM1024
}
return kem.KeyExchangeMLKEM768
}
// sequencerID returns the validator-set identity that sequences chainID.
// This resolves the distinction between:
// - chainID: execution domain (C-Chain, Zoo L2, etc.)
@@ -1208,7 +1275,19 @@ func (n *network) samplePeers(
if requestedValidators <= 0 {
requestedValidators = defaultSampleK
}
// Chicken-and-egg fallback: in a freshly bootstrapped sovereign
// primary network, the validator manager is briefly empty until
// the first P-chain block commits initial stakers from genesis.
// During that window, treating peers tracking the chain as
// validator candidates is the only way out — otherwise P-chain
// can never produce its first block, because the proposer would
// sample 0 peers and reach 0 votes. Once the manager has any
// validators registered, the strict cap returns.
bootstrapFallback := numValidatorsInManager == 0
numValidatorsToSample := min(requestedValidators, numValidatorsInManager)
if bootstrapFallback {
numValidatorsToSample = requestedValidators
}
n.peersLock.RLock()
defer n.peersLock.RUnlock()
@@ -1258,7 +1337,12 @@ func (n *network) samplePeers(
return true
}
if areTheyAValidator {
// Bootstrap fallback (see numValidatorsToSample setup above):
// when validator manager is empty, every chain-tracking peer
// counts as a validator candidate. Without this, sample
// always returns zero because GetValidator() can't yet see
// the genesis-declared initial stakers.
if areTheyAValidator || bootstrapFallback {
numValidatorsToSample--
return numValidatorsToSample >= 0
}
+13
View File
@@ -74,4 +74,17 @@ type Config struct {
// IngressConnectionCount counts the ingress (to us) connections.
IngressConnectionCount atomic.Int64
// PQHandshakeConfig pins the strict-PQ application-layer handshake
// (ML-KEM session + ML-DSA-65 identity) the peer goroutine runs
// after the TLS upgrade. Non-nil on strict-PQ chains; nil leaves the
// peer on the legacy bare-TLS path. Shared across every peer
// instance for the same chain — never mutated post-construction.
PQHandshakeConfig *HandshakeConfig
// PQLocalIdentity is this node's ML-DSA-65 long-term identity. The
// per-session ML-KEM keypair is regenerated inside the handshake; the
// identity is the only persistent secret. Non-nil iff
// PQHandshakeConfig is non-nil.
PQLocalIdentity *LocalIdentity
}
+681
View File
@@ -0,0 +1,681 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
"github.com/luxfi/ids"
"github.com/luxfi/node/network/kem"
)
// PQHandshake is the application-layer post-quantum handshake performed
// after a TLS 1.3 (X25519+ML-KEM-768) channel is established. The TLS
// layer authenticates the underlying transport; this handshake binds the
// node-level ML-DSA-65 identity to an ML-KEM session, deriving an AEAD
// session key under cSHAKE256 customisation "NODE_AEAD_V1".
//
// Wire protocol (4 byte tags described below are encoded big-endian
// length-prefixed):
//
// INIT (initiator → responder)
// ProtocolVersion u8 ; 0x01 today
// ProfileID u8 ; LuxStrictPQ etc.
// ChainID [32]byte ; primary-network or per-chain
// KEMScheme u8 ; ML-KEM-768 / ML-KEM-1024
// NodeID [20]byte ; canonical Lux NodeID
// MLDSAPublicKey [PublicKeySize]byte ; FIPS 204 ML-DSA-65 pub
// KEMPublicKey [PublicKeySize]byte ; FIPS 203 ML-KEM-{768|1024}
// Sig [SignatureSize]byte ; ML-DSA-65 over running transcript
//
// RESP (responder → initiator)
// ProtocolVersion u8 ; echoed
// ProfileID u8 ; echoed
// ChainID [32]byte ; echoed
// KEMScheme u8 ; echoed (responder MUST not downgrade)
// NodeID [20]byte ; responder's NodeID
// MLDSAPublicKey [PublicKeySize]byte ; responder's identity
// KEMCiphertext [CiphertextSize]byte ; encapsulated against init KEM pub
// Sig [SignatureSize]byte ; ML-DSA-65 over running transcript
//
// Both sides then:
//
// 1. Derive shared_secret = ML-KEM.Decapsulate(KEMCiphertext)
// 2. Derive bound transcript = TupleHash256("NODE_TRANSCRIPT_V1",
// INIT bytes ‖ RESP bytes ‖
// init_ml_dsa_pub ‖ resp_ml_dsa_pub ‖
// profile_id ‖ chain_id)
// 3. Derive aead_key = cSHAKE256("NODE_AEAD_V1",
// scheme ‖ shared_secret ‖ transcript)
//
// The aead_key is what callers feed to ChaCha20-Poly1305 or AES-256-GCM
// for every subsequent frame on the connection.
//
// Strict-PQ enforcement: a peer with ForbidClassicalKEM=true refuses any
// INIT whose KEMScheme matches IsForbiddenInPQMode(). The check happens
// before any signature work runs, so a malicious peer cannot waste the
// verifier's CPU on a bogus signature.
const (
// pqProtocolVersionV1 is the current PQ handshake protocol version
// byte. A peer that receives a version it does not understand MUST
// abort the handshake before any KEM / signature work runs.
pqProtocolVersionV1 uint8 = 0x01
// nodeIDSize is the canonical Lux NodeID width (20 bytes; SHA-256
// truncation per ids.NodeID).
nodeIDSize = 20
// chainIDSize is the canonical Lux ChainID width (32 bytes; SHA-256
// per ids.ID).
chainIDSize = 32
// IdentityPublicKeySize is the ML-DSA-65 public key byte width per
// FIPS 204 (1952 bytes).
IdentityPublicKeySize = mldsa65.PublicKeySize
// IdentitySignatureSize is the ML-DSA-65 signature byte width per
// FIPS 204 (3309 bytes).
IdentitySignatureSize = mldsa65.SignatureSize
)
// ProfileID is the per-handshake commitment to the chain-wide security
// profile. Mirrors consensus/config.ProfileID; declared locally because
// the published consensus v1.23.2 does not yet export
// security_profile.go. Once consensus rolls a release that ships it,
// this becomes a type alias.
type ProfileID uint8
const (
// ProfileNone — no profile committed; rejected by every strict peer.
ProfileNone ProfileID = 0x00
// ProfileStrictPQ — canonical strict-PQ profile, NIST-aligned.
ProfileStrictPQ ProfileID = 0x01
// ProfilePermissive — testnet / devnet.
ProfilePermissive ProfileID = 0x02
// ProfileFIPS — FIPS-204 + FIPS-202 only.
ProfileFIPS ProfileID = 0x03
)
// String returns the canonical lowercase profile name.
func (p ProfileID) String() string {
switch p {
case ProfileNone:
return "none"
case ProfileStrictPQ:
return "strict-pq"
case ProfilePermissive:
return "permissive"
case ProfileFIPS:
return "fips"
default:
return fmt.Sprintf("profile(0x%02x)", uint8(p))
}
}
// IsStrict reports whether this profile mandates ForbidClassicalKEM.
func (p ProfileID) IsStrict() bool {
return p == ProfileStrictPQ || p == ProfileFIPS
}
// HandshakeRole is initiator or responder; used only to scope ML-DSA-65
// signature contexts so an initiator's signature cannot be replayed as a
// responder's and vice versa.
type HandshakeRole uint8
const (
roleInitiator HandshakeRole = 0x01
roleResponder HandshakeRole = 0x02
)
func (r HandshakeRole) String() string {
switch r {
case roleInitiator:
return "initiator"
case roleResponder:
return "responder"
default:
return fmt.Sprintf("role(0x%02x)", uint8(r))
}
}
// LocalIdentity carries this node's ML-DSA-65 keypair and NodeID. The
// keypair is the ONLY long-term secret the PQ handshake holds; the KEM
// keypair is regenerated per-session.
type LocalIdentity struct {
NodeID ids.NodeID
Public *mldsa65.PublicKey
Secret *mldsa65.PrivateKey
}
// NewLocalIdentity generates a fresh ML-DSA-65 identity. Convenience for
// tests and one-shot tooling; production nodes load their identity from
// persistent storage.
func NewLocalIdentity(nodeID ids.NodeID) (*LocalIdentity, error) {
pk, sk, err := mldsa65.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("peer: generate ML-DSA-65 identity: %w", err)
}
return &LocalIdentity{NodeID: nodeID, Public: pk, Secret: sk}, nil
}
// HandshakeConfig pins the policy bits the handshake enforces. One value
// per peer connection; the network-level config builds one shared
// HandshakeConfig and hands a pointer to every per-peer goroutine.
type HandshakeConfig struct {
// Profile is the chain-wide security profile this peer runs under.
// The remote MUST advertise the same byte or the handshake aborts.
Profile ProfileID
// ChainID is the 32-byte commitment to the chain this peer is on.
// Pinned into the transcript and the AEAD-key derivation, so two
// peers on different chains never derive the same key even with
// identical KEM material.
ChainID [chainIDSize]byte
// KEMScheme is the KEM the initiator offers. Responder MUST echo it
// back; downgrading on the wire is refused.
KEMScheme kem.KeyExchangeID
// ForbidClassicalKEM is true on strict-PQ profiles; when set, the
// handshake refuses any peer whose KEMScheme byte is in the
// IsForbiddenInPQMode() set. Also refuses any non-PQ scheme in
// non-classical positions.
ForbidClassicalKEM bool
}
// Errors returned by the PQ handshake. Each names exactly one contract
// violation.
var (
// ErrHandshakeBadVersion — protocol version byte not understood.
ErrHandshakeBadVersion = errors.New("peer: PQ handshake protocol version not understood")
// ErrHandshakeProfileMismatch — initiator profile != responder profile
// or != local-config profile.
ErrHandshakeProfileMismatch = errors.New("peer: PQ handshake profile mismatch")
// ErrHandshakeChainMismatch — chain IDs disagree across the handshake.
ErrHandshakeChainMismatch = errors.New("peer: PQ handshake chain ID mismatch")
// ErrHandshakeBadIdentity — ML-DSA-65 identity key or signature
// failed to parse / verify.
ErrHandshakeBadIdentity = errors.New("peer: PQ handshake identity invalid")
// ErrHandshakeClassicalKEM — peer offered an explicit classical KEM
// marker (X25519, P-256, P-384); strict-PQ peers refuse.
ErrHandshakeClassicalKEM = errors.New("peer: PQ handshake refused classical KEM")
// ErrHandshakeKEMScheme — peer offered a KEM scheme not allowed by
// local config (e.g. responder downgraded ML-KEM-1024 to ML-KEM-768).
ErrHandshakeKEMScheme = errors.New("peer: PQ handshake KEM scheme not admissible")
// ErrHandshakeNodeIDZero — peer presented the zero NodeID.
ErrHandshakeNodeIDZero = errors.New("peer: PQ handshake peer NodeID is zero")
)
// HandshakeInit is the initiator's first message. Field order is the
// canonical wire order; the running transcript hashes every field in this
// order before the initiator signs the running prefix.
type HandshakeInit struct {
ProtocolVersion uint8
Profile ProfileID
ChainID [chainIDSize]byte
KEMScheme kem.KeyExchangeID
NodeID ids.NodeID
MLDSAPub []byte // length IdentityPublicKeySize
KEMPub []byte // length depends on KEMScheme
Sig []byte // length IdentitySignatureSize; covers transcript prefix
}
// HandshakeResp is the responder's reply. Same field shape; KEMCiphertext
// replaces KEMPublicKey.
type HandshakeResp struct {
ProtocolVersion uint8
Profile ProfileID
ChainID [chainIDSize]byte
KEMScheme kem.KeyExchangeID
NodeID ids.NodeID
MLDSAPub []byte // length IdentityPublicKeySize
KEMCiphertext []byte // length depends on KEMScheme
Sig []byte // length IdentitySignatureSize; covers transcript prefix
}
// HandshakeResult is what both sides return at the end of a successful
// handshake. KEMSession carries the raw shared secret + bound transcript
// hash; AEADKey is the 256-bit cSHAKE256-derived key for the connection's
// authenticated cipher.
type HandshakeResult struct {
Local *LocalIdentity
PeerNodeID ids.NodeID
PeerMLDSA *mldsa65.PublicKey
KEMSession *kem.KEMSession
AEADKey [kem.AEADKeySize]byte
}
// InitiateHandshake is the initiator's side of the PQ peer handshake.
// Produces the INIT message ready to send on the wire, plus the
// per-session KEM decapsulation key the caller must retain until
// FinishInitiatorHandshake runs.
//
// Caller workflow:
//
// init, kemSec, err := InitiateHandshake(cfg, local)
// // send init.Bytes() on the wire
// // receive resp bytes from peer
// resp, err := ParseHandshakeResp(respBytes, cfg.KEMScheme)
// result, err := FinishInitiatorHandshake(cfg, local, init, resp, kemSec)
func InitiateHandshake(
cfg *HandshakeConfig,
local *LocalIdentity,
) (*HandshakeInit, []byte /* kemSec */, error) {
if err := validateConfig(cfg); err != nil {
return nil, nil, err
}
kemPub, kemSec, err := kem.GenerateKEMKeypair(cfg.KEMScheme, rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("peer: PQ init keypair: %w", err)
}
init := &HandshakeInit{
ProtocolVersion: pqProtocolVersionV1,
Profile: cfg.Profile,
ChainID: cfg.ChainID,
KEMScheme: cfg.KEMScheme,
NodeID: local.NodeID,
MLDSAPub: packMLDSAPub(local.Public),
KEMPub: kemPub,
}
// Sign the transcript prefix (every field above Sig). The signature
// context ("NODE_PQ_HANDSHAKE_V1") makes role and version part of
// the signed bytes so a captured signature cannot be replayed as a
// responder signature or under a future protocol version.
transcriptPrefix := init.transcriptPrefix(roleInitiator)
sig := make([]byte, IdentitySignatureSize)
if err := mldsa65.SignTo(
local.Secret,
transcriptPrefix,
[]byte("NODE_PQ_HANDSHAKE_V1/initiator"),
false, // deterministic mode; randomized=true is also valid, but determinism keeps test vectors stable
sig,
); err != nil {
return nil, nil, fmt.Errorf("peer: PQ init sign: %w", err)
}
init.Sig = sig
return init, kemSec, nil
}
// RespondHandshake is the responder's side. Accepts the parsed INIT,
// validates every cross-axis invariant, encapsulates against the
// initiator's KEM public key, signs the running transcript prefix, and
// returns both the RESP message and the resulting HandshakeResult.
//
// The result's AEADKey is already derived; the caller switches its
// transport to authenticated mode the moment RESP is sent.
func RespondHandshake(
cfg *HandshakeConfig,
local *LocalIdentity,
init *HandshakeInit,
) (*HandshakeResp, *HandshakeResult, error) {
if err := validateConfig(cfg); err != nil {
return nil, nil, err
}
if err := validateRemoteInit(cfg, init); err != nil {
return nil, nil, err
}
// Parse initiator's MLDSA pub.
peerPub, err := unpackMLDSAPub(init.MLDSAPub)
if err != nil {
return nil, nil, fmt.Errorf("%w: parse peer MLDSA pub: %v",
ErrHandshakeBadIdentity, err)
}
// Verify initiator's signature over the transcript prefix.
if !mldsa65.Verify(
peerPub,
init.transcriptPrefix(roleInitiator),
[]byte("NODE_PQ_HANDSHAKE_V1/initiator"),
init.Sig,
) {
return nil, nil, fmt.Errorf("%w: initiator signature failed", ErrHandshakeBadIdentity)
}
// KEM encapsulate against initiator's KEM pub. transcript bytes here
// are exactly the initiator's INIT message in canonical order; the
// responder's RESP will append onto this for the responder's signing
// transcript.
transcript := init.canonicalBytes()
kemSess, ct, err := kem.InitiateKEMSession(cfg.KEMScheme, init.KEMPub, transcript)
if err != nil {
return nil, nil, fmt.Errorf("peer: PQ encapsulate: %w", err)
}
resp := &HandshakeResp{
ProtocolVersion: pqProtocolVersionV1,
Profile: cfg.Profile,
ChainID: cfg.ChainID,
KEMScheme: cfg.KEMScheme,
NodeID: local.NodeID,
MLDSAPub: packMLDSAPub(local.Public),
KEMCiphertext: ct,
}
respPrefix := resp.transcriptPrefix(init, roleResponder)
sig := make([]byte, IdentitySignatureSize)
if err := mldsa65.SignTo(
local.Secret,
respPrefix,
[]byte("NODE_PQ_HANDSHAKE_V1/responder"),
false,
sig,
); err != nil {
return nil, nil, fmt.Errorf("peer: PQ resp sign: %w", err)
}
resp.Sig = sig
// Bind the final transcript (init+resp+both ML-DSA pubs+profile+chain)
// for AEAD key derivation.
fullTranscript := bindAEADTranscript(init, resp)
finalSession := &kem.KEMSession{
SchemeID: kemSess.SchemeID,
SharedSecret: kemSess.SharedSecret,
TranscriptHash: kem.HashTranscript(fullTranscript),
}
return resp, &HandshakeResult{
Local: local,
PeerNodeID: init.NodeID,
PeerMLDSA: peerPub,
KEMSession: finalSession,
AEADKey: finalSession.DeriveAEADKey(),
}, nil
}
// FinishInitiatorHandshake is the initiator's third-stage step: validate
// the responder's RESP, decapsulate to recover the shared secret, bind
// the full transcript, and derive the AEAD key. Returns the same
// HandshakeResult shape as RespondHandshake so a caller can treat the
// post-handshake API symmetrically.
func FinishInitiatorHandshake(
cfg *HandshakeConfig,
local *LocalIdentity,
init *HandshakeInit,
resp *HandshakeResp,
kemSec []byte,
) (*HandshakeResult, error) {
if err := validateRemoteResp(cfg, init, resp); err != nil {
return nil, err
}
peerPub, err := unpackMLDSAPub(resp.MLDSAPub)
if err != nil {
return nil, fmt.Errorf("%w: parse responder MLDSA pub: %v",
ErrHandshakeBadIdentity, err)
}
if !mldsa65.Verify(
peerPub,
resp.transcriptPrefix(init, roleResponder),
[]byte("NODE_PQ_HANDSHAKE_V1/responder"),
resp.Sig,
) {
return nil, fmt.Errorf("%w: responder signature failed", ErrHandshakeBadIdentity)
}
transcript := init.canonicalBytes()
kemSess, err := kem.RespondKEMSession(cfg.KEMScheme, kemSec, resp.KEMCiphertext, transcript)
if err != nil {
return nil, fmt.Errorf("peer: PQ decapsulate: %w", err)
}
fullTranscript := bindAEADTranscript(init, resp)
finalSession := &kem.KEMSession{
SchemeID: kemSess.SchemeID,
SharedSecret: kemSess.SharedSecret,
TranscriptHash: kem.HashTranscript(fullTranscript),
}
return &HandshakeResult{
Local: local,
PeerNodeID: resp.NodeID,
PeerMLDSA: peerPub,
KEMSession: finalSession,
AEADKey: finalSession.DeriveAEADKey(),
}, nil
}
// validateConfig refuses obviously bad configs at construction time. The
// rest of the handshake assumes cfg is sane.
func validateConfig(cfg *HandshakeConfig) error {
if cfg == nil {
return errors.New("peer: PQ handshake config is nil")
}
if cfg.KEMScheme.IsForbiddenInPQMode() {
return fmt.Errorf("%w: local config offers %s",
ErrHandshakeClassicalKEM, cfg.KEMScheme)
}
if !cfg.KEMScheme.IsPostQuantum() {
return fmt.Errorf("%w: local config offers %s",
ErrHandshakeKEMScheme, cfg.KEMScheme)
}
if cfg.Profile == ProfileNone {
return fmt.Errorf("%w: local profile is None", ErrHandshakeProfileMismatch)
}
return nil
}
// validateRemoteInit runs every cross-axis check on the parsed INIT
// before any expensive crypto runs.
func validateRemoteInit(cfg *HandshakeConfig, init *HandshakeInit) error {
if init == nil {
return errors.New("peer: PQ handshake init is nil")
}
if init.ProtocolVersion != pqProtocolVersionV1 {
return fmt.Errorf("%w: peer offered version=0x%02x, want=0x%02x",
ErrHandshakeBadVersion, init.ProtocolVersion, pqProtocolVersionV1)
}
if init.Profile != cfg.Profile {
return fmt.Errorf("%w: local=%s peer=%s",
ErrHandshakeProfileMismatch, cfg.Profile, init.Profile)
}
if init.ChainID != cfg.ChainID {
return fmt.Errorf("%w: local=%x peer=%x",
ErrHandshakeChainMismatch, cfg.ChainID, init.ChainID)
}
if cfg.ForbidClassicalKEM && init.KEMScheme.IsForbiddenInPQMode() {
return fmt.Errorf("%w: peer offered %s",
ErrHandshakeClassicalKEM, init.KEMScheme)
}
if init.KEMScheme != cfg.KEMScheme {
return fmt.Errorf("%w: local=%s peer=%s",
ErrHandshakeKEMScheme, cfg.KEMScheme, init.KEMScheme)
}
if init.NodeID == ids.EmptyNodeID {
return ErrHandshakeNodeIDZero
}
if len(init.MLDSAPub) != IdentityPublicKeySize {
return fmt.Errorf("%w: peer MLDSAPub size=%d want=%d",
ErrHandshakeBadIdentity, len(init.MLDSAPub), IdentityPublicKeySize)
}
wantKEMPub, err := kem.PublicKeySize(init.KEMScheme)
if err != nil {
return fmt.Errorf("%w: %v", ErrHandshakeKEMScheme, err)
}
if len(init.KEMPub) != wantKEMPub {
return fmt.Errorf("%w: peer KEMPub size=%d want=%d (scheme=%s)",
ErrHandshakeKEMScheme, len(init.KEMPub), wantKEMPub, init.KEMScheme)
}
if len(init.Sig) != IdentitySignatureSize {
return fmt.Errorf("%w: peer Sig size=%d want=%d",
ErrHandshakeBadIdentity, len(init.Sig), IdentitySignatureSize)
}
return nil
}
// validateRemoteResp runs the responder-message version of the same
// cross-axis checks. Includes the no-downgrade rule (resp.KEMScheme must
// echo init.KEMScheme exactly).
func validateRemoteResp(cfg *HandshakeConfig, init *HandshakeInit, resp *HandshakeResp) error {
if resp == nil {
return errors.New("peer: PQ handshake resp is nil")
}
if resp.ProtocolVersion != pqProtocolVersionV1 {
return fmt.Errorf("%w: peer offered version=0x%02x, want=0x%02x",
ErrHandshakeBadVersion, resp.ProtocolVersion, pqProtocolVersionV1)
}
if resp.Profile != cfg.Profile {
return fmt.Errorf("%w: local=%s peer=%s",
ErrHandshakeProfileMismatch, cfg.Profile, resp.Profile)
}
if resp.ChainID != cfg.ChainID {
return fmt.Errorf("%w: local=%x peer=%x",
ErrHandshakeChainMismatch, cfg.ChainID, resp.ChainID)
}
if cfg.ForbidClassicalKEM && resp.KEMScheme.IsForbiddenInPQMode() {
return fmt.Errorf("%w: peer offered %s",
ErrHandshakeClassicalKEM, resp.KEMScheme)
}
if resp.KEMScheme != init.KEMScheme {
return fmt.Errorf("%w: responder downgraded KEM init=%s resp=%s",
ErrHandshakeKEMScheme, init.KEMScheme, resp.KEMScheme)
}
if resp.NodeID == ids.EmptyNodeID {
return ErrHandshakeNodeIDZero
}
if len(resp.MLDSAPub) != IdentityPublicKeySize {
return fmt.Errorf("%w: peer MLDSAPub size=%d want=%d",
ErrHandshakeBadIdentity, len(resp.MLDSAPub), IdentityPublicKeySize)
}
wantCT, err := kem.CiphertextSize(resp.KEMScheme)
if err != nil {
return fmt.Errorf("%w: %v", ErrHandshakeKEMScheme, err)
}
if len(resp.KEMCiphertext) != wantCT {
return fmt.Errorf("%w: peer KEMCiphertext size=%d want=%d (scheme=%s)",
ErrHandshakeKEMScheme, len(resp.KEMCiphertext), wantCT, resp.KEMScheme)
}
if len(resp.Sig) != IdentitySignatureSize {
return fmt.Errorf("%w: peer Sig size=%d want=%d",
ErrHandshakeBadIdentity, len(resp.Sig), IdentitySignatureSize)
}
return nil
}
// transcriptPrefix returns the canonical byte string the initiator signs
// over: every field of the INIT in wire order, MINUS the signature
// itself. role is bound into the signature context (not the prefix), so
// callers that need the bound prefix can recompute it deterministically.
func (h *HandshakeInit) transcriptPrefix(_ HandshakeRole) []byte {
buf := make([]byte, 0, 256+len(h.MLDSAPub)+len(h.KEMPub))
buf = append(buf, h.ProtocolVersion)
buf = append(buf, byte(h.Profile))
buf = append(buf, h.ChainID[:]...)
buf = append(buf, byte(h.KEMScheme))
nid := h.NodeID
buf = append(buf, nid[:]...)
buf = appendLengthPrefixed(buf, h.MLDSAPub)
buf = appendLengthPrefixed(buf, h.KEMPub)
return buf
}
// canonicalBytes returns the full INIT in wire order including the
// signature. Used as the transcript seed by the responder for KEM and as
// part of the AEAD-binding transcript.
func (h *HandshakeInit) canonicalBytes() []byte {
buf := h.transcriptPrefix(roleInitiator)
buf = appendLengthPrefixed(buf, h.Sig)
return buf
}
// transcriptPrefix for the responder includes the entire INIT bytes so
// the responder's signature commits to what it observed from the
// initiator. role is unused (bound in context); kept for symmetry.
func (h *HandshakeResp) transcriptPrefix(init *HandshakeInit, _ HandshakeRole) []byte {
buf := make([]byte, 0, 512+len(h.MLDSAPub)+len(h.KEMCiphertext))
buf = append(buf, init.canonicalBytes()...)
buf = append(buf, h.ProtocolVersion)
buf = append(buf, byte(h.Profile))
buf = append(buf, h.ChainID[:]...)
buf = append(buf, byte(h.KEMScheme))
nid := h.NodeID
buf = append(buf, nid[:]...)
buf = appendLengthPrefixed(buf, h.MLDSAPub)
buf = appendLengthPrefixed(buf, h.KEMCiphertext)
return buf
}
// canonicalBytes returns the full RESP in wire order including the
// signature. Independent of INIT bytes because the wire delivers RESP
// alone; the transcript-binding APIs glue the two.
func (h *HandshakeResp) canonicalBytes() []byte {
buf := make([]byte, 0, 256+len(h.MLDSAPub)+len(h.KEMCiphertext)+len(h.Sig))
buf = append(buf, h.ProtocolVersion)
buf = append(buf, byte(h.Profile))
buf = append(buf, h.ChainID[:]...)
buf = append(buf, byte(h.KEMScheme))
nid := h.NodeID
buf = append(buf, nid[:]...)
buf = appendLengthPrefixed(buf, h.MLDSAPub)
buf = appendLengthPrefixed(buf, h.KEMCiphertext)
buf = appendLengthPrefixed(buf, h.Sig)
return buf
}
// bindAEADTranscript returns the byte string that gets hashed into the
// final TranscriptHash for AEAD key derivation. Per HIP-0078 §"Session
// key derivation", this MUST include: profile_id, chain_id, both peers'
// ML-DSA public keys, both wire messages. Re-listing fields that already
// appear inside init/resp.canonicalBytes() is intentional defense in
// depth: a manifest mismatch on profile or chain shows up here even if
// the encoding of init or resp drifted.
func bindAEADTranscript(init *HandshakeInit, resp *HandshakeResp) []byte {
buf := make([]byte, 0, 1024)
buf = append(buf, init.canonicalBytes()...)
buf = append(buf, resp.canonicalBytes()...)
buf = append(buf, byte(init.Profile))
buf = append(buf, init.ChainID[:]...)
buf = append(buf, init.MLDSAPub...)
buf = append(buf, resp.MLDSAPub...)
return buf
}
// packMLDSAPub packs an ML-DSA-65 public key into its canonical byte form.
func packMLDSAPub(pk *mldsa65.PublicKey) []byte {
var buf [mldsa65.PublicKeySize]byte
pk.Pack(&buf)
out := make([]byte, mldsa65.PublicKeySize)
copy(out, buf[:])
return out
}
// unpackMLDSAPub parses an ML-DSA-65 public key from canonical bytes.
func unpackMLDSAPub(b []byte) (*mldsa65.PublicKey, error) {
if len(b) != mldsa65.PublicKeySize {
return nil, fmt.Errorf("public key size=%d want=%d", len(b), mldsa65.PublicKeySize)
}
var buf [mldsa65.PublicKeySize]byte
copy(buf[:], b)
pk := new(mldsa65.PublicKey)
pk.Unpack(&buf)
return pk, nil
}
// appendLengthPrefixed appends a 4-byte big-endian length and then b.
// Used inside transcript byte strings so two distinct concatenations
// cannot collide via a boundary ambiguity.
func appendLengthPrefixed(dst, b []byte) []byte {
var lp [4]byte
binary.BigEndian.PutUint32(lp[:], uint32(len(b)))
dst = append(dst, lp[:]...)
dst = append(dst, b...)
return dst
}
+305
View File
@@ -0,0 +1,305 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
"github.com/luxfi/node/network/kem"
)
// newTestIdentities returns two fresh ML-DSA-65 identities and a shared
// chain ID. Used by every handshake test that needs a happy-path setup.
func newTestIdentities(t *testing.T) (initiator, responder *LocalIdentity, chainID [32]byte) {
t.Helper()
require := require.New(t)
initNodeID := ids.GenerateTestNodeID()
respNodeID := ids.GenerateTestNodeID()
init, err := NewLocalIdentity(initNodeID)
require.NoError(err)
resp, err := NewLocalIdentity(respNodeID)
require.NoError(err)
_, err = rand.Read(chainID[:])
require.NoError(err)
return init, resp, chainID
}
// runHandshake drives a full INIT/RESP/FINISH on a strict-PQ config under
// the supplied KEM scheme. Returns both result objects.
func runHandshake(
t *testing.T,
initiator, responder *LocalIdentity,
chainID [32]byte,
scheme kem.KeyExchangeID,
) (*HandshakeResult, *HandshakeResult) {
t.Helper()
require := require.New(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: scheme,
ForbidClassicalKEM: true,
}
init, kemSec, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
require.NotNil(init)
require.NotEmpty(kemSec)
resp, respResult, err := RespondHandshake(cfg, responder, init)
require.NoError(err)
require.NotNil(resp)
require.NotNil(respResult)
initResult, err := FinishInitiatorHandshake(cfg, initiator, init, resp, kemSec)
require.NoError(err)
require.NotNil(initResult)
require.Equal(initResult.AEADKey, respResult.AEADKey,
"both sides MUST derive the same AEAD key")
return initResult, respResult
}
// TestHandshake_StrictPQ_MLKEM768_RoundTrip exercises the default
// production handshake: strict-PQ profile, ML-KEM-768.
func TestHandshake_StrictPQ_MLKEM768_RoundTrip(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
initResult, respResult := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM768)
require.Equal(initiator.NodeID, respResult.PeerNodeID)
require.Equal(responder.NodeID, initResult.PeerNodeID)
require.Equal(kem.KeyExchangeMLKEM768, initResult.KEMSession.SchemeID)
}
// TestHandshake_StrictPQ_MLKEM1024_RoundTrip exercises the high-value
// DKG-grade handshake under ML-KEM-1024.
func TestHandshake_StrictPQ_MLKEM1024_RoundTrip(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
initResult, respResult := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM1024)
require.Equal(kem.KeyExchangeMLKEM1024, initResult.KEMSession.SchemeID)
require.Equal(kem.KeyExchangeMLKEM1024, respResult.KEMSession.SchemeID)
// AEADKey under DKG customisation MUST differ from peer-AEAD customisation.
dkgKey, err := initResult.KEMSession.DeriveDKGAEADKey()
require.NoError(err)
require.NotEqual(initResult.AEADKey, dkgKey)
}
// TestHandshake_StrictPQ_RefusesClassicalKEM asserts a strict-PQ peer
// rejects any peer offering an explicit classical KEM marker.
func TestHandshake_StrictPQ_RefusesClassicalKEM(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
// Local config trying to offer a classical KEM is refused at config
// validation time, before any wire bytes leave the box.
badCfg := *cfg
badCfg.KEMScheme = kem.KeyExchangeX25519Unsafe
_, _, err := InitiateHandshake(&badCfg, initiator)
require.ErrorIs(err, ErrHandshakeClassicalKEM)
// A peer that hand-crafts a classical KEM byte into an INIT is
// refused by the responder.
init, _, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
init.KEMScheme = kem.KeyExchangeX25519Unsafe
_, _, err = RespondHandshake(cfg, responder, init)
require.ErrorIs(err, ErrHandshakeClassicalKEM)
}
// TestHandshake_StrictPQ_RefusesECDSAIdentity asserts the handshake
// rejects an identity public key that isn't a valid ML-DSA-65 key. We
// simulate this by replacing MLDSAPub with arbitrary bytes of the wrong
// length (ECDSA-P256 sec1 width = 65 bytes; ML-DSA-65 pub = 1952 bytes).
func TestHandshake_StrictPQ_RefusesECDSAIdentity(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
init, _, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
// Replace ML-DSA-65 public key with a 65-byte ECDSA-P256 sec1-uncompressed
// representation. The handshake MUST refuse before any signature work.
init.MLDSAPub = make([]byte, 65)
init.MLDSAPub[0] = 0x04 // sec1 uncompressed prefix
_, _, err = RespondHandshake(cfg, responder, init)
require.ErrorIs(err, ErrHandshakeBadIdentity)
}
// TestHandshake_PreservesNodeIdentitySignature asserts that the identity
// signature actually covers the transcript: flipping any byte in the
// INIT or RESP after signing causes the verifier to reject.
func TestHandshake_PreservesNodeIdentitySignature(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
// Tamper INIT after signing: flip one byte of the KEMPub.
init, _, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
require.NotEmpty(init.Sig)
init.KEMPub[0] ^= 0x01
_, _, err = RespondHandshake(cfg, responder, init)
require.ErrorIs(err, ErrHandshakeBadIdentity,
"flipping a signed-over byte MUST cause initiator signature to fail")
// Tamper RESP after signing: re-run a clean handshake, mutate resp.
init2, kemSec2, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
resp, _, err := RespondHandshake(cfg, responder, init2)
require.NoError(err)
require.NotEmpty(resp.Sig)
resp.KEMCiphertext[0] ^= 0x01
_, err = FinishInitiatorHandshake(cfg, initiator, init2, resp, kemSec2)
require.ErrorIs(err, ErrHandshakeBadIdentity,
"flipping a signed-over byte MUST cause responder signature to fail")
}
// TestHandshake_RefusesProfileMismatch asserts two peers running
// different security profiles cannot complete the handshake.
func TestHandshake_RefusesProfileMismatch(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
initCfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
respCfg := *initCfg
respCfg.Profile = ProfileFIPS
init, _, err := InitiateHandshake(initCfg, initiator)
require.NoError(err)
_, _, err = RespondHandshake(&respCfg, responder, init)
require.ErrorIs(err, ErrHandshakeProfileMismatch)
}
// TestHandshake_RefusesChainMismatch asserts two peers on different
// chains cannot complete the handshake.
func TestHandshake_RefusesChainMismatch(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
var otherChain [32]byte
_, err := rand.Read(otherChain[:])
require.NoError(err)
initCfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
respCfg := *initCfg
respCfg.ChainID = otherChain
init, _, err := InitiateHandshake(initCfg, initiator)
require.NoError(err)
_, _, err = RespondHandshake(&respCfg, responder, init)
require.ErrorIs(err, ErrHandshakeChainMismatch)
}
// TestHandshake_RefusesResponderKEMDowngrade asserts the initiator
// rejects a responder that echoes back a different KEM scheme (e.g.
// downgrades ML-KEM-1024 to ML-KEM-768).
func TestHandshake_RefusesResponderKEMDowngrade(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM1024,
ForbidClassicalKEM: true,
}
init, kemSec, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
resp, _, err := RespondHandshake(cfg, responder, init)
require.NoError(err)
// Tamper the scheme byte in the responder message; the initiator MUST
// catch the downgrade before deriving any key material.
resp.KEMScheme = kem.KeyExchangeMLKEM768
_, err = FinishInitiatorHandshake(cfg, initiator, init, resp, kemSec)
require.ErrorIs(err, ErrHandshakeKEMScheme)
}
// TestHandshake_DistinctSessionsDistinctKeys asserts two independent
// handshakes between the same pair derive distinct AEAD keys (the KEM
// randomness alone is enough to differentiate; the transcript binding
// reinforces it).
func TestHandshake_DistinctSessionsDistinctKeys(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
a1, _ := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM768)
a2, _ := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM768)
require.NotEqual(a1.AEADKey, a2.AEADKey,
"independent sessions MUST yield distinct AEAD keys")
}
// TestHandshake_NodeIDZeroRefused asserts the handshake rejects a peer
// presenting the zero NodeID.
func TestHandshake_NodeIDZeroRefused(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
init, _, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
init.NodeID = ids.EmptyNodeID
_, _, err = RespondHandshake(cfg, responder, init)
require.ErrorIs(err, ErrHandshakeNodeIDZero)
}
+145 -11
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
@@ -12,9 +12,10 @@ import (
"net/netip"
"time"
"github.com/luxfi/codec/wrappers"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/hash"
"github.com/luxfi/codec/wrappers"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/node/staking"
)
@@ -22,8 +23,29 @@ var (
errTimestampTooFarInFuture = errors.New("timestamp too far in the future")
errTimestampTooFarInPast = errors.New("timestamp too far in the past")
errInvalidTLSSignature = errors.New("invalid TLS signature")
// errMissingMLDSASignature is returned by Verify when strict-PQ profile
// is in force but the SignedIP carries no ML-DSA leg. Strict-PQ chains
// refuse classical-only IP gossip because both TLS and BLS12-381 are
// quantum-broken.
errMissingMLDSASignature = errors.New("missing ML-DSA-65 signature under strict-PQ profile")
// errMissingMLDSAPublicKey is returned by Verify when an ML-DSA signature
// is present on the wire but the caller did not supply the validator's
// ML-DSA public key needed to verify it. Configuration bug — the caller
// must pass the pubkey when verification proceeds under strict-PQ.
errMissingMLDSAPublicKey = errors.New("missing ML-DSA-65 public key for verification")
// errInvalidMLDSASignature is returned by Verify when the ML-DSA-65
// signature over the canonical UnsignedIP bytes fails to verify under
// the supplied public key.
errInvalidMLDSASignature = errors.New("invalid ML-DSA-65 signature")
)
// signedIPMLDSAContext is the FIPS 204 context string bound into every
// ML-DSA-65 signature over an UnsignedIP. Pinning a context here makes a
// captured IP-gossip signature un-replayable in any other Lux signing
// surface (transaction auth, UTXO Fx, peer handshake) even though they all
// share the same long-term ML-DSA-65 identity key.
var signedIPMLDSAContext = []byte("lux-peer-signed-ip-v1")
// UnsignedIP is used for a validator to claim an IP. The [Timestamp] is used to
// ensure that the most updated IP claim is tracked by peers for a given
// validator.
@@ -32,8 +54,34 @@ type UnsignedIP struct {
Timestamp uint64
}
// Sign this IP with the provided signer and return the signed IP.
// Sign produces a classical-only SignedIP carrying TLS + BLS legs. Retained
// for backwards compatibility with permissive (non-strict-PQ) chains and
// with the existing test surface. Strict-PQ producers MUST call SignPQ.
func (ip *UnsignedIP) Sign(tlsSigner crypto.Signer, blsSigner bls.Signer) (*SignedIP, error) {
return ip.SignPQ(tlsSigner, blsSigner, nil)
}
// SignPQ produces a SignedIP carrying every leg the caller can mint:
//
// - TLS leg (classical, RSA/ECDSA staker cert) — always required.
// - BLS12-381 leg (classical, proof-of-possession) — always required.
// - ML-DSA-65 leg (FIPS 204 post-quantum) — minted when mldsaSigner is
// non-nil.
//
// Both classical legs are quantum-broken; the ML-DSA leg is what gives a
// strict-PQ chain a witness over the IP gossip that survives a CRQC. The
// ML-DSA leg signs the same canonical UnsignedIP bytes the classical legs
// sign, bound under the "lux-peer-signed-ip-v1" FIPS 204 context so the
// signature cannot be replayed into another Lux signing surface.
//
// Backwards compat: passing mldsaSigner == nil produces a SignedIP with
// an empty MLDSASignature. Permissive chains accept this; strict-PQ chains
// refuse it at Verify time.
func (ip *UnsignedIP) SignPQ(
tlsSigner crypto.Signer,
blsSigner bls.Signer,
mldsaSigner *mldsa.PrivateKey,
) (*SignedIP, error) {
ipBytes := ip.bytes()
tlsSignature, err := tlsSigner.Sign(
rand.Reader,
@@ -49,11 +97,20 @@ func (ip *UnsignedIP) Sign(tlsSigner crypto.Signer, blsSigner bls.Signer) (*Sign
return nil, err
}
var mldsaSignature []byte
if mldsaSigner != nil {
mldsaSignature, err = mldsaSigner.SignCtx(rand.Reader, ipBytes, signedIPMLDSAContext)
if err != nil {
return nil, fmt.Errorf("ml-dsa-65 sign: %w", err)
}
}
return &SignedIP{
UnsignedIP: *ip,
TLSSignature: tlsSignature,
BLSSignature: blsSignature,
BLSSignatureBytes: bls.SignatureToBytes(blsSignature),
MLDSASignature: mldsaSignature,
}, nil
}
@@ -69,24 +126,75 @@ func (ip *UnsignedIP) bytes() []byte {
}
// SignedIP is a wrapper of an UnsignedIP with the signature from a signer.
//
// Wire-format note: the ML-DSA leg is append-only on the gossip wire.
// Legacy peers that never set MLDSASignature send an empty bytes field;
// the receiver-side Verify treats absence as "classical-only" and refuses
// it only when a strict-PQ profile is in force (see VerifyUnderProfile).
// This preserves backwards compatibility with non-strict-PQ chains
// (testnet, devnet) while enabling strict-PQ chains to refuse the
// quantum-broken-only path.
type SignedIP struct {
UnsignedIP
TLSSignature []byte
BLSSignature *bls.Signature
BLSSignatureBytes []byte
// MLDSASignature is the FIPS 204 ML-DSA-65 signature over the
// canonical UnsignedIP bytes under the "lux-peer-signed-ip-v1"
// context. Empty on classical-only SignedIPs produced by legacy
// peers; required under strict-PQ profile.
MLDSASignature []byte
}
// Returns nil if:
// * [ip.Timestamp] is within the allowed clock skew range (not too far in past or future).
// * [ip.TLSSignature] is a valid signature over [ip.UnsignedIP] from [cert].
// Verify checks the classical (TLS) leg only. Retained for backwards
// compatibility with callers that do not have access to the chain's
// security profile or the validator's ML-DSA-65 public key. Strict-PQ
// callers MUST go through VerifyUnderProfile so the ML-DSA leg gets
// checked.
//
// [maxTimestamp] defines the maximum allowed timestamp (current time + MaxClockDifference).
// The minimum allowed timestamp is inferred as (maxTimestamp - ReasonableClockSkewWindow) to
// prevent replay attacks. We use a conservative 10-minute window to account for various
// MaxClockDifference configurations while still protecting against replay attacks.
// Returns nil if:
// - [ip.Timestamp] is within the allowed clock skew range (not too far
// in past or future).
// - [ip.TLSSignature] is a valid signature over [ip.UnsignedIP] from
// [cert].
//
// [maxTimestamp] defines the maximum allowed timestamp (current time +
// MaxClockDifference). The minimum allowed timestamp is inferred as
// (maxTimestamp - reasonableClockSkewWindow) to prevent replay attacks.
// We use a conservative 10-minute window to account for various
// MaxClockDifference configurations while still protecting against
// replay attacks.
func (ip *SignedIP) Verify(
cert *staking.Certificate,
maxTimestamp time.Time,
) error {
return ip.VerifyUnderProfile(cert, nil, maxTimestamp, false)
}
// VerifyUnderProfile is the profile-aware verifier. It always checks the
// TLS leg (classical), and additionally:
//
// - When strictPQ == true, requires a non-empty MLDSASignature and
// verifies it under mldsaPubKey. mldsaPubKey MUST be supplied; passing
// a nil pubkey under strict-PQ is a configuration error (returns
// errMissingMLDSAPublicKey). The strict-PQ profile is what every
// Lux-derived strict-PQ chain pins (SigSchemeMLDSA65); permissive
// profiles (testnet, devnet) call this with strictPQ == false.
//
// - When strictPQ == false but MLDSASignature is present AND mldsaPubKey
// is non-nil, the ML-DSA leg is still verified (defence in depth —
// the wire bytes are already there, refusing to validate them would
// waste evidence). Absence under permissive is tolerated.
//
// The BLS leg is verified by the caller separately (peer.shouldDisconnect
// runs BLS validation against the validator's BLS public key tracked in
// the platformvm validator set, not against the staker cert; that path
// is unchanged).
func (ip *SignedIP) VerifyUnderProfile(
cert *staking.Certificate,
mldsaPubKey *mldsa.PublicKey,
maxTimestamp time.Time,
strictPQ bool,
) error {
maxUnixTimestamp := uint64(maxTimestamp.Unix())
if ip.Timestamp > maxUnixTimestamp {
@@ -111,12 +219,38 @@ func (ip *SignedIP) Verify(
return fmt.Errorf("%w: timestamp %d < minTimestamp %d", errTimestampTooFarInPast, ip.Timestamp, minUnixTimestamp)
}
ipBytes := ip.UnsignedIP.bytes()
if err := staking.CheckSignature(
cert,
ip.UnsignedIP.bytes(),
ipBytes,
ip.TLSSignature,
); err != nil {
return fmt.Errorf("%w: %w", errInvalidTLSSignature, err)
}
// ML-DSA leg. Three cases:
//
// strictPQ && empty sig → refuse (errMissingMLDSASignature)
// strictPQ && nil pubkey → refuse (errMissingMLDSAPublicKey)
// sig present && pubkey present → verify; refuse on failure
// !strictPQ && empty sig → tolerate (classical-only OK)
//
// We never refuse a permissive chain that supplies a pubkey but the
// peer chose not to sign with ML-DSA — that combination is normal on
// a mixed network and the strictPQ flag is what enforces the
// "must have a sig" policy.
if strictPQ {
if len(ip.MLDSASignature) == 0 {
return errMissingMLDSASignature
}
if mldsaPubKey == nil {
return errMissingMLDSAPublicKey
}
}
if len(ip.MLDSASignature) > 0 && mldsaPubKey != nil {
if !mldsaPubKey.VerifySignatureCtx(ipBytes, ip.MLDSASignature, signedIPMLDSAContext) {
return errInvalidMLDSASignature
}
}
return nil
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"crypto"
"crypto/rand"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/crypto/bls/signer/localsigner"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/node/staking"
)
// TestSignedIP_RoundTripsPQ verifies that a SignedIP minted with the
// ML-DSA-65 leg round-trips correctly: TLS leg verifies, ML-DSA leg
// verifies, and the strict-PQ profile gate is satisfied.
func TestSignedIP_RoundTripsPQ(t *testing.T) {
require := require.New(t)
tlsCert, err := staking.NewTLSCert()
require.NoError(err)
cert, err := staking.ParseCertificate(tlsCert.Leaf.Raw)
require.NoError(err)
tlsKey := tlsCert.PrivateKey.(crypto.Signer)
blsKey, err := localsigner.New()
require.NoError(err)
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
mldsaPub := mldsaPriv.PublicKey
now := time.Now()
unsigned := UnsignedIP{
AddrPort: netip.MustParseAddrPort("1.2.3.4:9650"),
Timestamp: uint64(now.Unix()),
}
signed, err := unsigned.SignPQ(tlsKey, blsKey, mldsaPriv)
require.NoError(err)
require.NotEmpty(signed.TLSSignature)
require.NotEmpty(signed.BLSSignatureBytes)
require.NotEmpty(signed.MLDSASignature, "ML-DSA leg must be present after SignPQ with a real signer")
// Classical-only verifier accepts the cert + TLS sig.
require.NoError(signed.Verify(cert, now))
// Strict-PQ verifier accepts when both legs are present and the
// ML-DSA public key matches.
require.NoError(signed.VerifyUnderProfile(cert, mldsaPub, now, true))
// Tampering with the ML-DSA signature byte flips the gate.
tampered := *signed
tampered.MLDSASignature = append([]byte(nil), signed.MLDSASignature...)
tampered.MLDSASignature[0] ^= 0xFF
require.ErrorIs(tampered.VerifyUnderProfile(cert, mldsaPub, now, true), errInvalidMLDSASignature)
// A different ML-DSA public key fails verification.
otherPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
require.ErrorIs(
signed.VerifyUnderProfile(cert, otherPriv.PublicKey, now, true),
errInvalidMLDSASignature,
)
}
// TestSignedIP_RefusesMissingMLDSAUnderStrictPQ verifies that a classical-only
// SignedIP (TLS + BLS, no ML-DSA leg) is refused by the strict-PQ verifier.
// This is the property the task asks us to enforce: both classical legs
// (TLS, BLS12-381) are quantum-broken, so the strict-PQ chain refuses any
// gossip that lacks the post-quantum witness.
func TestSignedIP_RefusesMissingMLDSAUnderStrictPQ(t *testing.T) {
require := require.New(t)
tlsCert, err := staking.NewTLSCert()
require.NoError(err)
cert, err := staking.ParseCertificate(tlsCert.Leaf.Raw)
require.NoError(err)
tlsKey := tlsCert.PrivateKey.(crypto.Signer)
blsKey, err := localsigner.New()
require.NoError(err)
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
mldsaPub := mldsaPriv.PublicKey
now := time.Now()
unsigned := UnsignedIP{
AddrPort: netip.MustParseAddrPort("1.2.3.4:9650"),
Timestamp: uint64(now.Unix()),
}
// Classical-only sign — no ML-DSA leg.
classicalOnly, err := unsigned.Sign(tlsKey, blsKey)
require.NoError(err)
require.Empty(classicalOnly.MLDSASignature)
// Strict-PQ verifier refuses the classical-only sig.
err = classicalOnly.VerifyUnderProfile(cert, mldsaPub, now, true)
require.ErrorIs(err, errMissingMLDSASignature)
// And refuses again when the caller forgets to supply the pubkey
// (different failure mode, same outcome — refusal).
signedWithLeg, err := unsigned.SignPQ(tlsKey, blsKey, mldsaPriv)
require.NoError(err)
require.ErrorIs(
signedWithLeg.VerifyUnderProfile(cert, nil, now, true),
errMissingMLDSAPublicKey,
)
}
// TestSignedIP_AcceptsClassicalOnlyUnderPermissive verifies backwards
// compatibility: a non-strict-PQ chain (testnet / devnet / permissive
// profile) still accepts a SignedIP that carries only the classical TLS
// + BLS legs and no ML-DSA leg. This is what keeps a freshly-built strict-
// PQ binary from breaking peer-handshake with the existing testnet fleet.
func TestSignedIP_AcceptsClassicalOnlyUnderPermissive(t *testing.T) {
require := require.New(t)
tlsCert, err := staking.NewTLSCert()
require.NoError(err)
cert, err := staking.ParseCertificate(tlsCert.Leaf.Raw)
require.NoError(err)
tlsKey := tlsCert.PrivateKey.(crypto.Signer)
blsKey, err := localsigner.New()
require.NoError(err)
now := time.Now()
unsigned := UnsignedIP{
AddrPort: netip.MustParseAddrPort("1.2.3.4:9650"),
Timestamp: uint64(now.Unix()),
}
// Classical-only signer (no ML-DSA key wired) — mirrors the
// permissive testnet binary that has not yet rolled the PQ identity
// key onto its validator nodes.
classicalOnly, err := unsigned.Sign(tlsKey, blsKey)
require.NoError(err)
require.Empty(classicalOnly.MLDSASignature)
// Permissive verifier (strictPQ == false, nil pubkey) accepts.
require.NoError(classicalOnly.VerifyUnderProfile(cert, nil, now, false))
// And the legacy Verify path keeps working (peers that bypass the
// profile-aware verifier — e.g. test rigs).
require.NoError(classicalOnly.Verify(cert, now))
// Defence in depth: when a permissive verifier happens to have a
// pubkey on hand AND the peer supplied a sig, the leg is still
// validated. We synthesise a valid PQ sig to confirm.
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
withPQ, err := unsigned.SignPQ(tlsKey, blsKey, mldsaPriv)
require.NoError(err)
require.NoError(withPQ.VerifyUnderProfile(cert, mldsaPriv.PublicKey, now, false))
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"testing"
"github.com/stretchr/testify/require"
zappb "github.com/luxfi/proto/node/zap/p2p"
)
// TestHandshakeWire_LegacyFrameOmitsMLDSA verifies the wire-compat
// guarantee for the append-only IpMldsaSig field. A handshake frame
// emitted by a legacy peer (no IpMldsaSig at all) still round-trips
// through the new marshal/unmarshal pair, with IpMldsaSig reading as
// nil/empty.
func TestHandshakeWire_LegacyFrameOmitsMLDSA(t *testing.T) {
require := require.New(t)
// Build a handshake without ML-DSA.
hs := &zappb.Handshake{
NetworkId: 1337,
MyTime: 12345,
IpAddr: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4},
IpPort: 9650,
IpSigningTime: 12345,
IpNodeIdSig: []byte("legacy-tls-sig"),
TrackedNets: [][]byte{},
Client: &zappb.Client{
Name: "lux",
Major: 1,
Minor: 26,
Patch: 12,
},
SupportedLps: []uint32{},
ObjectedLps: []uint32{},
KnownPeers: &zappb.BloomFilter{
Filter: []byte{0x01, 0x02},
Salt: []byte{0x03, 0x04},
},
IpBlsSig: []byte("legacy-bls-sig"),
AllChains: false,
IpMldsaSig: nil,
}
// Wrap in a Message and round-trip via the codec.
msg := &zappb.Message{Message: &zappb.Message_Handshake{Handshake: hs}}
raw, err := zappb.Marshal(msg)
require.NoError(err)
var decoded zappb.Message
require.NoError(zappb.Unmarshal(raw, &decoded))
roundTripped, ok := decoded.Message.(*zappb.Message_Handshake)
require.True(ok, "decoded message must be a Handshake")
require.Empty(roundTripped.Handshake.IpMldsaSig,
"empty ML-DSA on the wire decodes to empty on the receiver")
require.Equal(hs.IpNodeIdSig, roundTripped.Handshake.IpNodeIdSig)
require.Equal(hs.IpBlsSig, roundTripped.Handshake.IpBlsSig)
}
// TestHandshakeWire_TruncatedLegacyFrameDecodes mimics a true legacy
// wire payload that ends after IpBlsSig with no trailing IpMldsaSig
// length-prefix. The new unmarshaller MUST tolerate this — that's the
// whole point of the HasMore() guard around the new field read. Without
// the guard, decoding would fail with io.ErrUnexpectedEOF and the peer
// handshake would refuse every legacy peer on the network.
func TestHandshakeWire_TruncatedLegacyFrameDecodes(t *testing.T) {
require := require.New(t)
// First produce a new-format frame, then strip the trailing 4-byte
// length prefix (the empty-bytes encoding of IpMldsaSig). The result
// is byte-for-byte what a pre-PQ binary would have produced.
hs := &zappb.Handshake{
NetworkId: 1337,
MyTime: 7777,
IpAddr: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4},
IpPort: 9650,
IpSigningTime: 7777,
IpNodeIdSig: []byte("legacy-tls"),
TrackedNets: [][]byte{},
Client: &zappb.Client{Name: "lux", Major: 1, Minor: 0, Patch: 0},
SupportedLps: []uint32{},
ObjectedLps: []uint32{},
KnownPeers: &zappb.BloomFilter{Filter: []byte{}, Salt: []byte{}},
IpBlsSig: []byte("legacy-bls"),
AllChains: false,
IpMldsaSig: nil,
}
msg := &zappb.Message{Message: &zappb.Message_Handshake{Handshake: hs}}
raw, err := zappb.Marshal(msg)
require.NoError(err)
// The last 4 bytes of the inner Handshake encoding are the empty
// IpMldsaSig length-prefix (0x00000000). Truncating them simulates
// a legacy peer that never wrote the field.
require.GreaterOrEqual(len(raw), 4, "marshalled frame must be larger than the trailing field")
truncated := raw[:len(raw)-4]
var decoded zappb.Message
require.NoError(zappb.Unmarshal(truncated, &decoded),
"truncated legacy frame must decode without error")
roundTripped, ok := decoded.Message.(*zappb.Message_Handshake)
require.True(ok)
require.Empty(roundTripped.Handshake.IpMldsaSig,
"absent field reads as empty")
require.Equal(hs.IpBlsSig, roundTripped.Handshake.IpBlsSig)
}
// TestHandshakeWire_NewFrameRoundTripsMLDSA verifies that a new-format
// frame (with a non-empty IpMldsaSig) round-trips intact.
func TestHandshakeWire_NewFrameRoundTripsMLDSA(t *testing.T) {
require := require.New(t)
mldsaSig := make([]byte, 3309) // ML-DSA-65 signature size
for i := range mldsaSig {
mldsaSig[i] = byte(i)
}
hs := &zappb.Handshake{
NetworkId: 1,
MyTime: 99999,
IpAddr: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 1},
IpPort: 9630,
IpSigningTime: 99999,
IpNodeIdSig: []byte("strict-pq-tls-sig"),
TrackedNets: [][]byte{},
Client: &zappb.Client{
Name: "lux",
Major: 1,
Minor: 26,
Patch: 13,
},
SupportedLps: []uint32{},
ObjectedLps: []uint32{},
KnownPeers: &zappb.BloomFilter{Filter: []byte{}, Salt: []byte{}},
IpBlsSig: []byte("strict-pq-bls-sig"),
AllChains: true,
IpMldsaSig: mldsaSig,
}
msg := &zappb.Message{Message: &zappb.Message_Handshake{Handshake: hs}}
raw, err := zappb.Marshal(msg)
require.NoError(err)
var decoded zappb.Message
require.NoError(zappb.Unmarshal(raw, &decoded))
roundTripped, ok := decoded.Message.(*zappb.Message_Handshake)
require.True(ok, "decoded message must be a Handshake")
require.Equal(mldsaSig, roundTripped.Handshake.IpMldsaSig,
"ML-DSA sig must round-trip byte-for-byte")
}
+35 -9
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
@@ -10,6 +10,7 @@ import (
"time"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/timer/mockable"
"github.com/luxfi/utils"
)
@@ -22,11 +23,19 @@ const (
)
// IPSigner will return a signedIP for the current value of our dynamic IP.
//
// Carries up to three signing legs: TLS (classical, staker cert),
// BLS12-381 (classical, proof-of-possession), and ML-DSA-65 (FIPS 204
// post-quantum). The ML-DSA leg is optional at construction time —
// passing mldsaSigner == nil to NewIPSigner produces classical-only
// gossip that strict-PQ verifiers refuse. Strict-PQ chains MUST be wired
// with a real ML-DSA-65 private key.
type IPSigner struct {
ip *utils.Atomic[netip.AddrPort]
clock mockable.Clock
tlsSigner crypto.Signer
blsSigner bls.Signer
ip *utils.Atomic[netip.AddrPort]
clock mockable.Clock
tlsSigner crypto.Signer
blsSigner bls.Signer
mldsaSigner *mldsa.PrivateKey
// Must be held while accessing [signedIP]
signedIPLock sync.RWMutex
@@ -35,15 +44,32 @@ type IPSigner struct {
signedIP *SignedIP
}
// NewIPSigner constructs a classical-only IPSigner (TLS + BLS legs). Use
// NewIPSignerPQ when the node has an ML-DSA-65 identity key and the chain
// runs under a strict-PQ profile.
func NewIPSigner(
ip *utils.Atomic[netip.AddrPort],
tlsSigner crypto.Signer,
blsSigner bls.Signer,
) *IPSigner {
return NewIPSignerPQ(ip, tlsSigner, blsSigner, nil)
}
// NewIPSignerPQ constructs an IPSigner that signs every refresh with all
// three legs the caller supplies. mldsaSigner may be nil to keep the
// classical-only behaviour (for permissive chains or for tests that have
// not yet generated a long-term ML-DSA key).
func NewIPSignerPQ(
ip *utils.Atomic[netip.AddrPort],
tlsSigner crypto.Signer,
blsSigner bls.Signer,
mldsaSigner *mldsa.PrivateKey,
) *IPSigner {
return &IPSigner{
ip: ip,
tlsSigner: tlsSigner,
blsSigner: blsSigner,
ip: ip,
tlsSigner: tlsSigner,
blsSigner: blsSigner,
mldsaSigner: mldsaSigner,
}
}
@@ -82,7 +108,7 @@ func (s *IPSigner) GetSignedIP() (*SignedIP, error) {
AddrPort: ip,
Timestamp: s.clock.Unix(),
}
signedIP, err := unsignedIP.Sign(s.tlsSigner, s.blsSigner)
signedIP, err := unsignedIP.SignPQ(s.tlsSigner, s.blsSigner, s.mldsaSigner)
if err != nil {
return nil, err
}
+108 -1
View File
@@ -7,6 +7,7 @@ import (
"bufio"
"context"
"errors"
"fmt"
"io"
"math"
"net"
@@ -198,12 +199,27 @@ type peer struct {
// isIngress is true only if the remote peer is connected to this node,
// in contrast of this node being connected to the remote peer.
isIngress bool
// pqAEADKey is the 32-byte AEAD session key derived by the strict-PQ
// application-layer handshake (ML-KEM + ML-DSA-65). Zero-valued on
// chains that don't run the PQ handshake; non-zero only after
// runPQHandshakeIfRequired completes successfully. The AEAD wrapper
// over p.conn reads this key once at handshake completion; it is
// never mutated after Start returns.
pqAEADKey [32]byte
}
// Start a new peer instance.
//
// Invariant: There must only be one peer running at a time with a reference to
// the same [config.InboundMsgThrottler].
//
// On strict-PQ chains (config.PQHandshakeConfig non-nil) Start runs the
// application-layer ML-KEM + ML-DSA-65 handshake synchronously on the wire
// before launching the message-pump goroutines. The role is derived from
// isIngress — the dial side is the initiator, the accept side is the
// responder. If the PQ handshake fails the connection is closed and the
// returned Peer is in the closed state; no fallback to bare TLS.
func Start(
config *Config,
conn net.Conn,
@@ -233,6 +249,23 @@ func Start(
p.IngressConnectionCount.Add(1)
}
// PQ handshake gate: strict-PQ chains run the application-layer
// ML-KEM + ML-DSA-65 handshake on the wire BEFORE any p2p message
// is exchanged. Permissive / classical-compat chains skip this step.
if err := p.runPQHandshakeIfRequired(); err != nil {
p.Log.Warn("PQ handshake refused; closing connection",
log.Stringer("nodeID", p.id),
log.Bool("isIngress", isIngress),
log.Reflect("error", err),
)
// Mark all 3 goroutines as not running so close() doesn't wait
// for them. The connection is torn down; the peer is closed.
atomic.StoreInt64(&p.numExecuting, 1)
_ = p.conn.Close()
p.close()
return p
}
go p.readMessages()
go p.writeMessages()
go p.sendNetworkMessages()
@@ -240,6 +273,74 @@ func Start(
return p
}
// runPQHandshakeIfRequired runs the strict-PQ application-layer handshake
// synchronously over p.conn. Returns nil when the chain is on the legacy
// bare-TLS path (no PQHandshakeConfig pinned) so the caller proceeds
// unchanged. On strict-PQ, completes INIT / RESP / FINISH and stashes the
// derived AEAD key on the peer; subsequent application messages are
// authenticated against this key by the AEAD wrapper installed by the
// next CR. Wire framing mirrors the peer's existing 4-byte length-prefix
// scheme so the framing logic is identical on both sides of the wire.
func (p *peer) runPQHandshakeIfRequired() error {
cfg := p.Config
if cfg == nil || cfg.PQHandshakeConfig == nil || cfg.PQLocalIdentity == nil {
return nil
}
deadline := time.Now().Add(cfg.MaxClockDifference + 30*time.Second)
if err := p.conn.SetDeadline(deadline); err != nil {
return fmt.Errorf("set PQ handshake deadline: %w", err)
}
defer func() {
// Clear deadline so the rest of the peer flow uses its own.
_ = p.conn.SetDeadline(time.Time{})
}()
if p.isIngress {
// Responder: read INIT, send RESP, derive AEAD.
initBytes, err := readPQFrame(p.conn)
if err != nil {
return fmt.Errorf("read PQ INIT: %w", err)
}
init, err := parsePQHandshakeInit(initBytes, cfg.PQHandshakeConfig.KEMScheme)
if err != nil {
return fmt.Errorf("parse PQ INIT: %w", err)
}
resp, result, err := RespondHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity, init)
if err != nil {
return fmt.Errorf("respond PQ handshake: %w", err)
}
if err := writePQFrame(p.conn, resp.canonicalBytes()); err != nil {
return fmt.Errorf("write PQ RESP: %w", err)
}
p.pqAEADKey = result.AEADKey
return nil
}
// Initiator: send INIT, read RESP, finalize.
init, kemSec, err := InitiateHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity)
if err != nil {
return fmt.Errorf("initiate PQ handshake: %w", err)
}
if err := writePQFrame(p.conn, init.canonicalBytes()); err != nil {
return fmt.Errorf("write PQ INIT: %w", err)
}
respBytes, err := readPQFrame(p.conn)
if err != nil {
return fmt.Errorf("read PQ RESP: %w", err)
}
resp, err := parsePQHandshakeResp(respBytes, cfg.PQHandshakeConfig.KEMScheme)
if err != nil {
return fmt.Errorf("parse PQ RESP: %w", err)
}
result, err := FinishInitiatorHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity, init, resp, kemSec)
if err != nil {
return fmt.Errorf("finish PQ handshake: %w", err)
}
p.pqAEADKey = result.AEADKey
return nil
}
func (p *peer) ID() ids.NodeID {
return p.id
}
@@ -552,6 +653,7 @@ func (p *peer) writeMessages() {
knownPeersFilter,
knownPeersSalt,
areWeAPrimaryNetworkValidator,
mySignedIP.MLDSASignature,
)
if err != nil {
p.Log.Error(failedToCreateMessageLog,
@@ -714,7 +816,7 @@ func (p *peer) shouldDisconnect() bool {
}
// Enforce that all validators that have registered a BLS key are signing
// their IP with it after the activation of Durango.
// their IP with the signed-IP payload.
vdr, ok := p.Validators.GetValidator(constants.PrimaryNetworkID, p.id)
if !ok || vdr.PublicKey == nil || vdr.TxID == p.txIDOfVerifiedBLSKey {
return false
@@ -1015,6 +1117,11 @@ func (p *peer) handleHandshake(msg *p2p.Handshake) {
Timestamp: msg.IpSigningTime,
},
TLSSignature: msg.IpNodeIdSig,
// Empty on legacy peers (classical-only); the SignedIP.Verify
// call below only enforces the ML-DSA leg under strict-PQ
// profile and only when the validator's ML-DSA public key is
// known to the verifier. The wire format tolerates absence.
MLDSASignature: msg.GetIpMldsaSig(),
}
maxTimestamp := localTime.Add(p.MaxClockDifference)
if err := p.ip.Verify(p.cert, maxTimestamp); err != nil {
+1
View File
@@ -101,6 +101,7 @@ func FuzzPeerMessageHandling(f *testing.F) {
[]byte{},
[]byte{},
false,
[]byte{},
)
case 3: // PeerList
claimedIPs := []*endpoints.ClaimedIPPort{
+208
View File
@@ -0,0 +1,208 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"github.com/luxfi/node/network/kem"
)
// pqFrameMaxSize is the upper bound on a single PQ-handshake frame (INIT
// or RESP). Both messages carry an ML-DSA-65 signature (3309 B), an
// ML-DSA-65 public key (1952 B), an ML-KEM-1024 public key (1568 B) or
// ciphertext (1568 B), plus version / profile / chain-id / NodeID fields
// (~70 B). 16 KiB is comfortably above the largest legitimate frame and
// well below any DoS-relevant threshold.
const pqFrameMaxSize = 16 * 1024
// errPQFrameTooLarge is returned when a peer announces a PQ-handshake
// frame larger than pqFrameMaxSize. Refusing oversize frames before the
// allocation prevents a memory-exhaustion DoS.
var errPQFrameTooLarge = errors.New("peer: PQ handshake frame exceeds size cap")
// readPQFrame reads a single 4-byte big-endian length-prefixed PQ frame
// from conn. Mirrors the framing scheme the rest of the peer wire uses;
// strict-PQ chains carry one INIT + one RESP via these calls before any
// p2p message flows.
func readPQFrame(conn net.Conn) ([]byte, error) {
var hdr [4]byte
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
return nil, fmt.Errorf("read PQ frame header: %w", err)
}
size := binary.BigEndian.Uint32(hdr[:])
if size > pqFrameMaxSize {
return nil, fmt.Errorf("%w: %d > %d", errPQFrameTooLarge, size, pqFrameMaxSize)
}
buf := make([]byte, size)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read PQ frame body: %w", err)
}
return buf, nil
}
// writePQFrame writes a single 4-byte big-endian length-prefixed PQ
// frame onto conn. Symmetric with readPQFrame.
func writePQFrame(conn net.Conn, payload []byte) error {
if len(payload) > pqFrameMaxSize {
return fmt.Errorf("%w: payload=%d > cap=%d",
errPQFrameTooLarge, len(payload), pqFrameMaxSize)
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
if _, err := conn.Write(hdr[:]); err != nil {
return fmt.Errorf("write PQ frame header: %w", err)
}
if _, err := conn.Write(payload); err != nil {
return fmt.Errorf("write PQ frame body: %w", err)
}
return nil
}
// parsePQHandshakeInit decodes the canonical wire form of HandshakeInit.
// Field layout matches HandshakeInit.canonicalBytes / transcriptPrefix in
// handshake.go. The KEM scheme parameter pins the expected lengths of
// KEMPub and Sig — passing the wrong scheme produces a length mismatch
// error before any signature work runs.
func parsePQHandshakeInit(b []byte, scheme kem.KeyExchangeID) (*HandshakeInit, error) {
r := newPQReader(b)
h := &HandshakeInit{}
var err error
if h.ProtocolVersion, err = r.readU8(); err != nil {
return nil, err
}
profByte, err := r.readU8()
if err != nil {
return nil, err
}
h.Profile = ProfileID(profByte)
if err := r.readFixed(h.ChainID[:]); err != nil {
return nil, err
}
kemByte, err := r.readU8()
if err != nil {
return nil, err
}
h.KEMScheme = kem.KeyExchangeID(kemByte)
if h.KEMScheme != scheme {
return nil, fmt.Errorf("%w: peer offered %s, expected %s",
ErrHandshakeKEMScheme, h.KEMScheme, scheme)
}
var nid [nodeIDSize]byte
if err := r.readFixed(nid[:]); err != nil {
return nil, err
}
copy(h.NodeID[:], nid[:])
if h.MLDSAPub, err = r.readBytes(); err != nil {
return nil, err
}
if h.KEMPub, err = r.readBytes(); err != nil {
return nil, err
}
if h.Sig, err = r.readBytes(); err != nil {
return nil, err
}
if r.remaining() != 0 {
return nil, fmt.Errorf("%w: trailing bytes in PQ INIT (%d)",
ErrHandshakeBadIdentity, r.remaining())
}
return h, nil
}
// parsePQHandshakeResp decodes the canonical wire form of HandshakeResp.
// Symmetric with parsePQHandshakeInit; KEMCiphertext replaces KEMPub.
func parsePQHandshakeResp(b []byte, scheme kem.KeyExchangeID) (*HandshakeResp, error) {
r := newPQReader(b)
h := &HandshakeResp{}
var err error
if h.ProtocolVersion, err = r.readU8(); err != nil {
return nil, err
}
profByte, err := r.readU8()
if err != nil {
return nil, err
}
h.Profile = ProfileID(profByte)
if err := r.readFixed(h.ChainID[:]); err != nil {
return nil, err
}
kemByte, err := r.readU8()
if err != nil {
return nil, err
}
h.KEMScheme = kem.KeyExchangeID(kemByte)
if h.KEMScheme != scheme {
return nil, fmt.Errorf("%w: peer offered %s, expected %s",
ErrHandshakeKEMScheme, h.KEMScheme, scheme)
}
var nid [nodeIDSize]byte
if err := r.readFixed(nid[:]); err != nil {
return nil, err
}
copy(h.NodeID[:], nid[:])
if h.MLDSAPub, err = r.readBytes(); err != nil {
return nil, err
}
if h.KEMCiphertext, err = r.readBytes(); err != nil {
return nil, err
}
if h.Sig, err = r.readBytes(); err != nil {
return nil, err
}
if r.remaining() != 0 {
return nil, fmt.Errorf("%w: trailing bytes in PQ RESP (%d)",
ErrHandshakeBadIdentity, r.remaining())
}
return h, nil
}
// pqReader is a tiny zero-allocation reader for the PQ handshake wire
// format. Kept local to this file so the encoding stays reviewable in
// one place; the writer side lives on the HandshakeInit / HandshakeResp
// methods in handshake.go.
type pqReader struct {
buf []byte
offset int
}
func newPQReader(b []byte) *pqReader { return &pqReader{buf: b} }
func (r *pqReader) remaining() int { return len(r.buf) - r.offset }
func (r *pqReader) readU8() (uint8, error) {
if r.remaining() < 1 {
return 0, io.ErrUnexpectedEOF
}
v := r.buf[r.offset]
r.offset++
return v, nil
}
func (r *pqReader) readFixed(dst []byte) error {
if r.remaining() < len(dst) {
return io.ErrUnexpectedEOF
}
copy(dst, r.buf[r.offset:])
r.offset += len(dst)
return nil
}
func (r *pqReader) readBytes() ([]byte, error) {
if r.remaining() < 4 {
return nil, io.ErrUnexpectedEOF
}
n := binary.BigEndian.Uint32(r.buf[r.offset:])
r.offset += 4
if uint64(n) > uint64(r.remaining()) {
return nil, io.ErrUnexpectedEOF
}
out := make([]byte, n)
copy(out, r.buf[r.offset:r.offset+int(n)])
r.offset += int(n)
return out, nil
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// scheme_gate.go — single cross-axis gate between an incoming peer's
// NodeIDScheme byte and the chain's pinned ValidatorSchemeID. The gate
// is the primitive; callers that turn a wire NodeID into a "this peer
// is admissible on this chain" decision call Classify and pass the
// site name so the refused-by log line names which boundary refused.
//
// Sites that funnel through this gate:
//
// "handshake" — peer TLS upgrade (network/peer/upgrader.go)
// "proposer" — proposervm block proposer attribution
// "validator" — platformvm validator registration
// "mempool-sender" — mempool signed-tx submission
//
// Forward-only PQ: there is no transition window and no classical
// fallback. Every site funnels through Classify which admits only the
// pinned PQ NodeIDScheme byte (ML-DSA-65 today); anything else — a
// classical secp256k1 byte, a cross-PQ scheme like ML-DSA-87 on a chain
// pinning ML-DSA-65, or an unknown byte — is refused at the gate.
package peer
import (
"errors"
"fmt"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
)
// SchemeGate is the chain-scoped policy object that decides whether a
// peer's NodeIDScheme is admissible under the chain's pinned profile.
// One SchemeGate per chain; created at chain-bootstrap from the chain's
// ChainSecurityProfile and pinned for the chain's lifetime (no
// re-derivation in the hot path).
//
// Forward-only PQ: the gate has no transition window and no operator
// classical-compat escape hatch. Every Classify call admits only the
// pinned PQ scheme byte; classical secp256k1 NodeIDs and cross-PQ
// bytes are refused at every height.
type SchemeGate struct {
// Profile is the chain's locked ChainSecurityProfile. The pinned
// ValidatorSchemeID is read through profile.ValidatorSchemeID().
Profile *consensusconfig.ChainSecurityProfile
// ActivationHeight is preserved for symmetry with the per-chain
// fork-height bookkeeping; the gate's policy is the same on both
// sides of it (PQ-only). Reserved for downstream callers that want
// a single field to log "where strict-PQ took effect" alongside
// other hardfork heights — the gate itself does not branch on it.
ActivationHeight uint64
}
// NewSchemeGate constructs a SchemeGate. The profile pointer MUST be
// non-nil and MUST have already passed Validate at chain-bootstrap;
// the gate does not re-validate the profile on each call.
func NewSchemeGate(
profile *consensusconfig.ChainSecurityProfile,
activationHeight uint64,
) (*SchemeGate, error) {
if profile == nil {
return nil, fmt.Errorf("%w: profile is nil", ErrSchemeGateConfig)
}
return &SchemeGate{
Profile: profile,
ActivationHeight: activationHeight,
}, nil
}
// Classify is the entry point for every cross-axis check. It takes the
// bare NodeID, the scheme byte derived by the caller (handshake / proposer
// / validator / mempool), the block height we are mounting against, and
// a site tag for the log line; it returns a TypedNodeID stamped with the
// scheme byte once the chain's policy admits the pair.
//
// Forward-only PQ: only the pinned PQ scheme byte is admitted. A
// classical secp256k1 byte, a cross-PQ byte (ML-DSA-87 on a chain
// pinning ML-DSA-65), or an unknown byte is refused at every height.
//
// site is a free-form tag ("handshake", "proposer", "validator",
// "mempool-sender" are the conventional values) included verbatim in
// the refused-by error so a log reader knows which boundary refused.
func (g *SchemeGate) Classify(
nodeID ids.NodeID,
derivedScheme ids.NodeIDScheme,
height uint64,
site string,
) (ids.TypedNodeID, error) {
if !derivedScheme.IsKnown() {
return ids.TypedNodeID{}, fmt.Errorf("%w: site=%s scheme=0x%02x",
ErrSchemeGateUnknownScheme, site, byte(derivedScheme))
}
// PQ-only: defer to the profile's cross-axis check with the
// classical-compat escape hatch pinned off. The profile refuses
// classical schemes; this gate refuses them at every height.
presentedSig := consensusconfig.SigSchemeID(derivedScheme)
if err := g.Profile.AcceptsValidatorScheme(presentedSig, false); err != nil {
return ids.TypedNodeID{}, fmt.Errorf("%w: site=%s height=%d: %w",
ErrSchemeGateMismatch, site, height, err)
}
typed, err := ids.NewTypedNodeID(derivedScheme, nodeID)
if err != nil {
return ids.TypedNodeID{}, fmt.Errorf("%w: site=%s: %w",
ErrSchemeGateMismatch, site, err)
}
return typed, nil
}
// Typed validation errors. Distinct from the consensus / ids errors so
// a log reader can tell exactly which boundary refused.
var (
// ErrSchemeGateConfig — the gate could not be constructed
// (nil profile, invalid configuration).
ErrSchemeGateConfig = errors.New("peer: SchemeGate misconfigured")
// ErrSchemeGateMismatch — a peer / proposer / validator /
// mempool sender presented a NodeIDScheme byte the chain's
// SchemeGate refuses. Wraps the underlying mismatch reason from
// consensus/config.AcceptsValidatorScheme or ids.NewTypedNodeID.
ErrSchemeGateMismatch = errors.New("peer: NodeIDScheme refused by SchemeGate")
// ErrSchemeGateUnknownScheme — a wire input named a NodeIDScheme
// byte this build does not understand. Always refused, regardless
// of profile class or activation height.
ErrSchemeGateUnknownScheme = errors.New("peer: NodeIDScheme byte is unknown")
)
+131
View File
@@ -0,0 +1,131 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"testing"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
)
// TestSchemeGate_NewSchemeGate_RejectsNilProfile — construction is
// fail-closed: a nil profile produces ErrSchemeGateConfig and never a
// half-formed gate.
func TestSchemeGate_NewSchemeGate_RejectsNilProfile(t *testing.T) {
require := require.New(t)
_, err := NewSchemeGate(nil, 0)
require.ErrorIs(err, ErrSchemeGateConfig)
}
// TestSchemeGate_StrictPQ_AcceptsMatchedScheme — under a strict-PQ
// profile an ML-DSA-65 NodeID is accepted at every site at every
// height.
func TestSchemeGate_StrictPQ_AcceptsMatchedScheme(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
id := ids.NodeID{0xaa, 0xbb}
for _, site := range []string{
"handshake", "proposer", "validator", "mempool-sender",
} {
for _, height := range []uint64{0, 100, 1_000_000} {
typed, err := g.Classify(id, ids.NodeIDSchemeMLDSA65, height, site)
require.NoError(err, "site=%s height=%d", site, height)
require.Equal(ids.NodeIDSchemeMLDSA65, typed.Scheme)
require.Equal(id, typed.NodeID)
}
}
}
// TestSchemeGate_StrictPQ_RejectsClassicalAtEveryHeight — a strict-PQ
// chain refuses a classical secp256k1 NodeID at every site and every
// height. There is no transition window in forward-only PQ mode.
func TestSchemeGate_StrictPQ_RejectsClassicalAtEveryHeight(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
id := ids.NodeID{0x11, 0x22}
for _, site := range []string{
"handshake", "proposer", "validator", "mempool-sender",
} {
for _, height := range []uint64{0, 50, 1_000_000} {
_, err := g.Classify(id, ids.NodeIDSchemeSecp256k1, height, site)
require.ErrorIs(err, ErrSchemeGateMismatch,
"site=%s height=%d", site, height)
}
}
}
// TestSchemeGate_RejectsCrossPQScheme — a chain pinning ML-DSA-65 must
// refuse an ML-DSA-87 NodeID. Cross-PQ bytes are never admissible.
func TestSchemeGate_RejectsCrossPQScheme(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
_, err = g.Classify(ids.NodeID{0x33}, ids.NodeIDSchemeMLDSA87, 0, "handshake")
require.ErrorIs(err, ErrSchemeGateMismatch)
}
// TestSchemeGate_RejectsUnknownSchemeByte — an unknown scheme byte is
// refused at every height. The PQ-only rule does not widen the byte
// set.
func TestSchemeGate_RejectsUnknownSchemeByte(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 1000)
require.NoError(err)
id := ids.NodeID{0x99}
_, err = g.Classify(id, ids.NodeIDScheme(0x91), 500, "handshake")
require.ErrorIs(err, ErrSchemeGateUnknownScheme)
_, err = g.Classify(id, ids.NodeIDScheme(0x91), 2000, "handshake")
require.ErrorIs(err, ErrSchemeGateUnknownScheme)
}
// TestSchemeGate_PinsProfileScheme — the gate's pinned scheme reads
// through the profile's ValidatorSchemeID. A profile-byte change
// changes the gate's acceptance set without any gate-level edit.
func TestSchemeGate_PinsProfileScheme(t *testing.T) {
require := require.New(t)
p := consensusconfig.StrictPQ()
g, err := NewSchemeGate(p, 0)
require.NoError(err)
id := ids.NodeID{0x01}
_, err = g.Classify(id, ids.NodeIDScheme(p.ValidatorSchemeID()), 0, "handshake")
require.NoError(err)
otherPQ := ids.NodeIDSchemeMLDSA87
if p.ValidatorSchemeID() == consensusconfig.SigSchemeMLDSA87 {
otherPQ = ids.NodeIDSchemeMLDSA65
}
_, err = g.Classify(id, otherPQ, 0, "handshake")
require.ErrorIs(err, ErrSchemeGateMismatch)
}
// TestSchemeGate_SiteTagIncludedInError — the site tag passed to
// Classify must appear in the returned error so a log reader can
// identify which boundary refused.
func TestSchemeGate_SiteTagIncludedInError(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
_, err = g.Classify(ids.NodeID{0x01}, ids.NodeIDSchemeSecp256k1, 1, "proposer")
require.Error(err)
require.Contains(err.Error(), "site=proposer")
}
+1
View File
@@ -73,6 +73,7 @@ func StartTestPeer(
clientUpgrader := NewTLSClientUpgrader(
tlsConfg,
metric.NewCounter(metric.CounterOpts{}),
nil, // no SchemeGate in tests; the cross-axis check is opt-in.
)
peerID, conn, cert, err := clientUpgrader.Upgrade(conn)
+20
View File
@@ -28,6 +28,26 @@ var (
//
// It is safe, and typically expected, for [keyLogWriter] to be [nil].
// [keyLogWriter] should only be enabled for debugging.
//
// F103 KEM semantics — TLS curve preference:
//
// CurvePreferences pins the IANA-registered hybrid X25519MLKEM768
// (curve ID 0x11ec) as the only acceptable curve. This is STRICTLY
// STRONGER than pure ML-KEM-768: an attacker must break both X25519
// AND ML-KEM-768 to derive the session key. The chain-wide
// ChainSecurityProfile pins KeyExchangeMLKEM768 — the post-quantum
// component required on the wire — and the hybrid satisfies that
// requirement (it CONTAINS ML-KEM-768). ForbidClassicalKEM in the
// profile refuses a pure-classical curve (e.g. X25519 alone) at the
// application layer; it does NOT refuse a hybrid that includes
// ML-KEM-768. See consensus/config.KeyExchangeID for the full
// rationale.
//
// Decision recorded by F103: keep the hybrid as the only TLS-layer
// KEM. Adding a separate "pure vs hybrid" enum byte to the profile
// would multiply downstream verifier obligations without strengthening
// the posture; the audit signed off on the single-byte KeyExchangeID
// surface and the hybrid is mathematically stronger than pure.
func TLSConfig(cert tls.Certificate, keyLogWriter io.Writer) *tls.Config {
return &tls.Config{
Certificates: []tls.Certificate{cert},
+51 -5
View File
@@ -4,6 +4,8 @@
package peer
import (
"crypto/ecdsa"
"crypto/rsa"
"crypto/tls"
"errors"
"net"
@@ -29,36 +31,53 @@ type Upgrader interface {
type tlsServerUpgrader struct {
config *tls.Config
invalidCerts metric.Counter
gate *SchemeGate
}
func NewTLSServerUpgrader(config *tls.Config, invalidCerts metric.Counter) Upgrader {
// NewTLSServerUpgrader returns an Upgrader that runs the server-side TLS
// handshake and (when gate is non-nil) the cross-axis NodeIDScheme gate
// against the chain's locked ChainSecurityProfile. A nil gate disables the
// cross-axis check; this is the legacy / classical-compat behaviour.
func NewTLSServerUpgrader(config *tls.Config, invalidCerts metric.Counter, gate *SchemeGate) Upgrader {
return &tlsServerUpgrader{
config: config,
invalidCerts: invalidCerts,
gate: gate,
}
}
func (t *tlsServerUpgrader) Upgrade(conn net.Conn) (ids.NodeID, net.Conn, *staking.Certificate, error) {
return connToIDAndCert(tls.Server(conn, t.config), t.invalidCerts)
return connToIDAndCert(tls.Server(conn, t.config), t.invalidCerts, t.gate)
}
type tlsClientUpgrader struct {
config *tls.Config
invalidCerts metric.Counter
gate *SchemeGate
}
func NewTLSClientUpgrader(config *tls.Config, invalidCerts metric.Counter) Upgrader {
// NewTLSClientUpgrader returns an Upgrader that runs the client-side TLS
// handshake and (when gate is non-nil) the cross-axis NodeIDScheme gate.
// A nil gate disables the cross-axis check.
func NewTLSClientUpgrader(config *tls.Config, invalidCerts metric.Counter, gate *SchemeGate) Upgrader {
return &tlsClientUpgrader{
config: config,
invalidCerts: invalidCerts,
gate: gate,
}
}
func (t *tlsClientUpgrader) Upgrade(conn net.Conn) (ids.NodeID, net.Conn, *staking.Certificate, error) {
return connToIDAndCert(tls.Client(conn, t.config), t.invalidCerts)
return connToIDAndCert(tls.Client(conn, t.config), t.invalidCerts, t.gate)
}
func connToIDAndCert(conn *tls.Conn, invalidCerts metric.Counter) (ids.NodeID, net.Conn, *staking.Certificate, error) {
// connToIDAndCert runs the TLS handshake, parses the peer leaf certificate,
// derives the canonical NodeID, and (when gate is non-nil) refuses the
// connection at the cross-axis gate before any downstream wiring sees it.
// Refusal here is by design: the gate is the single place that says "this
// peer's NodeIDScheme is not admissible on this chain"; later boundaries
// trust that an admitted peer has already passed.
func connToIDAndCert(conn *tls.Conn, invalidCerts metric.Counter, gate *SchemeGate) (ids.NodeID, net.Conn, *staking.Certificate, error) {
if err := conn.Handshake(); err != nil {
return ids.EmptyNodeID, nil, nil, err
}
@@ -79,5 +98,32 @@ func connToIDAndCert(conn *tls.Conn, invalidCerts metric.Counter) (ids.NodeID, n
Raw: peerCert.Raw,
PublicKey: peerCert.PublicKey,
})
if gate != nil {
scheme := schemeFromCert(peerCert)
if _, err := gate.Classify(nodeID, scheme, 0, "handshake"); err != nil {
return ids.EmptyNodeID, nil, nil, err
}
}
return nodeID, conn, peerCert, nil
}
// schemeFromCert derives the wire NodeIDScheme byte from a parsed TLS leaf
// certificate. TLS staking certs today carry RSA or ECDSA public keys —
// both classify as NodeIDSchemeSecp256k1 (the canonical classical-compat
// byte). A future ML-DSA cert extension (an X.509 extension carrying the
// FIPS 204 public key) is recognised by its OID; until that extension
// lands the function returns the classical byte. The gate's profile pin
// is what decides admission — this function only names the byte.
func schemeFromCert(cert *staking.Certificate) ids.NodeIDScheme {
if cert == nil || cert.PublicKey == nil {
return ids.NodeIDSchemeInvalid
}
switch cert.PublicKey.(type) {
case *rsa.PublicKey, *ecdsa.PublicKey:
return ids.NodeIDSchemeSecp256k1
default:
return ids.NodeIDSchemeInvalid
}
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer_test
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"net"
"testing"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/metric"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"github.com/luxfi/node/network/peer"
)
// TestUpgrader_StrictPQ_RefusesClassicalTLS — closes BLOCKERS CR-3.
// Constructs a tlsServerUpgrader wired with a strict-PQ peer.SchemeGate
// (ActivationHeight=0 — gate active from genesis) and dials it with a
// vanilla RSA TLS client. The server-side upgrade MUST refuse the
// connection at the SchemeGate, not later in some peer-init step. The
// returned error MUST wrap peer.ErrSchemeGateMismatch with site="handshake"
// so a log reader can identify the refusing boundary.
func TestUpgrader_StrictPQ_RefusesClassicalTLS(t *testing.T) {
require := require.New(t)
// Strict-PQ SchemeGate active from genesis. Forward-only PQ —
// the gate refuses the classical scheme byte at every height.
gate, err := peer.NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
serverKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
serverCert := makeTLSCert(t, serverKey)
serverTLS := peer.TLSConfig(serverCert, nil)
clientKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
clientCert := makeTLSCert(t, clientKey)
invalidCertCounter := metric.NewCounter(metric.CounterOpts{})
upgrader := peer.NewTLSServerUpgrader(serverTLS, invalidCertCounter, gate)
clientConfig := tls.Config{
ClientAuth: tls.RequireAnyClientCert,
InsecureSkipVerify: true, //#nosec G402 — staking TLS, trust derives from cert hash
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{clientCert},
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(err)
defer listener.Close()
eg := &errgroup.Group{}
eg.Go(func() error {
conn, err := listener.Accept()
if err != nil {
return err
}
_, _, _, upgradeErr := upgrader.Upgrade(conn)
return upgradeErr
})
conn, err := tls.Dial("tcp", listener.Addr().String(), &clientConfig)
require.NoError(err)
require.NoError(conn.Handshake())
err = eg.Wait()
require.Error(err, "strict-PQ SchemeGate MUST refuse classical RSA cert")
require.ErrorIs(err, peer.ErrSchemeGateMismatch,
"refusal MUST come from the SchemeGate, not later peer-init")
require.Contains(err.Error(), "site=handshake",
"refusal MUST name the handshake boundary in the error")
}
// TestUpgrader_NilGate_AcceptsClassicalTLS — the negative control. Without
// a SchemeGate (legacy / classical-compat networks), the upgrader accepts
// the same RSA TLS connection that the strict-PQ test refuses. This keeps
// the gate's behaviour orthogonal to the existing TLS path.
func TestUpgrader_NilGate_AcceptsClassicalTLS(t *testing.T) {
require := require.New(t)
serverKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
serverCert := makeTLSCert(t, serverKey)
serverTLS := peer.TLSConfig(serverCert, nil)
clientKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
clientCert := makeTLSCert(t, clientKey)
upgrader := peer.NewTLSServerUpgrader(
serverTLS,
metric.NewCounter(metric.CounterOpts{}),
nil, // no SchemeGate
)
clientConfig := tls.Config{
ClientAuth: tls.RequireAnyClientCert,
InsecureSkipVerify: true, //#nosec G402
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{clientCert},
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(err)
defer listener.Close()
eg := &errgroup.Group{}
eg.Go(func() error {
conn, err := listener.Accept()
if err != nil {
return err
}
_, _, _, upgradeErr := upgrader.Upgrade(conn)
return upgradeErr
})
conn, err := tls.Dial("tcp", listener.Addr().String(), &clientConfig)
require.NoError(err)
require.NoError(conn.Handshake())
require.NoError(eg.Wait(), "nil gate MUST accept classical TLS as today")
}
+1 -1
View File
@@ -95,7 +95,7 @@ func TestBlockClientsWithIncorrectRSAKeys(t *testing.T) {
require.FailNow(t, "should not have invoked")
},
}
upgrader := peer.NewTLSServerUpgrader(config, failOnIncrementCounter)
upgrader := peer.NewTLSServerUpgrader(config, failOnIncrementCounter, nil)
clientConfig := tls.Config{
ClientAuth: tls.RequireAnyClientCert,
+1 -1
View File
@@ -205,7 +205,7 @@ func NewTestNetwork(
return NewNetwork(
cfg,
upgrade.GetConfig(cfg.NetworkID).FortunaTime, // Must be updated for each network upgrade
upgrade.InitiallyActiveTime,
msgCreator,
registry,
log,
+1 -1
View File
@@ -152,7 +152,7 @@ type Config struct {
// Genesis information
GenesisBytes []byte `json:"-"`
LuxAssetID ids.ID `json:"xAssetID"`
XAssetID ids.ID `json:"xAssetID"`
// ID of the network this node should connect to
NetworkID uint32 `json:"networkID"`
+271 -46
View File
@@ -25,11 +25,13 @@ import (
"github.com/luxfi/metric"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/networking/timeout"
"github.com/luxfi/constants"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/database"
"github.com/luxfi/database/prefixdb"
genesiscfg "github.com/luxfi/genesis/pkg/genesis"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
@@ -52,7 +54,9 @@ import (
"github.com/luxfi/node/service/info"
"github.com/luxfi/node/service/keystore"
"github.com/luxfi/node/service/metrics"
luxsecurity "github.com/luxfi/node/service/security"
"github.com/luxfi/node/staking"
"github.com/luxfi/node/upgrade"
"github.com/luxfi/node/version"
"github.com/luxfi/node/vms"
"github.com/luxfi/node/vms/xvm"
@@ -122,15 +126,25 @@ func New(
return nil, fmt.Errorf("invalid staking certificate: %w", err)
}
// NodeID derivation routes through the single seam
// StakingConfig.DeriveNodeID — strict-PQ chains (StakingMLDSAPub
// non-empty) derive NodeID via SHAKE256-384("NODE_ID_V1" ||
// chainID || 0x42 || pubKey)[:20] under ids.NodeIDSchemeMLDSA65;
// classical-compat chains fall through to ids.NodeIDFromCert.
// chainID is the primary-network chain id (ids.Empty, encoded as
// "11111111111111111111111111111111LpoYY" in cb58); per-chain
// chain ids are bound at chain-creation time and don't affect
// the validator's primary identity.
derivedNodeID, err := config.StakingConfig.DeriveNodeID(ids.Empty)
if err != nil {
return nil, fmt.Errorf("derive NodeID: %w", err)
}
n := &Node{
Log: logger,
LogFactory: logFactory,
StakingTLSCert: stakingCert,
ID: ids.NodeIDFromCert(&ids.Certificate{
Raw: stakingCert.Raw,
PublicKey: stakingCert.PublicKey,
}),
Config: config,
ID: derivedNodeID,
Config: config,
}
pop, err := signer.NewProofOfPossession(n.Config.StakingSigningKey)
@@ -176,6 +190,14 @@ func New(
return nil, fmt.Errorf("problem initializing node beacons: %w", err)
}
// Load the chain-wide ChainSecurityProfile from the genesis pin
// BEFORE any networking / chain manager / VM init runs — those
// consumers should see the resolved profile (or its absence) via
// n.SecurityProfile() at construction time. Closes F102.
if err := n.initSecurityProfile(); err != nil {
return nil, fmt.Errorf("problem loading security profile: %w", err)
}
// Set up tracer
n.tracer, err = trace.New(n.Config.TraceConfig)
if err != nil {
@@ -264,7 +286,7 @@ func New(
if err := n.addDefaultVMAliases(); err != nil {
return nil, fmt.Errorf("couldn't initialize API aliases: %w", err)
}
if err := n.initChainManager(n.Config.LuxAssetID); err != nil { // Set up the chain manager
if err := n.initChainManager(n.Config.XAssetID); err != nil { // Set up the chain manager
return nil, fmt.Errorf("couldn't initialize chain manager: %w", err)
}
if err := n.initVMs(); err != nil { // Initialize the VM registry.
@@ -276,6 +298,9 @@ func New(
if err := n.initInfoAPI(); err != nil { // Start the Info API
return nil, fmt.Errorf("couldn't initialize info API: %w", err)
}
if err := n.initSecurityAPI(); err != nil { // Start the securityProfile API
return nil, fmt.Errorf("couldn't initialize security API: %w", err)
}
if err := n.initKeystoreAPI(); err != nil { // Start the Keystore API
return nil, fmt.Errorf("couldn't initialize keystore API: %w", err)
}
@@ -320,6 +345,17 @@ type Node struct {
StakingTLSSigner crypto.Signer
StakingTLSCert *staking.Certificate
// securityProfile is the chain-wide ChainSecurityProfile this node
// is operating under, loaded from the genesis SecurityProfile pin
// at boot via initSecurityProfile. nil when the genesis file
// carries no pin (legacy networks); non-nil after a successful
// pin resolution. Downstream consumers (signer registration, peer
// handshake, mempool, validator registry, bridge oracle) take this
// as a dependency via SecurityProfile().
//
// Closes red-team finding F102.
securityProfile *consensusconfig.ChainSecurityProfile
// Storage for this node
DB database.Database
@@ -663,6 +699,10 @@ func (n *Node) initNetworking(reg metric.Registerer) error {
n.Config.NetworkConfig.CPUTargeter = n.cpuTargeter
n.Config.NetworkConfig.DiskTargeter = n.diskTargeter
n.Config.NetworkConfig.GenesisBytes = n.Config.GenesisBytes
// Forward the resolved ChainSecurityProfile to the network layer
// so the peer.SchemeGate (CR-3) and PQ handshake (CR-5) fire on
// strict-PQ chains. Nil on legacy / classical-compat networks.
n.Config.NetworkConfig.SecurityProfile = n.securityProfile
// Map native chains (P/C/X/etc.) to the primary network validator set.
// For L2 chains, return ids.Empty to let blockchainToNetwork map resolve
// the correct chain ID. Returning chainID here would short-circuit the
@@ -683,7 +723,7 @@ func (n *Node) initNetworking(reg metric.Registerer) error {
n.Net, err = network.NewNetwork(
&n.Config.NetworkConfig,
n.Config.UpgradeConfig.FortunaTime,
upgrade.InitiallyActiveTime,
n.msgCreator,
networkRegistry,
n.Log,
@@ -776,9 +816,6 @@ func (n *Node) Dispatch() error {
}
// Add bootstrap nodes to the peer network
fmt.Println("================================================================================")
fmt.Printf("[BOOTSTRAP] Adding %d bootstrap nodes to peer network\n", len(n.Config.Bootstrappers))
fmt.Println("================================================================================")
n.Log.Info("Adding bootstrap nodes to peer network",
"count", len(n.Config.Bootstrappers),
)
@@ -801,7 +838,6 @@ func (n *Node) Dispatch() error {
}
n.Net.ManuallyTrack(bootstrapper.ID, bootstrapper.Endpoint)
}
fmt.Println("[BOOTSTRAP] Finished adding bootstrap nodes, starting Dispatch")
n.Log.Info("Finished adding bootstrap nodes, starting Dispatch")
// Start P2P connections
@@ -882,16 +918,18 @@ func (n *Node) initDatabase() error {
}
if genesisHash != expectedGenesisHash {
if n.Config.AllowGenesisUpdate {
n.Log.Warn("genesis hash changed, updating stored hash (--allow-genesis-update)",
"oldHash", genesisHash,
"newHash", expectedGenesisHash,
)
if err := n.DB.Put(genesisHashKey, rawExpectedGenesisHash); err != nil {
return fmt.Errorf("failed to update genesis hash: %w", err)
}
} else {
return fmt.Errorf("db contains invalid genesis hash. DB Genesis: %s Generated Genesis: %s (use --allow-genesis-update to migrate)", genesisHash, expectedGenesisHash)
// Sovereign-L1 invariant: the operator owns the chainset; if the
// generated genesis disagrees with the stored hash, the operator
// changed it on purpose (initial bootstrap, validator-set rotation,
// or chainset upgrade) and we silently advance. No --allow-genesis-update
// flag, no manual migration step — the stored hash is a tag, not
// a lock.
n.Log.Info("genesis hash advanced, updating stored hash",
"oldHash", genesisHash,
"newHash", expectedGenesisHash,
)
if err := n.DB.Put(genesisHashKey, rawExpectedGenesisHash); err != nil {
return fmt.Errorf("failed to update genesis hash: %w", err)
}
}
@@ -938,6 +976,97 @@ func (n *Node) initBootstrappers() error {
return nil
}
// SecurityProfile returns the chain-wide ChainSecurityProfile this node
// is operating under, or nil when the genesis carries no pin. The
// returned pointer is the post-Validate+ComputeHash result already
// stamped with ProfileHash; callers MUST NOT mutate it.
//
// Downstream consumers (signer factory, peer handshake gate, mempool
// admittance, validator registry, bridge oracle co-signer) take this
// as a dependency at construction time. The single load happens at
// node bootstrap (initSecurityProfile); there is no per-request
// re-resolution.
//
// Closes red-team finding F102 at the consumer API boundary.
func (n *Node) SecurityProfile() *consensusconfig.ChainSecurityProfile {
return n.securityProfile
}
// initSecurityProfile loads the chain-wide ChainSecurityProfile from
// the genesis pin. Called once during New(). Refuses to start the node
// if a pin is present but its content hash does not match the live
// canonical profile from consensus/config — a forked binary that
// swapped in a different canonical content would otherwise pass the
// ProfileID check silently.
//
// When the genesis file carries no pin (legacy networks pre-locked-
// profile), this is a logged warning, not a fatal error: the node
// boots in classical-compat mode and downstream consumers see a nil
// SecurityProfile().
//
// Closes red-team finding F102 at the node bootstrap layer.
func (n *Node) initSecurityProfile() error {
cfg := genesiscfg.GetConfig(n.Config.NetworkID)
if cfg == nil {
n.Log.Warn("genesis config not found — node boots in classical-compat mode")
return nil
}
return n.applySecurityProfile(cfg.SecurityProfile)
}
// applySecurityProfile is the test-and-prod common path: take the
// genesiscfg.SecurityProfile pin from a genesis config struct, resolve
// it through consensus/config.ProfileByID + ComputeHash verification,
// stamp the result onto n.securityProfile, and emit the startup banner.
// Split out so unit tests can exercise the resolve+banner path without
// touching the filesystem-based genesiscfg.GetConfig.
//
// The startup banner format is fixed (callers grep on these strings):
//
// SECURITY PROFILE: <name>
// PROFILE HASH: <hex>
// POST-QUANTUM: <true|false>
// NIST-FRIENDLY: <true|false>
// CLASSICAL SNARKS: <forbidden|allowed>
// BLS FALLBACK: <forbidden|allowed>
func (n *Node) applySecurityProfile(pin *genesiscfg.SecurityProfile) error {
if pin == nil {
n.Log.Warn("genesis carries no SecurityProfile pin — node boots in classical-compat mode")
return nil
}
profile, err := pin.Resolve()
if err != nil {
return fmt.Errorf("genesis SecurityProfile failed to resolve: %w", err)
}
n.securityProfile = profile
// Startup banner. Format mirrors the one in the F102 task spec so
// audit tooling and grep scripts can rely on it.
classicalSNARKs := "allowed"
if profile.ForbidClassicalSNARKs {
classicalSNARKs = "forbidden"
}
blsFallback := "allowed"
if profile.ForbidPairings ||
profile.ProfileID == uint32(consensusconfig.ProfileStrictPQ) ||
profile.ProfileID == uint32(consensusconfig.ProfileFIPS) {
blsFallback = "forbidden"
}
postQuantum := profile.ProofPolicyID.IsPostQuantum()
nistFriendly := profile.HashSuiteID == consensusconfig.HashSuiteSHA3NIST
hashHex := fmt.Sprintf("%x", profile.ProfileHash[:])
n.Log.Info("SECURITY PROFILE: " + profile.ProfileName)
n.Log.Info("PROFILE HASH: " + hashHex)
n.Log.Info(fmt.Sprintf("POST-QUANTUM: %v", postQuantum))
n.Log.Info(fmt.Sprintf("NIST-FRIENDLY: %v", nistFriendly))
n.Log.Info("CLASSICAL SNARKS: " + classicalSNARKs)
n.Log.Info("BLS FALLBACK: " + blsFallback)
return nil
}
// Create the EventDispatcher used for hooking events
// into the general process flow.
func (n *Node) initEventDispatchers() {
@@ -1129,17 +1258,32 @@ func (n *Node) addDefaultVMAliases() error {
// XVM, Simple Payments DAG, Simple Payments Chain, and Platform VM
// Assumes n.DBManager, n.vdrs all initialized (non-nil)
func (n *Node) initChainManager(xAssetID ids.ID) error {
createXVMTx, err := builder.VMGenesis(n.Config.GenesisBytes, constants.XVMID)
if err != nil {
return err
// X-Chain (XVM, the historic exchange chain) is OPT-IN. Networks that
// don't bake an XVM into platform genesis run in "P-only" mode where
// asset creation and UTXO ops are first-class on the P-Chain (see
// CreateAssetTx/OperationTx in vms/platformvm/txs). All cross-chain
// flows that historically went P→X→C are replaced with direct
// P→chain warp transfers.
var xChainID ids.ID
if createXVMTx, err := builder.VMGenesis(n.Config.GenesisBytes, constants.XVMID); err == nil {
xChainID = createXVMTx.ID()
} else {
n.Log.Info("X-Chain not in genesis — skipping",
"reason", "platform genesis has no constants.XVMID chain (P-only mode)")
}
xChainID := createXVMTx.ID()
createEVMTx, err := builder.VMGenesis(n.Config.GenesisBytes, constants.EVMID)
if err != nil {
return err
// Primary-network EVM (the historic "C-Chain") is OPT-IN. Networks
// that don't bake an EVM into platform genesis (because they register
// their own EVM as a dedicated chain via `platform.createChainTx`)
// leave cChainID = ids.Empty. The chain manager and aliasing logic
// already handle the empty ID case (see chains/manager.go).
var cChainID ids.ID
if createEVMTx, err := builder.VMGenesis(n.Config.GenesisBytes, constants.EVMID); err == nil {
cChainID = createEVMTx.ID()
} else {
n.Log.Info("C-Chain (primary-network EVM) not in genesis — skipping",
"reason", "platform genesis has no constants.EVMID chain")
}
cChainID := createEVMTx.ID()
// P-Chain is always critical regardless of configuration.
criticalChains := set.Of(constants.PlatformChainID)
@@ -1162,7 +1306,7 @@ func (n *Node) initChainManager(xAssetID ids.ID) error {
}
}
_, err = metric.MakeAndRegister(
_, err := metric.MakeAndRegister(
n.MetricsGatherer,
requestsNamespace,
)
@@ -1234,6 +1378,10 @@ func (n *Node) initChainManager(xAssetID ids.ID) error {
Nets: netsManager,
SkipBootstrap: n.Config.SkipBootstrap,
EnableAutomining: n.Config.EnableAutomining,
// F118: forward the chain-wide ChainSecurityProfile to the
// chain manager so it can stamp the profile pin into every
// C-Chain (coreth) plugin Initialize payload.
SecurityProfile: n.securityProfile,
},
)
if err != nil {
@@ -1289,6 +1437,14 @@ func (n *Node) initVMs() error {
RewardConfig: n.Config.RewardConfig,
UpgradeConfig: n.Config.UpgradeConfig,
UseCurrentHeight: n.Config.UseCurrentHeight,
// F102 close-out: thread the chain-wide profile into the
// P-chain mempool builder. Nil for legacy networks; the
// chain builder MUST set this for strict-PQ chains.
SecurityProfile: n.securityProfile,
// Registry is intentionally nil under strict-PQ (refuse
// every classical credential). A classical-compat fork
// would inject its named allow-list here.
ClassicalCompatRegistry: nil,
},
}),
// C-Chain (EVM) loaded as plugin via ZAP transport from plugin-dir
@@ -1299,31 +1455,56 @@ func (n *Node) initVMs() error {
}
n.Log.Info("Platform VM registered successfully")
// Register X-Chain VM (Exchange VM)
n.Log.Info("Registering X-Chain VM", "vmID", constants.XVMID)
err = n.VMManager.RegisterFactory(context.Background(), constants.XVMID, &xvm.Factory{})
if err != nil {
n.Log.Error("Failed to register X-Chain VM", "error", err)
return err
// Register X-Chain VM (Exchange VM) only if X-Chain is in genesis.
// In P-only mode there is no XVMID chain in genesis; the X-Chain factory
// is therefore not loaded, saving init time and reducing attack surface.
xvmRegistered := false
if _, xErr := builder.VMGenesis(n.Config.GenesisBytes, constants.XVMID); xErr == nil {
n.Log.Info("Registering X-Chain VM", "vmID", constants.XVMID)
err = n.VMManager.RegisterFactory(context.Background(), constants.XVMID, &xvm.Factory{
SecurityProfile: n.securityProfile,
ClassicalCompatRegistry: nil,
})
if err != nil {
n.Log.Error("Failed to register X-Chain VM", "error", err)
return err
}
n.Log.Info("X-Chain VM registered successfully")
xvmRegistered = true
} else {
n.Log.Info("X-Chain VM not loaded — P-only mode (CreateAssetTx/OperationTx live on P-Chain)")
}
n.Log.Info("X-Chain VM registered successfully")
// C-Chain VM (EVM) is loaded as a plugin via ZAP transport.
// Plugin binary placed at <plugin-dir>/<EVMID> by init container.
// C-Chain VM (EVM) is loaded as a plugin via ZAP transport when present
// in genesis. Plugin binary placed at <plugin-dir>/<EVMID> by init container.
// Register all VMs (Q, A, B, T, Z, G, D, K, O, R, I chains)
if err := n.registerOptionalVMs(); err != nil {
return err
}
// Log summary of registered VMs
coreVMCount := 3 // P-Chain, X-Chain, C-Chain (plugin via ZAP)
// Log summary of registered VMs — derive count from what is actually in
// genesis rather than hard-coding "3" for legacy P+X+C.
coreVMCount := 1 // P-Chain is always present
if xvmRegistered {
coreVMCount++
}
if _, cErr := builder.VMGenesis(n.Config.GenesisBytes, constants.EVMID); cErr == nil {
coreVMCount++ // C-Chain (plugin via ZAP)
}
if _, qErr := builder.VMGenesis(n.Config.GenesisBytes, constants.QuantumVMID); qErr == nil {
coreVMCount++ // Q-Chain (quantum finality)
}
n.Log.Info("═══════════════════════════════════════════════════════════════════")
n.Log.Info("VMs REGISTERED", "core", coreVMCount)
n.Log.Info("───────────────────────────────────────────────────────────────────")
n.Log.Info("P-Chain (Platform): Validators & staking", "vmID", constants.PlatformVMID)
n.Log.Info("X-Chain (Exchange): UTXO asset exchange", "vmID", constants.XVMID)
n.Log.Info("C-Chain (Contract): EVM smart contracts (ZAP plugin)", "vmID", constants.EVMID)
n.Log.Info("P-Chain (Platform): Validators & staking & UTXO assets", "vmID", constants.PlatformVMID)
if xvmRegistered {
n.Log.Info("X-Chain (Exchange): UTXO asset exchange", "vmID", constants.XVMID)
}
if _, cErr := builder.VMGenesis(n.Config.GenesisBytes, constants.EVMID); cErr == nil {
n.Log.Info("C-Chain (Contract): EVM smart contracts (ZAP plugin)", "vmID", constants.EVMID)
}
n.Log.Info("═══════════════════════════════════════════════════════════════════")
// initialize vm runtime manager
@@ -1357,9 +1538,8 @@ func (n *Node) initVMs() error {
"error", err,
)
}
// Don't fail if Reload returns an error - it might be due to already registered VMs
if err != nil {
n.Log.Warn("VM registry reload encountered issues, continuing anyway", "error", err)
return fmt.Errorf("reload VM registry: %w", err)
}
return nil
}
@@ -1499,6 +1679,51 @@ func (n *Node) initInfoAPI() error {
)
}
// initSecurityAPI exposes the chain-wide ChainSecurityProfile as a
// read-only API surface. Three endpoints share one handler:
//
// - JSON-RPC: POST /ext/security with methods securityProfile and
// blockSecurity (dispatched on the wire as security_securityProfile
// / security_blockSecurity per gorilla/rpc namespace convention)
// - REST: GET /ext/security/profile
// - REST: GET /ext/security/block/{n}
//
// All three share the same Service receiver; the shape returned is
// the SCREAMING_SNAKE canonical profile JSON consumed by audit tooling,
// wallet posture banners, and block explorers.
//
// Prometheus gauges for the active profile are stamped onto the
// node-wide metrics gatherer here so /ext/metrics carries the profile
// posture immediately after boot.
//
// Closes F102 follow-ups (securityProfile RPC + profile metrics).
func (n *Node) initSecurityAPI() error {
n.Log.Info("initializing security API")
// Register profile metrics under the "security" namespace on the
// node-wide gatherer so /ext/metrics carries them alongside the
// existing process / api / chain metric families.
securityMetricsReg, err := metric.MakeAndRegister(
n.MetricsGatherer,
"security",
)
if err != nil {
return fmt.Errorf("security: register metrics namespace: %w", err)
}
securityMetrics := luxsecurity.NewMetrics(metric.NewWithRegistry("security", securityMetricsReg))
securityMetrics.SetActiveProfile(n.securityProfile)
handler, err := luxsecurity.NewHandler(n.Log, n.securityProfile)
if err != nil {
return err
}
return n.APIServer.AddRoute(
handler,
"security",
"",
)
}
// initKeystoreAPI initializes the Keystore API service
func (n *Node) initKeystoreAPI() error {
if !n.Config.KeystoreAPIEnabled {
+135
View File
@@ -0,0 +1,135 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package node
import (
"encoding/hex"
"errors"
"strings"
"testing"
consensusconfig "github.com/luxfi/consensus/config"
genesiscfg "github.com/luxfi/genesis/pkg/genesis"
"github.com/luxfi/log"
)
// TestApplySecurityProfile_StrictPQ proves the locked-profile pin
// resolves end-to-end inside the node bootstrap path and the resulting
// *ChainSecurityProfile is reachable via SecurityProfile(). Closes the
// node-side half of red-team F102.
func TestApplySecurityProfile_StrictPQ(t *testing.T) {
canonical := consensusconfig.StrictPQ()
live, err := canonical.ComputeHash()
if err != nil {
t.Fatalf("ComputeHash: %v", err)
}
pin := &genesiscfg.SecurityProfile{
ProfileID: uint8(consensusconfig.ProfileStrictPQ),
ProfileHashHex: hex.EncodeToString(live[:]),
}
n := &Node{Log: log.NoLog{}}
if err := n.applySecurityProfile(pin); err != nil {
t.Fatalf("applySecurityProfile returned: %v", err)
}
got := n.SecurityProfile()
if got == nil {
t.Fatal("SecurityProfile() returned nil after a successful pin resolution")
}
if got.ProfileID != uint32(consensusconfig.ProfileStrictPQ) {
t.Errorf("SecurityProfile().ProfileID = %d; want %d",
got.ProfileID, consensusconfig.ProfileStrictPQ)
}
if got.ProfileHash != live {
t.Errorf("SecurityProfile().ProfileHash mismatch")
}
if !got.ProofPolicyID.IsPostQuantum() {
t.Errorf("StrictPQ profile should be post-quantum")
}
if got.HashSuiteID != consensusconfig.HashSuiteSHA3NIST {
t.Errorf("StrictPQ profile should pin SHA3NIST, got %s",
got.HashSuiteID.String())
}
}
// TestApplySecurityProfile_NilPin proves an absent pin is a logged
// warning, not a fatal error — legacy networks pre-locked-profile
// keep booting in classical-compat mode.
func TestApplySecurityProfile_NilPin(t *testing.T) {
n := &Node{Log: log.NoLog{}}
if err := n.applySecurityProfile(nil); err != nil {
t.Fatalf("applySecurityProfile(nil) returned %v; want nil", err)
}
if n.SecurityProfile() != nil {
t.Errorf("SecurityProfile() = %v; want nil", n.SecurityProfile())
}
}
// TestApplySecurityProfile_HashMismatchRejected proves the node
// refuses to boot when the genesis-pinned hash diverges from the live
// canonical profile. This is the load-bearing F102 anti-regression:
// a forked binary cannot silently boot under a different profile by
// pinning the same ProfileID.
func TestApplySecurityProfile_HashMismatchRejected(t *testing.T) {
pin := &genesiscfg.SecurityProfile{
ProfileID: uint8(consensusconfig.ProfileStrictPQ),
ProfileHashHex: strings.Repeat("00", 48),
}
n := &Node{Log: log.NoLog{}}
err := n.applySecurityProfile(pin)
if err == nil {
t.Fatal("applySecurityProfile accepted a wrong-hash pin")
}
if !errors.Is(err, genesiscfg.ErrSecurityProfileHashMismatch) {
t.Errorf("applySecurityProfile returned %v; want wrap of ErrSecurityProfileHashMismatch", err)
}
if n.SecurityProfile() != nil {
t.Errorf("SecurityProfile() = %v; want nil after rejection", n.SecurityProfile())
}
}
// TestApplySecurityProfile_UnknownProfileIDRejected proves an unknown
// ProfileID byte refuses to boot. This closes the "downstream chain
// spoofs a profile ID it didn't register" attack class.
func TestApplySecurityProfile_UnknownProfileIDRejected(t *testing.T) {
pin := &genesiscfg.SecurityProfile{
ProfileID: 0xFE, // unregistered
ProfileHashHex: strings.Repeat("ab", 48),
}
n := &Node{Log: log.NoLog{}}
err := n.applySecurityProfile(pin)
if err == nil {
t.Fatal("applySecurityProfile accepted an unknown ProfileID")
}
if !errors.Is(err, genesiscfg.ErrSecurityProfileInvalidID) {
t.Errorf("applySecurityProfile returned %v; want wrap of ErrSecurityProfileInvalidID", err)
}
}
// TestApplySecurityProfile_FIPS proves the FIPS profile resolves
// alongside StrictPQ — both share the strict-PQ posture but FIPS
// pins SHA3NIST exclusively (no BLAKE3-legacy).
func TestApplySecurityProfile_FIPS(t *testing.T) {
canonical := consensusconfig.FIPS()
live, err := canonical.ComputeHash()
if err != nil {
t.Fatalf("FIPS ComputeHash: %v", err)
}
pin := &genesiscfg.SecurityProfile{
ProfileID: uint8(consensusconfig.ProfileFIPS),
ProfileHashHex: hex.EncodeToString(live[:]),
}
n := &Node{Log: log.NoLog{}}
if err := n.applySecurityProfile(pin); err != nil {
t.Fatalf("applySecurityProfile(FIPS) returned: %v", err)
}
got := n.SecurityProfile()
if got.ProfileID != uint32(consensusconfig.ProfileFIPS) {
t.Errorf("ProfileID = %d; want %d", got.ProfileID, consensusconfig.ProfileFIPS)
}
if got.HashSuiteID != consensusconfig.HashSuiteSHA3NIST {
t.Errorf("FIPS HashSuiteID = %s; want sha3-nist", got.HashSuiteID.String())
}
}
-120
View File
@@ -1,120 +0,0 @@
//go:build grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package p2p re-exports P2P types from the appropriate wire format implementation.
// Without grpc tag: uses ZAP wire format (zero protobuf)
// With grpc tag: uses protobuf wire format
package p2p
import (
"github.com/luxfi/node/proto/pb/p2p"
"google.golang.org/protobuf/proto"
)
// Re-export all types from protobuf implementation
type (
EngineType = p2p.EngineType
Message = p2p.Message
Message_CompressedZstd = p2p.Message_CompressedZstd
Message_Ping = p2p.Message_Ping
Message_Pong = p2p.Message_Pong
Message_Handshake = p2p.Message_Handshake
Message_GetPeerList = p2p.Message_GetPeerList
Message_PeerList_ = p2p.Message_PeerList_
Message_GetStateSummaryFrontier = p2p.Message_GetStateSummaryFrontier
Message_StateSummaryFrontier_ = p2p.Message_StateSummaryFrontier_
Message_GetAcceptedStateSummary = p2p.Message_GetAcceptedStateSummary
Message_AcceptedStateSummary_ = p2p.Message_AcceptedStateSummary_
Message_GetAcceptedFrontier = p2p.Message_GetAcceptedFrontier
Message_AcceptedFrontier_ = p2p.Message_AcceptedFrontier_
Message_GetAccepted = p2p.Message_GetAccepted
Message_Accepted_ = p2p.Message_Accepted_
Message_GetAncestors = p2p.Message_GetAncestors
Message_Ancestors_ = p2p.Message_Ancestors_
Message_Get = p2p.Message_Get
Message_Put = p2p.Message_Put
Message_PushQuery = p2p.Message_PushQuery
Message_PullQuery = p2p.Message_PullQuery
Message_Chits = p2p.Message_Chits
Message_Request = p2p.Message_Request
Message_Response = p2p.Message_Response
Message_Gossip = p2p.Message_Gossip
Message_Error = p2p.Message_Error
Message_Simplex = p2p.Message_Simplex
Ping = p2p.Ping
Pong = p2p.Pong
Handshake = p2p.Handshake
Client = p2p.Client
BloomFilter = p2p.BloomFilter
GetPeerList = p2p.GetPeerList
PeerList = p2p.PeerList
ClaimedIpPort = p2p.ClaimedIpPort
GetStateSummaryFrontier = p2p.GetStateSummaryFrontier
StateSummaryFrontier = p2p.StateSummaryFrontier
GetAcceptedStateSummary = p2p.GetAcceptedStateSummary
AcceptedStateSummary = p2p.AcceptedStateSummary
GetAcceptedFrontier = p2p.GetAcceptedFrontier
AcceptedFrontier = p2p.AcceptedFrontier
GetAccepted = p2p.GetAccepted
Accepted = p2p.Accepted
GetAncestors = p2p.GetAncestors
Ancestors = p2p.Ancestors
Get = p2p.Get
Put = p2p.Put
PushQuery = p2p.PushQuery
PullQuery = p2p.PullQuery
Chits = p2p.Chits
Request = p2p.Request
Response = p2p.Response
Gossip = p2p.Gossip
Error = p2p.Error
Simplex = p2p.Simplex
// BFT aliases - ZAP uses "BFT", protobuf uses "Simplex" for the same concept
BFT = p2p.Simplex
Message_BFT = p2p.Message_Simplex
BFT_BlockProposal = p2p.Simplex_BlockProposal
BFT_Vote = p2p.Simplex_Vote
BFT_EmptyVote = p2p.Simplex_EmptyVote
BFT_FinalizeVote = p2p.Simplex_FinalizeVote
BFT_Notarization = p2p.Simplex_Notarization
BFT_EmptyNotarization = p2p.Simplex_EmptyNotarization
BFT_Finalization = p2p.Simplex_Finalization
BFT_ReplicationRequest = p2p.Simplex_ReplicationRequest
BFT_ReplicationResponse = p2p.Simplex_ReplicationResponse
BlockProposal = p2p.BlockProposal
ProtocolMetadata = p2p.ProtocolMetadata
BlockHeader = p2p.BlockHeader
Signature = p2p.Signature
Vote = p2p.Vote
EmptyVote = p2p.EmptyVote
QuorumCertificate = p2p.QuorumCertificate
EmptyNotarization = p2p.EmptyNotarization
ReplicationRequest = p2p.ReplicationRequest
ReplicationResponse = p2p.ReplicationResponse
QuorumRound = p2p.QuorumRound
)
// Re-export constants
const (
EngineType_ENGINE_TYPE_UNSPECIFIED = p2p.EngineType_ENGINE_TYPE_UNSPECIFIED
EngineType_ENGINE_TYPE_CHAIN = p2p.EngineType_ENGINE_TYPE_CHAIN
EngineType_ENGINE_TYPE_DAG = p2p.EngineType_ENGINE_TYPE_DAG
)
// Marshal encodes a Message using protobuf
func Marshal(m *Message) ([]byte, error) {
return proto.Marshal(m)
}
// Unmarshal decodes a Message using protobuf
func Unmarshal(data []byte, m *Message) error {
return proto.Unmarshal(data, m)
}
// Size returns the encoded size of a message
func Size(m *Message) int {
return proto.Size(m)
}
+4 -7
View File
@@ -1,14 +1,11 @@
//go:build !grpc
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package p2p re-exports P2P types from the appropriate wire format implementation.
// Without grpc tag: uses ZAP wire format (zero protobuf)
// With grpc tag: uses protobuf wire format
// Package p2p re-exports P2P types from the ZAP wire format
// implementation. ZAP is the only supported transport.
package p2p
import "github.com/luxfi/node/proto/zap/p2p"
import "github.com/luxfi/proto/node/zap/p2p"
// Re-export all types from ZAP implementation
type (
@@ -42,7 +39,7 @@ type (
Message_BFT = p2p.Message_BFT
Ping = p2p.Ping
Pong = p2p.Pong
SubnetUptime = p2p.SubnetUptime
ChainUptime = p2p.ChainUptime
Handshake = p2p.Handshake
Client = p2p.Client
BloomFilter = p2p.BloomFilter

Some files were not shown because too many files have changed in this diff Show More