mirror of
https://github.com/luxfi/genesis.git
synced 2026-07-27 04:11:41 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user