mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
@@ -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
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
[](https://github.com/luxfi/consensus/actions)
|
||||
[](https://go.dev)
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -225,4 +225,3 @@ func GetAncestors(
|
||||
|
||||
return ancestors, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user