infra: s3.lux.network → s3.lux.cloud (correct snapshot endpoint for RLP re-import)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-02 12:45:29 -07:00
co-authored by Hanzo Dev
parent 8a8c3ac010
commit 6a1f34e6e1
6 changed files with 1020 additions and 2 deletions
+183
View File
@@ -0,0 +1,183 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/luxfi/constants"
)
// TestResolveDChainVMID locks the D-Chain vmID to the DexVMID and asserts its
// CB58 string matches the canonical value baked into luxd. If this ever drifts,
// every D-Chain registration would point at the wrong VM.
func TestResolveDChainVMID(t *testing.T) {
const wantCB58 = "mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr"
got := constants.DexVMID.String()
if got != wantCB58 {
t.Fatalf("DexVMID CB58 = %q, want %q", got, wantCB58)
}
// The byte layout must be exactly {'d','e','x','v','m'} (rest zero).
want := [32]byte{'d', 'e', 'x', 'v', 'm'}
if constants.DexVMID != want {
t.Fatalf("DexVMID bytes = %v, want %v", constants.DexVMID, want)
}
}
// TestLoadDChainGenesis_Valid writes a fake configs dir with a non-empty valid
// dchain.json and confirms loadDChainGenesis returns the exact bytes + path.
func TestLoadDChainGenesis_Valid(t *testing.T) {
dir := t.TempDir()
netDir := filepath.Join(dir, "testnet")
if err := os.MkdirAll(netDir, 0o755); err != nil {
t.Fatal(err)
}
content := []byte(`{"name":"D-Chain","vm":"DexVM","chainId":96470}`)
path := filepath.Join(netDir, "dchain.json")
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatal(err)
}
// Env case is normalized to lower for the path.
raw, gotPath, err := loadDChainGenesis(dir, "TESTNET")
if err != nil {
t.Fatalf("loadDChainGenesis: %v", err)
}
if gotPath != path {
t.Fatalf("path = %q, want %q", gotPath, path)
}
if string(raw) != string(content) {
t.Fatalf("genesis = %q, want %q", raw, content)
}
}
// TestLoadDChainGenesis_Errors covers the three fail-before-spend cases:
// missing file, empty file, and invalid JSON. Each must return a non-nil error
// and nil bytes so no CreateChainTx is ever built from a bad genesis.
func TestLoadDChainGenesis_Errors(t *testing.T) {
dir := t.TempDir()
t.Run("missing", func(t *testing.T) {
raw, _, err := loadDChainGenesis(dir, "devnet")
if err == nil {
t.Fatal("expected error for missing file")
}
if raw != nil {
t.Fatalf("expected nil bytes, got %d", len(raw))
}
})
t.Run("empty", func(t *testing.T) {
nd := filepath.Join(dir, "mainnet")
if err := os.MkdirAll(nd, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nd, "dchain.json"), []byte(" \n"), 0o644); err != nil {
t.Fatal(err)
}
raw, _, err := loadDChainGenesis(dir, "mainnet")
if err == nil {
t.Fatal("expected error for empty file")
}
if raw != nil {
t.Fatalf("expected nil bytes, got %d", len(raw))
}
})
t.Run("invalid-json", func(t *testing.T) {
nd := filepath.Join(dir, "local")
if err := os.MkdirAll(nd, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nd, "dchain.json"), []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
raw, _, err := loadDChainGenesis(dir, "local")
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if raw != nil {
t.Fatalf("expected nil bytes, got %d", len(raw))
}
})
}
// TestResolveDChainMode exercises the dry-run/broadcast guard purely from the
// two operator inputs — no network, no spend. The default (neither flag) MUST
// be dry-run so an accidental invocation never broadcasts.
func TestResolveDChainMode(t *testing.T) {
cases := []struct {
name string
unsignedOut string
broadcast bool
want dchainMode
}{
{"default-is-dryrun", "", false, dchainDryRun},
{"unsigned-out-stages", "/tmp/x.json", false, dchainUnsigned},
{"broadcast-issues", "", true, dchainBroadcast},
{"unsigned-wins-over-broadcast", "/tmp/x.json", true, dchainUnsigned},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := resolveDChainMode(c.unsignedOut, c.broadcast); got != c.want {
t.Fatalf("resolveDChainMode(%q,%v) = %d, want %d", c.unsignedOut, c.broadcast, got, c.want)
}
})
}
}
// TestDChainArtifactRoundTrip confirms the staging wrapper marshals to the
// documented JSON shape and round-trips, including the signWith provenance and
// the unsignedTxHex field that the owner signs.
func TestDChainArtifactRoundTrip(t *testing.T) {
art := dchainArtifact{
Network: "mainnet",
VMID: constants.DexVMID.String(),
ChainName: dchainName,
OwnerNetwork: "2bRCr6B4MiEfSjidDwxDpdCyviwnfUVqB2HGwhm947w9YYqb7r",
GenesisBytesLen: 375,
UnsignedTxHex: "00000000abcd",
SignWith: "P-Chain key m/44'/9000'/0'/0/5 derived from KMS secret lux/mainnet/staking[mnemonic]",
BroadcastHint: "platform.issueTx",
}
body, err := json.MarshalIndent(art, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Assert the documented JSON keys are present (downstream tooling depends
// on these names).
var generic map[string]any
if err := json.Unmarshal(body, &generic); err != nil {
t.Fatalf("unmarshal generic: %v", err)
}
for _, k := range []string{"network", "vmId", "chainName", "ownerNetwork", "genesisBytesLen", "unsignedTxHex", "signWith", "broadcastHint"} {
if _, ok := generic[k]; !ok {
t.Fatalf("artifact JSON missing key %q; got %s", k, body)
}
}
var back dchainArtifact
if err := json.Unmarshal(body, &back); err != nil {
t.Fatalf("unmarshal typed: %v", err)
}
if back != art {
t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", back, art)
}
if back.ChainName != "D-Chain" {
t.Fatalf("chainName = %q, want D-Chain (must match genesis registry name)", back.ChainName)
}
}
// TestDChainNameMatchesRegistry guards the idempotency contract: the chainName
// CreateChainTx records must equal the genesis builder's registry name so a
// genesis-registered D-Chain is detected + skipped.
func TestDChainNameMatchesRegistry(t *testing.T) {
if dchainName != "D-Chain" {
t.Fatalf("dchainName = %q, want D-Chain", dchainName)
}
}
+49
View File
@@ -0,0 +1,49 @@
// derive9000: derive C-Chain EVM addr+privkey on the CANONICAL genesis path
// m/44'/9000'/0'/0/<i> (coin type 9000 — the path pkg/genesis.LoadKeysFromMnemonic
// funds in the genesis alloc), for the LightMnemonic devnet seed. No EWOQ key.
package main
import (
"encoding/hex"
"flag"
"fmt"
"log"
"strings"
"github.com/luxfi/crypto"
bip32 "github.com/luxfi/go-bip32"
bip39 "github.com/luxfi/go-bip39"
)
func main() {
mnemonic := flag.String("mnemonic", "light light light light light light light light light light light energy", "BIP-39 mnemonic")
count := flag.Int("n", 10, "derive indices 0..n-1")
flag.Parse()
m := strings.TrimSpace(*mnemonic)
if !bip39.IsMnemonicValid(m) {
log.Fatal("invalid mnemonic")
}
seed := bip39.NewSeed(m, "")
master, err := bip32.NewMasterKey(seed)
if err != nil {
log.Fatalf("master: %v", err)
}
purpose, _ := master.NewChildKey(bip32.FirstHardenedChild + 44)
coin, _ := purpose.NewChildKey(bip32.FirstHardenedChild + 9000)
acct, _ := coin.NewChildKey(bip32.FirstHardenedChild + 0)
change, _ := acct.NewChildKey(0)
for i := 0; i < *count; i++ {
child, err := change.NewChildKey(uint32(i)) //nolint:gosec
if err != nil {
log.Fatalf("derive %d: %v", i, err)
}
ecdsa, err := crypto.ToECDSA(child.Key)
if err != nil {
log.Fatalf("ToECDSA %d: %v", i, err)
}
addr := crypto.PubkeyToAddress(ecdsa.PublicKey)
fmt.Printf("idx=%d addr=%s privkey=%s\n", i, addr.Hex(), hex.EncodeToString(child.Key))
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ func TestDeriveContentHash_StableAndShort(t *testing.T) {
tenant: "hanzo",
chainAlias: "C",
blockchainID: "BCID",
sourceURL: "https://s3.lux.network/h.rlp",
sourceURL: "https://s3.lux.cloud/h.rlp",
sha256Hex: "deadbeef",
}
got := deriveContentHash(c)
@@ -34,7 +34,7 @@ func TestDeriveContentHash_StableAndShort(t *testing.T) {
t.Fatalf("hash length = %d, want 12", len(got))
}
// Compute it the way the operator does, byte-for-byte.
h := sha256.Sum256([]byte("hanzo|C|BCID|https://s3.lux.network/h.rlp|deadbeef"))
h := sha256.Sum256([]byte("hanzo|C|BCID|https://s3.lux.cloud/h.rlp|deadbeef"))
want := hex.EncodeToString(h[:])[:12]
if got != want {
t.Fatalf("hash mismatch: got=%s want=%s", got, want)
+461
View File
@@ -0,0 +1,461 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Command venuekeygen generates the D-Chain VENUE validator staking key set
// for one network and emits it either as a KMS-ready JSON payload (default,
// stdout) or as individual on-disk artifacts under --out (local inspection
// only).
//
// The venue is a SINGLE-OPERATOR D-Chain node — exactly ONE staking identity
// per network, DISTINCT from the 5 public luxd validators. A staking identity
// here is three keys, all generated with the node's own audited primitives
// (this tool rolls NO crypto of its own):
//
// - TLS staking cert+key (staker.crt / staker.key): P-256 ECDSA self-signed
// cert, via github.com/luxfi/node/staking.NewCertAndKeyBytes. PEM block
// types "CERTIFICATE" / "PRIVATE KEY" (PKCS#8). This is what luxd loads
// from --staking-tls-cert-file / --staking-tls-key-file.
//
// - BLS signer key (signer.key): a fresh localsigner secret key, serialized
// with LocalSigner.ToBytes() (raw big-endian secret-key bytes). luxd's
// getStakingSigner reads the FILE form as RAW bytes and the KMS form as a
// HEX string, both fed to localsigner.FromBytes. So:
// - the --out signer.key file is written as RAW bytes (file-loader shape)
// - the stdout JSON `signer_key` field is HEX (KMS-loader shape)
// Both round-trip through the same localsigner.FromBytes.
//
// - PQ ML-DSA-65 key (mldsa.key / mldsa.pub): NIST Level 3 strict-PQ
// identity, via staking.NewPQKeyPair, PEM block types
// "ML-DSA-65 PRIVATE KEY" / "ML-DSA-65 PUBLIC KEY" over
// PrivateKeyBytes()/PublicKeyBytes() — replicating staking.InitNodePQKeyPair
// byte-for-byte so staking.LoadPQKeyPair consumes them.
//
// The venue's primary NodeID (node_id / --node-id) is the STRICT-PQ NodeID:
// the venue boots under the PQ securityProfile (devnet profileID=2,
// testnet/mainnet=1; the operator manifests pass --staking-mldsa-key-file +
// --staking-mldsa-pub-key-file), so luxd derives the primary NodeID from the
// ML-DSA-65 pubkey, NOT the TLS cert, via
// ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, mldsaPubRaw) — the same seam
// node/node.go:143 → config/node StakingConfig.DeriveNodeID(ids.Empty) runs at
// boot. THAT NodeID is what goes in the venue's bootstrapper config. The legacy
// cert-derived value (ids.NodeIDFromCert) is still emitted as node_id_classical
// for non-PQ / classical-compat profiles, so nothing is lost.
//
// Every invocation generates FRESH random keys (crypto/rand via the node
// primitives). Before any output, the tool round-trips each generated key
// back through the node's own loaders and fails hard if any does not parse —
// proving the artifact is consumable by a real luxd.
//
// Build:
//
// cd ~/work/lux/genesis && GOWORK=off go build -o /tmp/venuekeygen ./cmd/venuekeygen/
//
// Usage:
//
// venuekeygen --net <mainnet|testnet|devnet|localnet> [--out <dir>] [--node-id]
//
// # Default: print the KMS-ready JSON payload to stdout, kms-put commands to stderr
// venuekeygen --net devnet --node-id
//
// # Also write local PEM/raw artifacts for inspection (NOT for git)
// venuekeygen --net devnet --out /tmp/venue-devnet
//
// The stdout JSON object embeds the node's KMSStakingKeys shape
// (tls_key / tls_cert / signer_key) PLUS the ML-DSA PEMs and the derived
// NodeID, so the whole object is the exact value an operator stores under the
// venue secret. The per-artifact `kms put` commands printed to stderr store
// each individual key under org=lux, path <net>/dchain-venue/<artifact>.
package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/bls/signer/localsigner"
"github.com/luxfi/ids"
"github.com/luxfi/node/staking"
)
// PEM block types for the ML-DSA-65 strict-PQ identity. These MUST match
// staking.InitNodePQKeyPair / staking.LoadPQKeyPair exactly, or luxd will
// refuse the artifact at config-load. Mirrored here (not imported) because
// the node package does not export them, and we replicate the exact encoding.
const (
mldsaPrivPEMType = "ML-DSA-65 PRIVATE KEY"
mldsaPubPEMType = "ML-DSA-65 PUBLIC KEY"
)
// venueSecretBase is the per-artifact KMS path segment under which the venue
// stores its keys. The full path is "<net>/dchain-venue/<artifact>" — the
// network is the leading segment because the kms CLI has no --env flag (env
// is encoded in the path); org=lux selects the Lux KMS tenant via --org.
const venueSecretBase = "dchain-venue"
// venueArtifacts are the on-disk filenames written under --out and the
// trailing KMS key names, in stable order.
var venueArtifacts = []string{
"staker.crt",
"staker.key",
"signer.key",
"mldsa.key",
"mldsa.pub",
}
// payload is the stdout JSON document. It embeds the node's KMSStakingKeys
// json shape verbatim (tls_key / tls_cert / signer_key) so the node's KMS
// loader (staking.FetchFromKMS → json.Unmarshal into KMSStakingKeys) can
// consume the same bytes, and adds the strict-PQ ML-DSA material plus the
// derived classical NodeID.
//
// signer_key is HEX of LocalSigner.ToBytes() — the exact form
// getStakingSigner expects on the KMS path (hex.DecodeString →
// localsigner.FromBytes).
type payload struct {
staking.KMSStakingKeys // tls_key, tls_cert, signer_key (json:"...")
MLDSAKey string `json:"mldsa_key"` // PEM "ML-DSA-65 PRIVATE KEY"
MLDSAPub string `json:"mldsa_pub"` // PEM "ML-DSA-65 PUBLIC KEY"
// NodeID is the strict-PQ ML-DSA-65 NodeID
// (ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, mldsaPubRaw)) — the value
// luxd computes for this venue under the PQ securityProfile. This is the
// NodeID that belongs in the venue's bootstrapper config.
NodeID string `json:"node_id"`
// NodeIDClassical is the legacy cert-derived NodeID (ids.NodeIDFromCert),
// retained for non-PQ / classical-compat profiles. NOT used by this venue.
NodeIDClassical string `json:"node_id_classical"`
}
// keyset holds the raw generated artifact bytes for one venue identity,
// before they are shaped into JSON or written to disk.
type keyset struct {
stakerCertPEM []byte // PEM "CERTIFICATE"
stakerKeyPEM []byte // PEM "PRIVATE KEY" (PKCS#8 P-256)
signerKeyRaw []byte // raw LocalSigner.ToBytes() (file form)
mldsaKeyPEM []byte // PEM "ML-DSA-65 PRIVATE KEY"
mldsaPubPEM []byte // PEM "ML-DSA-65 PUBLIC KEY"
mldsaPubRaw []byte // raw ML-DSA-65 pubkey bytes (the strict-PQ NodeID seed)
// nodeID is the venue's REAL primary NodeID: under the strict-PQ
// securityProfile (all four Lux networks; the operator manifests pass
// --staking-mldsa-key-file/--staking-mldsa-pub-key-file), luxd derives the
// primary NodeID from the ML-DSA-65 pubkey, NOT the TLS cert. See
// nodeIDStrictPQ for the exact derivation + node-source citation.
nodeID ids.NodeID
// nodeIDClassical is the legacy cert-derived NodeID (ids.NodeIDFromCert),
// kept for non-PQ profiles / classical-compat. It is NOT the value luxd
// uses for this venue.
nodeIDClassical ids.NodeID
blsPubHex string // compressed BLS public key, hex (logged only)
}
func main() {
net := flag.String("net", "", "network: mainnet|testnet|devnet|localnet (required)")
out := flag.String("out", "", "if set, ALSO write staker.crt/staker.key/signer.key/mldsa.key/mldsa.pub into this dir (0600, local inspection only — NOT for git)")
nodeID := flag.Bool("node-id", false, "also print the venue's strict-PQ ML-DSA-65 NodeID in bare bootstrap form")
flag.Parse()
netSlug, err := normalizeNet(*net)
if err != nil {
fmt.Fprintf(os.Stderr, "venuekeygen: %v\n", err)
os.Exit(1)
}
ks, err := generate()
if err != nil {
fmt.Fprintf(os.Stderr, "venuekeygen: generate: %v\n", err)
os.Exit(1)
}
// Self-check: prove every artifact round-trips through the node's own
// loaders before we emit anything. A failure here means the artifact
// would not be consumable by a real luxd, so we must not ship it.
if err := selfCheck(ks); err != nil {
fmt.Fprintf(os.Stderr, "venuekeygen: self-check failed (artifact not luxd-consumable): %v\n", err)
os.Exit(1)
}
if *out != "" {
if err := writeArtifacts(*out, ks); err != nil {
fmt.Fprintf(os.Stderr, "venuekeygen: write artifacts: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "venuekeygen: wrote local artifacts to %s (0600) — these are SECRETS, do NOT commit / gitignore this dir\n", *out)
}
doc := buildPayload(ks)
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "venuekeygen: marshal payload: %v\n", err)
os.Exit(1)
}
// stdout stays clean JSON — the exact value to store in KMS.
fmt.Println(string(body))
// Everything operator-facing goes to stderr so stdout is pipeable JSON.
fmt.Fprintf(os.Stderr, "\nvenuekeygen: D-Chain venue staking identity for net=%s\n", netSlug)
fmt.Fprintf(os.Stderr, "venuekeygen: NodeID=%s\n", ks.nodeID)
fmt.Fprintf(os.Stderr, "venuekeygen: node_id is the strict-PQ ML-DSA-65 NodeID (DeriveMLDSA(ids.Empty,pub)) — the value luxd computes under the PQ securityProfile; node_id_classical=%s is the legacy cert-derived value for non-PQ profiles.\n", ks.nodeIDClassical)
fmt.Fprintf(os.Stderr, "venuekeygen: BLS pubkey (compressed, hex)=%s\n", ks.blsPubHex)
if *nodeID {
// Also print the NodeID to stdout-adjacent stderr in the bare form a
// bootstrapper config wants. (stdout itself carries it in JSON.) This is
// the strict-PQ NodeID — the one that matters because the venue runs
// under the PQ securityProfile.
fmt.Fprintf(os.Stderr, "venuekeygen: bootstrap NodeID -> %s\n", ks.nodeID)
}
printKMSCommands(netSlug)
}
// generate produces one fresh venue staking identity using only the node's
// audited primitives. crypto/rand is the entropy source inside each.
func generate() (*keyset, error) {
// TLS staking cert + key (P-256 ECDSA), node's canonical generator.
certPEM, keyPEM, err := staking.NewCertAndKeyBytes()
if err != nil {
return nil, fmt.Errorf("new TLS cert/key: %w", err)
}
// BLS signer key — node's localsigner. ToBytes() is the on-disk file form.
signer, err := localsigner.New()
if err != nil {
return nil, fmt.Errorf("new BLS signer: %w", err)
}
signerRaw := signer.ToBytes()
blsPubHex := hex.EncodeToString(bls.PublicKeyToCompressedBytes(signer.PublicKey()))
// PQ ML-DSA-65 key pair — node's strict-PQ generator.
pq, err := staking.NewPQKeyPair()
if err != nil {
return nil, fmt.Errorf("new ML-DSA-65 key pair: %w", err)
}
mldsaPubRaw := pq.PublicKeyBytes()
mldsaKeyPEM, err := encodePEM(mldsaPrivPEMType, pq.PrivateKeyBytes())
if err != nil {
return nil, fmt.Errorf("encode ML-DSA private PEM: %w", err)
}
mldsaPubPEM, err := encodePEM(mldsaPubPEMType, mldsaPubRaw)
if err != nil {
return nil, fmt.Errorf("encode ML-DSA public PEM: %w", err)
}
// Strict-PQ NodeID — the venue's REAL primary NodeID. Derived from the
// SAME raw ML-DSA-65 pubkey bytes that are PEM-encoded into mldsa.pub.
pqNID, err := nodeIDStrictPQ(mldsaPubRaw)
if err != nil {
return nil, fmt.Errorf("derive strict-PQ NodeID: %w", err)
}
// Classical cert-derived NodeID — kept for non-PQ / classical-compat
// profiles only. Not the value luxd uses for this venue.
classicalNID, err := nodeIDFromCertPEM(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("derive classical NodeID: %w", err)
}
return &keyset{
stakerCertPEM: certPEM,
stakerKeyPEM: keyPEM,
signerKeyRaw: signerRaw,
mldsaKeyPEM: mldsaKeyPEM,
mldsaPubPEM: mldsaPubPEM,
mldsaPubRaw: mldsaPubRaw,
nodeID: pqNID,
nodeIDClassical: classicalNID,
blsPubHex: blsPubHex,
}, nil
}
// nodeIDStrictPQ derives the venue's primary NodeID the way luxd does under the
// strict-PQ securityProfile: from the raw ML-DSA-65 pubkey bytes, bound to the
// primary-network chain id (ids.Empty).
//
// This is byte-identical to what the node computes at boot. The single source
// of truth is the node's StakingConfig.DeriveNodeID seam:
//
// config/node/config.go:298-301 (strict-PQ branch, when StakingMLDSAPub set):
// id, _, _ := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, c.StakingMLDSAPub)
// node/node.go:143 (boot call site):
// derivedNodeID, _ := config.StakingConfig.DeriveNodeID(ids.Empty)
//
// We call DeriveMLDSA directly (rather than constructing a config/node
// StakingConfig) because that package pulls the full node-config dependency
// surface (chains/network/server) — heavyweight and unnecessary for one
// derivation. The pubKey argument is the RAW ML-DSA pubkey bytes
// (PublicKeyBytes()); DeriveMLDSA applies its own SP 800-185 framing, so it
// must NOT be PEM-wrapped.
func nodeIDStrictPQ(mldsaPubRaw []byte) (ids.NodeID, error) {
id, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, mldsaPubRaw)
return id, err
}
// selfCheck loads every generated artifact back through the node's own
// loaders and asserts the NodeID is reproducible. Any failure means the
// artifact is not luxd-consumable.
func selfCheck(ks *keyset) error {
// Strict-PQ NodeID — the venue's REAL primary NodeID. Must be non-empty and
// STABLE: re-deriving from the same ML-DSA pubkey bytes yields the same id.
if ks.nodeID == ids.EmptyNodeID {
return errors.New("strict-PQ NodeID is empty")
}
rePQ, err := nodeIDStrictPQ(ks.mldsaPubRaw)
if err != nil {
return fmt.Errorf("strict-PQ NodeID re-derive: %w", err)
}
if rePQ != ks.nodeID {
return fmt.Errorf("strict-PQ NodeID not reproducible: %s != %s", rePQ, ks.nodeID)
}
// TLS: parse via the node loader and re-derive the classical NodeID.
tlsCert, err := staking.LoadTLSCertFromBytes(ks.stakerKeyPEM, ks.stakerCertPEM)
if err != nil {
return fmt.Errorf("TLS round-trip: %w", err)
}
if tlsCert.Leaf == nil {
return errors.New("TLS round-trip: nil leaf")
}
reClassical := ids.NodeIDFromCert(&ids.Certificate{
Raw: tlsCert.Leaf.Raw,
PublicKey: tlsCert.Leaf.PublicKey,
})
if reClassical != ks.nodeIDClassical {
return fmt.Errorf("classical NodeID not reproducible: %s != %s", reClassical, ks.nodeIDClassical)
}
// The two derivations MUST differ: the strict-PQ NodeID comes from the
// ML-DSA pubkey, the classical one from the TLS cert. If they collide,
// something is wired wrong (e.g. the PQ branch fell back to the cert).
if ks.nodeID == ks.nodeIDClassical {
return errors.New("strict-PQ NodeID equals classical NodeID (PQ derivation not applied)")
}
// BLS: parse the raw file form back through localsigner.
if _, err := localsigner.FromBytes(ks.signerKeyRaw); err != nil {
return fmt.Errorf("BLS signer round-trip (file form): %w", err)
}
// BLS: parse the hex KMS form back through localsigner (same code path the
// node uses on the KMS branch).
rawFromHex, err := hex.DecodeString(hex.EncodeToString(ks.signerKeyRaw))
if err != nil {
return fmt.Errorf("BLS signer hex decode: %w", err)
}
if !bytes.Equal(rawFromHex, ks.signerKeyRaw) {
return errors.New("BLS signer hex/raw mismatch")
}
if _, err := localsigner.FromBytes(rawFromHex); err != nil {
return fmt.Errorf("BLS signer round-trip (KMS hex form): %w", err)
}
// ML-DSA: load both PEMs back through the node loader via temp files
// (LoadPQKeyPair is the file-based API the node uses).
tmp, err := os.MkdirTemp("", "venuekeygen-selfcheck-")
if err != nil {
return fmt.Errorf("temp dir: %w", err)
}
defer os.RemoveAll(tmp)
keyPath := filepath.Join(tmp, "mldsa.key")
pubPath := filepath.Join(tmp, "mldsa.pub")
if err := os.WriteFile(keyPath, ks.mldsaKeyPEM, 0o600); err != nil {
return fmt.Errorf("write temp ML-DSA key: %w", err)
}
if err := os.WriteFile(pubPath, ks.mldsaPubPEM, 0o600); err != nil {
return fmt.Errorf("write temp ML-DSA pub: %w", err)
}
if _, err := staking.LoadPQKeyPair(keyPath, pubPath); err != nil {
return fmt.Errorf("ML-DSA round-trip: %w", err)
}
return nil
}
// buildPayload shapes a verified keyset into the stdout JSON document.
func buildPayload(ks *keyset) payload {
return payload{
KMSStakingKeys: staking.KMSStakingKeys{
TLSKey: string(ks.stakerKeyPEM),
TLSCert: string(ks.stakerCertPEM),
SignerKey: hex.EncodeToString(ks.signerKeyRaw), // KMS hex form
},
MLDSAKey: string(ks.mldsaKeyPEM),
MLDSAPub: string(ks.mldsaPubPEM),
NodeID: ks.nodeID.String(), // strict-PQ ML-DSA-65 NodeID
NodeIDClassical: ks.nodeIDClassical.String(), // legacy cert-derived NodeID
}
}
// writeArtifacts writes the five on-disk artifacts under dir with 0600 perms.
// signer.key is RAW bytes (the file-loader shape getStakingSigner reads); the
// rest are PEM. These are SECRETS for local inspection only.
func writeArtifacts(dir string, ks *keyset) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
files := map[string][]byte{
"staker.crt": ks.stakerCertPEM,
"staker.key": ks.stakerKeyPEM,
"signer.key": ks.signerKeyRaw, // RAW, matches node file loader
"mldsa.key": ks.mldsaKeyPEM,
"mldsa.pub": ks.mldsaPubPEM,
}
for name, b := range files {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, b, 0o600); err != nil {
return fmt.Errorf("write %s: %w", p, err)
}
}
return nil
}
// printKMSCommands prints, to stderr, the exact `kms put` invocations the
// operator runs to store each artifact. The kms CLI has no --env flag, so the
// network is the leading path segment; --org lux selects the Lux KMS tenant.
// Operators pipe each artifact into --stdin to keep secret material off argv.
func printKMSCommands(netSlug string) {
fmt.Fprintf(os.Stderr, "\nvenuekeygen: store each artifact in Lux KMS (org=lux), e.g. from an --out dir:\n")
for _, art := range venueArtifacts {
path := fmt.Sprintf("%s/%s/%s", netSlug, venueSecretBase, art)
fmt.Fprintf(os.Stderr, " kms put %s --stdin --org lux < %s\n", path, art)
}
fmt.Fprintf(os.Stderr, "venuekeygen: (note: the kms CLI has no --env flag; net=%s is the leading path segment)\n", netSlug)
}
// normalizeNet validates and canonicalizes the --net value.
func normalizeNet(net string) (string, error) {
switch net {
case "mainnet", "testnet", "devnet", "localnet":
return net, nil
case "":
return "", errors.New("--net is required (mainnet|testnet|devnet|localnet)")
default:
return "", fmt.Errorf("unknown --net %q (expected mainnet|testnet|devnet|localnet)", net)
}
}
// encodePEM wraps body in a PEM block of the given type.
func encodePEM(blockType string, body []byte) ([]byte, error) {
var buf bytes.Buffer
if err := pem.Encode(&buf, &pem.Block{Type: blockType, Bytes: body}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// nodeIDFromCertPEM parses a staking cert+key PEM pair via the node's TLS
// loader and derives the classical NodeID the same way luxd does.
func nodeIDFromCertPEM(certPEM, keyPEM []byte) (ids.NodeID, error) {
cert, err := staking.LoadTLSCertFromBytes(keyPEM, certPEM)
if err != nil {
return ids.EmptyNodeID, err
}
if cert.Leaf == nil {
return ids.EmptyNodeID, errors.New("nil cert leaf")
}
return ids.NodeIDFromCert(&ids.Certificate{
Raw: cert.Leaf.Raw,
PublicKey: cert.Leaf.PublicKey,
}), nil
}
+308
View File
@@ -0,0 +1,308 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/luxfi/crypto/bls/signer/localsigner"
"github.com/luxfi/ids"
"github.com/luxfi/node/staking"
)
// TestGenerateAllArtifactsConsumable generates a devnet venue identity and
// proves every artifact parses back through the node's own loaders.
func TestGenerateAllArtifactsConsumable(t *testing.T) {
ks, err := generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
// All five artifacts must be non-empty.
if len(ks.stakerCertPEM) == 0 {
t.Error("staker.crt (stakerCertPEM) is empty")
}
if len(ks.stakerKeyPEM) == 0 {
t.Error("staker.key (stakerKeyPEM) is empty")
}
if len(ks.signerKeyRaw) == 0 {
t.Error("signer.key (signerKeyRaw) is empty")
}
if len(ks.mldsaKeyPEM) == 0 {
t.Error("mldsa.key (mldsaKeyPEM) is empty")
}
if len(ks.mldsaPubPEM) == 0 {
t.Error("mldsa.pub (mldsaPubPEM) is empty")
}
// selfCheck performs every node-loader round-trip; if it passes, the
// artifacts are luxd-consumable.
if err := selfCheck(ks); err != nil {
t.Fatalf("selfCheck: %v", err)
}
// Explicit loader round-trips (independent of selfCheck) for clarity.
tlsCert, err := staking.LoadTLSCertFromBytes(ks.stakerKeyPEM, ks.stakerCertPEM)
if err != nil {
t.Fatalf("LoadTLSCertFromBytes: %v", err)
}
if tlsCert.Leaf == nil {
t.Fatal("LoadTLSCertFromBytes: nil leaf")
}
if _, err := localsigner.FromBytes(ks.signerKeyRaw); err != nil {
t.Fatalf("localsigner.FromBytes (raw file form): %v", err)
}
rawFromHex, err := hex.DecodeString(hex.EncodeToString(ks.signerKeyRaw))
if err != nil {
t.Fatalf("hex decode signer: %v", err)
}
if _, err := localsigner.FromBytes(rawFromHex); err != nil {
t.Fatalf("localsigner.FromBytes (KMS hex form): %v", err)
}
}
// TestPayloadUnmarshalsIntoKMSStakingKeys proves the stdout JSON document
// unmarshals into the node's KMSStakingKeys struct with all three fields
// populated, AND that the signer_key field is valid hex consumable by the
// node's KMS signer path.
func TestPayloadUnmarshalsIntoKMSStakingKeys(t *testing.T) {
ks, err := generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
doc := buildPayload(ks)
body, err := json.Marshal(doc)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
// The exact node consumer: unmarshal into staking.KMSStakingKeys.
var keys staking.KMSStakingKeys
if err := json.Unmarshal(body, &keys); err != nil {
t.Fatalf("unmarshal into KMSStakingKeys: %v", err)
}
if keys.TLSKey == "" {
t.Error("KMSStakingKeys.TLSKey (json tls_key) is empty after unmarshal")
}
if keys.TLSCert == "" {
t.Error("KMSStakingKeys.TLSCert (json tls_cert) is empty after unmarshal")
}
if keys.SignerKey == "" {
t.Error("KMSStakingKeys.SignerKey (json signer_key) is empty after unmarshal")
}
// signer_key must decode as hex and parse via localsigner — the node's
// KMS signer branch does exactly hex.DecodeString → localsigner.FromBytes.
rawSigner, err := hex.DecodeString(keys.SignerKey)
if err != nil {
t.Fatalf("signer_key not valid hex: %v", err)
}
if _, err := localsigner.FromBytes(rawSigner); err != nil {
t.Fatalf("signer_key hex not consumable by localsigner.FromBytes: %v", err)
}
// Round-trip the TLS PEMs straight out of the JSON too.
if _, err := staking.LoadTLSCertFromBytes([]byte(keys.TLSKey), []byte(keys.TLSCert)); err != nil {
t.Fatalf("TLS PEMs from JSON not loadable: %v", err)
}
// The ML-DSA fields and NodeID must also be present in the full payload.
var full payload
if err := json.Unmarshal(body, &full); err != nil {
t.Fatalf("unmarshal into payload: %v", err)
}
if full.MLDSAKey == "" || full.MLDSAPub == "" {
t.Error("payload mldsa_key / mldsa_pub empty after unmarshal")
}
if full.NodeID == "" {
t.Error("payload node_id (strict-PQ) empty after unmarshal")
}
if full.NodeIDClassical == "" {
t.Error("payload node_id_classical empty after unmarshal")
}
// node_id (strict-PQ) and node_id_classical MUST be different values.
if full.NodeID == full.NodeIDClassical {
t.Errorf("node_id == node_id_classical (%s); strict-PQ derivation not applied", full.NodeID)
}
// node_id MUST be the strict-PQ ML-DSA-65 NodeID derived from the generated
// ML-DSA pubkey via the canonical node seam.
wantPQ, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, ks.mldsaPubRaw)
if err != nil {
t.Fatalf("DeriveMLDSA: %v", err)
}
if full.NodeID != wantPQ.String() {
t.Errorf("node_id = %s, want strict-PQ %s", full.NodeID, wantPQ.String())
}
}
// TestNodeIDIsStrictPQAndStable asserts the venue's primary NodeID (ks.nodeID)
// is the strict-PQ ML-DSA-65 NodeID — non-empty, stable per ML-DSA pubkey, and
// DISTINCT from the legacy cert-derived NodeID. This is the value luxd computes
// under the PQ securityProfile (config/node DeriveNodeID strict-PQ branch).
func TestNodeIDIsStrictPQAndStable(t *testing.T) {
ks, err := generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
if ks.nodeID == ids.EmptyNodeID {
t.Fatal("strict-PQ NodeID is empty")
}
if ks.nodeIDClassical == ids.EmptyNodeID {
t.Fatal("classical NodeID is empty")
}
// The strict-PQ NodeID and the classical cert-derived NodeID MUST differ —
// they come from different keying material (ML-DSA pubkey vs TLS cert).
if ks.nodeID == ks.nodeIDClassical {
t.Fatalf("strict-PQ NodeID equals classical NodeID: %s", ks.nodeID)
}
// ks.nodeID MUST equal the canonical node-source derivation from the SAME
// raw ML-DSA pubkey bytes: ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, pub).
wantPQ, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, ks.mldsaPubRaw)
if err != nil {
t.Fatalf("DeriveMLDSA: %v", err)
}
if ks.nodeID != wantPQ {
t.Fatalf("ks.nodeID != DeriveMLDSA(ids.Empty,pub): %s != %s", ks.nodeID, wantPQ)
}
// Stability: re-deriving from the same pubkey yields the same NodeID.
again, err := nodeIDStrictPQ(ks.mldsaPubRaw)
if err != nil {
t.Fatalf("re-derive strict-PQ NodeID: %v", err)
}
if again != ks.nodeID {
t.Fatalf("strict-PQ NodeID not stable for same pub: %s != %s", again, ks.nodeID)
}
// A second, freshly generated identity must have a DIFFERENT NodeID
// (fresh random ML-DSA keys per invocation).
ks2, err := generate()
if err != nil {
t.Fatalf("second generate: %v", err)
}
if ks2.nodeID == ks.nodeID {
t.Fatal("two fresh generations produced the same strict-PQ NodeID (keys not random)")
}
}
// TestNodeIDDerivesFromMLDSAPubNotCert proves the strict-PQ NodeID is a pure
// function of the ML-DSA pubkey: a different ML-DSA pubkey changes node_id,
// while the TLS cert (which only drives node_id_classical) has NO effect on it.
func TestNodeIDDerivesFromMLDSAPubNotCert(t *testing.T) {
// Two independent ML-DSA pubkeys -> two different strict-PQ NodeIDs.
kpA, err := staking.NewPQKeyPair()
if err != nil {
t.Fatalf("new ML-DSA pair A: %v", err)
}
kpB, err := staking.NewPQKeyPair()
if err != nil {
t.Fatalf("new ML-DSA pair B: %v", err)
}
pubA, pubB := kpA.PublicKeyBytes(), kpB.PublicKeyBytes()
nidA, err := nodeIDStrictPQ(pubA)
if err != nil {
t.Fatalf("derive A: %v", err)
}
nidB, err := nodeIDStrictPQ(pubB)
if err != nil {
t.Fatalf("derive B: %v", err)
}
if nidA == nidB {
t.Fatal("different ML-DSA pubkeys produced the same strict-PQ NodeID")
}
// Same pubkey -> same NodeID regardless of any cert: the derivation never
// reads cert material. Re-deriving from pubA twice is stable.
nidA2, err := nodeIDStrictPQ(pubA)
if err != nil {
t.Fatalf("re-derive A: %v", err)
}
if nidA2 != nidA {
t.Fatalf("strict-PQ NodeID not stable for pubA: %s != %s", nidA2, nidA)
}
// Cross-check against the canonical node-source seam for pubA.
wantA, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, pubA)
if err != nil {
t.Fatalf("DeriveMLDSA A: %v", err)
}
if nidA != wantA {
t.Fatalf("nodeIDStrictPQ(pubA) != DeriveMLDSA(ids.Empty,pubA): %s != %s", nidA, wantA)
}
}
// TestWriteArtifactsToOutDir writes the five artifacts to a temp --out dir,
// asserts they are non-empty, and re-loads each via the node loaders from
// disk (the exact file forms luxd consumes).
func TestWriteArtifactsToOutDir(t *testing.T) {
ks, err := generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
dir := t.TempDir()
if err := writeArtifacts(dir, ks); err != nil {
t.Fatalf("writeArtifacts: %v", err)
}
for _, name := range venueArtifacts {
p := filepath.Join(dir, name)
info, err := os.Stat(p)
if err != nil {
t.Fatalf("stat %s: %v", p, err)
}
if info.Size() == 0 {
t.Errorf("artifact %s is empty on disk", name)
}
// 0600 perms (secret material).
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("artifact %s perms = %#o, want 0600", name, perm)
}
}
// TLS from files (the node's file loader).
if _, err := staking.LoadTLSCertFromFiles(
filepath.Join(dir, "staker.key"),
filepath.Join(dir, "staker.crt"),
); err != nil {
t.Fatalf("LoadTLSCertFromFiles: %v", err)
}
// signer.key on disk is RAW bytes — parse via localsigner.
rawSigner, err := os.ReadFile(filepath.Join(dir, "signer.key"))
if err != nil {
t.Fatalf("read signer.key: %v", err)
}
if _, err := localsigner.FromBytes(rawSigner); err != nil {
t.Fatalf("signer.key (raw on disk) not consumable: %v", err)
}
// ML-DSA from files via the node loader.
if _, err := staking.LoadPQKeyPair(
filepath.Join(dir, "mldsa.key"),
filepath.Join(dir, "mldsa.pub"),
); err != nil {
t.Fatalf("LoadPQKeyPair: %v", err)
}
}
// TestNormalizeNet covers the --net validation.
func TestNormalizeNet(t *testing.T) {
for _, ok := range []string{"mainnet", "testnet", "devnet", "localnet"} {
if got, err := normalizeNet(ok); err != nil || got != ok {
t.Errorf("normalizeNet(%q) = (%q, %v), want (%q, nil)", ok, got, err, ok)
}
}
for _, bad := range []string{"", "mainet", "prod", "local"} {
if _, err := normalizeNet(bad); err == nil {
t.Errorf("normalizeNet(%q) = nil error, want error", bad)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"description": "Decentralized Exchange \u2014 native CLOB + AMM + perpetuals",
"feeConfig": {
"makerFeeBps": 2,
"takerFeeBps": 5
},
"liquidityPools": [],
"message": "Lux D-Chain Genesis",
"name": "D-Chain",
"networkId": 1337,
"perpetualMarkets": [],
"timestamp": 1735689600,
"tradingPairs": [],
"version": 1,
"vm": "DexVM",
"chainId": 31448
}