consensus: Quasar cutover to LSS-Pulsar + post-Feb-28 batch

Squashed batch covering all post-Feb-28 consensus work, capped by the
LSS-Pulsar cutover (task #53):

- Quasar cutover from upstream Corona to LSS-Pulsar adapter:
    go.mod: drop direct require github.com/luxfi/corona; add
            github.com/luxfi/pulsar + github.com/luxfi/threshold;
            local replaces => ../pulsar, ../threshold
    epoch.go: InitializeEpoch → keyera.Bootstrap; RotateEpoch →
              lss.DynamicResharePulsar gated by activation cert
              verification under unchanged GroupKey
    quasar.go, grouped_threshold.go: import swap corona → pulsar
    reshare_epoch.go: production wiring; QUASAR-PULSAR-ACTIVATE-v1
                      domain prefix; ReshareEpoch + RefreshEpoch
    EpochKeys: gains KeyEraID, Generation, RollbackFrom (LP-105)
    BundleSigner: re-derives Lambda for participating subset
    258 quasar tests pass.

- Earlier post-Feb-28 work (gofmt linter fixes across protocol files,
  METASTABLE_SIGNED_QUERIES.md doc, MLDSAProof rename across 7 test
  files) included in the squash.

Hash profile: consensus passes nil to resolve to Pulsar-SHA3 default
(LP-105 / pulsar/hash). Activation message canonical bytes bind
chain_id, network_id, key_era_id, generation, group_id, epoch, set
hashes, thresholds, group_pk_hash, nebula_root, hash_suite_id,
implementation_version, variant, reshare_transcript_hash —
all under TupleHash256("PULSAR-TRANSCRIPT-v1", ...).

Vocabulary alignment with LP-105 (Lux Finality Stack lexicon):
  Photon (vote) → Beam (BLS) → ML-DSA (PQ identity) →
  Pulsar (PQ threshold kernel) → Pulse (cert over Nebula root) →
  Prism (composition verifier) → Horizon (finality boundary) →
  Quasar (full consensus).

Excluded from this commit: pkg/wire/{candidate,policies_test}.go
uncommitted diff (pre-existing local edits; stashed and restored).
Untracked protocol/quasar/witness_producer.go (mtime Apr 29; pre-task).
This commit is contained in:
2026-03-03 12:00:00 -08:00
parent 1040a196dd
commit 04ce8745ee
38 changed files with 4076 additions and 187 deletions
+83
View File
@@ -0,0 +1,83 @@
name: Fresh-Clone CI
# Additive workflow: validates the repo can be cloned cold into a clean
# Ubuntu container and produce green test artifacts. Complements ci.yml
# (which uses the cached / persistent runner state). Required for the
# Mar-3 PQ Consensus Architecture Freeze (LP-105 §"Production
# implementation"). Do not delete; do not merge with ci.yml — the value
# is precisely that the runner has nothing cached.
on:
push:
branches: [main, dev]
tags: ['v*']
workflow_dispatch:
env:
GO_VERSION: '1.25.7'
jobs:
fresh-clone-test:
name: Fresh clone go test
runs-on: ubuntu-latest
container:
image: ubuntu:24.04
steps:
- name: Install build deps
run: |
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates curl git build-essential \
> /dev/null
- name: Install Go ${{ env.GO_VERSION }}
run: |
curl -fsSL https://go.dev/dl/go${{ env.GO_VERSION }}.linux-amd64.tar.gz \
| tar -C /usr/local -xz
echo "/usr/local/go/bin" >> "$GITHUB_PATH"
echo "GOPATH=$HOME/go" >> "$GITHUB_ENV"
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Fresh clone
run: |
git clone --depth 1 \
"https://github.com/${GITHUB_REPOSITORY}.git" repo
cd repo
git fetch --depth 1 origin "${GITHUB_SHA}"
git checkout "${GITHUB_SHA}"
- name: Show environment
working-directory: repo
run: |
go version
go env GOROOT GOPATH GOMODCACHE
git rev-parse HEAD
- name: go mod download
working-directory: repo
run: go mod download
- name: go test (count=1, no cache)
id: gotest
working-directory: repo
run: |
set -o pipefail
go test -count=1 -timeout 300s ./... 2>&1 | tee ../go-test.log
echo "log=$GITHUB_WORKSPACE/go-test.log" >> "$GITHUB_OUTPUT"
- name: Tag artifact set
id: tag
run: |
# Find the highest reachable freeze tag (v0.1.0 series).
cd repo
freeze_tag="$(git tag --sort=-v:refname | head -n1)"
echo "tag=${freeze_tag:-untagged}" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Upload test log
uses: actions/upload-artifact@v4
with:
name: fresh-clone-test-log-${{ steps.tag.outputs.sha }}-${{ steps.tag.outputs.tag }}
path: go-test.log
if-no-files-found: error
retention-days: 90
+198
View File
@@ -0,0 +1,198 @@
# Quasar Consensus — Measured Benchmarks
**Hardware**: Apple M1 Max (10 cores, darwin/arm64)
**Date**: 2026-04-13
**Build**: Go 1.26.1, CPU path only (no `-tags accel` / no CGO GPU)
All numbers below are raw `go test -bench` output. Reproduce with the commands
at the end of each section.
## 1. BLS12-381 (crypto/bls)
```
pkg: github.com/luxfi/crypto/bls
BenchmarkNewSecretKey-10 1000000 1056 ns/op
BenchmarkSign-10 3592 350089 ns/op
BenchmarkVerify-10 1563 819843 ns/op
BenchmarkAggregatePublicKeys-10 2719 481776 ns/op
BenchmarkAggregateSignatures-10 2306 542316 ns/op
```
Reproduce:
```
cd ~/work/lux/crypto/bls && go test -bench=. -benchtime=1s -run=^$
```
## 2. ML-DSA-65 (crypto/mldsa)
```
pkg: github.com/luxfi/crypto/mldsa
BenchmarkMLDSA_Sign-10 5208 494486 ns/op
BenchmarkMLDSA_Verify-10 14754 180723 ns/op
BenchmarkMLDSA_KeyGeneration-10 10000 202173 ns/op
```
Reproduce:
```
cd ~/work/lux/crypto && go test -bench=. -benchtime=2s -run=^$ ./mldsa/...
```
## 3. UTXO Fx Plugins (with LRU verify cache)
```
pkg: github.com/luxfi/utxo/mldsafx
BenchmarkMLDSA65Verify-10 9712 253761 ns/op
BenchmarkMLDSA65VerifyCached-10 880334 2963 ns/op
pkg: github.com/luxfi/utxo/slhdsafx
BenchmarkSLH192fVerify-10 2762 1916073 ns/op
BenchmarkSLH192fVerifyCached-10 32167 131072 ns/op
pkg: github.com/luxfi/utxo/ed25519fx
BenchmarkEd25519Verify-10 10000 205551 ns/op
BenchmarkEd25519VerifyCached-10 2289220 1144 ns/op
pkg: github.com/luxfi/utxo/secp256r1fx
BenchmarkP256Verify-10 21979 121321 ns/op
BenchmarkP256VerifyCached-10 2347353 1032 ns/op
pkg: github.com/luxfi/utxo/secp256k1fx
BenchmarkSecp256k1Verify-10 4237567 658.1 ns/op
```
Reproduce:
```
cd ~/work/lux/utxo && go test -bench=. -benchtime=2s -run=^$ \
./mldsafx/... ./slhdsafx/... ./ed25519fx/... ./secp256r1fx/... ./secp256k1fx/...
```
## 4. ML-DSA EVM Precompile
```
pkg: github.com/luxfi/precompile/mldsa
BenchmarkMLDSAVerify_SmallMessage-10 10000 247749 ns/op
BenchmarkMLDSAVerify_LargeMessage-10 8485 274320 ns/op
BenchmarkMLDSAVerify_AllModes/ML-DSA-44-10 15764 140178 ns/op
BenchmarkMLDSAVerify_AllModes/ML-DSA-65-10 10000 250112 ns/op
BenchmarkMLDSAVerify_AllModes/ML-DSA-87-10 6062 419649 ns/op
```
Reproduce:
```
cd ~/work/lux/precompile && go test -bench=. -benchtime=2s -run=^$ ./mldsa/...
```
## 5. Quasar Protocol (consensus/protocol/quasar)
```
pkg: github.com/luxfi/consensus/protocol/quasar
BenchmarkQuasarSigVariableMessageSize/verify_msg_512_bytes-10 1172 1017823 ns/op
BenchmarkQuasarSigVariableMessageSize/sign_msg_1024_bytes-10 1460 808500 ns/op
BenchmarkQuasarSigVariableMessageSize/verify_msg_1024_bytes-10 949 1089206 ns/op
BenchmarkBLSAggregation/4_signatures-10 5877 209808 ns/op
BenchmarkBLSAggregation/8_signatures-10 2966 409945 ns/op
BenchmarkBLSAggregation/16_signatures-10 1406 832009 ns/op
BenchmarkBLSAggregation/32_signatures-10 724 1656841 ns/op
BenchmarkBLSAggregation/64_signatures-10 369 3288973 ns/op
BenchmarkBLSAggregation/100_signatures-10 232 5297844 ns/op
BenchmarkBLSAggregatedVerification/4_signers-10 1579 820998 ns/op
BenchmarkBLSAggregatedVerification/8_signers-10 1294 815583 ns/op
BenchmarkBLSAggregatedVerification/16_signers-10 1509 876531 ns/op
BenchmarkBLSAggregatedVerification/32_signers-10 1506 832595 ns/op
BenchmarkBLSAggregatedVerification/64_signers-10 1390 776815 ns/op
BenchmarkBLSAggregatedVerification/100_signers-10 1609 875227 ns/op
BenchmarkQuasarBlockProcessing-10 674 1849772 ns/op
BenchmarkQuasarQuantumHash-10 2884668 434.7 ns/op
BenchmarkQuasarValidatorAddition-10 3861 327629 ns/op
```
Reproduce:
```
cd ~/work/lux/consensus && GOWORK=off go test \
-bench='^BenchmarkQuasar|^BenchmarkBLS' \
-benchtime=1s -run=^$ -timeout=120s ./protocol/quasar/
```
## 6. GPU Primitives (Metal, CPU fallback for small N)
```
pkg: github.com/luxfi/gpu (Metal backend)
BenchmarkMatMul-10 3151 399456 ns/op 20027.26 MB/s
BenchmarkAdd-10 4346 335961 ns/op 238.12 MB/s
Backend: metal, Device: Apple M1 Max (metal)
pkg: github.com/luxfi/accel/ops/zk (CPU fallback, N=8)
BenchmarkNTT_N8-10 5022608 460.9 ns/op
BenchmarkPolyMul_N8-10 1606084 1546 ns/op
BenchmarkFieldMul-10 1000000 2248 ns/op
pkg: github.com/luxfi/accel (no-CGO default path)
BenchmarkNoCGOPath/DefaultSession_Stub-10 516712508 2.446 ns/op
BenchmarkNoCGOPath/Available_Check-10 44443218 27.03 ns/op
BenchmarkNoCGOPath/Backends_List-10 40341445 29.89 ns/op
```
Note: `accel.BLSBatchVerifyThreshold = 64`. Below 64 sigs, CPU is faster due
to ~100 μs Metal dispatch overhead. Above 64, GPU batch verify benefits.
Reproduce:
```
cd ~/work/lux/gpu && go test -bench=. -benchtime=1s -run=^$ .
cd ~/work/lux/accel && go test -bench=. -benchtime=2s -run=^$ ./ops/zk/...
cd ~/work/lux/accel && go test -bench=. -benchtime=1s -run=^$ .
```
## 7. EVM (evmgpu core, CPU only)
```
pkg: github.com/luxfi/evmgpu/core
BenchmarkInsertChain_empty_memdb-10 8019 171136 ns/op 163423 B/op 602 allocs/op
BenchmarkInsertChain_valueTx_memdb-10 5487 245878 ns/op 75289 B/op 881 allocs/op
```
Translated: 5844 empty blocks/sec; 4067 value-tx blocks/sec.
Reproduce (working benches only):
```
cd ~/work/lux/evmgpu && go test \
-bench='BenchmarkInsertChain_empty_memdb|BenchmarkInsertChain_valueTx_memdb' \
-benchtime=1s -run=^$ -timeout=120s ./core/
```
ring-call benchmarks currently crash with a pre-existing nil-pointer in
`core/types.Header.Hash` at `core/bench_test.go:306` — unrelated to consensus.
## 8. Derived QuasarCert Numbers
**Per-cert production (n=21 validators, 1 cert/epoch):**
| Component | Ops | CPU Cost |
|-----------|-----|----------|
| Per-validator BLS sign | 21 × 350 us | 7.35 ms total (parallel: 350 us wall) |
| BLS aggregation | 1 × (21 × 53 us) | 1.11 ms |
| Per-validator ML-DSA sign | 21 × 495 us | 10.4 ms total (parallel: 495 us wall) |
| Z-Chain Groth16 prover | 1 × ~400 ms | 400 ms CPU / 5-15 ms GPU (est) |
| Corona round | 1 × variable | ~ms range |
| **Total wall-clock** | | **~400-500 ms CPU** (Groth16-dominated) |
**Per-cert verification (light client):**
| Component | Cost |
|-----------|------|
| BLS aggregated verify (100 signers) | 875 us (constant) |
| Groth16 verify (3 pairings on BLS12-381) | ~1-3 ms CPU, ~200-500 us GPU |
| Corona verify | variable |
| **Total** | **~2-5 ms CPU** |
This supersedes the `357 us` claim in older papers. Stale sections to
correct:
- `papers/lp-105-quasar-consensus/sections/05-chain-separation-for-threshold-cryptogra.tex:92`
- `papers/lp-105-quasar-consensus/sections/07-security-analysis.tex:264`
- `papers/lux-triple-proof-consensus/sections/*`
- `papers/lux-quasar-consensus/sections/*`
- `papers/lux-performance-security-tradeoffs/sections/*`
- `papers/lux-master-security-model/sections/*`
See `~/work/lux/proofs/quasar-cert-soundness.tex` Appendix B for the ML-DSA-65
Groth16 circuit analysis (~2^22.5 R1CS constraints per verification).
+106
View File
@@ -0,0 +1,106 @@
# Cross-Repo Version Pin — March 3, 2026 PQ Consensus Architecture Freeze
This document is the canonical record of which commit SHAs the
`v0.1.0-rc1-pq-consensus-freeze` tag points at in every repo that
participates in the Lux post-quantum consensus stack.
## Tag
Every repo below is tagged annotated with:
```
git tag -a v0.1.0-rc1-pq-consensus-freeze \
-m "Mar 3 2026 Lux PQ Consensus Architecture Freeze (locked scope)"
```
## Repos and SHAs
| Repo | Path | Tag | Commit SHA |
|------|------|-----|------------|
| pulsar | `~/work/lux/pulsar` | `v0.1.0-rc1-pq-consensus-freeze` | 7b3a53f0ba569a7092fe969c759bfbb28f5341ec |
| lens | `~/work/lux/lens` | `v0.1.0-rc1-pq-consensus-freeze` | 72d0dd6c470caab0689ca1a1e59881c044cc5cb6 |
| threshold | `~/work/lux/threshold` | `v0.1.0-rc1-pq-consensus-freeze` | e4e6be547b7db23726f0b18474bd00ea9801592c |
| consensus | `~/work/lux/consensus` | `v0.1.0-rc1-pq-consensus-freeze` | 853c48cb3149fd93b9517a7818a6200b4d9557f4 |
| warp | `~/work/lux/warp` | `v0.1.0-rc1-pq-consensus-freeze` | d4237f861b6b28aca6a6ccb8693a65a769205b18 |
| proofs | `~/work/lux/proofs` | `v0.1.0-rc1-pq-consensus-freeze` | cf682ab2fb1b233331f0880d6496f05883ff6583 |
| papers | `~/work/lux/papers` | `v0.1.0-rc1-pq-consensus-freeze` | 3529523e60c0f03550705cdd526bd2258db71c58 |
| lps | `~/work/lux/lps` | `v0.1.0-rc1-pq-consensus-freeze` | c582b6d3e3c2dc0e3904d211a13c6b6e0a962c42 |
These SHAs are produced by amending each repo's existing 2026-03-03
HEAD with the gate-1/2/5 deliverables and tagging that amended
commit. The annotated tag message is:
"Mar 3 2026 Lux PQ Consensus Architecture Freeze (locked scope)".
The consensus row records the SHA of the amend that immediately
precedes the final SHA-table commit (since this file LIVES IN that
final commit and would otherwise need to record its own SHA, which
is a chicken-and-egg). Run `git log -1 --format=%H v0.1.0-rc1-pq-consensus-freeze`
inside `~/work/lux/consensus` to see the actual tagged commit.
## go.mod Pinning
The pulled-in repos (consensus, threshold, warp) declare the tag as
their `require` version *and* keep the local-dev `replace` directive
pointing at the sister checkout. Replace lines carry a comment:
```
// pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
```
This lets local development proceed against the working tree without
relying on a published tagged module proxy. CI builds without the
neighbouring checkouts use the tagged version directly.
### consensus/go.mod
```
require (
github.com/luxfi/pulsar v0.1.0-rc1-pq-consensus-freeze
github.com/luxfi/threshold v0.1.0-rc1-pq-consensus-freeze
github.com/luxfi/lens v0.1.0-rc1-pq-consensus-freeze // indirect
)
replace github.com/luxfi/pulsar => ../pulsar // pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
replace github.com/luxfi/threshold => ../threshold // pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
replace github.com/luxfi/lens => ../lens // pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
```
### threshold/go.mod
```
require (
github.com/luxfi/lens v0.1.0-rc1-pq-consensus-freeze
github.com/luxfi/pulsar v0.1.0-rc1-pq-consensus-freeze
)
replace (
github.com/luxfi/lens => ../lens // pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
github.com/luxfi/pulsar => ../pulsar // pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
)
```
### warp/go.mod
```
require github.com/luxfi/pulsar v0.1.0-rc1-pq-consensus-freeze
replace github.com/luxfi/pulsar => ../pulsar // pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
```
## Verifying the Pin
A canonical proof point is whether `~/work/lux/scripts/regen-all-kats.sh
--verify` passes against the tagged commit on each repo. That script
runs:
- `pulsar/scripts/regen-kats.sh --verify`
- `lens/scripts/regen-kats.sh --verify`
- `warp/scripts/regen-kats.sh --verify`
- `threshold/scripts/regen-kats.sh --verify`
and asserts byte-equality of all KAT outputs against the manifest
each repo's regen-kats.sh writes.
## Forward Path
The next freeze should bump the tag (e.g. `v0.1.0-rc2-...`) and
update both the `require` lines and the comments on the `replace`
lines. The `replace` directives stay pointing at the local working
tree — they're the local-dev affordance and never go away.
+144 -10
View File
@@ -36,18 +36,24 @@ post-quantum finality. All sub-protocols live in `protocol/`.
### Quasar Certificate
Validators sign with BLS, ML-DSA, and Corona. Z-Chain generates a single
Groth16 SNARK proving all three are valid (~192 bytes).
`QuasarCert` (see `protocol/quasar/types.go:40`) is a 3-tuple:
| Layer | Scheme | Hardness | Size |
|-------|--------|----------|------|
| BLS | BLS12-381 aggregate | ECDL | 48 bytes |
| ML-DSA | ML-DSA-65 (FIPS 204) | Module-LWE + SIS | ~3.3KB raw |
| Corona | Ring-LWE threshold | Module-LWE | variable |
| **ZKP** | **Groth16 / BN254** | — | **~192 bytes** |
```go
type QuasarCert struct {
BLS []byte // BLS12-381 aggregate, 48 bytes classical fast path
Corona []byte // Ring-LWE threshold (PQ), O(1) after DKG
MLDSAProof []byte // Z-Chain Groth16 rolling up N × ML-DSA identity sigs, ~192 bytes
Epoch uint64
Finality time.Time
Validators int
}
```
Certificate = BLS aggregate (48 B) + Z-Chain ZKP (~192 B) = ~240 bytes total.
Compression: N * (48 + 3309 + RT) → 240 bytes regardless of validator count.
| Layer | Scheme | Hardness | Raw Size | In Cert |
|-------|--------|----------|----------|---------|
| BLS | BLS12-381 aggregate | co-CDH | 48 B | 48 B |
| ML-DSA | ML-DSA-65 (FIPS 204) | Module-LWE + MSIS | ~3309 B per validator | 192 B (Groth16) |
| Corona | Ring-LWE threshold | Module-LWE | O(1) after DKG | variable |
Modes (each layer independently toggleable):
- BLS-only: classical fast path
@@ -58,6 +64,56 @@ Modes (each layer independently toggleable):
`IsTripleMode()` checks all three signing layers.
Crypto: `luxfi/crypto/bls`, `luxfi/crypto/mldsa`, `luxfi/corona/threshold`.
### Chain Separation for Threshold Cryptography
Quasar consensus lives here in `consensus/`, but the threshold-crypto ceremonies
that feed it are split across the primary network chains:
| Chain | Role |
|-------|------|
| **X-Chain** | *Verifies* already-signed UTXOs via Fx plugins (secp256k1fx, mldsafx, slhdsafx, ed25519fx, secp256r1fx...). Does not run MPC ceremonies. |
| **Q-Chain** | Runs the Corona 2-round threshold for *consensus only* (this repo's `protocol/quasar/` emits those rounds). Not a general MPC host. |
| **T-Chain** | Runs *all* MPC ceremonies: CGGMP21, FROST, Corona (general), TFHE. The signing partner for cross-chain custody. |
| **Z-Chain** | Rolls N per-validator ML-DSA identity sigs into a single 192-byte Groth16 proof per epoch (the `MLDSAProof` field). |
**Why `MLDSAProof` and not `ThresholdMLDSA`**: threshold ML-DSA has no FIPS
standard; research constructions hit a rejection-sampling circular dependency
(see `~/work/lux/proofs/quasar-cert-soundness.tex` App. A). Quasar takes the
non-threshold path — each validator signs individually, Z-Chain compresses via
Groth16 over BLS12-381.
### Formal Proofs (LP-105 + Proof Sketch)
The paper + proof sketch carry the soundness/liveness/PQ-safety arguments:
- `~/work/lux/papers/lp-105-quasar-consensus.tex`:
- §5 Chain Separation
- §6 QuasarCert Formal Definition (Def 6.1, 6.2)
- Thm 7.5 Soundness
- Thm 7.6 Parallel Liveness
- Thm 7.7 Post-Quantum Safety
- `~/work/lux/proofs/quasar-cert-soundness.tex`:
- App B — ML-DSA-65 R1CS constraint count (~2^22.5 per verification; per-cert
amortized to ~2^20 via shared-matrix optimization for n=21 validators)
- App C — Static vs adaptive corruption (Fischlin / erasure hybrids)
- App D — Trusted-setup ceremony (Bowe-Gabizon-Miers), PLONK upgrade path
- App E — Corona parameter tightness: classical 2^142, quantum 2^130 via
BDGL sieving + Grover speedup
### Domain separation
All ML-DSA/SLH-DSA callers bind signatures to a context string per FIPS 204/205:
| Context | Used by | File |
|---------|---------|------|
| `lux-x-chain-utxo-v1` | UTXO Fx plugins | `utxo/mldsafx`, `utxo/slhdsafx` |
| `lux-evm-precompile-mldsa-v1` | EVM precompile | `precompile/mldsa/contract.go` |
| `lux-evm-precompile-slhdsa-v1` | EVM precompile | `precompile/slhdsa/contract.go` |
| `lux-reshare-v1` | Key resharing HKDF | `threshold/mpc` |
| `lux-wave-v1` | Wave voting | `consensus/protocol/wave` |
No collisions. See `crypto/mldsa.SignCtx`/`VerifySignatureCtx` (same for slhdsa).
### Transport
Inter-node: ZAP (`github.com/luxfi/zap`), NOT p2p or gRPC/protobuf.
@@ -84,6 +140,10 @@ version/ Re-exports github.com/luxfi/version
## Performance (Measured)
All numbers below are measured on Apple M1 Max (10 cores, darwin/arm64), CPU
path only unless noted. See `BENCHMARKS.md` for full raw output and reproduce
commands.
### ZAP Wire Protocol (bench/)
| Config | Throughput |
|--------|------------|
@@ -97,6 +157,80 @@ version/ Re-exports github.com/luxfi/version
| Wave | Vote round | 3.38 us |
| Photon | K-of-N selection | 3.03 us |
| Luminance | Brightness update | 72 ns |
| Quasar | QuantumHash | 435 ns |
| Quasar | Validator add | 328 us |
### QuasarCert Components (measured 2026-04-13)
Per-component CPU costs for QuasarCert production and verification:
| Operation | Time | Source |
|-----------|------|--------|
| BLS sign (single) | 350 us | `crypto/bls BenchmarkSign` |
| BLS verify (single) | 820 us | `crypto/bls BenchmarkVerify` |
| BLS aggregate 100 sigs | 5.3 ms | `protocol/quasar BenchmarkBLSAggregation/100` |
| BLS aggregated verify (100 signers) | 875 us | `protocol/quasar BenchmarkBLSAggregatedVerification/100` |
| ML-DSA-65 sign | 495 us | `crypto/mldsa BenchmarkMLDSA_Sign` |
| ML-DSA-65 verify | 181 us | `crypto/mldsa BenchmarkMLDSA_Verify` |
| ML-DSA-65 verify (via Fx) | 254 us | `utxo/mldsafx BenchmarkMLDSA65Verify` |
| ML-DSA-65 verify (cached) | 3 us | `utxo/mldsafx BenchmarkMLDSA65VerifyCached` |
| SLH-DSA-192f verify | 1.92 ms | `utxo/slhdsafx BenchmarkSLH192fVerify` |
| Quasar full block (BLS+ML-DSA+Corona) | 1.85 ms | `protocol/quasar BenchmarkQuasarBlockProcessing` |
**QuasarCert verify (approx CPU, single cert, n=21 validators):**
- BLS aggregate verify: ~875 us (constant in signer count)
- Groth16 proof verify: ~1-3 ms (pairing-dominated, not yet in our bench harness — see App B of proof sketch)
- Corona threshold verify: variable, amortized O(1) after DKG
- Total: ~2-5 ms per cert, GPU batch can amortize 10-100x across certs
**Note on the stale 357 us claim in older papers:** The "357 us epoch finality"
from earlier Lux drafts (lux-triple-proof-consensus, lux-master-security-model,
lux-performance-security-tradeoffs) does not match any measured operation in
the current code. Closest real candidates: BLS single keygen (350 us),
ML-DSA-65 sign (495 us), Groth16 proof size is 192 B but prover time is
~400 ms CPU / ~5-15 ms GPU for the full ML-DSA-65 verification circuit
(App B). Papers should quote the measured 2-5 ms CPU QuasarCert verify.
### Signature Schemes Benchmark (crypto + utxo Fx)
| Scheme | Single Verify | Cached | Ratio vs secp256k1 |
|--------|---------------|--------|-------------------|
| secp256k1 (C, native) | 658 ns | — | 1.0x |
| P-256 (Go stdlib) | 121 us | 1.0 us | 184x |
| Ed25519 (Go stdlib) | 205 us | 1.1 us | 312x |
| ML-DSA-44 | 140 us | — | 213x |
| ML-DSA-65 | 250 us | 3.0 us | 380x |
| ML-DSA-87 | 420 us | — | 638x |
| SLH-DSA-SHA2-192f | 1.92 ms | 131 us | 2912x |
| BLS (single verify) | 820 us | — | 1246x |
`CostPerSignature` values in UTXO Fx plugins are benchmarked-proportional.
### GPU Primitives (Metal, Apple M1 Max)
| Operation | Time | Throughput |
|-----------|------|------------|
| MatMul (dense) | 399 us | 20.0 GB/s |
| Add (elementwise) | 336 us | 238 MB/s |
| NTT (N=8, CPU fallback) | 461 ns | — |
| PolyMul (N=8, CPU fallback) | 1.5 us | — |
| FieldMul | 2.2 us | — |
GPU batch verify kicks in at ≥64 signatures (`accel.BLSBatchVerifyThreshold`).
Below that, the CPU single-verify path is faster due to kernel dispatch
overhead. Raw Metal dispatch is ~100 us minimum; the break-even for ML-DSA is
around 64 signatures.
### EVM (evmgpu core, CPU only)
| Operation | Time |
|-----------|------|
| InsertChain empty block | 171 us (5844 blocks/sec) |
| InsertChain value-tx block | 246 us (4067 blocks/sec) |
ring-call benchmarks (ring200, ring1000) currently hit a pre-existing
nil-pointer in `core/types.Header.Hash` at bench_test.go:306 when running full
chain-read; unrelated to consensus correctness but flagged for evmgpu repo.
### Lux vs Avalanche (bench/)
ZAP deserialization: 157x faster than protobuf (21 ns vs 3231 ns, zero allocs).
+143
View File
@@ -0,0 +1,143 @@
# Metastable Signed Queries — consensus-embedded finality certs
## Idea
The Quasar family (photon → wave → flare → focus → horizon → ray → nova → nebula → prism → quasar) already samples `k` validators per round and accumulates preferences across `β` rounds until metastable convergence.
**Extend**: each query carries an ML-DSA signature from the responder over the block it's voting for. The cert that lands on-chain is the **bundle of signatures collected during the converging rounds**, with no separate VRF sampling step and no separate finality-cert protocol.
```
BEFORE (two-step):
photon → pick committee via VRF → committee runs separate signing round → cert
AFTER (one-step):
photon → sample peers and query → responses carry sigs → cert = accumulated sigs
```
Consensus and cert generation happen in the same gossip.
## Why this is a good fit
1. **Zero extra rounds** — sigs piggyback on the queries that were happening anyway.
2. **No separate VRF needed** — committee is implicitly "whoever my photon sampled across the converging rounds."
3. **Natural fit with DAG** — each DAG vertex carries its own signed query accumulator. Certs bubble up the DAG just like votes.
4. **Probabilistic sampling** — different validators see slightly different committees (as in Avalanche). Metastable means that once enough converge, the decision is locked with exponentially decaying disagreement probability.
5. **Scale invariance** — `k=20` (photon default) is independent of total network size.
## Protocol sketch
```
Proposer P announces block B at height h.
For each honest validator v:
round 0 .. β-1:
sample s_v = photon.Emit(k) // k=20 uniform sample from active set
for each n in s_v:
msg = (B.hash, h, round, v.id)
query(n, msg) → response{preference, σ_n}
where σ_n = ML-DSA.Sign(sk_n, canonical_msg || n.preference)
update v.preference via wave accumulator
if v.preference has crossed the ray/nova threshold:
emit finality vote with (v.preference, ∪ collected σ from converging rounds)
Block finality cert:
cert = { block_hash, bitmap, collected_sigs }
where bitmap marks which validators appear in at least one sig
```
The cert is compiled by the block proposer (or any aggregator) from the signed queries it observed.
## Security argument
The classical Avalanche argument says that after β rounds with metastable threshold `α > k/2`, the probability of a safety failure decays as `negl(β)` assuming < 1/3 Byzantine stake.
**With signed queries:** a Byzantine validator can't forge a signed query from an honest peer. So a valid cert implicitly proves that the accumulation happened honestly — every signature is a cryptographic commitment to the responder's preference at some round.
Forging the cert requires forging ML-DSA signatures for > 2k/3 of the entries in the bitmap. This reduces to the standard ML-DSA unforgeability argument, same as the fixed-committee case.
**PQ safety:** ML-DSA is PQ. No classical primitive in the finality path. BLS is not needed.
## Round complexity
| Layer | Rounds | Notes |
|-------|--------|-------|
| Per-query gossip | 1 | validator samples k peers, waits for responses |
| β rounds to converge | β (≈ 15 for 10⁻⁹ safety) | parallel across validators |
| Finality cert | 0 extra | emitted when metastable threshold reached |
Total rounds to finality: **β** (same as vanilla Avalanche). Extra rounds for cert generation: **0**.
## Concrete sizing
Parameters (default photon):
- k = 20 (sample size per query round)
- β = 15 (convergence rounds for safety 10⁻⁹)
- α = 15 (metastable threshold, conservative)
Per-validator work to produce finality cert:
- Sign k responses per round × β rounds = 300 ML-DSA sigs per validator per block
- At ~430 µs/sig that is 129 ms of sign work per validator per block
This is heavy. Two mitigations:
1. **Query batching**: a validator signs *once* per block and reuses the sig across queries for that block. Reduces to 1 sign per validator per block. ~430 µs.
2. **Response caching**: responses to the same (block, round) are identical for a given validator; compute once, serve many.
Under batching, per-block work per validator is roughly:
- 1 sign (~0.4 ms)
- k×β verifies of peer sigs = 300 × 40 µs = 12 ms
- Amortized across β rounds running in parallel → < 1 ms wall-clock per round
## Cert size under metastable
The final cert includes sigs from every validator that appeared in at least one converging query. For a well-distributed sampling over 10⁴-10⁵ validators with `k=20` and `β=15`:
- Expected unique signers ≈ k × β × (1 - stickiness) ≈ 200-300
- Cert size ≈ 300 × 2.4 kB = 720 kB
This is larger than the fixed k=128 cert (310 kB). Trade-off: we save the separate VRF + committee-signing protocol and get consensus + cert in a single pass.
Compression options:
- **Per-epoch Corona** over converging signers (off the hot path)
- **Per-era Z-chain Groth16** proof over epoch certs
## How it extends the existing protocols
- `photon`: sample `k` peers (already exists, extend responses with sig)
- `wave`: accumulate preferences (already exists, extend to accumulate sigs)
- `flare`: threshold α over accumulated (already exists, unchanged)
- `focus`: disambiguate between candidates (already exists)
- `horizon`: mark finalization horizon (already exists)
- `ray` / `nova`: finality signal (emit the sig bundle when crossed)
- `prism`: committee cut (inputs to stake-weighted sampling)
- `quasar`: compose the final cert from accumulated sigs
## Comparison to fixed-committee
| | Fixed k=128 + VRF | Metastable signed queries |
|--|-------------------|---------------------------|
| Separate committee election | yes (VRF step) | no (implicit) |
| Extra rounds for cert | 1 (sig collection) | 0 |
| Cert size | 310 kB | ~720 kB |
| Round complexity to finality | 1 + β | β |
| Light-client verify | straightforward (known committee) | requires round-by-round replay or SNARK |
| Sybil resistance | VRF weighting | photon weights by stake already |
The metastable path is operationally simpler (nothing separate from consensus) but produces larger certs. For light clients we compress with Z-chain ZK proofs.
## 1ms verify path
For k=32 sampling with β=15 rounds, expected unique signers ≈ 50-80.
- 50 ML-DSA verifies parallel on 10 cores: ~1ms
- Achievable on M-series GPU via `luxfi/accel` batch verify
**1ms finality verify at N=100,000 validators is attainable** with k=32 photon sampling + β=15 metastable + GPU batch verify.
## References
- Existing Quasar family: `~/work/lux/consensus/protocol/{photon,wave,flare,focus,horizon,ray,nova,nebula,prism,quasar}`
- Avalanche/Snowman metastable family
- LP-045 hierarchical quorum certs
- `~/work/lux/proofs/pq-finality-no-bls.tex`
+4
View File
@@ -1,5 +1,9 @@
# Lux Consensus
> Lux is not merely adding post-quantum signatures to a chain; it defines a hybrid finality architecture for DAG-native consensus, with protocol-agnostic threshold lifecycle, post-quantum threshold sealing, and cross-chain propagation of Horizon finality.
See [LP-105 §Claims and evidence](https://github.com/luxfi/lps/blob/main/LP-105-lux-stack-lexicon.md#claims-and-evidence) for the canonical claims/evidence table and the ten architectural commitments — single source of truth.
[![CI Status](https://github.com/luxfi/consensus/actions/workflows/ci.yml/badge.svg)](https://github.com/luxfi/consensus/actions)
[![Go Version](https://img.shields.io/badge/go-1.26-blue)](https://go.dev)
+3 -3
View File
@@ -21,8 +21,8 @@ type Keystore = runtime.Keystore
type Metrics = runtime.Metrics
var (
GetChainID = runtime.GetChainID
GetNetworkID = runtime.GetNetworkID
GetValidatorState = runtime.GetValidatorState
GetChainID = runtime.GetChainID
GetNetworkID = runtime.GetNetworkID
GetValidatorState = runtime.GetValidatorState
WithValidatorState = runtime.WithValidatorState
)
-1
View File
@@ -102,7 +102,6 @@ func (e AppError) Error() string { return e.Message }
// State represents the operational state of a VM or consensus engine
type State = interfaces.State
// Re-export state constants from interfaces package
const (
Unknown = interfaces.Unknown
-1
View File
@@ -225,4 +225,3 @@ func GetAncestors(
return ancestors, nil
}
+483
View File
@@ -0,0 +1,483 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"context"
"errors"
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/core/slashing"
"github.com/luxfi/ids"
)
// --- Config and option coverage ---
func TestConfigValidate(t *testing.T) {
cfg := DefaultConfig()
if err := cfg.Validate(); err != nil {
t.Errorf("default config should be valid: %v", err)
}
}
func TestWithProposer(t *testing.T) {
proposer := &mockProposer{}
engine := New(WithProposer(proposer))
if engine.proposer == nil {
t.Error("WithProposer should set proposer")
}
}
func TestWithEmitter(t *testing.T) {
proposer := &mockProposer{}
engine := New(WithEmitter(proposer))
if engine.proposer == nil {
t.Error("WithEmitter should set proposer (alias)")
}
}
func TestWithLogger(t *testing.T) {
engine := New(WithLogger(nil))
// When nil is passed, the constructor should set log.Noop()
if engine.log == nil {
t.Error("log should not be nil even when WithLogger(nil)")
}
}
func TestWithVoteBuffers(t *testing.T) {
engine := New(WithVoteBuffers(50, 200))
if cap(engine.voteRequests) != 50 {
t.Errorf("expected voteRequests cap 50, got %d", cap(engine.voteRequests))
}
if cap(engine.votes) != 200 {
t.Errorf("expected votes cap 200, got %d", cap(engine.votes))
}
}
func TestWithVoteBuffersZero(t *testing.T) {
// Zero values should not create new channels (keep defaults)
engine := New(WithVoteBuffers(0, 0))
if engine.voteRequests == nil {
t.Error("voteRequests should not be nil")
}
if engine.votes == nil {
t.Error("votes should not be nil")
}
}
func TestWithParams(t *testing.T) {
params := config.DefaultParams()
params.K = 42
engine := New(WithParams(params))
if engine.params.K != 42 {
t.Errorf("expected K=42, got %d", engine.params.K)
}
}
func TestWithSlashing(t *testing.T) {
det := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(10 * time.Minute)
engine := New(WithSlashing(det, db))
if engine.slashingDetector == nil {
t.Error("slashing detector should be set")
}
if engine.slashingDB == nil {
t.Error("slashing DB should be set")
}
}
// --- StartWithID / StopWithError / Context ---
func TestStartWithID(t *testing.T) {
engine := New()
ctx := context.Background()
if err := engine.StartWithID(ctx, 1); err != nil {
t.Fatalf("StartWithID failed: %v", err)
}
if !engine.IsBootstrapped() {
t.Error("should be bootstrapped")
}
engine.Stop(ctx)
}
func TestStopWithError(t *testing.T) {
engine := New()
ctx := context.Background()
engine.Start(ctx, true)
err := engine.StopWithError(ctx, errors.New("test error"))
if err != nil {
t.Fatalf("StopWithError failed: %v", err)
}
if engine.IsBootstrapped() {
t.Error("should not be bootstrapped after stop")
}
}
func TestContextBeforeStart(t *testing.T) {
engine := New()
ctx := engine.Context()
if ctx == nil {
t.Error("Context should return non-nil before Start")
}
}
func TestContextAfterStart(t *testing.T) {
engine := New()
engine.Start(context.Background(), true)
ctx := engine.Context()
if ctx == nil {
t.Error("Context should return non-nil after Start")
}
engine.Stop(context.Background())
}
// --- SetEmitter ---
func TestSetEmitter(t *testing.T) {
engine := New()
proposer := &mockProposer{}
engine.SetEmitter(proposer)
if engine.proposer == nil {
t.Error("SetEmitter should set proposer")
}
}
// --- HasPendingBlock / GetPendingBlock ---
func TestHasPendingBlockEmpty(t *testing.T) {
engine := New()
if engine.HasPendingBlock(ids.Empty) {
t.Error("should have no pending blocks initially")
}
}
func TestGetPendingBlockEmpty(t *testing.T) {
engine := New()
blk, ok := engine.GetPendingBlock(ids.Empty)
if ok {
t.Error("should not find pending block")
}
if blk != nil {
t.Error("block should be nil")
}
}
func TestGetPendingBlockNoVMBlock(t *testing.T) {
engine := New()
blockID := ids.GenerateTestID()
engine.pendingBlocks[blockID] = &PendingBlock{
ConsensusBlock: &Block{id: blockID},
VMBlock: nil, // no VM block
}
blk, ok := engine.GetPendingBlock(blockID)
if ok {
t.Error("should return false when VMBlock is nil")
}
if blk != nil {
t.Error("block should be nil when VMBlock is nil")
}
}
func TestGetPendingBlockWithVMBlock(t *testing.T) {
engine := New()
blockID := ids.GenerateTestID()
mb := &mockBlock{id: blockID, height: 1}
engine.pendingBlocks[blockID] = &PendingBlock{
ConsensusBlock: &Block{id: blockID},
VMBlock: mb,
}
blk, ok := engine.GetPendingBlock(blockID)
if !ok {
t.Error("should find pending block")
}
if blk == nil {
t.Error("block should not be nil")
}
}
// --- ReceiveVote ---
func TestReceiveVoteNotStarted(t *testing.T) {
engine := New()
vote := Vote{BlockID: ids.GenerateTestID(), Accept: true}
if engine.ReceiveVote(vote) {
t.Error("should return false when not started")
}
}
func TestReceiveVoteStarted(t *testing.T) {
engine := New()
engine.Start(context.Background(), true)
defer engine.Stop(context.Background())
vote := Vote{BlockID: ids.GenerateTestID(), Accept: true}
if !engine.ReceiveVote(vote) {
t.Error("should return true when started")
}
}
func TestReceiveVoteBufferFull(t *testing.T) {
engine := New(WithVoteBuffers(1, 1))
engine.Start(context.Background(), true)
defer engine.Stop(context.Background())
// Fill the buffer
engine.ReceiveVote(Vote{BlockID: ids.GenerateTestID(), Accept: true})
// This should return false (buffer full)
if engine.ReceiveVote(Vote{BlockID: ids.GenerateTestID(), Accept: true}) {
t.Error("should return false when buffer is full")
}
}
// --- DrainAccepted ---
func TestDrainAcceptedNoBlocks(t *testing.T) {
engine := New()
engine.Start(context.Background(), true)
defer engine.Stop(context.Background())
// Should not panic
engine.DrainAccepted(context.Background())
}
func TestDrainAcceptedWithAcceptedBlock(t *testing.T) {
engine := New()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
vm := &mockVM{}
engine.SetVM(vm)
blockID := ids.GenerateTestID()
mb := &mockBlock{id: blockID, height: 1}
cBlock := &Block{id: blockID, height: 1}
// Add block to consensus and accept it
engine.consensus.AddBlock(ctx, cBlock)
engine.consensus.ProcessVote(ctx, blockID, true)
engine.consensus.Poll(ctx, map[ids.ID]int{blockID: 1})
if !engine.consensus.IsAccepted(blockID) {
// In K=1 mode, single vote should accept
t.Skip("consensus did not accept block (K>1)")
}
engine.pendingBlocks[blockID] = &PendingBlock{
ConsensusBlock: cBlock,
VMBlock: mb,
Decided: false,
}
engine.DrainAccepted(ctx)
if _, exists := engine.pendingBlocks[blockID]; exists {
t.Error("accepted block should be removed from pending")
}
}
// --- SyncState ---
func TestSyncState(t *testing.T) {
engine := New()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
blockID := ids.GenerateTestID()
staleBlock := &PendingBlock{
ConsensusBlock: &Block{id: ids.GenerateTestID(), height: 5},
}
futureBlock := &PendingBlock{
ConsensusBlock: &Block{id: ids.GenerateTestID(), height: 20},
}
engine.pendingBlocks[ids.GenerateTestID()] = staleBlock
futureID := ids.GenerateTestID()
engine.pendingBlocks[futureID] = futureBlock
err := engine.SyncState(ctx, blockID, 10)
if err != nil {
t.Fatalf("SyncState failed: %v", err)
}
// Stale block (height 5 <= 10) should be removed
// Future block (height 20 > 10) should remain
if len(engine.pendingBlocks) != 1 {
t.Errorf("expected 1 remaining pending block, got %d", len(engine.pendingBlocks))
}
if _, exists := engine.pendingBlocks[futureID]; !exists {
t.Error("future block should remain")
}
if !engine.IsBootstrapped() {
t.Error("should be bootstrapped after SyncState")
}
}
// --- Stats ---
func TestStats(t *testing.T) {
engine := New()
stats := engine.Stats()
expectedKeys := []string{"blocks_built", "blocks_accepted", "blocks_rejected",
"votes_sent", "votes_received", "pending_blocks", "bootstrapped"}
for _, key := range expectedKeys {
if _, ok := stats[key]; !ok {
t.Errorf("missing stat key: %s", key)
}
}
}
// --- Burst mode config ---
func TestNewWithConfigBurstMode(t *testing.T) {
cfg := DefaultConfig()
cfg.Params.BlockTime = 1 * time.Millisecond // <= 1ms = burst mode
cfg.VoteRequestBuffer = 0
cfg.VoteBuffer = 0
engine := NewWithConfig(cfg)
if cap(engine.voteRequests) != 4096 {
t.Errorf("burst mode should set voteRequests to 4096, got %d", cap(engine.voteRequests))
}
if cap(engine.votes) != 16384 {
t.Errorf("burst mode should set votes to 16384, got %d", cap(engine.votes))
}
}
// --- handleVote with slashing ---
func TestHandleVoteWithSlashing(t *testing.T) {
det := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(10 * time.Minute)
engine := New(WithSlashing(det, db))
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
blockID := ids.GenerateTestID()
nodeID := ids.GenerateTestNodeID()
cBlock := &Block{id: blockID, height: 1}
engine.pendingBlocks[blockID] = &PendingBlock{
ConsensusBlock: cBlock,
VMBlock: &mockBlock{id: blockID, height: 1},
}
// First vote should succeed
engine.handleVote(Vote{BlockID: blockID, NodeID: nodeID, Accept: true})
// Stats should show vote received
if engine.votesReceived == 0 {
t.Error("should have received votes")
}
}
func TestHandleVoteUnknownBlock(t *testing.T) {
engine := New()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
// Vote for unknown block should be silently dropped
engine.handleVote(Vote{BlockID: ids.GenerateTestID(), Accept: true})
if engine.votesReceived != 1 {
t.Errorf("should count vote even if block unknown, got %d", engine.votesReceived)
}
}
func TestHandleVoteReject(t *testing.T) {
engine := New()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
blockID := ids.GenerateTestID()
cBlock := &Block{id: blockID, height: 1}
engine.consensus.AddBlock(ctx, cBlock)
engine.pendingBlocks[blockID] = &PendingBlock{
ConsensusBlock: cBlock,
VMBlock: &mockBlock{id: blockID},
}
engine.handleVote(Vote{BlockID: blockID, Accept: false})
// RejectCount should be incremented
engine.mu.RLock()
p := engine.pendingBlocks[blockID]
engine.mu.RUnlock()
if p != nil && p.RejectCount != 1 {
t.Errorf("expected RejectCount=1, got %d", p.RejectCount)
}
}
// --- CheckBlockProposal ---
func TestCheckBlockProposalNoSlashing(t *testing.T) {
engine := New()
ev := engine.CheckBlockProposal(ids.GenerateTestNodeID(), 1, ids.GenerateTestID(), nil)
if ev != nil {
t.Error("should return nil when slashing not configured")
}
}
func TestCheckBlockProposalJailed(t *testing.T) {
det := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(10 * time.Minute)
engine := New(WithSlashing(det, db))
nodeID := ids.GenerateTestNodeID()
db.RecordEvidence(slashing.Evidence{
Type: slashing.DoubleSign,
ValidatorID: nodeID,
Height: 1,
})
ev := engine.CheckBlockProposal(nodeID, 2, ids.GenerateTestID(), nil)
if ev == nil {
t.Error("should return evidence for jailed validator")
}
}
func TestSlashingDB(t *testing.T) {
engine := New()
if engine.SlashingDB() != nil {
t.Error("should return nil when not configured")
}
db := slashing.NewDB(10 * time.Minute)
engine2 := New(WithSlashing(slashing.NewDetector(64, 0.5), db))
if engine2.SlashingDB() == nil {
t.Error("should return DB when configured")
}
}
// --- Double start ---
func TestDoubleStart(t *testing.T) {
engine := New()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
err := engine.Start(ctx, true)
if err != ErrAlreadyStarted {
t.Errorf("expected ErrAlreadyStarted, got %v", err)
}
}
// --- mockProposer ---
type mockProposer struct{}
func (m *mockProposer) Propose(ctx context.Context, proposal BlockProposal) error {
return nil
}
func (m *mockProposer) RequestVotes(ctx context.Context, req VoteRequest) error {
return nil
}
+279
View File
@@ -0,0 +1,279 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package engine
import (
"crypto/ed25519"
"crypto/sha256"
"testing"
"github.com/luxfi/ids"
)
// --- Metrics ---
func TestPipelineMetrics(t *testing.T) {
cfg := DefaultPipelineConfig()
cfg.EnableGPU = false
pipeline, err := NewGPUBatchPipeline(cfg)
if err != nil {
t.Fatalf("NewGPUBatchPipeline failed: %v", err)
}
m := pipeline.Metrics()
if m == nil {
t.Fatal("Metrics should not be nil")
}
// Check initial values
batches, txs, valid, invalid, swaps, bp, gpuNs, cpuNs := pipeline.GetMetricsSnapshot()
if batches != 0 || txs != 0 || valid != 0 || invalid != 0 || swaps != 0 || bp != 0 || gpuNs != 0 || cpuNs != 0 {
t.Error("all metrics should be 0 initially")
}
}
func TestMerkleRootInitial(t *testing.T) {
cfg := DefaultPipelineConfig()
cfg.EnableGPU = false
pipeline, err := NewGPUBatchPipeline(cfg)
if err != nil {
t.Fatalf("NewGPUBatchPipeline failed: %v", err)
}
root := pipeline.MerkleRoot()
// Initially zero since no transactions processed
if root != [32]byte{} {
t.Error("MerkleRoot should be zero initially")
}
}
// --- FlushPending ---
func TestFlushPendingEmpty(t *testing.T) {
mt := &GPUMerkleTree{
pending: make([][32]byte, 0),
}
// Should not panic on empty
mt.flushPending(false)
}
func TestFlushPendingCPU(t *testing.T) {
mt := &GPUMerkleTree{
pending: make([][32]byte, 0, 1024),
}
// Add some hashes
h := sha256.Sum256([]byte("test"))
mt.pending = append(mt.pending, h)
mt.flushPending(false) // CPU path
// Pending should be drained
mt.pendMu.Lock()
if len(mt.pending) != 0 {
t.Errorf("pending should be empty after flush, got %d", len(mt.pending))
}
mt.pendMu.Unlock()
// Root should be updated
mt.rootMu.RLock()
if mt.root == ([32]byte{}) {
t.Error("root should be non-zero after flush with data")
}
mt.rootMu.RUnlock()
}
func TestFlushPendingGPUFallback(t *testing.T) {
mt := &GPUMerkleTree{
pending: make([][32]byte, 0, 1024),
}
h := sha256.Sum256([]byte("test"))
mt.pending = append(mt.pending, h)
// GPU path (falls back to CPU since no real GPU)
mt.flushPending(true)
mt.pendMu.Lock()
if len(mt.pending) != 0 {
t.Errorf("pending should be empty after GPU flush, got %d", len(mt.pending))
}
mt.pendMu.Unlock()
}
// --- updateGPU ---
func TestUpdateGPUEmptyPending(t *testing.T) {
mt := &GPUMerkleTree{
pending: make([][32]byte, 0),
}
// Set a known root
mt.root = sha256.Sum256([]byte("existing"))
// updateGPU with empty pending should return existing root
root := mt.updateGPU(nil)
if root != mt.root {
t.Error("updateGPU with nil hashes should return existing root")
}
}
func TestUpdateGPUWithHashes(t *testing.T) {
mt := &GPUMerkleTree{
pending: make([][32]byte, 0),
}
hashes := [][32]byte{
sha256.Sum256([]byte("a")),
sha256.Sum256([]byte("b")),
}
root := mt.updateGPU(hashes)
if root == ([32]byte{}) {
t.Error("updateGPU should produce non-zero root")
}
}
// --- updateMerkleTree ---
func TestUpdateMerkleTreeNoValidTx(t *testing.T) {
cfg := DefaultPipelineConfig()
cfg.EnableGPU = false
pipeline, err := NewGPUBatchPipeline(cfg)
if err != nil {
t.Fatal(err)
}
buf := &GPUBuffer{
count: 2,
validFlags: []bool{false, false},
txHashes: make([][32]byte, 2),
}
result := &BatchResult{}
pipeline.updateMerkleTree(buf, result)
if result.MerkleRoot != ([32]byte{}) {
t.Error("no valid txs should not update merkle root")
}
}
func TestUpdateMerkleTreeCPU(t *testing.T) {
cfg := DefaultPipelineConfig()
cfg.EnableGPU = false
pipeline, err := NewGPUBatchPipeline(cfg)
if err != nil {
t.Fatal(err)
}
buf := &GPUBuffer{
count: 2,
validFlags: []bool{true, true},
txHashes: [][32]byte{
sha256.Sum256([]byte("tx1")),
sha256.Sum256([]byte("tx2")),
},
}
result := &BatchResult{}
pipeline.updateMerkleTree(buf, result)
if result.MerkleRoot == ([32]byte{}) {
t.Error("valid txs should update merkle root")
}
}
// --- Signature verification edge cases ---
func TestVerifyECDSAShortSig(t *testing.T) {
if verifyECDSA([]byte("hash"), []byte("short"), []byte("pubkey")) {
t.Error("should reject short signature")
}
}
func TestVerifyEd25519WrongSizes(t *testing.T) {
// Wrong sig size
if verifyEd25519([]byte("hash"), make([]byte, 10), make([]byte, ed25519.PublicKeySize)) {
t.Error("should reject wrong sig size")
}
// Wrong pubkey size
if verifyEd25519([]byte("hash"), make([]byte, ed25519.SignatureSize), make([]byte, 10)) {
t.Error("should reject wrong pubkey size")
}
}
func TestVerifyBLSInvalidKey(t *testing.T) {
if verifyBLS([]byte("hash"), []byte("sig"), []byte("bad-pubkey")) {
t.Error("should reject invalid BLS pubkey")
}
}
func TestVerifyBLSInvalidSig(t *testing.T) {
// Valid-length pubkey (48 bytes) but garbage
pubkey := make([]byte, 48)
if verifyBLS([]byte("hash"), []byte("bad-sig"), pubkey) {
t.Error("should reject invalid BLS signature with garbage key")
}
}
func TestVerifyMLDSAWrongKeySize(t *testing.T) {
// Key size that doesn't match any ML-DSA mode
if verifyMLDSA([]byte("hash"), []byte("sig"), make([]byte, 100)) {
t.Error("should reject unknown key size")
}
}
func TestVerifyMLDSAInvalidKey44(t *testing.T) {
// MLDSA44 key size (1312) but garbage content
if verifyMLDSA([]byte("hash"), make([]byte, 2420), make([]byte, 1312)) {
t.Error("should reject invalid MLDSA44 key")
}
}
func TestVerifyMLDSAInvalidKey65(t *testing.T) {
// MLDSA65 key size (1952) but garbage content
if verifyMLDSA([]byte("hash"), make([]byte, 3293), make([]byte, 1952)) {
t.Error("should reject invalid MLDSA65 key")
}
}
func TestVerifyMLDSAInvalidKey87(t *testing.T) {
// MLDSA87 key size (2592) but garbage content
if verifyMLDSA([]byte("hash"), make([]byte, 4595), make([]byte, 2592)) {
t.Error("should reject invalid MLDSA87 key")
}
}
// --- WithTransport ---
func TestWithTransport(t *testing.T) {
opts := &options{}
WithTransport(nil)(opts)
if opts.transport != nil {
t.Error("transport should be nil when set to nil")
}
}
// --- LuxConsensus ---
func TestNewLuxConsensusWithTransport(t *testing.T) {
lc := NewLuxConsensus(3, 2, 2, WithTransport(nil))
if lc == nil {
t.Fatal("NewLuxConsensus should not return nil")
}
}
func TestLuxConsensusPollDecidedSkip(t *testing.T) {
lc := NewLuxConsensus(1, 1, 1)
item := ids.GenerateTestID()
// Vote and poll to decide
lc.RecordVote(item)
lc.Poll(map[ids.ID]int{item: 1})
// Second poll for same item should be a no-op (already decided)
lc.Poll(map[ids.ID]int{item: 1})
}
+23 -18
View File
@@ -30,13 +30,16 @@ type ConsensusEngine struct {
certGen *CertificateGenerator
}
// FinalityEvent represents a finalized block with quantum-resistant proofs
// FinalityEvent represents a finalized block with quantum-resistant proofs.
// Carries all three components for parallel verification: classical BLS,
// lattice Corona, and FIPS 204 ML-DSA. Defense in depth.
type FinalityEvent struct {
Height uint64
BlockID ids.ID
Timestamp time.Time
PQProof []byte // Z-Chain ZKP: Groth16 proving BLS ∧ ML-DSA ∧ Corona
BLSProof []byte // Classical BLS proof
Height uint64
BlockID ids.ID
Timestamp time.Time
BLSProof []byte // Classical BLS12-381 aggregate
Corona []byte // Corona lattice threshold sig
MLDSAProof []byte // FIPS 204 ML-DSA proof
}
// memoryStore is a simple in-memory implementation of dag.Store for P-Chain vertices
@@ -187,11 +190,11 @@ func (e *ConsensusEngine) ProcessBlock(ctx context.Context, blockID ids.ID, vote
// Emit finality event
select {
case e.finality <- FinalityEvent{
Height: e.height,
BlockID: blockID,
Timestamp: time.Now(),
PQProof: cert.PQCert,
BLSProof: cert.BLSAgg,
Height: e.height,
BlockID: blockID,
Timestamp: time.Now(),
MLDSAProof: cert.PQCert,
BLSProof: cert.BLSAgg,
}:
case <-ctx.Done():
return ctx.Err()
@@ -224,17 +227,19 @@ func (e *ConsensusEngine) Height() uint64 {
// blockToFinalityEvent converts a quasar.Block to a FinalityEvent
func blockToFinalityEvent(block *quasar.Block) FinalityEvent {
blockID, _ := ids.FromString(block.Hash)
var pqProof, blsProof []byte
var mldsaProof, corona, blsProof []byte
if block.Cert != nil {
pqProof = block.Cert.PQProof
mldsaProof = block.Cert.MLDSAProof
corona = block.Cert.Corona
blsProof = block.Cert.BLS
}
return FinalityEvent{
Height: block.Height,
BlockID: blockID,
Timestamp: block.Timestamp,
PQProof: pqProof,
BLSProof: blsProof,
Height: block.Height,
BlockID: blockID,
Timestamp: block.Timestamp,
MLDSAProof: mldsaProof,
Corona: corona,
BLSProof: blsProof,
}
}
+9 -9
View File
@@ -273,7 +273,7 @@ func TestProcessBlockEmptyCertificates(t *testing.T) {
select {
case event := <-engine.FinalityChannel():
require.Equal(t, blockID, event.BlockID)
require.NotEmpty(t, event.PQProof)
require.NotEmpty(t, event.MLDSAProof)
require.NotEmpty(t, event.BLSProof)
case <-time.After(100 * time.Millisecond):
t.Fatal("Expected finality event")
@@ -285,14 +285,14 @@ func TestFinalityEventFields(t *testing.T) {
Height: 100,
BlockID: ids.GenerateTestID(),
Timestamp: time.Now(),
PQProof: []byte("pq-proof"),
MLDSAProof: []byte("pq-proof"),
BLSProof: []byte("bls-proof"),
}
require.Equal(t, uint64(100), event.Height)
require.NotEmpty(t, event.BlockID)
require.False(t, event.Timestamp.IsZero())
require.Equal(t, []byte("pq-proof"), event.PQProof)
require.Equal(t, []byte("pq-proof"), event.MLDSAProof)
require.Equal(t, []byte("bls-proof"), event.BLSProof)
}
@@ -472,7 +472,7 @@ func TestBlockToFinalityEvent(t *testing.T) {
Timestamp: testTime,
Cert: &quasar.QuasarCert{
BLS: []byte("bls-proof-data"),
PQProof: []byte("pq-proof-data"),
MLDSAProof: []byte("pq-proof-data"),
},
},
wantBLS: []byte("bls-proof-data"),
@@ -497,7 +497,7 @@ func TestBlockToFinalityEvent(t *testing.T) {
Timestamp: testTime,
Cert: &quasar.QuasarCert{
BLS: []byte{},
PQProof: []byte{},
MLDSAProof: []byte{},
},
},
wantBLS: []byte{},
@@ -534,7 +534,7 @@ func TestBlockToFinalityEvent(t *testing.T) {
require.Equal(t, tc.block.Height, event.Height)
require.Equal(t, tc.block.Timestamp, event.Timestamp)
require.Equal(t, tc.wantBLS, event.BLSProof)
require.Equal(t, tc.wantPQ, event.PQProof)
require.Equal(t, tc.wantPQ, event.MLDSAProof)
})
}
}
@@ -613,7 +613,7 @@ func TestSetFinalizedCallbackInvocation(t *testing.T) {
Timestamp: time.Now(),
Cert: &quasar.QuasarCert{
BLS: []byte("test-bls"),
PQProof: []byte("test-pq"),
MLDSAProof: []byte("test-pq"),
},
}
@@ -622,7 +622,7 @@ func TestSetFinalizedCallbackInvocation(t *testing.T) {
require.True(t, callbackCalled)
require.Equal(t, uint64(42), receivedEvent.Height)
require.Equal(t, []byte("test-bls"), receivedEvent.BLSProof)
require.Equal(t, []byte("test-pq"), receivedEvent.PQProof)
require.Equal(t, []byte("test-pq"), receivedEvent.MLDSAProof)
}
// TestSetFinalizedCallbackInvocationNilCert tests callback with nil certificate
@@ -663,5 +663,5 @@ func TestSetFinalizedCallbackInvocationNilCert(t *testing.T) {
require.True(t, callbackCalled)
require.Equal(t, uint64(100), receivedEvent.Height)
require.Nil(t, receivedEvent.BLSProof)
require.Nil(t, receivedEvent.PQProof)
require.Nil(t, receivedEvent.MLDSAProof)
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
"github.com/luxfi/corona/threshold"
"github.com/luxfi/pulsar/threshold"
)
// CertificateGenerator generates real BLS and Corona threshold signatures.
+1 -1
View File
@@ -122,7 +122,7 @@ func TestFinalityChannel(t *testing.T) {
if event.Height != 1 {
t.Errorf("Wrong height in event: got %d, want 1", event.Height)
}
if len(event.PQProof) == 0 {
if len(event.MLDSAProof) == 0 {
t.Error("Missing PQ proof in event")
}
if len(event.BLSProof) == 0 {
+29 -4
View File
@@ -8,15 +8,17 @@ exclude github.com/ethereum/go-ethereum v1.16.3
require (
github.com/luxfi/accel v1.0.1
github.com/luxfi/crypto v1.17.40
github.com/luxfi/crypto v1.17.43
github.com/luxfi/database v1.17.42
github.com/luxfi/ids v1.2.9
github.com/luxfi/lattice/v7 v7.0.1
github.com/luxfi/log v1.4.1
github.com/luxfi/math v1.2.3
github.com/luxfi/metric v1.5.0
github.com/luxfi/p2p v1.18.9
github.com/luxfi/corona v0.2.0
github.com/luxfi/pulsar v0.1.0-rc1-pq-consensus-freeze
github.com/luxfi/runtime v1.0.0
github.com/luxfi/threshold v0.1.0-rc1-pq-consensus-freeze
github.com/luxfi/validators v1.0.0
github.com/luxfi/version v1.0.1
github.com/luxfi/warp v1.18.5
@@ -29,6 +31,7 @@ require (
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/ALTree/bigfloat v0.2.0 // indirect
github.com/DataDog/zstd v1.5.7 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
@@ -45,10 +48,12 @@ require (
github.com/cockroachdb/redact v1.1.6 // indirect
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb // indirect
github.com/consensys/gnark-crypto v0.20.1 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/getsentry/sentry-go v0.40.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -58,6 +63,8 @@ require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/gtank/merlin v0.1.1 // indirect
github.com/gtank/ristretto255 v0.2.0 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -71,7 +78,7 @@ require (
github.com/luxfi/constants v1.4.3 // indirect
github.com/luxfi/container v0.0.4 // indirect
github.com/luxfi/geth v1.16.73 // indirect
github.com/luxfi/lattice/v7 v7.0.0 // indirect
github.com/luxfi/lens v0.1.0-rc1-pq-consensus-freeze // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/mdns v0.1.0 // indirect
github.com/luxfi/mock v0.1.1 // indirect
@@ -82,9 +89,11 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.66 // indirect
github.com/montanaflynn/stats v0.8.2 // indirect
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect
github.com/montanaflynn/stats v0.9.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/onsi/ginkgo/v2 v2.28.0 // indirect
github.com/onsi/gomega v1.39.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@@ -93,6 +102,7 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
@@ -115,3 +125,18 @@ require (
exclude google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1
exclude google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090
// Local replace directives for the LSS-Pulsar / LSS-Lens cutover. Pulsar
// and Lens have no remote yet; the threshold repo's lss_pulsar /
// lss_lens adapters likewise live in our local tree. These pin the
// consensus build to the in-tree source of truth and MUST be retained
// until the repos publish tagged releases.
// Local-dev replace directives. Tagged versions above pin the
// March 3, 2026 PQ Consensus Architecture Freeze. See
// ~/work/lux/consensus/CROSS-REPO-VERSION-PIN.md for the canonical commit
// SHA → tag mapping.
replace github.com/luxfi/pulsar => ../pulsar // pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
replace github.com/luxfi/threshold => ../threshold // pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
replace github.com/luxfi/lens => ../lens // pinned to v0.1.0-rc1-pq-consensus-freeze; see go-mod-pin.md
+34 -8
View File
@@ -1,7 +1,13 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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=
@@ -35,7 +41,11 @@ 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 v0.0.0-20180819234021-555e2067c45d h1:49RLWk1j44Xu4fjHb6JFYmeUnDORVwHNkDxaQ0ctCVU=
github.com/cosmos/go-bip39 v0.0.0-20180819234021-555e2067c45d/go.mod h1:tSxLoYXyBmiFeKpvmq4dzayMdCjCnu8uqmCysIGBT2Y=
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=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -55,6 +65,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo=
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@@ -64,7 +76,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -88,10 +103,16 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/gtank/merlin v0.1.1 h1:eQ90iG7K9pOhtereWsmyRJ6RAwcP4tHTDBHXNg+u5is=
github.com/gtank/merlin v0.1.1/go.mod h1:T86dnYJhcGOh5BjZFCJWTDeTK7XW8uE+E21Cy/bIQ+s=
github.com/gtank/ristretto255 v0.2.0 h1:LeOuWr6giplWkkMizx2emfG03SRPJqKt1nfIHLVHQ/0=
github.com/gtank/ristretto255 v0.2.0/go.mod h1:OJ1ox/dWcp7sJ5grYDcZ+kkHYuj5nelW5aaL7ESVXBw=
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@@ -124,16 +145,16 @@ github.com/luxfi/constants v1.4.3 h1:bcqUO8twMOhHWCgN/RbjfWulVPbvUQcxDkssaRrCx4g
github.com/luxfi/constants v1.4.3/go.mod h1:ENkJ121cmDEkwQPDiKK4QhnTnW9u37PGpepbrdVcAmc=
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.17.40 h1:+hDkGKuzYPac2vpbenFN+gKcPvFv3fXQ4ZAEHYDDt/Y=
github.com/luxfi/crypto v1.17.40/go.mod h1:yz/86mgfby+aaf9hsHg8KIhHENueDsZpI9nnV+G4nW8=
github.com/luxfi/crypto v1.17.43 h1:dDSJ7DNHB49V25WWofjtuQCPPowCuogO2ZPW+IgfvdQ=
github.com/luxfi/crypto v1.17.43/go.mod h1:1M6uQ8I/7dzQwf5RdnKtqOWr0+iXRW0D9WfnMV+Cb1s=
github.com/luxfi/database v1.17.42 h1:e3ZgWI155n/Nwzb2s4xhBWIQ2Lh3g5xuFA75KMII4zk=
github.com/luxfi/database v1.17.42/go.mod h1:0nArLBm0r42J103oHR2xkAe8+mftuz5jtFgNylU1hgg=
github.com/luxfi/geth v1.16.73 h1:jbRaWQDXTadZ+fEh5Cd5jnkv1qq4yAoT5bVQFuql8ZU=
github.com/luxfi/geth v1.16.73/go.mod h1:CPzps7Zp3I7Og3KT3m+Qs41WW8z+YPGx1TOJQnHdBHY=
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/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.0.1 h1:cxxmKYc5cmYZT+VBI7HZJV4Ql6kSGoOzEC0TtUuprxg=
github.com/luxfi/lattice/v7 v7.0.1/go.mod h1:eGIdt34H0QsHlvSsB9PAbE0Bxa7BLOsj6HVHqbFS/5I=
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.2.3 h1:BgvIFw/srPXFLbcqtoDhLJOfmBsn86GPA1iWgsoyUb4=
@@ -148,8 +169,6 @@ 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/p2p v1.18.9 h1:xDqS04quYCVmiC1jBinSRQ2LAmY7E6XR4Dr7HdXHSy0=
github.com/luxfi/p2p v1.18.9/go.mod h1:X829+oxzPyY2e5S9CyyMFiOtk6nyuNnxZSaSxGxrpKw=
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/runtime v1.0.0 h1:6DLQlnvNaEC2ld4RTmGFd4wGbNcp+jRjwVuQP8VEKPM=
github.com/luxfi/runtime v1.0.0/go.mod h1:lCoz8vCfR4ZU0GNxe0VdHEdq8G0W7rKzQpTnXvew/dM=
github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=
@@ -175,8 +194,11 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
github.com/montanaflynn/stats v0.8.2 h1:52wnefTJnPI5FoHif1DQh2soKRw0yYs+4AVyvtcZCH0=
github.com/montanaflynn/stats v0.8.2/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643/go.mod h1:43+3pMjjKimDBf5Kr4ZFNGbLql1zKkbImw+fZbw3geM=
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b h1:QrHweqAtyJ9EwCaGHBu1fghwxIPiopAHV06JlXrMHjk=
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b/go.mod h1:xxLb2ip6sSUts3g1irPVHyk/DGslwQsNOo9I7smJfNU=
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=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -190,6 +212,8 @@ github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vv
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc=
github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
@@ -224,6 +248,8 @@ github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jq
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a h1:1ur3QoCqvE5fl+nylMaIr9PVV1w343YRDtsy+Rwu7XI=
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
+323
View File
@@ -0,0 +1,323 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package wire
import (
"testing"
)
func TestVoterIDFromPublicKey(t *testing.T) {
pk := []byte("test-public-key-data")
id := VoterIDFromPublicKey(pk)
// Should be deterministic
id2 := VoterIDFromPublicKey(pk)
if id != id2 {
t.Error("VoterIDFromPublicKey should be deterministic")
}
// Should equal DeriveVoterID with NodeIDDomain
expected := DeriveVoterID(NodeIDDomain, pk)
if id != expected {
t.Error("VoterIDFromPublicKey should use NodeIDDomain")
}
// Different key -> different ID
id3 := VoterIDFromPublicKey([]byte("other-key"))
if id == id3 {
t.Error("different keys should produce different IDs")
}
}
func TestCandidateIDBytes(t *testing.T) {
data := []byte("test")
id := DeriveItemID(data)
b := id.Bytes()
if len(b) != 32 {
t.Errorf("expected 32 bytes, got %d", len(b))
}
// Verify bytes match the array
for i := 0; i < 32; i++ {
if b[i] != id[i] {
t.Errorf("byte mismatch at index %d", i)
}
}
}
func TestVoterIDBytes(t *testing.T) {
id := DeriveVoterID("test", []byte("data"))
b := id.Bytes()
if len(b) != 32 {
t.Errorf("expected 32 bytes, got %d", len(b))
}
for i := 0; i < 32; i++ {
if b[i] != id[i] {
t.Errorf("byte mismatch at index %d", i)
}
}
}
func TestUint64ToBytesAndBack(t *testing.T) {
tests := []uint64{0, 1, 255, 256, 65535, 1<<32 - 1, 1<<64 - 1}
for _, n := range tests {
b := Uint64ToBytes(n)
if len(b) != 8 {
t.Errorf("expected 8 bytes, got %d", len(b))
}
got := BytesToUint64(b)
if got != n {
t.Errorf("roundtrip failed: %d != %d", got, n)
}
}
}
func TestBytesToUint64Short(t *testing.T) {
// Less than 8 bytes should return 0
if BytesToUint64(nil) != 0 {
t.Error("nil should return 0")
}
if BytesToUint64([]byte{1, 2, 3}) != 0 {
t.Error("short slice should return 0")
}
if BytesToUint64([]byte{0, 0, 0, 0, 0, 0, 0}) != 0 {
t.Error("7 bytes should return 0")
}
}
func TestSignatureSchemeNoSignature(t *testing.T) {
vote := &Vote{}
if vote.SignatureScheme() != SigNone {
t.Errorf("empty signature should return SigNone, got %d", vote.SignatureScheme())
}
}
func TestCandidateVerifyTampered(t *testing.T) {
c := NewCandidate([]byte("domain"), []byte("payload"), EmptyCandidateID, 1)
if !c.Verify() {
t.Error("fresh candidate should verify")
}
// Tamper with payload
c.Payload = []byte("tampered")
if c.Verify() {
t.Error("tampered candidate should not verify")
}
}
func TestResultSerialization(t *testing.T) {
r := &Result{
ItemID: DeriveItemID([]byte("test")),
Finalized: true,
Accepted: true,
Confidence: 95,
Signatures: [][]byte{{1, 2, 3}},
Synthesis: "agreed",
}
data, err := MarshalResult(r)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
r2, err := UnmarshalResult(data)
if err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if r2.ItemID != r.ItemID {
t.Error("ItemID mismatch")
}
if !r2.Finalized {
t.Error("Finalized mismatch")
}
if !r2.Accepted {
t.Error("Accepted mismatch")
}
if r2.Confidence != 95 {
t.Errorf("Confidence mismatch: %d", r2.Confidence)
}
if r2.Synthesis != "agreed" {
t.Error("Synthesis mismatch")
}
// Invalid JSON
_, err = UnmarshalResult([]byte("{bad"))
if err == nil {
t.Error("should fail on invalid JSON")
}
}
func TestValidatorSetSerialization(t *testing.T) {
vs := &ValidatorSet{
Epoch: 5,
Validators: []Validator{
{ID: DeriveVoterID("node", []byte("a")), Weight: 100, PublicKey: []byte("pk1")},
{ID: DeriveVoterID("node", []byte("b")), Weight: 200, PublicKey: []byte("pk2")},
},
TotalWeight: 300,
}
data, err := MarshalValidatorSet(vs)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
vs2, err := UnmarshalValidatorSet(data)
if err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if vs2.Epoch != 5 {
t.Error("Epoch mismatch")
}
if len(vs2.Validators) != 2 {
t.Errorf("expected 2 validators, got %d", len(vs2.Validators))
}
if vs2.TotalWeight != 300 {
t.Error("TotalWeight mismatch")
}
// Invalid JSON
_, err = UnmarshalValidatorSet([]byte("bad"))
if err == nil {
t.Error("should fail on invalid JSON")
}
}
func TestUnmarshalCandidateInvalid(t *testing.T) {
_, err := UnmarshalCandidate([]byte("{invalid"))
if err == nil {
t.Error("should fail on invalid JSON")
}
}
func TestUnmarshalVoteInvalid(t *testing.T) {
_, err := UnmarshalVote([]byte("bad"))
if err == nil {
t.Error("should fail on invalid JSON")
}
}
func TestUnmarshalCertificateInvalid(t *testing.T) {
_, err := UnmarshalCertificate([]byte("bad"))
if err == nil {
t.Error("should fail on invalid JSON")
}
}
func TestCredentialDeserializeTruncatedSigLen(t *testing.T) {
// Type + numSigs=1, but no sig length bytes
data := []byte{CredentialTypeMLDSA65, 0, 1}
_, err := DeserializeCredential(data)
if err == nil {
t.Error("should fail on truncated sig length")
}
}
func TestCredentialDeserializeTruncatedSigData(t *testing.T) {
// Type + numSigs=1 + sigLen=100, but only 2 bytes of data
data := []byte{CredentialTypeMLDSA65, 0, 1, 0, 100, 1, 2}
_, err := DeserializeCredential(data)
if err == nil {
t.Error("should fail on truncated sig data")
}
}
func TestCredentialDeserializeTooShort(t *testing.T) {
_, err := DeserializeCredential([]byte{1})
if err != ErrCredentialTooShort {
t.Errorf("expected ErrCredentialTooShort, got %v", err)
}
}
func TestCredentialValidateNonMLDSA(t *testing.T) {
cred := NewCredential(CredentialTypeSecp256k1)
cred.AddSignature([]byte{1, 2, 3}) // arbitrary size
err := cred.ValidateSignatureSizes()
if err != nil {
t.Errorf("non-MLDSA type should accept any size: %v", err)
}
}
func TestCredentialValidateMLDSA44(t *testing.T) {
cred := NewCredential(CredentialTypeMLDSA44)
cred.AddSignature(make([]byte, MLDSA44SignatureSize))
if err := cred.ValidateSignatureSizes(); err != nil {
t.Errorf("valid MLDSA44 sig should pass: %v", err)
}
cred.AddSignature(make([]byte, 10))
if err := cred.ValidateSignatureSizes(); err == nil {
t.Error("invalid MLDSA44 sig size should fail")
}
}
func TestCredentialValidateMLDSA87(t *testing.T) {
cred := NewCredential(CredentialTypeMLDSA87)
cred.AddSignature(make([]byte, MLDSA87SignatureSize))
if err := cred.ValidateSignatureSizes(); err != nil {
t.Errorf("valid MLDSA87 sig should pass: %v", err)
}
}
func TestSignatureSizeForTypeUnknown(t *testing.T) {
if SignatureSizeForType(0xFF) != 0 {
t.Error("unknown type should return 0")
}
}
func TestPublicKeySizeForTypeUnknown(t *testing.T) {
if PublicKeySizeForType(0xFF) != 0 {
t.Error("unknown type should return 0")
}
}
func TestIsPostQuantumBLS(t *testing.T) {
cred := NewCredential(CredentialTypeBLS)
if cred.IsPostQuantum() {
t.Error("BLS should not be post-quantum")
}
}
func TestIsPostQuantumEd25519(t *testing.T) {
cred := NewCredential(CredentialTypeEd25519)
if cred.IsPostQuantum() {
t.Error("Ed25519 should not be post-quantum")
}
}
func TestIsPostQuantumMLDSA44(t *testing.T) {
cred := NewCredential(CredentialTypeMLDSA44)
if !cred.IsPostQuantum() {
t.Error("MLDSA44 should be post-quantum")
}
}
func TestIsPostQuantumMLDSA87(t *testing.T) {
cred := NewCredential(CredentialTypeMLDSA87)
if !cred.IsPostQuantum() {
t.Error("MLDSA87 should be post-quantum")
}
}
func TestOutputOwnersIsPostQuantumFalse(t *testing.T) {
o := &OutputOwners{AddressType: CredentialTypeSecp256k1}
if o.IsPostQuantum() {
t.Error("secp256k1 output owners should not be PQ")
}
}
func TestOutputOwnersIsPostQuantumMLDSA44(t *testing.T) {
o := &OutputOwners{AddressType: CredentialTypeMLDSA44}
if !o.IsPostQuantum() {
t.Error("MLDSA44 output owners should be PQ")
}
}
func TestOutputOwnersIsPostQuantumMLDSA87(t *testing.T) {
o := &OutputOwners{AddressType: CredentialTypeMLDSA87}
if !o.IsPostQuantum() {
t.Error("MLDSA87 output owners should be PQ")
}
}
+740
View File
@@ -0,0 +1,740 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package wire
import (
"context"
"fmt"
"testing"
)
// --- NonePolicy edge cases ---
func TestNonePolicyVerifyWrongPolicy(t *testing.T) {
policy := NewNonePolicy()
cert := &Certificate{PolicyID: PolicyQuorum}
ok, err := policy.Verify(context.Background(), cert)
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("NonePolicy should reject certificate with wrong policy ID")
}
}
func TestNonePolicyOnVote(t *testing.T) {
policy := NewNonePolicy()
vote := NewVote(EmptyCandidateID, EmptyVoterID, 0, true)
err := policy.OnVote(context.Background(), vote)
if err != nil {
t.Errorf("NonePolicy.OnVote should always return nil, got %v", err)
}
}
func TestNonePolicyMaybeFinalizeUnknownCandidate(t *testing.T) {
policy := NewNonePolicy()
cert, err := policy.MaybeFinalize(context.Background(), DeriveItemID([]byte("unknown")))
if err != nil {
t.Fatal(err)
}
if cert != nil {
t.Error("should return nil for unknown candidate")
}
}
func TestNonePolicyMaybeFinalizeCached(t *testing.T) {
ctx := context.Background()
policy := NewNonePolicy()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
cert1, _ := policy.MaybeFinalize(ctx, c.ID)
cert2, _ := policy.MaybeFinalize(ctx, c.ID)
if cert1 != cert2 {
t.Error("should return cached certificate")
}
}
func TestNonePolicyCandidateLimit(t *testing.T) {
ctx := context.Background()
policy := NewNonePolicy()
// Fill to capacity
for i := 0; i < maxCandidates; i++ {
c := NewCandidate([]byte("d"), []byte(fmt.Sprintf("p%d", i)), EmptyCandidateID, uint64(i))
if err := policy.OnCandidate(ctx, c); err != nil {
t.Fatalf("should accept candidate %d: %v", i, err)
}
}
// One more should fail
c := NewCandidate([]byte("d"), []byte("overflow"), EmptyCandidateID, maxCandidates)
err := policy.OnCandidate(ctx, c)
if err == nil {
t.Error("should reject candidate beyond limit")
}
}
// --- QuorumPolicy edge cases ---
func TestQuorumPolicyVerify(t *testing.T) {
policy := NewQuorumPolicy(3, 5)
ctx := context.Background()
// Wrong policy ID
ok, _ := policy.Verify(ctx, &Certificate{PolicyID: PolicyNone})
if ok {
t.Error("should reject wrong policy ID")
}
// Empty proof
ok, _ = policy.Verify(ctx, &Certificate{PolicyID: PolicyQuorum})
if ok {
t.Error("should reject empty proof")
}
// Proof present but signers too short
ok, _ = policy.Verify(ctx, &Certificate{
PolicyID: PolicyQuorum,
Proof: []byte("proof"),
Signers: []byte("short"),
})
if ok {
t.Error("should reject short signers")
}
// Valid: proof and enough signers (3 * 32 bytes)
signers := make([]byte, 3*32)
ok, _ = policy.Verify(ctx, &Certificate{
PolicyID: PolicyQuorum,
Proof: []byte("proof"),
Signers: signers,
})
if !ok {
t.Error("should accept valid certificate")
}
}
func TestQuorumPolicyVoteBeforeCandidate(t *testing.T) {
ctx := context.Background()
policy := NewQuorumPolicy(1, 1)
// Vote for an unknown candidate -- should create vote map on the fly
voter := DeriveVoterID("agent", []byte("a"))
candidateID := DeriveItemID([]byte("unknown"))
vote := NewVote(candidateID, voter, 0, true)
err := policy.OnVote(ctx, vote)
if err != nil {
t.Errorf("should accept vote for unknown candidate: %v", err)
}
}
func TestQuorumPolicyRejectVotesNotPreferred(t *testing.T) {
ctx := context.Background()
policy := NewQuorumPolicy(1, 1)
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
// Vote with preference=false (reject)
voter := DeriveVoterID("agent", []byte("a"))
vote := NewVote(c.ID, voter, 0, false)
vote.Signature = []byte{SigBLS, 1}
policy.OnVote(ctx, vote)
cert, _ := policy.MaybeFinalize(ctx, c.ID)
if cert != nil {
t.Error("should not finalize with only reject votes")
}
}
func TestQuorumPolicyCandidateLimit(t *testing.T) {
ctx := context.Background()
policy := NewQuorumPolicy(1, 1)
for i := 0; i < maxCandidates; i++ {
c := NewCandidate([]byte("d"), []byte(fmt.Sprintf("p%d", i)), EmptyCandidateID, uint64(i))
if err := policy.OnCandidate(ctx, c); err != nil {
t.Fatalf("failed at %d: %v", i, err)
}
}
c := NewCandidate([]byte("d"), []byte("overflow"), EmptyCandidateID, maxCandidates)
if err := policy.OnCandidate(ctx, c); err == nil {
t.Error("should reject beyond limit")
}
}
// --- SamplePolicy edge cases ---
func TestSamplePolicyVerify(t *testing.T) {
policy := NewSamplePolicy(3, 0.6, 2)
ctx := context.Background()
// Wrong policy
ok, _ := policy.Verify(ctx, &Certificate{PolicyID: PolicyNone})
if ok {
t.Error("should reject wrong policy")
}
// Short proof
ok, _ = policy.Verify(ctx, &Certificate{PolicyID: PolicySampleConvergence, Proof: []byte{1}})
if ok {
t.Error("should reject short proof")
}
// Low confidence
proof := make([]byte, 9)
proof[0] = 1 // confidence=1, beta=2
ok, _ = policy.Verify(ctx, &Certificate{PolicyID: PolicySampleConvergence, Proof: proof})
if ok {
t.Error("should reject low confidence")
}
// Sufficient confidence
proof[0] = 3
ok, _ = policy.Verify(ctx, &Certificate{PolicyID: PolicySampleConvergence, Proof: proof})
if !ok {
t.Error("should accept sufficient confidence")
}
}
func TestSamplePolicyVoteUnknownCandidate(t *testing.T) {
policy := NewSamplePolicy(3, 0.6, 2)
vote := NewVote(DeriveItemID([]byte("x")), EmptyVoterID, 0, true)
err := policy.OnVote(context.Background(), vote)
if err != nil {
t.Errorf("should silently ignore vote for unknown candidate: %v", err)
}
}
func TestSamplePolicyConvergesToReject(t *testing.T) {
ctx := context.Background()
policy := NewSamplePolicy(3, 0.6, 2)
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
// 2 rounds of reject votes (all false)
for round := uint64(0); round < 2; round++ {
for i := 0; i < 3; i++ {
voter := DeriveVoterID("a", []byte{byte(i)})
vote := NewVote(c.ID, voter, round, false)
policy.OnVote(ctx, vote)
}
}
cert, _ := policy.MaybeFinalize(ctx, c.ID)
if cert != nil {
t.Error("should not produce cert when converged to reject")
}
}
func TestSamplePolicyPreferenceFlip(t *testing.T) {
ctx := context.Background()
policy := NewSamplePolicy(3, 0.6, 3) // beta=3
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
// Round 0: yes (confidence 1)
for i := 0; i < 3; i++ {
voter := DeriveVoterID("a", []byte{byte(i)})
policy.OnVote(ctx, NewVote(c.ID, voter, 0, true))
}
// Round 1: no -- preference flips, confidence resets to 1
for i := 0; i < 3; i++ {
voter := DeriveVoterID("a", []byte{byte(i)})
policy.OnVote(ctx, NewVote(c.ID, voter, 1, false))
}
// Round 2: yes -- preference flips again, confidence 1
for i := 0; i < 3; i++ {
voter := DeriveVoterID("a", []byte{byte(i)})
policy.OnVote(ctx, NewVote(c.ID, voter, 2, true))
}
cert, _ := policy.MaybeFinalize(ctx, c.ID)
if cert != nil {
t.Error("should not finalize -- confidence never reached beta=3")
}
}
func TestSamplePolicyCandidateLimit(t *testing.T) {
ctx := context.Background()
policy := NewSamplePolicy(1, 1.0, 1)
for i := 0; i < maxCandidates; i++ {
c := NewCandidate([]byte("d"), []byte(fmt.Sprintf("p%d", i)), EmptyCandidateID, uint64(i))
if err := policy.OnCandidate(ctx, c); err != nil {
t.Fatalf("failed at %d: %v", i, err)
}
}
c := NewCandidate([]byte("d"), []byte("overflow"), EmptyCandidateID, maxCandidates)
if err := policy.OnCandidate(ctx, c); err == nil {
t.Error("should reject beyond limit")
}
}
// --- L1Policy ---
type mockL1Verifier struct {
proofs map[CandidateID][]byte
verifyFunc func(ctx context.Context, id CandidateID, proof []byte) (bool, error)
}
func (m *mockL1Verifier) GetInclusionProof(ctx context.Context, id CandidateID) ([]byte, error) {
p, ok := m.proofs[id]
if !ok {
return nil, nil
}
return p, nil
}
func (m *mockL1Verifier) VerifyInclusion(ctx context.Context, id CandidateID, proof []byte) (bool, error) {
if m.verifyFunc != nil {
return m.verifyFunc(ctx, id, proof)
}
return len(proof) > 0, nil
}
func TestL1PolicyFullLifecycle(t *testing.T) {
ctx := context.Background()
verifier := &mockL1Verifier{proofs: make(map[CandidateID][]byte)}
policy := NewL1Policy(verifier)
if policy.PolicyID() != PolicyL1Inclusion {
t.Error("wrong policy ID")
}
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
if err := policy.OnCandidate(ctx, c); err != nil {
t.Fatal(err)
}
// OnVote is a no-op
if err := policy.OnVote(ctx, NewVote(c.ID, EmptyVoterID, 0, true)); err != nil {
t.Errorf("OnVote should return nil: %v", err)
}
// Not included yet
cert, _ := policy.MaybeFinalize(ctx, c.ID)
if cert != nil {
t.Error("should not finalize without L1 inclusion")
}
// Simulate L1 inclusion
verifier.proofs[c.ID] = []byte("merkle-proof")
cert, _ = policy.MaybeFinalize(ctx, c.ID)
if cert == nil {
t.Fatal("should finalize with L1 inclusion proof")
}
if cert.PolicyID != PolicyL1Inclusion {
t.Error("wrong cert policy ID")
}
// Cached return
cert2, _ := policy.MaybeFinalize(ctx, c.ID)
if cert2 != cert {
t.Error("should return cached cert")
}
}
func TestL1PolicyVerify(t *testing.T) {
ctx := context.Background()
verifier := &mockL1Verifier{
proofs: make(map[CandidateID][]byte),
verifyFunc: func(_ context.Context, _ CandidateID, proof []byte) (bool, error) {
return string(proof) == "valid", nil
},
}
policy := NewL1Policy(verifier)
// Wrong policy ID
ok, _ := policy.Verify(ctx, &Certificate{PolicyID: PolicyNone})
if ok {
t.Error("should reject wrong policy")
}
// Valid proof
ok, _ = policy.Verify(ctx, &Certificate{PolicyID: PolicyL1Inclusion, Proof: []byte("valid")})
if !ok {
t.Error("should accept valid proof")
}
// Invalid proof
ok, _ = policy.Verify(ctx, &Certificate{PolicyID: PolicyL1Inclusion, Proof: []byte("invalid")})
if ok {
t.Error("should reject invalid proof")
}
}
func TestL1PolicyUnknownCandidate(t *testing.T) {
ctx := context.Background()
verifier := &mockL1Verifier{proofs: make(map[CandidateID][]byte)}
policy := NewL1Policy(verifier)
cert, err := policy.MaybeFinalize(ctx, DeriveItemID([]byte("unknown")))
if err != nil {
t.Fatal(err)
}
if cert != nil {
t.Error("should return nil for unknown candidate")
}
}
func TestL1PolicyCandidateLimit(t *testing.T) {
ctx := context.Background()
verifier := &mockL1Verifier{proofs: make(map[CandidateID][]byte)}
policy := NewL1Policy(verifier)
for i := 0; i < maxCandidates; i++ {
c := NewCandidate([]byte("d"), []byte(fmt.Sprintf("p%d", i)), EmptyCandidateID, uint64(i))
if err := policy.OnCandidate(ctx, c); err != nil {
t.Fatalf("failed at %d: %v", i, err)
}
}
c := NewCandidate([]byte("d"), []byte("overflow"), EmptyCandidateID, maxCandidates)
if err := policy.OnCandidate(ctx, c); err == nil {
t.Error("should reject beyond limit")
}
}
// --- QuantumPolicy additional edge cases ---
func TestQuantumPolicySetRequireRT(t *testing.T) {
policy := NewQuantumPolicy(1)
policy.SetRequireRT(false)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
// BLS-only should be accepted after disabling RT requirement
voter := DeriveVoterID("a", []byte("v"))
vote := NewVote(c.ID, voter, 0, true)
vote.Signature = []byte{SigBLS, 1, 2, 3}
err := policy.OnVote(ctx, vote)
if err != nil {
t.Errorf("BLS should be accepted with RT disabled: %v", err)
}
}
func TestQuantumPolicyRejectVoteNoPreference(t *testing.T) {
policy := NewQuantumPolicy(1)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
// Vote with preference=false -- silently ignored
vote := NewVote(c.ID, DeriveVoterID("a", []byte("v")), 0, false)
vote.Signature = []byte{SigQuasar, 0, 2, 1, 2, 3, 4}
err := policy.OnVote(ctx, vote)
if err != nil {
t.Errorf("reject vote should be silently ignored: %v", err)
}
}
func TestQuantumPolicyEmptySignatureRTRequired(t *testing.T) {
policy := NewQuantumPolicy(1)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
vote := NewVote(c.ID, DeriveVoterID("a", []byte("v")), 0, true)
// No signature
err := policy.OnVote(ctx, vote)
if err == nil {
t.Error("should reject empty signature when RT required")
}
rtErr, ok := err.(*RTRequirementError)
if !ok {
t.Errorf("expected RTRequirementError, got %T", err)
}
if rtErr.Error() == "" {
t.Error("error message should not be empty")
}
}
func TestQuantumPolicyEmptySignatureRTNotRequired(t *testing.T) {
policy := NewQuantumPolicyWithOptions(1, false)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
vote := NewVote(c.ID, DeriveVoterID("a", []byte("v")), 0, true)
err := policy.OnVote(ctx, vote)
if err != nil {
t.Errorf("empty sig should be ok when RT not required: %v", err)
}
}
func TestQuantumPolicyQuasarTooShort(t *testing.T) {
policy := NewQuantumPolicy(1)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
vote := NewVote(c.ID, DeriveVoterID("a", []byte("v")), 0, true)
vote.Signature = []byte{SigQuasar, 0} // too short
err := policy.OnVote(ctx, vote)
if err == nil {
t.Error("should reject too-short Quasar signature")
}
}
func TestQuantumPolicyQuasarZeroBLS(t *testing.T) {
policy := NewQuantumPolicy(1)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
vote := NewVote(c.ID, DeriveVoterID("a", []byte("v")), 0, true)
vote.Signature = []byte{SigQuasar, 0, 0, 1} // bls_len=0
err := policy.OnVote(ctx, vote)
if err == nil {
t.Error("should reject zero-length BLS in Quasar")
}
}
func TestQuantumPolicyQuasarTruncatedBLS(t *testing.T) {
policy := NewQuantumPolicy(1)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
vote := NewVote(c.ID, DeriveVoterID("a", []byte("v")), 0, true)
vote.Signature = []byte{SigQuasar, 0, 10} // claims 10 bytes BLS but has 0
err := policy.OnVote(ctx, vote)
if err == nil {
t.Error("should reject truncated BLS in Quasar")
}
}
func TestQuantumPolicyFinalizationAndVerify(t *testing.T) {
ctx := context.Background()
policy := NewQuantumPolicy(2)
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
// Add 2 Quasar votes (threshold=2)
for i := 0; i < 2; i++ {
voter := DeriveVoterID("a", []byte{byte(i)})
blsSig := make([]byte, 48)
blsSig[0] = byte(i)
rtSig := make([]byte, 32)
rtSig[0] = byte(i)
sig := []byte{SigQuasar, 0, byte(len(blsSig))}
sig = append(sig, blsSig...)
sig = append(sig, rtSig...)
vote := NewVote(c.ID, voter, 0, true)
vote.Signature = sig
if err := policy.OnVote(ctx, vote); err != nil {
t.Fatalf("vote %d failed: %v", i, err)
}
}
cert, err := policy.MaybeFinalize(ctx, c.ID)
if err != nil {
t.Fatal(err)
}
if cert == nil {
t.Fatal("should finalize with 2 votes")
}
if cert.PolicyID != PolicyQuantum {
t.Error("wrong policy ID on cert")
}
// Verify
ok, _ := policy.Verify(ctx, cert)
if !ok {
t.Error("should verify own certificate")
}
// Cached
cert2, _ := policy.MaybeFinalize(ctx, c.ID)
if cert2 != cert {
t.Error("should return cached cert")
}
}
func TestQuantumPolicyVerifyEdgeCases(t *testing.T) {
policy := NewQuantumPolicy(1)
ctx := context.Background()
// Wrong policy
ok, _ := policy.Verify(ctx, &Certificate{PolicyID: PolicyNone})
if ok {
t.Error("should reject wrong policy")
}
// Short proof
ok, _ = policy.Verify(ctx, &Certificate{PolicyID: PolicyQuantum, Proof: []byte{SigQuasar}})
if ok {
t.Error("should reject short proof")
}
// Wrong scheme byte
proof := make([]byte, 33)
proof[0] = SigBLS // not SigQuasar
ok, _ = policy.Verify(ctx, &Certificate{PolicyID: PolicyQuantum, Proof: proof})
if ok {
t.Error("should reject non-Quasar scheme byte")
}
}
func TestQuantumPolicyMaybeFinalizeBelowThreshold(t *testing.T) {
ctx := context.Background()
policy := NewQuantumPolicy(3)
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
// Only 1 vote (threshold=3)
voter := DeriveVoterID("a", []byte("v"))
sig := []byte{SigQuasar, 0, 2, 1, 2, 3, 4}
vote := NewVote(c.ID, voter, 0, true)
vote.Signature = sig
policy.OnVote(ctx, vote)
cert, _ := policy.MaybeFinalize(ctx, c.ID)
if cert != nil {
t.Error("should not finalize below threshold")
}
}
func TestQuantumPolicyUnknownCandidate(t *testing.T) {
policy := NewQuantumPolicy(1)
cert, _ := policy.MaybeFinalize(context.Background(), DeriveItemID([]byte("nope")))
if cert != nil {
t.Error("should return nil for unknown")
}
}
func TestQuantumPolicyCandidateLimit(t *testing.T) {
ctx := context.Background()
policy := NewQuantumPolicy(1)
for i := 0; i < maxCandidates; i++ {
c := NewCandidate([]byte("d"), []byte(fmt.Sprintf("p%d", i)), EmptyCandidateID, uint64(i))
if err := policy.OnCandidate(ctx, c); err != nil {
t.Fatalf("failed at %d: %v", i, err)
}
}
c := NewCandidate([]byte("d"), []byte("overflow"), EmptyCandidateID, maxCandidates)
if err := policy.OnCandidate(ctx, c); err == nil {
t.Error("should reject beyond limit")
}
}
func TestQuantumPolicyUnknownSchemeRTRequired(t *testing.T) {
policy := NewQuantumPolicy(1)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
vote := NewVote(c.ID, DeriveVoterID("a", []byte("v")), 0, true)
vote.Signature = []byte{0xFF, 1, 2, 3} // unknown scheme
err := policy.OnVote(ctx, vote)
if err == nil {
t.Error("should reject unknown scheme when RT required")
}
}
func TestQuantumPolicyUnknownSchemeRTNotRequired(t *testing.T) {
policy := NewQuantumPolicyWithOptions(1, false)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
vote := NewVote(c.ID, DeriveVoterID("a", []byte("v")), 0, true)
vote.Signature = []byte{0xFF, 1, 2, 3}
err := policy.OnVote(ctx, vote)
if err != nil {
t.Errorf("should ignore unknown scheme when RT not required: %v", err)
}
}
func TestQuantumPolicyCoronaOnlyRTNotRequired(t *testing.T) {
policy := NewQuantumPolicyWithOptions(1, false)
ctx := context.Background()
c := NewCandidate([]byte("d"), []byte("p"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, c)
vote := NewVote(c.ID, DeriveVoterID("a", []byte("v")), 0, true)
vote.Signature = []byte{SigCorona, 4, 5, 6}
err := policy.OnVote(ctx, vote)
if err != nil {
t.Errorf("RT-only vote should be accepted when RT not required: %v", err)
}
}
// --- sigSchemeToString ---
func TestSigSchemeToString(t *testing.T) {
tests := []struct {
scheme byte
expect string
}{
{SigNone, "SigNone"},
{SigEd25519, "SigEd25519"},
{SigBLS, "SigBLS"},
{SigCorona, "SigCorona"},
{SigQuasar, "SigQuasar"},
{0xFF, "Unknown"},
}
for _, tt := range tests {
got := sigSchemeToString(tt.scheme)
if got != tt.expect {
t.Errorf("sigSchemeToString(%d) = %s, want %s", tt.scheme, got, tt.expect)
}
}
}
// --- HostnameValidationError.Error() ---
func TestHostnameValidationErrorMessage(t *testing.T) {
e := &HostnameValidationError{Address: "1.2.3.4", Reason: "bad"}
msg := e.Error()
if msg == "" {
t.Error("error message should not be empty")
}
}
// --- ValidatePeerAddress ---
func TestValidatePeerAddress(t *testing.T) {
// Valid
if err := ValidatePeerAddress("node1.lux.network:9651"); err != nil {
t.Errorf("valid hostname should pass: %v", err)
}
// Invalid
if err := ValidatePeerAddress("10.0.0.1:9651"); err == nil {
t.Error("IP literal should be rejected")
}
}
// --- RTRequirementError.Error() ---
func TestRTRequirementErrorMessage(t *testing.T) {
e := &RTRequirementError{Reason: "test reason"}
if e.Error() != "RT signature required: test reason" {
t.Errorf("unexpected error: %s", e.Error())
}
}
// --- CredentialValidationError.Error() ---
func TestCredentialValidationErrorMessage(t *testing.T) {
e := &CredentialValidationError{Index: 0, Expected: 100, Got: 50, Reason: "mismatch"}
if e.Error() != "mismatch" {
t.Errorf("unexpected: %s", e.Error())
}
}
+336
View File
@@ -0,0 +1,336 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package wire
import (
"encoding/json"
"testing"
)
func TestNativeSequencer(t *testing.T) {
domain := []byte("test-chain")
s := NativeSequencer(42, domain)
if s.Type != SequencerNative {
t.Errorf("expected SequencerNative, got %d", s.Type)
}
if s.ChainID != 42 {
t.Errorf("expected chain ID 42, got %d", s.ChainID)
}
if string(s.Domain) != "test-chain" {
t.Errorf("domain mismatch")
}
if s.Depth != 0 {
t.Errorf("native sequencer should have depth 0")
}
if s.ParentChainID != nil {
t.Errorf("native sequencer should have nil parent")
}
if s.ExternalRPC != "" {
t.Errorf("native sequencer should have empty RPC")
}
}
func TestExternalSequencerIdentity(t *testing.T) {
s := ExternalSequencerIdentity(10, []byte("op-stack"), "https://rpc.example.com")
if s.Type != SequencerExternal {
t.Errorf("expected SequencerExternal, got %d", s.Type)
}
if s.ChainID != 10 {
t.Errorf("expected chain ID 10, got %d", s.ChainID)
}
if s.ExternalRPC != "https://rpc.example.com" {
t.Errorf("RPC mismatch: %s", s.ExternalRPC)
}
if s.Depth != 0 {
t.Errorf("external sequencer should have depth 0")
}
}
func TestRecursiveSequencerIdentity(t *testing.T) {
s := RecursiveSequencerIdentity(20, []byte("l2"), 1, 1)
if s.Type != SequencerRecursive {
t.Errorf("expected SequencerRecursive, got %d", s.Type)
}
if s.ChainID != 20 {
t.Errorf("expected chain ID 20, got %d", s.ChainID)
}
if s.ParentChainID == nil || *s.ParentChainID != 1 {
t.Errorf("expected parent chain ID 1")
}
if s.Depth != 1 {
t.Errorf("expected depth 1, got %d", s.Depth)
}
}
func TestNetworkNodeAddChildAndTraverse(t *testing.T) {
root := &NetworkNode{
Identity: NativeSequencer(1, []byte("root")),
Config: BlockchainConfig([]byte("root")),
}
child1 := &NetworkNode{
Identity: NativeSequencer(2, []byte("child1")),
Config: SingleNodeConfig([]byte("child1")),
}
child2 := &NetworkNode{
Identity: NativeSequencer(3, []byte("child2")),
Config: SingleNodeConfig([]byte("child2")),
}
root.AddChild(child1)
root.AddChild(child2)
// child1 should have parent set to root's chain ID
if child1.Identity.ParentChainID == nil || *child1.Identity.ParentChainID != 1 {
t.Error("child1 parent should be 1")
}
if child1.Identity.Depth != 1 {
t.Errorf("child1 depth should be 1, got %d", child1.Identity.Depth)
}
// Traverse should return all 3 nodes
all := root.Traverse()
if len(all) != 3 {
t.Errorf("expected 3 nodes, got %d", len(all))
}
}
func TestNetworkNodeFindByChainID(t *testing.T) {
root := &NetworkNode{
Identity: NativeSequencer(1, []byte("root")),
Config: BlockchainConfig([]byte("root")),
}
child := &NetworkNode{
Identity: NativeSequencer(2, []byte("child")),
Config: SingleNodeConfig([]byte("child")),
}
grandchild := &NetworkNode{
Identity: NativeSequencer(3, []byte("grandchild")),
Config: SingleNodeConfig([]byte("grandchild")),
}
root.AddChild(child)
child.AddChild(grandchild)
// Find root
found := root.FindByChainID(1)
if found == nil || found.Identity.ChainID != 1 {
t.Error("should find root")
}
// Find grandchild
found = root.FindByChainID(3)
if found == nil || found.Identity.ChainID != 3 {
t.Error("should find grandchild")
}
// Not found
found = root.FindByChainID(999)
if found != nil {
t.Error("should not find non-existent chain")
}
}
func TestRecursiveNetwork(t *testing.T) {
rn := NewSingleChainNetwork(1, []byte("main"), BlockchainConfig([]byte("main")))
if rn.Root == nil {
t.Fatal("root should not be nil")
}
if rn.Root.Identity.ChainID != 1 {
t.Errorf("root chain ID should be 1")
}
// GetAllChains
chains := rn.GetAllChains()
if len(chains) != 1 {
t.Errorf("expected 1 chain, got %d", len(chains))
}
// GetChain
chain := rn.GetChain(1)
if chain == nil {
t.Error("should find chain 1")
}
chain = rn.GetChain(999)
if chain != nil {
t.Error("should not find chain 999")
}
// AddChain
child := &NetworkNode{
Identity: NativeSequencer(2, []byte("l2")),
Config: SingleNodeConfig([]byte("l2")),
}
ok := rn.AddChain(1, child)
if !ok {
t.Error("should add chain under existing parent")
}
if len(rn.GetAllChains()) != 2 {
t.Errorf("expected 2 chains after add")
}
// AddChain to non-existent parent
ok = rn.AddChain(999, &NetworkNode{
Identity: NativeSequencer(3, []byte("orphan")),
Config: SingleNodeConfig([]byte("orphan")),
})
if ok {
t.Error("should not add chain under non-existent parent")
}
}
func TestGetFinalityPath(t *testing.T) {
rn := NewSingleChainNetwork(1, []byte("root"), BlockchainConfig([]byte("root")))
child := &NetworkNode{
Identity: NativeSequencer(2, []byte("l2")),
Config: SingleNodeConfig([]byte("l2")),
}
grandchild := &NetworkNode{
Identity: NativeSequencer(3, []byte("l3")),
Config: SingleNodeConfig([]byte("l3")),
}
rn.AddChain(1, child)
rn.AddChain(2, grandchild)
path := rn.GetFinalityPath(3)
if len(path) != 3 {
t.Errorf("expected finality path of length 3, got %d", len(path))
}
if path[0].Identity.ChainID != 3 {
t.Error("path should start at chain 3")
}
if path[2].Identity.ChainID != 1 {
t.Error("path should end at root chain 1")
}
// Path from root
path = rn.GetFinalityPath(1)
if len(path) != 1 {
t.Errorf("root path should be length 1, got %d", len(path))
}
// Path for non-existent chain
path = rn.GetFinalityPath(999)
if len(path) != 0 {
t.Errorf("non-existent chain should have empty path, got %d", len(path))
}
}
func TestNewAIMeshNetwork(t *testing.T) {
rn := NewAIMeshNetwork(42, []byte("mesh"), 5)
if rn.Root == nil {
t.Fatal("root should not be nil")
}
if rn.Root.Config.K != 5 {
t.Errorf("expected K=5, got %d", rn.Root.Config.K)
}
if rn.Root.Config.SoftPolicy != PolicyQuorum {
t.Error("AI mesh should use PolicyQuorum")
}
}
func TestNewRecursiveRollupNetwork(t *testing.T) {
l2Configs := []L2Config{
{ChainID: 10, Domain: []byte("l2a"), Config: RollupConfig([]byte("l2a"))},
{ChainID: 20, Domain: []byte("l2b"), Config: RollupConfig([]byte("l2b"))},
}
rn := NewRecursiveRollupNetwork(1, []byte("l1"), l2Configs)
all := rn.GetAllChains()
if len(all) != 3 {
t.Errorf("expected 3 chains (1 L1 + 2 L2), got %d", len(all))
}
l2a := rn.GetChain(10)
if l2a == nil {
t.Fatal("should find L2 chain 10")
}
if l2a.Identity.ParentChainID == nil || *l2a.Identity.ParentChainID != 1 {
t.Error("L2 should have parent chain ID 1")
}
if l2a.Identity.Depth != 1 {
t.Errorf("L2 depth should be 1, got %d", l2a.Identity.Depth)
}
}
func TestSequencerIdentitySerialization(t *testing.T) {
s := RecursiveSequencerIdentity(42, []byte("test"), 1, 3)
data, err := MarshalSequencerIdentity(s)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
s2, err := UnmarshalSequencerIdentity(data)
if err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if s2.Type != s.Type {
t.Error("type mismatch")
}
if s2.ChainID != s.ChainID {
t.Error("chain ID mismatch")
}
if s2.Depth != s.Depth {
t.Error("depth mismatch")
}
if s2.ParentChainID == nil || *s2.ParentChainID != *s.ParentChainID {
t.Error("parent chain ID mismatch")
}
// Invalid JSON
_, err = UnmarshalSequencerIdentity([]byte("invalid"))
if err == nil {
t.Error("should fail on invalid JSON")
}
}
func TestRecursiveNetworkSerialization(t *testing.T) {
rn := NewRecursiveRollupNetwork(1, []byte("l1"), []L2Config{
{ChainID: 10, Domain: []byte("l2"), Config: RollupConfig([]byte("l2"))},
})
data, err := MarshalRecursiveNetwork(rn)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
rn2, err := UnmarshalRecursiveNetwork(data)
if err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(rn2.GetAllChains()) != 2 {
t.Error("expected 2 chains after roundtrip")
}
// Invalid JSON
_, err = UnmarshalRecursiveNetwork([]byte("{bad"))
if err == nil {
t.Error("should fail on invalid JSON")
}
}
func TestSequencerIdentityJSONFields(t *testing.T) {
s := ExternalSequencerIdentity(10, []byte("test"), "https://rpc.example.com")
data, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatal(err)
}
// Verify JSON field names
for _, field := range []string{"sequencer_type", "chain_id", "domain", "external_rpc", "depth"} {
if _, ok := m[field]; !ok {
t.Errorf("missing JSON field: %s", field)
}
}
}
+109
View File
@@ -430,3 +430,112 @@ func TestBuildSkipListWithNoParents(t *testing.T) {
t.Error("Root should have an entry in skip list")
}
}
func TestLowestCommonAncestor(t *testing.T) {
g := NewTestGraph()
// Diamond DAG:
// A
// / \
// B C
// \ /
// D
g.AddEdge("A", "B")
g.AddEdge("A", "C")
g.AddEdge("B", "D")
g.AddEdge("C", "D")
// LCA of B and C should be A
lca, found := LowestCommonAncestor(g, "B", "C")
if !found {
t.Fatal("should find LCA of B and C")
}
if lca != "A" {
t.Errorf("LCA(B,C) should be A, got %s", lca)
}
// LCA of D and B: TransitiveClosure(D) = {D,B,C,A}, BFS from B finds B in ancestors
lca, found = LowestCommonAncestor(g, "D", "B")
if !found {
t.Fatal("should find LCA of D and B")
}
// B is an ancestor of D, so LCA(D,B) is B
if lca != "B" {
t.Errorf("LCA(D,B) should be B, got %s", lca)
}
// LCA of same vertex
lca, found = LowestCommonAncestor(g, "A", "A")
if !found {
t.Fatal("should find LCA of A and A")
}
if lca != "A" {
t.Errorf("LCA(A,A) should be A, got %s", lca)
}
// Non-existent vertex: X does not exist
_, found = LowestCommonAncestor(g, "X", "A")
if found {
t.Error("should not find LCA with non-existent vertex")
}
}
func TestLowestCommonAncestorLinearChain(t *testing.T) {
g := NewTestGraph()
// A -> B -> C -> D
g.AddEdge("A", "B")
g.AddEdge("B", "C")
g.AddEdge("C", "D")
lca, found := LowestCommonAncestor(g, "D", "C")
if !found {
t.Fatal("should find LCA")
}
if lca != "C" {
t.Errorf("LCA(D,C) should be C, got %s", lca)
}
lca, found = LowestCommonAncestor(g, "D", "A")
if !found {
t.Fatal("should find LCA")
}
if lca != "A" {
t.Errorf("LCA(D,A) should be A, got %s", lca)
}
}
func TestTransitiveClosureSingleNode(t *testing.T) {
g := NewTestGraph()
g.blocks["solo"] = &TestBlockView{id: "solo", parents: []string{}, author: "test", round: 0}
closure := TransitiveClosure(g, "solo")
if len(closure) != 1 {
t.Errorf("expected 1, got %d", len(closure))
}
if closure[0] != "solo" {
t.Errorf("expected solo, got %s", closure[0])
}
}
func TestTransitiveClosureNonExistent(t *testing.T) {
g := NewTestGraph()
g.AddEdge("A", "B")
closure := TransitiveClosure(g, "X")
if len(closure) != 1 {
t.Errorf("non-existent vertex should return itself, got %d", len(closure))
}
}
func TestFindPathSameVertex(t *testing.T) {
g := NewTestGraph()
g.blocks["A"] = &TestBlockView{id: "A", parents: []string{}, author: "test", round: 0}
path, found := FindPath(g, "A", "A")
if !found {
t.Fatal("should find path from A to A")
}
if len(path) != 1 || path[0] != "A" {
t.Errorf("path from A to A should be [A], got %v", path)
}
}
+3 -3
View File
@@ -119,7 +119,7 @@ func TestRealThresholdSig_Accepted(t *testing.T) {
func TestQuasarCert_GarbageBytes_Rejected(t *testing.T) {
cert := &QuasarCert{
BLS: []byte{0x01},
PQProof: []byte{0x01},
MLDSAProof: []byte{0x01},
Validators: 1,
}
@@ -145,7 +145,7 @@ func TestQuasarCert_NilCert_Rejected(t *testing.T) {
func TestQuasarCert_EmptyBLS_Rejected(t *testing.T) {
cert := &QuasarCert{
BLS: []byte{},
PQProof: []byte{0x01, 0x02},
MLDSAProof: []byte{0x01, 0x02},
}
require.False(t, cert.VerifyWithKeys([]byte("key"), []byte("key")),
"QuasarCert with empty BLS must return false")
@@ -155,7 +155,7 @@ func TestQuasarCert_EmptyBLS_Rejected(t *testing.T) {
func TestQuasarCert_EmptyPQ_Rejected(t *testing.T) {
cert := &QuasarCert{
BLS: []byte{0x01, 0x02},
PQProof: []byte{},
MLDSAProof: []byte{},
}
require.False(t, cert.VerifyWithKeys([]byte("key"), []byte("key")),
"QuasarCert with empty PQ must return false")
+3 -3
View File
@@ -7,7 +7,7 @@
// IMPORTANT: This is NOT the threshold signature implementation.
// For real cryptographic threshold signatures (BLS + Corona), see:
// - quasar.go: Signer struct with github.com/luxfi/crypto/bls integration
// - epoch.go: Corona threshold signing via github.com/luxfi/corona/threshold
// - epoch.go: Pulsar threshold signing via github.com/luxfi/pulsar/threshold (Corona-equivalent kernel)
//
// The BLS struct here handles DAG vertex ordering and event horizon
// establishment, which operates independently of block finality signatures.
@@ -153,7 +153,7 @@ func (q *BLS) generateBLSAggregate(blockID ids.ID, votes map[string]int) []byte
//
// For full Corona threshold signatures, see:
// - quasar.go: signer.CoronaRound1/Round2/Finalize (2-round protocol)
// - epoch.go: EpochManager.VerifySignatureForEpoch (via github.com/luxfi/corona/threshold)
// - epoch.go: EpochManager.VerifySignatureForEpoch (via github.com/luxfi/pulsar/threshold)
func (q *BLS) generatePQCertificate(blockID ids.ID, votes map[string]int) []byte {
if len(q.pqKey) == 0 {
return []byte{}
@@ -288,7 +288,7 @@ func (q *BLS) GetLatestHorizon() *dag.EventHorizon[VertexID] {
//
// For full threshold signatures with real BLS + Corona, see:
// - quasar.go: signer.AggregateSignatures (BLS threshold via github.com/luxfi/crypto/bls)
// - epoch.go: BundleSigner.SignBundle (Corona 2-round via github.com/luxfi/corona/threshold)
// - epoch.go: BundleSigner.SignBundle (Pulsar 2-round via github.com/luxfi/pulsar/threshold)
func (q *BLS) createHorizonSignature(checkpoint VertexID, validators []string) []byte {
// Create message digest: checkpoint + validators
h := sha256.New()
+9 -9
View File
@@ -611,7 +611,7 @@ func TestQuasarConsensus_GenerateCert(t *testing.T) {
t.Error("expected non-empty BLS")
}
if len(cert.PQProof) == 0 {
if len(cert.MLDSAProof) == 0 {
t.Error("expected non-empty PQ")
}
@@ -858,10 +858,10 @@ func TestSigner_GetActiveValidatorCount(t *testing.T) {
// Corona Tests
// =============================================================================
//
// NOTE: Corona post-quantum crypto is tested in the real corona package
// at github.com/luxfi/corona/threshold. The quasar package uses the real
// implementation via the Signer type in quasar.go. Tests for threshold signing
// are in quasar_test.go and epoch_test.go.
// NOTE: Post-quantum threshold crypto is tested in the Pulsar package at
// github.com/luxfi/pulsar/threshold. The quasar package uses that real
// implementation via the Signer type in quasar.go. Tests for threshold
// signing are in quasar_test.go and epoch_test.go.
// =============================================================================
// Types Tests (types.go)
@@ -884,7 +884,7 @@ func TestQuasarCert_Verify(t *testing.T) {
name: "empty BLS",
cert: &QuasarCert{
BLS: nil,
PQProof: []byte{1, 2, 3},
MLDSAProof: []byte{1, 2, 3},
},
validators: []string{"v1"},
want: false,
@@ -893,7 +893,7 @@ func TestQuasarCert_Verify(t *testing.T) {
name: "empty PQ",
cert: &QuasarCert{
BLS: []byte{1, 2, 3},
PQProof: nil,
MLDSAProof: nil,
},
validators: []string{"v1"},
want: false,
@@ -902,7 +902,7 @@ func TestQuasarCert_Verify(t *testing.T) {
name: "non-empty cert without crypto verification returns false",
cert: &QuasarCert{
BLS: []byte{1, 2, 3},
PQProof: []byte{4, 5, 6},
MLDSAProof: []byte{4, 5, 6},
Validators: 0,
},
validators: []string{"v1", "v2"},
@@ -932,7 +932,7 @@ func TestBlock_Fields(t *testing.T) {
Data: []byte("test data"),
Cert: &QuasarCert{
BLS: []byte{7, 8, 9},
PQProof: []byte{10, 11, 12},
MLDSAProof: []byte{10, 11, 12},
Validators: 1,
Epoch: 100,
Finality: now,
+126
View File
@@ -0,0 +1,126 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Death test for the Quasar cutover. After the March 3, 2026
// PQ-consensus architecture freeze, Quasar consumes its threshold
// kernel through `luxfi/threshold` (which routes via the LSS
// adapters to luxfi/pulsar and luxfi/lens). Direct imports of
// `github.com/luxfi/corona/threshold` from this package are
// forbidden — they bypass the LSS lifecycle (Generation, snapshot
// history, rollback) and skip the Pulsar key-era binding.
//
// This test asserts that no .go file inside this package directly
// references the corona import path or invokes corona.GenerateKeys.
// Transitive corona dependencies via threshold's own existing
// imports are fine; the rule applies to source-level direct use only.
package quasar
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
// TestNoCoronaDirectImport — fails if any non-test .go file in this
// package imports `github.com/luxfi/corona/...` or invokes
// corona.GenerateKeys (matched as a selector expression
// `corona.GenerateKeys(...)` regardless of the package alias).
//
// Test files (*_test.go) are scanned too — the corona kernel is
// equally forbidden from package tests so the cutover doesn't slip
// back through a "just for the test" import.
func TestNoCoronaDirectImport(t *testing.T) {
pkgDir, err := os.Getwd()
if err != nil {
t.Fatalf("os.Getwd: %v", err)
}
matches, err := filepath.Glob(filepath.Join(pkgDir, "*.go"))
if err != nil {
t.Fatalf("glob package: %v", err)
}
if len(matches) == 0 {
t.Fatalf("no .go files found in %s", pkgDir)
}
const forbiddenImportPrefix = "github.com/luxfi/corona"
fset := token.NewFileSet()
var violations []string
for _, f := range matches {
// Skip *this* file from the import scan because we name
// `github.com/luxfi/corona` in this comment / string
// literals as part of the death-test guard — the regex /
// fixture below would match itself otherwise.
base := filepath.Base(f)
if base == "corona_death_test.go" {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
file, err := parser.ParseFile(fset, f, src, parser.ImportsOnly|parser.ParseComments)
if err != nil {
t.Fatalf("parse imports %s: %v", f, err)
}
for _, imp := range file.Imports {
path := strings.Trim(imp.Path.Value, "\"")
if strings.HasPrefix(path, forbiddenImportPrefix) {
violations = append(violations,
base+": forbidden import "+path)
}
}
}
// Second pass: full AST parse (not imports-only) to catch
// `corona.GenerateKeys(...)` selector expressions that survive
// even when the import is renamed/aliased. We scan only the
// files whose imports passed the first pass — if a file has no
// corona import, it can still call corona.GenerateKeys via a
// dot-imported alias, which we reject below by name.
for _, f := range matches {
base := filepath.Base(f)
if base == "corona_death_test.go" {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
file, err := parser.ParseFile(fset, f, src, 0)
if err != nil {
// Non-imports-only parse can fail on intentionally
// invalid files (e.g. build-tag-gated stubs); skip.
continue
}
ast.Inspect(file, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
x, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
if x.Name == "corona" && sel.Sel.Name == "GenerateKeys" {
violations = append(violations,
base+": forbidden call corona.GenerateKeys")
}
return true
})
}
if len(violations) > 0 {
t.Fatalf("Quasar must not import corona directly after the "+
"Mar-3-2026 PQ-consensus architecture freeze. The kernel "+
"is consumed via github.com/luxfi/threshold (LSS-Pulsar "+
"and LSS-Lens adapters) only.\n\nViolations:\n %s",
strings.Join(violations, "\n "))
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"github.com/luxfi/crypto/threshold"
coronaThreshold "github.com/luxfi/corona/threshold"
coronaThreshold "github.com/luxfi/pulsar/threshold"
"github.com/stretchr/testify/require"
)
+1 -1
View File
@@ -240,7 +240,7 @@ func (h *Certifier) generateCert(block *Block) *QuasarCert {
return &QuasarCert{
BLS: blsData[:],
PQProof: pqData[:],
MLDSAProof: pqData[:],
Epoch: block.Height,
Finality: time.Now(),
Validators: len(h.validators),
+4 -4
View File
@@ -253,7 +253,7 @@ func TestBlock(t *testing.T) {
Timestamp: time.Now(),
Cert: &QuasarCert{
BLS: []byte("bls"),
PQProof: []byte("pq"),
MLDSAProof: []byte("pq"),
},
}
@@ -335,7 +335,7 @@ func TestQuantumFinality_Integration(t *testing.T) {
Timestamp: time.Now(),
Cert: &QuasarCert{
BLS: cert.BLSAgg,
PQProof: cert.PQCert,
MLDSAProof: cert.PQCert,
},
}
@@ -416,8 +416,8 @@ func BenchmarkCertVerifyWithKeys(b *testing.B) {
}
}
// NOTE: Corona post-quantum crypto tests are in the real corona package
// at github.com/luxfi/corona/threshold. The quasar package uses the real
// NOTE: Post-quantum threshold crypto tests live in the Pulsar package at
// github.com/luxfi/pulsar/threshold. The quasar package uses that real
// implementation via the Signer type in quasar.go.
func TestBundle(t *testing.T) {
+517 -44
View File
@@ -1,6 +1,27 @@
// Copyright (C) 2025, Lux Industries Inc All rights reserved.
// Epoch-based Corona key management for quantum-safe consensus.
// Keys rotate when validator sets change, with rate limiting for security.
// Epoch-based Pulsar share management for quantum-safe consensus.
//
// The cosmic vocabulary stack (see pulsar/DESIGN.md): a Pulsar group key
// is the persistent body that survives every validator-set rotation;
// only the share distribution rotates. Each Pulsar certificate emitted
// over a Quasar bundle is the "pulse" — one threshold signature under
// the unchanged GroupKey.
//
// Concretely: the master signing secret s and the public matrix A are
// preserved across every routine epoch (Refresh / Reshare). They change
// only at Reanchor — a rare governance event that opens a fresh
// KeyEra.
//
// This file owns the EpochManager — the consensus-layer driver that
// (a) bootstraps the first key era at chain genesis, (b) drives every
// subsequent rotation through the LSS-Pulsar adapter, and (c) wires
// the activation circuit-breaker so a chain never accepts a new
// committee whose shares fail to threshold-sign under the unchanged
// GroupKey.
//
// Rate limiting and validator-set change detection stay unchanged from
// the original RotateEpoch implementation — those are pure consensus
// policy and orthogonal to the share-rotation mechanics.
package quasar
@@ -9,12 +30,34 @@ import (
"encoding/binary"
"errors"
"fmt"
"math/big"
"sync"
"time"
coronaThreshold "github.com/luxfi/corona/threshold"
"github.com/luxfi/pulsar/keyera"
"github.com/luxfi/pulsar/primitives"
pulsarReshare "github.com/luxfi/pulsar/reshare"
pulsarSign "github.com/luxfi/pulsar/sign"
coronaThreshold "github.com/luxfi/pulsar/threshold"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/protocols/lss"
)
// signQ is the Pulsar lattice prime, re-exported here so the local
// Lambda recomputation does not require pulsar/sign to add a public
// accessor. Mirrors sign.Q at compile time.
var signQ = pulsarSign.Q
// pulsarComputeLagrange wraps primitives.ComputeLagrangeCoefficients so
// callers do not need to import pulsar/primitives directly. Returns
// Lagrange coefficients keyed by position in the input slice.
func pulsarComputeLagrange(r *ring.Ring, T []int, modulus *big.Int) []ring.Poly {
return primitives.ComputeLagrangeCoefficients(r, T, modulus)
}
const (
// MinEpochDuration is the minimum time between key rotations.
// Changing keys frequently makes quantum attacks harder since any
@@ -44,10 +87,20 @@ var (
ErrInvalidValidatorSet = errors.New("invalid validator set configuration")
)
// EpochManager manages Corona key epochs for the validator set.
// Fresh keys are generated when validators change, with rate limiting
// to prevent excessive key churn while still rotating frequently enough
// to frustrate quantum attacks.
// EpochManager drives the Pulsar share lifecycle for the validator set.
//
// At chain genesis the manager runs Bootstrap (one-time trusted-dealer
// ceremony confined to era 0). Every subsequent validator-set rotation
// is a SHARE rotation, not a key rotation: lss.DynamicResharePulsar
// preserves the GroupKey (A, bTilde) and the master secret s, and only
// rotates the share distribution. The activation circuit-breaker
// (pulsar/reshare.VerifyActivation) gates committing the new committee.
//
// Rate-limiting policy is unchanged from the original epoch design.
// History retention exists to support cross-epoch verification while
// the chain transitions; signatures from old epochs continue to
// verify under the unchanged GroupKey, so history is operationally a
// per-validator-set log rather than a per-key log.
type EpochManager struct {
mu sync.RWMutex
@@ -56,6 +109,13 @@ type EpochManager struct {
currentKeys *EpochKeys
lastKeygenTime time.Time
// currentEra is the persistent Pulsar key era. Bootstrap creates it
// once at genesis; every subsequent Reshare mutates currentEra.State
// in place (preserving currentEra.GroupKey). On Reanchor we discard
// the era and open a fresh one — currently unused; reanchor flows
// through governance and is wired via a separate API.
currentEra *keyera.KeyEra
// Historical epochs for signature verification
// We need to verify signatures from recent epochs during transitions
epochHistory map[uint64]*EpochKeys
@@ -64,9 +124,26 @@ type EpochManager struct {
// Validator set tracking
currentValidators []string
threshold int
// Chain identity bound into every resharing transcript. Defaults to
// "lux-quasar" if unset by the caller; production deployments should
// inject the chain-specific binding via SetChainID.
chainID []byte
groupID []byte
}
// EpochKeys holds the Corona keys for a specific epoch.
// EpochKeys holds the per-epoch Pulsar share state for a specific epoch.
//
// GroupKey is preserved across every Reshare within a key era. Shares
// rotate. Signers wrap each share for the 2-round Pulsar (Corona)
// signing protocol.
//
// Naming note: this type was historically called EpochKeys when each
// rotation generated a fresh keypair. With Pulsar resharing the type
// holds *shares* of one persistent key, but the public name is kept
// for byte-stable callers (BundleSigner, GroupedEpochManager,
// adversarial_test.go, etc.). Conceptually it is the per-epoch
// EpochShareState defined in pulsar/DESIGN.md.
type EpochKeys struct {
Epoch uint64
CreatedAt time.Time
@@ -77,6 +154,14 @@ type EpochKeys struct {
GroupKey *coronaThreshold.GroupKey
Shares map[string]*coronaThreshold.KeyShare
Signers map[string]*coronaThreshold.Signer
// LSS lifecycle fields. Generation increments by one on every
// successful Reshare under the same KeyEraID. RollbackFrom is
// nonzero only when this state descends from a Rollback. KeyEraID
// bumps only on Reanchor (rare governance event).
KeyEraID uint64
Generation uint64
RollbackFrom uint64
}
// NewEpochManager creates a new epoch manager.
@@ -89,11 +174,37 @@ func NewEpochManager(threshold int, historyLimit int) *EpochManager {
epochHistory: make(map[uint64]*EpochKeys),
historyLimit: historyLimit,
threshold: threshold,
chainID: []byte("lux-quasar"),
groupID: []byte{0},
}
}
// SetChainID binds the resharing transcript to a specific chain identity
// and Pulsar group. Production deployments should call this before
// InitializeEpoch so activation certs cannot be replayed across chains.
func (em *EpochManager) SetChainID(chainID, groupID []byte) {
em.mu.Lock()
defer em.mu.Unlock()
if len(chainID) > 0 {
em.chainID = append([]byte(nil), chainID...)
}
if len(groupID) > 0 {
em.groupID = append([]byte(nil), groupID...)
}
}
// InitializeEpoch creates the first epoch with the initial validator set.
// This should be called once at genesis or node startup.
//
// At chain genesis this runs the Pulsar Bootstrap ceremony — the one-time
// trusted-dealer step that forms the persistent GroupKey (A, bTilde) and
// distributes the initial shares. The dealer state is erased before
// returning. Subsequent rotations call RotateEpoch, which preserves
// GroupKey and only rotates the share distribution via the LSS-Pulsar
// adapter.
//
// Bootstrap is the only step where a master secret s exists in process
// memory. After this returns the node holds only public values and its
// own share.
func (em *EpochManager) InitializeEpoch(validators []string) (*EpochKeys, error) {
em.mu.Lock()
defer em.mu.Unlock()
@@ -107,13 +218,21 @@ func (em *EpochManager) InitializeEpoch(validators []string) (*EpochKeys, error)
ErrInvalidValidatorSet, em.threshold, len(validators))
}
keys, err := em.generateEpochKeys(0, validators)
era, err := keyera.Bootstrap(
em.threshold,
validators,
keyera.PulsarGroupID(0),
keyera.PulsarKeyEraID(1), // EraID 0 is reserved as "unset"
nil, // crypto/rand.Reader
)
if err != nil {
return nil, err
return nil, fmt.Errorf("pulsar bootstrap: %w", err)
}
keys := em.epochKeysFromState(0, validators, em.threshold, era.State, era.GroupKey)
em.currentEpoch = 0
em.currentKeys = keys
em.currentEra = era
em.currentValidators = validators
em.lastKeygenTime = time.Now()
em.epochHistory[0] = keys
@@ -121,9 +240,31 @@ func (em *EpochManager) InitializeEpoch(validators []string) (*EpochKeys, error)
return keys, nil
}
// RotateEpoch generates new keys for a new validator set.
// Returns ErrEpochRateLimited if called within MinEpochDuration of last rotation.
// Returns ErrNoValidatorChange if validator set hasn't changed (unless force=true).
// RotateEpoch transitions the chain to a new validator set by RESHARING
// the existing GroupKey to the new committee — never by generating a
// fresh keypair. The master secret s, the public matrix A, and the
// rounded public key bTilde are all preserved across this call.
//
// Two distinct flows live here:
//
// 1. First call (currentEra == nil): treated as a delayed Bootstrap
// for callers that did not invoke InitializeEpoch. Equivalent to
// InitializeEpoch but rate-limit-aware.
//
// 2. Subsequent calls: build a map[party.ID]*lss.PulsarConfig from the
// era's current shares, hand it to lss.DynamicResharePulsar, and
// gate acceptance on the activation circuit-breaker. The new
// committee threshold-signs the canonical activation message under
// the unchanged GroupKey; only when the signature verifies does
// this method commit the new state and return.
//
// Returns ErrEpochRateLimited if called within MinEpochDuration of the
// last rotation. Returns ErrNoValidatorChange if the validator set
// hasn't changed (unless force=true). Returns a wrapped activation
// error (pulsarReshare.ErrActivationFailed) if the new committee
// cannot collectively sign under the unchanged GroupKey — in which
// case the chain stays at the old epoch and the old committee
// continues signing.
func (em *EpochManager) RotateEpoch(validators []string, force bool) (*EpochKeys, error) {
em.mu.Lock()
defer em.mu.Unlock()
@@ -155,7 +296,7 @@ func (em *EpochManager) RotateEpoch(validators []string, force bool) (*EpochKeys
}
newEpoch := em.currentEpoch + 1
keys, err := em.generateEpochKeysWithThreshold(newEpoch, validators, effectiveThreshold)
keys, err := em.reshareEpochKeys(newEpoch, validators, effectiveThreshold)
if err != nil {
return nil, err
}
@@ -329,39 +470,307 @@ type EpochStats struct {
// Internal helpers
func (em *EpochManager) generateEpochKeys(epoch uint64, validators []string) (*EpochKeys, error) {
return em.generateEpochKeysWithThreshold(epoch, validators, em.threshold)
}
func (em *EpochManager) generateEpochKeysWithThreshold(epoch uint64, validators []string, threshold int) (*EpochKeys, error) {
n := len(validators)
// Generate Corona threshold keys
shares, groupKey, err := coronaThreshold.GenerateKeys(threshold, n, nil)
if err != nil {
return nil, fmt.Errorf("failed to generate Corona keys: %w", err)
// reshareEpochKeys drives the share rotation for a new validator set.
//
// First call (no era yet): runs Bootstrap and returns its initial state.
// This branch is the caller-friendly path for code that skipped
// InitializeEpoch — it keeps the rate-limit guard inline.
//
// Subsequent calls: builds the per-party PulsarConfig map from the
// current era's shares, calls lss.DynamicResharePulsar, then drives
// the new committee to threshold-sign the canonical activation message
// under the unchanged GroupKey. The activation cert is verified via
// pulsarReshare.VerifyActivation. The new state is committed only on
// successful verification.
func (em *EpochManager) reshareEpochKeys(epoch uint64, validators []string, threshold int) (*EpochKeys, error) {
if em.currentEra == nil {
era, err := keyera.Bootstrap(
threshold,
validators,
keyera.PulsarGroupID(0),
keyera.PulsarKeyEraID(1),
nil,
)
if err != nil {
return nil, fmt.Errorf("pulsar bootstrap (delayed): %w", err)
}
em.currentEra = era
return em.epochKeysFromState(epoch, validators, threshold, era.State, era.GroupKey), nil
}
era := em.currentEra
gkBefore := era.GroupKey
// Build per-party LSS-Pulsar configs from the current era state.
oldConfigs := make(map[party.ID]*lss.PulsarConfig, len(era.State.Validators))
for _, v := range era.State.Validators {
share, ok := era.State.Shares[v]
if !ok {
return nil, fmt.Errorf("pulsar reshare: missing share for old validator %s", v)
}
perParty := &keyera.EpochShareState{
KeyEraID: era.State.KeyEraID,
Generation: era.State.Generation,
RollbackFrom: era.State.RollbackFrom,
Epoch: era.State.Epoch,
Validators: era.State.Validators,
Threshold: era.State.Threshold,
Shares: map[string]*coronaThreshold.KeyShare{v: share},
}
oldConfigs[party.ID(v)] = &lss.PulsarConfig{State: perParty, PartyID: party.ID(v)}
}
newPartyIDs := make([]party.ID, len(validators))
for i, v := range validators {
newPartyIDs[i] = party.ID(v)
}
newConfigs, err := lss.DynamicResharePulsar(oldConfigs, newPartyIDs, threshold, nil, nil)
if err != nil {
return nil, fmt.Errorf("pulsar reshare (lss adapter): %w", err)
}
// Reassemble a single in-process state for the new committee. Each
// PulsarConfig holds only its own party's share; combine them into
// the era state so this node — which carries every share in the
// trusted-collaborator orchestration path — can drive activation
// signing on behalf of the committee.
var (
nextKeyEraID uint64
nextGeneration uint64
nextRollbackFrom uint64
nextEpoch uint64
nextThreshold int
)
combinedShares := make(map[string]*coronaThreshold.KeyShare, len(newPartyIDs))
for _, id := range newPartyIDs {
cfg := newConfigs[id]
if cfg == nil || cfg.State == nil {
return nil, fmt.Errorf("pulsar reshare: lss adapter returned no state for %s", id)
}
share, ok := cfg.State.Shares[string(id)]
if !ok {
return nil, fmt.Errorf("pulsar reshare: lss adapter returned no share for %s", id)
}
combinedShares[string(id)] = share
nextKeyEraID = cfg.State.KeyEraID
nextGeneration = cfg.State.Generation
nextRollbackFrom = cfg.State.RollbackFrom
nextEpoch = cfg.State.Epoch
nextThreshold = cfg.State.Threshold
}
nextState := &keyera.EpochShareState{
KeyEraID: nextKeyEraID,
Generation: nextGeneration,
RollbackFrom: nextRollbackFrom,
Epoch: nextEpoch,
Validators: append([]string(nil), validators...),
Threshold: nextThreshold,
Shares: combinedShares,
}
// Activation circuit-breaker. The new committee threshold-signs the
// canonical activation message under the UNCHANGED GroupKey. If the
// signature verifies, the chain commits; otherwise it stays at the
// old epoch (the caller treats the error as ErrActivationFailed and
// the old committee continues signing on the next round).
if err := em.verifyActivationLocked(gkBefore, era.State, nextState); err != nil {
return nil, fmt.Errorf("pulsar reshare activation: %w", err)
}
// Commit the new state to the era. Within a key era, era.GroupKey
// pointer is preserved.
era.State = nextState
return em.epochKeysFromState(epoch, validators, threshold, nextState, era.GroupKey), nil
}
// epochKeysFromState assembles an EpochKeys snapshot from a Pulsar
// EpochShareState plus the era's persistent GroupKey. The Signers map
// wraps each share for the 2-round Pulsar signing protocol, byte-stable
// with the historical EpochKeys layout.
func (em *EpochManager) epochKeysFromState(epoch uint64, validators []string, threshold int, state *keyera.EpochShareState, gk *coronaThreshold.GroupKey) *EpochKeys {
now := time.Now()
keys := &EpochKeys{
Epoch: epoch,
CreatedAt: now,
ExpiresAt: now.Add(MaxEpochDuration),
ValidatorSet: validators,
ValidatorSet: append([]string(nil), validators...),
Threshold: threshold,
TotalParties: n,
GroupKey: groupKey,
Shares: make(map[string]*coronaThreshold.KeyShare),
Signers: make(map[string]*coronaThreshold.Signer),
TotalParties: len(validators),
GroupKey: gk,
Shares: make(map[string]*coronaThreshold.KeyShare, len(validators)),
Signers: make(map[string]*coronaThreshold.Signer, len(validators)),
KeyEraID: state.KeyEraID,
Generation: state.Generation,
RollbackFrom: state.RollbackFrom,
}
for _, v := range validators {
share := state.Shares[v]
keys.Shares[v] = share
keys.Signers[v] = coronaThreshold.NewSigner(share)
}
return keys
}
// verifyActivationLocked drives the activation circuit-breaker for a
// resharing transition. The new committee threshold-signs the canonical
// activation message under the UNCHANGED GroupKey using their freshly-
// derived shares; the chain accepts the new state only when the
// signature verifies via pulsarReshare.VerifyActivation.
//
// In the trusted-collaborator orchestration path (this in-process
// EpochManager), every validator's share lives on this node, so the
// node can drive every Round1/Round2/Finalize step locally. In a
// distributed deployment, the round-based protocol at
// threshold/protocols/pulsar replaces this with per-party computation;
// the consensus mempool collects the partial signatures and the
// VerifyActivation check happens in the same shape.
//
// transcript and exchange hashes are bound to the chain identity
// (chainID, groupID), the era lineage (KeyEraID), and the
// generation/epoch tuple so that a malicious proposer cannot replay an
// activation cert across chains, eras, or generations.
func (em *EpochManager) verifyActivationLocked(gk *coronaThreshold.GroupKey, oldState, newState *keyera.EpochShareState) error {
// Threshold-sign the activation message under the new committee.
signers := make([]*coronaThreshold.Signer, 0, len(newState.Validators))
signerIndices := make([]int, 0, len(newState.Validators))
for _, v := range newState.Validators {
share, ok := newState.Shares[v]
if !ok {
return fmt.Errorf("activation: missing share for new validator %s", v)
}
signers = append(signers, coronaThreshold.NewSigner(share))
signerIndices = append(signerIndices, share.Index)
}
// Map shares to validator IDs
for i, validatorID := range validators {
keys.Shares[validatorID] = shares[i]
keys.Signers[validatorID] = coronaThreshold.NewSigner(shares[i])
// Sign the exact bytes the chain will verify. We don't yet have a
// distributed exchange transcript (no commits/complaints in the
// in-process path); the empty ReshareTranscript hashes deterministically
// to a fixed value the verifier reproduces.
tx := pulsarReshare.TranscriptInputs{
ChainID: em.chainID,
GroupID: em.groupID,
OldEpochID: oldState.Epoch,
NewEpochID: newState.Epoch,
OldSetHash: hashValidatorSetForActivation(oldState.Validators),
NewSetHash: hashValidatorSetForActivation(newState.Validators),
ThresholdOld: uint32(oldState.Threshold),
ThresholdNew: uint32(newState.Threshold),
GroupPublicKeyHash: hashGroupKey(gk),
Variant: "reshare",
}
exchange := pulsarReshare.ReshareTranscript{}
activation := pulsarReshare.ActivationMessage{
Transcript: tx,
ReshareTranscript: exchange,
}
// nil suite resolves to the production default Pulsar HashSuite.
msg := activation.SignableBytes(nil)
const sessionID = 0
prfKey := derivePRFKey(em.chainID, em.groupID, newState.KeyEraID, newState.Generation, newState.Epoch)
round1 := make(map[int]*coronaThreshold.Round1Data, len(signers))
for _, s := range signers {
// Round1 sets PartyID from the underlying share index.
r1 := s.Round1(sessionID, prfKey, signerIndices)
round1[r1.PartyID] = r1
}
round2 := make(map[int]*coronaThreshold.Round2Data, len(signers))
for _, s := range signers {
r2, err := s.Round2(sessionID, string(msg), prfKey, signerIndices, round1)
if err != nil {
return fmt.Errorf("activation: round2: %w", err)
}
round2[r2.PartyID] = r2
}
sig, err := signers[0].Finalize(round2)
if err != nil {
return fmt.Errorf("activation: finalize: %w", err)
}
return keys, nil
// VerifyActivation runs the chain-level circuit-breaker check.
// It re-derives both transcript hashes from cert.Message and
// confirms they match the chain's local view, then runs the
// supplied verifier callback against the unchanged GroupKey.
cert := &pulsarReshare.ActivationCert{
Message: activation,
Signature: nil, // opaque bytes; the verifier closure ignores Signature serialization here
}
localTranscriptHash := tx.Hash(nil)
localExchangeHash := exchange.Hash(nil)
verifier := func(message, _ []byte) bool {
// We hold the structured Pulsar Signature (sig) directly; the
// closure receives the same canonical message we just signed.
// The Signature reference is captured here; ActivationCert's
// opaque bytes are unused in the in-process path.
_ = message
return coronaThreshold.Verify(gk, string(msg), sig)
}
if err := pulsarReshare.VerifyActivation(cert, localTranscriptHash, localExchangeHash, nil, verifier); err != nil {
return err
}
return nil
}
// hashValidatorSetForActivation returns the canonical 32-byte hash of a validator set.
// Sorted-by-string ordering matches the OldSetHash/NewSetHash binding
// the activation transcript expects.
func hashValidatorSetForActivation(validators []string) [32]byte {
sorted := append([]string(nil), validators...)
// Insertion sort — committee size is bounded.
for i := 1; i < len(sorted); i++ {
for j := i; j > 0 && sorted[j-1] > sorted[j]; j-- {
sorted[j-1], sorted[j] = sorted[j], sorted[j-1]
}
}
h := sha256.New()
h.Write([]byte("quasar.validator-set.v1"))
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(sorted)))
h.Write(lenBuf[:])
for _, v := range sorted {
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(v)))
h.Write(lenBuf[:])
h.Write([]byte(v))
}
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// hashGroupKey returns a stable 32-byte digest of a Pulsar GroupKey for
// transcript binding. The current public Bytes() method returns a
// short-form summary; we wrap it in SHA-256 for fixed width.
func hashGroupKey(gk *coronaThreshold.GroupKey) [32]byte {
h := sha256.New()
h.Write([]byte("quasar.group-key.v1"))
if gk != nil {
h.Write(gk.Bytes())
}
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// derivePRFKey produces a 32-byte session-binding PRF key for the
// activation signing. Distinct from any block-level prfKey so an
// activation signature cannot be replayed for block-level signing.
func derivePRFKey(chainID, groupID []byte, keyEraID, generation, epoch uint64) []byte {
h := sha256.New()
h.Write([]byte("quasar.activation.prf.v1"))
h.Write(chainID)
h.Write(groupID)
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], keyEraID)
h.Write(buf[:])
binary.BigEndian.PutUint64(buf[:], generation)
h.Write(buf[:])
binary.BigEndian.PutUint64(buf[:], epoch)
h.Write(buf[:])
return h.Sum(nil)
}
func (em *EpochManager) validatorSetUnchanged(newValidators []string) bool {
@@ -587,8 +996,18 @@ func (bs *BundleSigner) LastBundle() *QuantumBundle {
return bs.lastBundle
}
// SignBundle performs the 2-round Corona signing for a bundle.
// This runs the full threshold signing protocol with participating validators.
// SignBundle performs the 2-round Pulsar (Corona) signing for a
// bundle. Runs the full threshold signing protocol with the
// participating validators.
//
// Each share's Lambda is recomputed for the participating subset
// before signing. Pulsar's keyera bakes a Lambda for the FULL
// committee at Bootstrap; when fewer parties sign, the Lagrange
// weights must be re-derived for the actual signing subset so the
// reconstructed signature evaluates the secret-sharing polynomial at
// 0 correctly. This is identical to how distributed Pulsar deployments
// recompute Lambda when a coordinator picks a quorum smaller than the
// full set.
func (bs *BundleSigner) SignBundle(
bundle *QuantumBundle,
sessionID int,
@@ -615,13 +1034,18 @@ func (bs *BundleSigner) SignBundle(
return fmt.Errorf("insufficient signers: %d < threshold %d", len(signerIndices), keys.Threshold)
}
subsetSigners, err := newSubsetSigners(keys, participatingValidators, signerIndices)
if err != nil {
return err
}
message := bundle.SignableMessage()
// Round 1: Collect commitments
round1Data := make(map[int]*coronaThreshold.Round1Data)
for _, v := range participatingValidators {
signer, exists := keys.Signers[v]
if !exists {
signer, ok := subsetSigners[v]
if !ok {
continue
}
r1 := signer.Round1(sessionID, prfKey, signerIndices)
@@ -631,8 +1055,8 @@ func (bs *BundleSigner) SignBundle(
// Round 2: Generate signature shares
round2Data := make(map[int]*coronaThreshold.Round2Data)
for _, v := range participatingValidators {
signer, exists := keys.Signers[v]
if !exists {
signer, ok := subsetSigners[v]
if !ok {
continue
}
r2, err := signer.Round2(sessionID, message, prfKey, signerIndices, round1Data)
@@ -644,8 +1068,8 @@ func (bs *BundleSigner) SignBundle(
// Finalize
firstValidator := participatingValidators[0]
signer := keys.Signers[firstValidator]
sig, err := signer.Finalize(round2Data)
finalSigner := subsetSigners[firstValidator]
sig, err := finalSigner.Finalize(round2Data)
if err != nil {
return fmt.Errorf("finalize failed: %w", err)
}
@@ -654,6 +1078,55 @@ func (bs *BundleSigner) SignBundle(
return nil
}
// newSubsetSigners produces a fresh set of Signers whose underlying
// shares carry Lambda recomputed for the participating subset.
//
// The per-share KeyShare has an Index field that the original keygen
// converted into an evaluation point (Index+1). When a strict subset
// signs, the Lagrange coefficient for each signer at the polynomial's
// constant term must be derived from the SUBSET evaluation points,
// not the full-committee evaluation points. We compute these on the
// fly and clone each KeyShare with the corrected Lambda.
//
// Side effects: this allocates one KeyShare clone and one Signer per
// participating validator. The original epoch state is not mutated.
func newSubsetSigners(keys *EpochKeys, participating []string, signerIndices []int) (map[string]*coronaThreshold.Signer, error) {
if len(participating) == 0 {
return nil, errors.New("no participating validators")
}
if keys == nil || keys.GroupKey == nil {
return nil, errors.New("missing group key")
}
r := keys.GroupKey.Params.R
q := big.NewInt(int64(signQ))
subsetLambda := pulsarComputeLagrange(r, signerIndices, q)
indexToSlot := make(map[int]int, len(signerIndices))
for slot, idx := range signerIndices {
indexToSlot[idx] = slot
}
out := make(map[string]*coronaThreshold.Signer, len(participating))
for _, v := range participating {
share, ok := keys.Shares[v]
if !ok {
continue
}
slot, ok := indexToSlot[share.Index]
if !ok {
continue
}
newLambda := r.NewPoly()
newLambda.Copy(subsetLambda[slot])
r.NTT(newLambda, newLambda)
r.MForm(newLambda, newLambda)
cloned := *share
cloned.Lambda = newLambda
out[v] = coronaThreshold.NewSigner(&cloned)
}
return out, nil
}
// VerifyBundle verifies a quantum bundle's Corona signature.
func (bs *BundleSigner) VerifyBundle(bundle *QuantumBundle) bool {
if bundle.Signature == nil {
+24 -9
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
coronaThreshold "github.com/luxfi/corona/threshold"
coronaThreshold "github.com/luxfi/pulsar/threshold"
"github.com/stretchr/testify/require"
)
@@ -276,15 +276,27 @@ func TestEpochManager_CrossEpochVerification(t *testing.T) {
require.Equal(t, uint64(1), em.GetCurrentEpoch())
// Signature from epoch 0 should still verify against epoch 0 keys
// Signature from epoch 0 still verifies against epoch 0 keys.
valid := em.VerifySignatureForEpoch(message, sig0, 0)
require.True(t, valid, "Epoch 0 signature should verify with epoch 0 keys")
// Should NOT verify against epoch 1 keys (different group key)
// Pulsar resharing preserves the GroupKey across epochs within a
// key era. Therefore an epoch 0 signature remains valid under
// epoch 1's GroupKey too — that is the entire point of the
// pulsar lifecycle (one persistent group public key, only the
// share distribution rotates). This is the new semantics relative
// to the deprecated fresh-DKG-per-epoch model. The chain
// distinguishes epochs by the metadata the signature is bound to,
// not by the group public key.
valid = em.VerifySignatureForEpoch(message, sig0, 1)
require.False(t, valid, "Epoch 0 signature should NOT verify with epoch 1 keys")
require.True(t, valid, "Epoch 0 signature SHOULD verify with epoch 1 keys (GroupKey preserved across reshare)")
t.Log("Cross-epoch verification working correctly")
// GroupKey pointer identity is preserved across the reshare.
keys1, err := em.GetEpochKeys(1)
require.NoError(t, err)
require.Same(t, keys0.GroupKey, keys1.GroupKey, "GroupKey must be preserved across reshare")
t.Log("Cross-epoch verification working correctly: GroupKey preserved across reshare")
}
func TestEpochManager_PruneHistory(t *testing.T) {
@@ -582,15 +594,18 @@ func TestQuasar_EpochSigningAfterRotation(t *testing.T) {
require.NoError(t, err)
require.Equal(t, uint64(1), q.GetCurrentEpoch())
// Epoch 0 signature should still verify with epoch 0 keys
// Epoch 0 signature should still verify with epoch 0 keys.
valid = q.epochManager.VerifySignatureForEpoch(message, sig0, 0)
require.True(t, valid, "Epoch 0 signature should still verify after rotation")
// But not with epoch 1 keys
// Pulsar resharing preserves the GroupKey across epochs within
// the same key era — see TestEpochManager_CrossEpochVerification
// for the long-form rationale. Hence an epoch 0 signature also
// verifies under epoch 1's keys.
valid = q.epochManager.VerifySignatureForEpoch(message, sig0, 1)
require.False(t, valid, "Epoch 0 signature should NOT verify with epoch 1 keys")
require.True(t, valid, "Epoch 0 signature should verify with epoch 1 keys (GroupKey preserved across reshare)")
t.Log("Signing and verification across epochs working correctly")
t.Log("Signing and verification across epochs working correctly: GroupKey preserved across reshare")
}
// ============================================================================
+21 -7
View File
@@ -19,7 +19,8 @@ import (
"sync"
"time"
coronaThreshold "github.com/luxfi/corona/threshold"
"github.com/luxfi/pulsar/keyera"
coronaThreshold "github.com/luxfi/pulsar/threshold"
)
const (
@@ -204,7 +205,13 @@ func (gem *GroupedEpochManager) assignToGroups(validators []string, seed []byte)
return groups
}
// generateGroupKeys creates Corona keys for a single group.
// generateGroupKeys creates Pulsar share state for a single group via
// the keyera Bootstrap ceremony. Each group is its own key era — the
// grouped layout partitions validators into smaller signing committees,
// each carrying an independent persistent GroupKey. Resharing across
// the outer validator-set rotation flows through the LSS-Pulsar adapter
// at the EpochManager level; per-group bootstrap is a one-time call
// when the group is first formed.
func (gem *GroupedEpochManager) generateGroupKeys(index int, validators []string) (*ValidatorGroup, error) {
n := len(validators)
t := gem.groupThreshold
@@ -212,7 +219,13 @@ func (gem *GroupedEpochManager) generateGroupKeys(index int, validators []string
t = n - 1
}
shares, groupKey, err := coronaThreshold.GenerateKeys(t, n, nil)
era, err := keyera.Bootstrap(
t,
validators,
keyera.PulsarGroupID(index),
keyera.PulsarKeyEraID(1),
nil,
)
if err != nil {
return nil, err
}
@@ -221,14 +234,15 @@ func (gem *GroupedEpochManager) generateGroupKeys(index int, validators []string
Index: index,
Validators: validators,
Threshold: t,
GroupKey: groupKey,
GroupKey: era.GroupKey,
Shares: make(map[string]*coronaThreshold.KeyShare),
Signers: make(map[string]*coronaThreshold.Signer),
}
for i, v := range validators {
group.Shares[v] = shares[i]
group.Signers[v] = coronaThreshold.NewSigner(shares[i])
for _, v := range validators {
share := era.State.Shares[v]
group.Shares[v] = share
group.Signers[v] = coronaThreshold.NewSigner(share)
}
return group, nil
+8 -4
View File
@@ -16,7 +16,12 @@ import (
"github.com/luxfi/crypto/threshold"
_ "github.com/luxfi/crypto/threshold/bls" // Register BLS threshold scheme
coronaThreshold "github.com/luxfi/corona/threshold"
// Pulsar threshold is the corrected lattice kernel that replaced
// upstream Corona. Type aliasing preserves the historical
// `coronaThreshold` identifier so this file's signing routines
// (Round1/Round2/Finalize) stay byte-stable while the underlying
// types come from pulsar/threshold.
coronaThreshold "github.com/luxfi/pulsar/threshold"
)
// Buffer pools for hot paths - reduces GC pressure during signing/verification
@@ -87,7 +92,7 @@ type Validator struct {
ID string
BLSPubKey *bls.PublicKey
CoronaPub []byte // Corona group public key contribution
MLDSAPubKey *mldsa.PublicKey // ML-DSA-65 identity key (nil if not configured)
MLDSAPubKey *mldsa.PublicKey // ML-DSA-65 identity key (nil if not configured)
Weight uint64
Active bool
}
@@ -451,7 +456,7 @@ func (s *signer) TripleSignRound1(ctx context.Context, validatorID string, messa
MLDSA: mldsaSig,
ValidatorID: validatorID,
IsThreshold: true,
SignerIndex: blsSigner.Index(),
SignerIndex: blsSigner.Index(),
}
return sig, round1Data, nil
@@ -872,7 +877,6 @@ func newSignerWithThresholdConfig(config ThresholdConfig) (*signer, error) {
return h, nil
}
// GenerateThresholdKeys generates threshold keys for a single scheme.
func GenerateThresholdKeys(schemeID threshold.SchemeID, t, n int) ([]threshold.KeyShare, threshold.PublicKey, error) {
scheme, err := threshold.GetScheme(schemeID)
+2 -2
View File
@@ -533,8 +533,8 @@ func BenchmarkMLDSAVerification(b *testing.B) {
// Corona Benchmarks (Reference)
// =============================================================================
// NOTE: Corona benchmarks are in the real corona package at
// github.com/luxfi/corona/threshold. The quasar package uses the real
// NOTE: Post-quantum threshold benchmarks live in the Pulsar package at
// github.com/luxfi/pulsar/threshold. The quasar package uses that real
// implementation via the Signer type in quasar.go.
// =============================================================================
+98
View File
@@ -0,0 +1,98 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quasar
// Quasar epoch resharing — LSS-Pulsar wiring.
//
// This file exposes the human-readable rotation entry points:
//
// - ReshareEpoch set rotation; t' may differ from t.
// - RefreshEpoch same set; same threshold; only share bytes rotate.
//
// Both call into RotateEpoch, whose body now performs Bootstrap on
// first invocation, dynamic resharing through the LSS-Pulsar adapter
// on subsequent invocations, and the activation circuit-breaker
// before committing the new state. See `epoch.go: reshareEpochKeys`
// for the lattice math integration; see `pulsar/DESIGN.md` for the
// architectural contract.
// ActivationMessagePersonalization is the canonical signing prefix
// for post-reshare activation certificates. Mirrors
// `ActivationMessage.SignableBytes()` in
// `github.com/luxfi/pulsar/reshare/activation.go`. Re-exported here
// so the consensus layer can name the prefix at the call site without
// a transitive Pulsar import.
const ActivationMessagePersonalization = "QUASAR-PULSAR-ACTIVATE-v1"
// TranscriptVariantReshare and TranscriptVariantRefresh are the
// canonical Variant strings expected by pulsar/reshare's
// TranscriptInputs. Bound into the transcript so an activation cert
// produced for one variant cannot be replayed for the other.
const (
TranscriptVariantReshare = "reshare"
TranscriptVariantRefresh = "refresh"
)
// ReshareTranscriptInputs is a pure-data shape mirroring pulsar/
// reshare.TranscriptInputs. Field semantics match the Pulsar source
// of truth exactly. The canonical 32-byte transcript hash is BLAKE3
// (legacy) or SHA3 (production) over the personalized concatenation
// of these fields with length prefixes; see pulsar/reshare/transcript
// for the wire format.
type ReshareTranscriptInputs struct {
ChainID []byte
GroupID []byte
OldEpochID uint64
NewEpochID uint64
OldSetHash [32]byte
NewSetHash [32]byte
ThresholdOld uint32
ThresholdNew uint32
GroupPublicKeyHash [32]byte
Variant string
}
// ReshareEpoch is the explicit name for a validator-set rotation
// under the Pulsar lifecycle. It performs a SHARE rotation under the
// unchanged GroupKey via lss.DynamicResharePulsar and gates the
// transition on the activation circuit-breaker.
//
// Naming rationale: "Rotate" reads as "swap to a different value",
// but with the Pulsar lifecycle the master secret s and the group
// public key DO NOT change — only the share distribution does.
// ReshareEpoch makes the new semantics explicit at the call site.
//
// The body delegates to RotateEpoch, which is the authoritative
// driver — the rate-limit guard, the change-detection guard, the
// LSS-Pulsar adapter call, and the activation cert verification all
// live there. Callers in core.go and adversarial_test.go SHOULD
// switch to ReshareEpoch for new code paths; the existing
// RotateEpoch signature is kept stable for compatibility.
func (em *EpochManager) ReshareEpoch(validators []string, force bool) (*EpochKeys, error) {
return em.RotateEpoch(validators, force)
}
// RefreshEpoch is the same-committee proactive update — the HJKY
// zero-polynomial primitive at pulsar/reshare.Refresh. It refreshes
// the share bytes within an unchanged validator set.
//
// Today's wiring delegates to RotateEpoch with force=true on the
// current validator set. Under that path lss.DynamicResharePulsar is
// called with newPartyIDs == oldPartyIDs and threshold unchanged,
// which yields a refresh-shaped rotation — a new share distribution
// for the same committee at the same threshold under the same
// GroupKey. A future commit can switch to a dedicated Refresh path
// in pulsar/reshare that uses the HJKY zero-polynomial primitive
// directly (smaller transcript, stronger forward security against
// mobile adversaries within an epoch).
//
// Use case: a chain runs RefreshEpoch on a periodic schedule (e.g.
// every Nth block within a stable validator set) to defeat mobile
// adversaries that compromise < t parties per period.
func (em *EpochManager) RefreshEpoch() (*EpochKeys, error) {
em.mu.RLock()
validators := append([]string{}, em.currentValidators...)
em.mu.RUnlock()
return em.RotateEpoch(validators, true)
}
+4 -4
View File
@@ -80,7 +80,7 @@ func TestCRITICAL1_ThresholdSigWithoutVerifier(t *testing.T) {
func TestCRITICAL2_QuasarCertVerifyRejectGarbage(t *testing.T) {
cert := &QuasarCert{
BLS: []byte("garbage-bls-not-a-real-signature"),
PQProof: []byte("garbage-pq-not-a-real-certificate"),
MLDSAProof: []byte("garbage-pq-not-a-real-certificate"),
Validators: 0,
}
@@ -99,15 +99,15 @@ func TestCRITICAL2_QuasarCertVerifyWithKeysNilCert(t *testing.T) {
// BLS or PQ fields are rejected.
func TestCRITICAL2_QuasarCertVerifyWithKeysEmptyFields(t *testing.T) {
// Empty BLS
cert := &QuasarCert{BLS: nil, PQProof: []byte("pq")}
cert := &QuasarCert{BLS: nil, MLDSAProof: []byte("pq")}
require.False(t, cert.VerifyWithKeys([]byte("key"), []byte("pq")))
// Empty PQ
cert = &QuasarCert{BLS: []byte("bls"), PQProof: nil}
cert = &QuasarCert{BLS: []byte("bls"), MLDSAProof: nil}
require.False(t, cert.VerifyWithKeys([]byte("key"), []byte("pq")))
// Empty group key
cert = &QuasarCert{BLS: []byte("bls"), PQProof: []byte("pq")}
cert = &QuasarCert{BLS: []byte("bls"), MLDSAProof: []byte("pq")}
require.False(t, cert.VerifyWithKeys(nil, []byte("pq")))
}
+1 -1
View File
@@ -665,7 +665,7 @@ func TestQuasarConsensusBasic(t *testing.T) {
cert := hybrid.generateCert(block)
require.NotNil(cert)
require.NotEmpty(cert.BLS)
require.NotEmpty(cert.PQProof)
require.NotEmpty(cert.MLDSAProof)
require.Equal(uint64(1), cert.Epoch)
// Verify certificate: Verify() now always returns false (requires VerifyWithKeys)
+44 -39
View File
@@ -11,37 +11,39 @@ import (
// Block represents a finalized block in the Quasar consensus.
// This is the primary block type used throughout the system.
type Block struct {
ID [32]byte // Unique block identifier
ChainID [32]byte // Chain this block belongs to
ChainName string // Human-readable chain name (e.g., "P-Chain", "X-Chain", "C-Chain")
Height uint64 // Block height
Hash string // Block hash
Timestamp time.Time // Block timestamp
Data []byte // Block payload data
ID [32]byte // Unique block identifier
ChainID [32]byte // Chain this block belongs to
ChainName string // Human-readable chain name (e.g., "P-Chain", "X-Chain", "C-Chain")
Height uint64 // Block height
Hash string // Block hash
Timestamp time.Time // Block timestamp
Data []byte // Block payload data
Cert *QuasarCert // Quantum certificate (nil if not finalized)
}
// QuasarCert is the finality certificate for Quasar consensus.
//
// Validators sign with three independent schemes (BLS, ML-DSA, Corona).
// Z-Chain generates a single Groth16 SNARK proving all three are valid:
// Three signature layers, each already compact on its own except ML-DSA:
//
// Statement: ∀ i ∈ [N]:
// BLS.Verify(bls_pk_i, msg, bls_σ_i) = 1 ∧
// ML-DSA.Verify(mldsa_pk_i, msg, mldsa_σ_i) = 1 ∧
// Corona.Verify(rt_pk, msg, rt_σ) = 1
// BLS12-381 — classical aggregate (48 bytes, O(1) via native BLS aggregation)
// Corona — lattice threshold signature (O(1) after DKG threshold signing)
// ML-DSA-65 — per-validator PQ identity (3309 bytes × N validators, O(N))
//
// The BLS aggregate is kept separately for fast classical verification (48 bytes).
// PQProof carries the Z-Chain ZKP (~192 bytes) proving BLS + ML-DSA + Corona.
// Z-Chain's sole job is to roll up ML-DSA × N into a single Groth16 SNARK:
//
// Compression: N * (48 + 3309 + RT) bytes → 48 + 192 bytes.
// Statement: ∀ i ∈ [N]: ML-DSA.Verify(mldsa_pk_i, msg, mldsa_σ_i) = 1
// Output: 192-byte Groth16 proof (MLDSAProof)
//
// BLS and Corona don't need Z-Chain — they're already O(1).
//
// Total cert size: 48 (BLS) + |Corona| + 192 (MLDSAProof), constant in N.
type QuasarCert struct {
BLS []byte // BLS aggregate (fast classical path, 48 bytes)
PQProof []byte // Z-Chain ZKP: Groth16 proving BLS ∧ ML-DSA ∧ Corona (~192 bytes)
Epoch uint64 // Epoch number
Finality time.Time // Time of finality
// Validators is the count of validators who signed the PQ proof.
Validators int `json:"validators,omitempty"`
BLS []byte // BLS12-381 aggregate (48 bytes, classical)
Corona []byte // Corona lattice threshold sig (O(1) after DKG)
MLDSAProof []byte // Z-Chain Groth16 rolling up N × ML-DSA identity sigs (192 bytes)
Epoch uint64 // Epoch number
Finality time.Time // Time of finality
Validators int `json:"validators,omitempty"` // Count of signing validators
}
// Verify checks structural presence of BLS and PQ certificates.
@@ -52,23 +54,24 @@ func (c *QuasarCert) Verify(validators []string) bool {
return false
}
// VerifyWithKeys performs cryptographic verification of the BLS aggregate
// signature against the provided group public key.
// pqKey is reserved for post-quantum certificate verification.
// VerifyWithKeys performs cryptographic verification of the certificate.
// A Quasar cert is finalized iff ALL three components verify in parallel:
// - BLS12-381 aggregate (classical, fast path)
// - Corona threshold sig (lattice PQ)
// - ML-DSA proof (FIPS 204 PQ)
//
// Defense in depth: classical AND two independent PQ schemes. Any single
// scheme broken does not break finality.
func (c *QuasarCert) VerifyWithKeys(groupKey []byte, pqKey []byte) bool {
if c == nil {
if c == nil || len(groupKey) == 0 {
return false
}
if len(c.BLS) == 0 || len(c.PQProof) == 0 {
if len(c.BLS) == 0 || len(c.Corona) == 0 || len(c.MLDSAProof) == 0 {
return false
}
if len(groupKey) == 0 {
return false
}
// Cryptographic BLS verification requires the full threshold verifier
// from the signer. This method provides the structural gate; real
// verification is done by the signer's VerifyAggregatedSignature path.
// Callers with access to the signer should use that instead.
// Real verification is delegated to the signer's full threshold verifier
// which runs all three checks in parallel goroutines via VerifyAggregatedSignature.
// This method provides the structural gate; callers with the signer use that path.
return false
}
@@ -126,13 +129,15 @@ type CoronaSignature struct {
// All three run in parallel via [signer.TripleSignRound1].
//
// Per-validator (collected during consensus, NOT stored in block):
// BLS: sign with BLS key → aggregate into 48 bytes (ECDL)
// Corona: sign with ring-LWE key → PQ threshold proof (Module-LWE)
// ML-DSA: sign with ML-DSA-65 key → PQ identity proof (Module-LWE + Module-SIS)
//
// BLS: sign with BLS key → aggregate into 48 bytes (ECDL)
// Corona: sign with ring-LWE key → PQ threshold proof (Module-LWE)
// ML-DSA: sign with ML-DSA-65 key → PQ identity proof (Module-LWE + Module-SIS)
//
// In QuasarCert (stored in block header):
// BLS aggregate: 48 bytes
// PQ proof: variable (aggregated Corona + ML-DSA, or future SNARK)
//
// BLS aggregate: 48 bytes
// PQ proof: variable (aggregated Corona + ML-DSA, or future SNARK)
type QuasarSignature struct {
BLS *BLSSignature // Classical fast path (aggregatable)
Corona *CoronaSignature // PQ anonymous path (ring-LWE threshold)
+162
View File
@@ -0,0 +1,162 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Parallel finality witness producers for Quasar (LP-020).
//
// Lux Quasar finality is layered, parallel witnesses. P-Chain BLS is the
// always-on finality witness. Q-Chain (Corona threshold) and Z-Chain
// (MLDSAGroth16 rollup) are independently toggleable parallel witnesses
// that produce additional finality artifacts at the same round-rate as P.
//
// Each round, the consensus driver computes a 32-byte round digest binding
// chain id, epoch, round, mode, and parent state. It then asks each enabled
// witness producer for that round's witness in parallel. Witnesses do not
// pipeline -- adding Q and/or Z does not change finality latency, only
// parallel verification cost.
package quasar
import (
"context"
"errors"
)
// RoundDigest is the 32-byte certificate subject for a consensus round.
// See LP-020 §2.3 (Definition: Certificate Subject).
type RoundDigest [32]byte
// ErrWitnessUnavailable is returned by a witness producer when the underlying
// chain cannot produce a witness for the given round (e.g. Q-Chain DKG not
// complete, Z-Chain prover missed deadline). The consensus driver downgrades
// to the next-lower witness set per operator policy when this occurs.
var ErrWitnessUnavailable = errors.New("parallel witness unavailable for round")
// PWitnessProducer produces P-Chain BLS aggregate witnesses. Always required.
//
// Implementations live in P-Chain's consensus path; this interface exists for
// symmetry with Q and Z and lets test harnesses substitute fakes.
type PWitnessProducer interface {
// Witness returns a BLS12-381 aggregate signature plus a signer bitmap
// over the round digest. Returns ErrWitnessUnavailable on quorum failure.
Witness(ctx context.Context, digest RoundDigest) (sig []byte, signers []byte, err error)
}
// QWitnessProducer produces Q-Chain Corona threshold-signature witnesses.
//
// The Q-Chain VM (chains/quantumvm) implements this by driving a 2-round
// Corona threshold ceremony per consensus round once a t-of-n DKG has
// produced the combined public key recorded in qchain_ceremony_root.
type QWitnessProducer interface {
// Witness returns a Corona threshold signature over the round digest,
// or ErrWitnessUnavailable if the ceremony fails or quorum is missed.
Witness(ctx context.Context, digest RoundDigest) ([]byte, error)
}
// ZWitnessProducer produces Z-Chain MLDSAGroth16 rollup witnesses.
//
// The Z-Chain VM (chains/zkvm) implements this by collecting per-validator
// ML-DSA-65 signatures over the round digest and producing a single Groth16
// proof attesting "for every i in [N], MLDSA.Verify(pk_i, digest, sig_i) = 1".
//
// The validator ML-DSA public-key list is bound to pchain_validator_root for
// the round; the prover takes those keys as a public input. The proof is
// verified by the Groth16 (bn254) precompile on Z-Chain.
type ZWitnessProducer interface {
// Witness returns a Groth16 proof aggregating per-validator ML-DSA-65
// signatures over the round digest. validatorMLDSAPubs is the canonical
// public-key list rooted in pchain_validator_root for the round.
Witness(ctx context.Context, digest RoundDigest, validatorMLDSAPubs [][]byte) ([]byte, error)
}
// WitnessSet bundles the witness producers configured for a network. Nil Q
// and/or Z producers are valid; the driver simply skips them and produces
// the corresponding lower-level certificate (PolicyQuorum, PolicyPQ, or
// PolicyPZ instead of PolicyQuantum).
type WitnessSet struct {
P PWitnessProducer
Q QWitnessProducer // optional
Z ZWitnessProducer // optional
}
// RoundWitnesses is the result of running a WitnessSet for one round.
// Q and/or Z may be nil if their producer was disabled, returned
// ErrWitnessUnavailable, or missed the round deadline.
type RoundWitnesses struct {
PSig []byte
PSigners []byte
Q []byte // nil if no Q producer or unavailable
Z []byte // nil if no Z producer or unavailable
}
// Run executes the configured witness producers in parallel against digest.
// P is mandatory: a P failure aborts the round. Q and Z run concurrently;
// if either returns ErrWitnessUnavailable (or any error), its slot is left
// nil and the round still finalizes at the next-lower witness level.
//
// The caller is responsible for bounding ctx with the round window.
func (ws WitnessSet) Run(ctx context.Context, digest RoundDigest, validatorMLDSAPubs [][]byte) (*RoundWitnesses, error) {
if ws.P == nil {
return nil, errors.New("WitnessSet: P producer required")
}
type qz struct {
sig []byte
err error
}
qch := make(chan qz, 1)
zch := make(chan qz, 1)
go func() {
if ws.Q == nil {
qch <- qz{}
return
}
sig, err := ws.Q.Witness(ctx, digest)
qch <- qz{sig: sig, err: err}
}()
go func() {
if ws.Z == nil {
zch <- qz{}
return
}
sig, err := ws.Z.Witness(ctx, digest, validatorMLDSAPubs)
zch <- qz{sig: sig, err: err}
}()
pSig, pSigners, err := ws.P.Witness(ctx, digest)
if err != nil {
return nil, err
}
q := <-qch
z := <-zch
out := &RoundWitnesses{PSig: pSig, PSigners: pSigners}
if q.err == nil {
out.Q = q.sig
}
if z.err == nil {
out.Z = z.sig
}
return out, nil
}
// DisabledZWitnessProducer is the explicit "no Z lane" sentinel producer.
// Networks that do not run a Z-Chain Groth16 prover (smaller deployments,
// classical-only configurations, or chains during their pre-Z bootstrap
// window) install this producer; the driver treats every call as
// ErrWitnessUnavailable and finalises at the next-lower witness set.
//
// The chains/zkvm package supplies the active-Z producer that proves
// "for each i, MLDSA.Verify(pk_i, digest, sig_i) = 1" via Groth16/bn254
// with the validator ML-DSA pubkey list as a public input. See LP-020 §6
// and proofs/quasar-cert-soundness.tex App. B for the R1CS constraint
// count and prover-cost analysis.
type DisabledZWitnessProducer struct{}
// Witness always returns ErrWitnessUnavailable so the driver downgrades
// uniformly with the rest of the optional-lane fallback path.
func (DisabledZWitnessProducer) Witness(ctx context.Context, digest RoundDigest, validatorMLDSAPubs [][]byte) ([]byte, error) {
return nil, ErrWitnessUnavailable
}