mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
Canonical finality cert: one policy-gated envelope (VerifyConsensusCert) with per-leg evidence modes — ThresholdSig (lattice O(1)), WeightedSigSet (independent FIPS sigs + weighted-Merkle quorum; the only mode for SLH-DSA, never seed-reconstructed), StarkCompressedSigSet (P3Q, same predicate, audit-gated), ClassicalAggregate (BLS, never satisfies a PQ leg). Required legs are policy-derived (CertPolicy.RequiredLegs), never cert bytes (Red H3 anti-downgrade); full-tuple domain binding. WeightedQuorumCert foundation: policy-as-parameter, message recomputed internally, strictly-increasing IDs, checked weight. 27 named envelope tests + F1-F5 + 6 CTO attacks PASS; verifier total (fuzz, zero panics); a fuzz-found threshold-sig flags-byte malleability fixed. Fixed go.sum [email protected] to canonical transparency-log hashes. Scrubbed ~54MB tracked binaries.
1029 lines
38 KiB
Go
1029 lines
38 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// quorum_cert.go — the verifiable weighted quorum certificate.
|
|
//
|
|
// A WeightedQuorumCert proves: a quorum-WEIGHT subset of the epoch
|
|
// validator set each produced a valid INDEPENDENT FIPS 204 ML-DSA /
|
|
// FIPS 205 SLH-DSA signature over the same domain-separated consensus
|
|
// message. The proof is the certificate itself — there is no succinct
|
|
// (STARK/SNARK) layer here; verification is direct (full-node-verifiable
|
|
// mode):
|
|
//
|
|
// for each signer record:
|
|
// (a) the record's leaf is included under validator_set_root (Merkle)
|
|
// (b) the record's parameter set is in the allowed set
|
|
// (c) the record's signature verifies under the stock FIPS verifier
|
|
// (d) validator_ids are STRICTLY INCREASING (distinct, canonical,
|
|
// anti-double-count)
|
|
// accumulate voting weight; then
|
|
// Σ weight ≥ quorum_threshold
|
|
// Σ weight == aggregate_weight
|
|
// commit(signer_ids) == signer_commitment
|
|
//
|
|
// Security posture (NIST Class N1): soundness rests ONLY on (i) unmodified
|
|
// FIPS 204/205 verification — each signer's signature is byte-identical to
|
|
// what cloudflare/circl, the NIST reference, or openssl-pq accept; and (ii)
|
|
// the weighted-validator-set Merkle commitment's second-preimage resistance
|
|
// (weighted_merkle.go). No private key material is shared, reconstructed,
|
|
// or combined. This is threshold CERTIFICATION, NOT threshold ML-DSA /
|
|
// SLH-DSA signing: there is no DKG, no seed / WOTS+ / FORS / secret share
|
|
// ever combined or exposed. A STARK compression layer over the SAME
|
|
// relation is a SEPARATE future backend (ProofBackendID 0x10/0x20 block);
|
|
// it is out of scope here.
|
|
//
|
|
// Permissionless: building a cert needs NO secrets — a prover collects
|
|
// already-produced signer records, sorts them, and assembles. Leaderless:
|
|
// any node with the records can build the identical cert.
|
|
package quasar
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/luxfi/consensus/config"
|
|
)
|
|
|
|
// QuorumCertVersion is the wire/struct version of the weighted quorum
|
|
// certificate. Bound into the signer commitment and the encodings so a
|
|
// version bump is non-malleable.
|
|
const QuorumCertVersion uint16 = 1
|
|
|
|
// signerCommitmentCustomization is the cSHAKE256 customization tag for the
|
|
// signer-set commitment. Wire-stable cryptographic constant.
|
|
const signerCommitmentCustomization = "QUASAR-WQC-SIGNERS-V1"
|
|
|
|
// signerCommitmentProtocolTag is the in-band redundant protocol tag for the
|
|
// signer-set commitment.
|
|
const signerCommitmentProtocolTag = "Quasar/WQC/Signers"
|
|
|
|
// QuorumSignerRecord is one signer's contribution to a weighted quorum
|
|
// certificate: their identity, the public key + accountability fields that
|
|
// MUST match the validator-set leaf, the Merkle path proving that leaf is
|
|
// committed, and their independent signature over the consensus message.
|
|
//
|
|
// Every field except MerklePath and Signature is also a validator-set leaf
|
|
// field; the verifier reconstructs the leaf from these and checks the path.
|
|
// A record whose embedded fields do not match a committed leaf fails the
|
|
// Merkle check — that is how an unknown validator / pubkey-mismatch /
|
|
// out-of-set record is rejected (cleanly, never a panic).
|
|
type QuorumSignerRecord struct {
|
|
// ValidatorID is the signer's canonical 32-byte identifier. Records are
|
|
// sorted strictly-increasing by this field.
|
|
ValidatorID [32]byte
|
|
|
|
// PublicKey is the signer's canonical public-key bytes. Bound into the
|
|
// leaf AND used as the verification key — both must agree.
|
|
PublicKey []byte
|
|
|
|
// VotingWeight is the signer's stake weight. Bound into the leaf and
|
|
// accumulated toward the quorum threshold.
|
|
VotingWeight uint64
|
|
|
|
// Scheme names the signature scheme this record was produced under
|
|
// (ML-DSA / SLH-DSA per FIPS 204 / 205). The verifier dispatches to the
|
|
// stock FIPS verifier for this scheme.
|
|
Scheme QuorumSchemeID
|
|
|
|
// ParamSetID is the parameter-set byte bound into the validator-set
|
|
// leaf. It MUST equal byte(Scheme) — the leaf commits to the parameter
|
|
// set, so a record cannot claim one scheme while the leaf pins another.
|
|
ParamSetID uint8
|
|
|
|
// KeyVersion is the signer's key-rotation counter, bound into the leaf.
|
|
KeyVersion uint32
|
|
|
|
// MerklePath proves leaf(ValidatorID, PublicKey, VotingWeight,
|
|
// ParamSetID, KeyVersion) is included under validator_set_root.
|
|
MerklePath *WeightedInclusionProof
|
|
|
|
// Signature is the signer's independent signature over the consensus
|
|
// message. Verified by the stock FIPS verifier for Scheme.
|
|
Signature []byte
|
|
}
|
|
|
|
// leaf reconstructs the validator-set leaf this record claims.
|
|
func (r *QuorumSignerRecord) leaf() WeightedValidatorLeaf {
|
|
return WeightedValidatorLeaf{
|
|
ValidatorID: r.ValidatorID,
|
|
PublicKey: r.PublicKey,
|
|
VotingWeight: r.VotingWeight,
|
|
ParameterSetID: r.ParamSetID,
|
|
KeyVersion: r.KeyVersion,
|
|
}
|
|
}
|
|
|
|
// WeightedQuorumCert is the certificate. It is NOT a signature: there is no
|
|
// "aggregate_signature" field, because nothing is aggregated — the cert
|
|
// carries (in full mode) the per-signer records, or (in compact mode) just
|
|
// the commitment to them. Verification is the predicate documented at the
|
|
// top of this file.
|
|
type WeightedQuorumCert struct {
|
|
// Version pins the cert format.
|
|
Version uint16
|
|
|
|
// Consensus position — the message-binding axes. These MUST equal the
|
|
// values the signers' message was derived from (quorum_message.go).
|
|
ChainID uint32
|
|
Epoch uint64
|
|
Height uint64
|
|
Round uint32
|
|
|
|
// ValueHash is the block_id / value hash this quorum finalises (the
|
|
// payload anchor of the consensus message).
|
|
ValueHash [32]byte
|
|
|
|
// QCType names the certificate's semantic role (prepare / commit /
|
|
// finality / …) so a signature for one role cannot be replayed as
|
|
// another. Bound into the consensus message.
|
|
QCType uint8
|
|
|
|
// ValidatorSetRoot is the weighted-validator-set commitment every
|
|
// signer's leaf is proven against (weighted_merkle.go). Equals the
|
|
// epoch's committed root.
|
|
ValidatorSetRoot [48]byte
|
|
|
|
// QuorumThreshold is the minimum total voting weight required for the
|
|
// cert to be valid. Σ signer weight must meet or exceed it.
|
|
QuorumThreshold uint64
|
|
|
|
// AggregateWeight is the prover's claimed total signer weight. The
|
|
// verifier recomputes Σ weight and asserts equality — a tampered
|
|
// aggregate is rejected.
|
|
AggregateWeight uint64
|
|
|
|
// SignerCommitment is the cSHAKE256 commitment to the ordered signer-id
|
|
// set (+ version + position). The verifier recomputes it from the
|
|
// records and asserts equality — closes signer-set malleability.
|
|
SignerCommitment [48]byte
|
|
|
|
// SignerCount is the number of signer records (the cardinality the
|
|
// compact encoding still commits to even when records are omitted).
|
|
SignerCount uint32
|
|
|
|
// Signers is the per-signer records. Populated in FULL mode (records in
|
|
// the certificate); EMPTY in COMPACT mode (records live in the DA layer,
|
|
// the header carries only the commitment). A compact cert is verified
|
|
// by re-attaching records from DA and calling Verify.
|
|
Signers []QuorumSignerRecord
|
|
}
|
|
|
|
// Typed verification errors. Each maps 1:1 to one predicate clause so a
|
|
// caller / test can name the exact failure. Every one is a CLEAN rejection
|
|
// (the cert is invalid); none is a panic or an unbounded-work DoS.
|
|
var (
|
|
ErrQCNil = errors.New("quasar: nil weighted quorum cert")
|
|
ErrQCVersion = errors.New("quasar: weighted quorum cert version mismatch")
|
|
ErrQCNoSigners = errors.New("quasar: weighted quorum cert has no signer records")
|
|
ErrQCCompactNoRecords = errors.New("quasar: compact cert carries no records; re-attach from DA before Verify")
|
|
ErrQCSignerCountMismatch = errors.New("quasar: signer record count does not match SignerCount")
|
|
ErrQCNotStrictlyIncreasing = errors.New("quasar: signer validator_ids are not strictly increasing (duplicate or unsorted)")
|
|
ErrQCSchemeNotAllowed = errors.New("quasar: signer scheme not in allowed set")
|
|
ErrQCParamSetMismatch = errors.New("quasar: signer ParamSetID does not equal scheme byte")
|
|
ErrQCPubKeyLen = errors.New("quasar: signer public key length wrong for its scheme")
|
|
ErrQCMerkleInclusion = errors.New("quasar: signer leaf not included under validator_set_root")
|
|
ErrQCSigInvalid = errors.New("quasar: signer signature failed FIPS verification")
|
|
ErrQCBelowThreshold = errors.New("quasar: total signer weight below quorum threshold")
|
|
ErrQCAggregateWeight = errors.New("quasar: recomputed signer weight != AggregateWeight")
|
|
ErrQCSignerCommitment = errors.New("quasar: recomputed signer commitment != SignerCommitment")
|
|
ErrQCWeightOverflow = errors.New("quasar: signer weight sum overflows uint64")
|
|
ErrQCThresholdZero = errors.New("quasar: quorum threshold is zero")
|
|
ErrQCMinThresholdUnset = errors.New("quasar: verifier MinThreshold is unset (zero); a cert may not assert its own security parameter — fail closed")
|
|
ErrQCThresholdBelowFloor = errors.New("quasar: cert QuorumThreshold is below the chain BFT quorum floor (MinThreshold)")
|
|
ErrQCProofBackendMismatch = errors.New("quasar: envelope proof backend does not match the cert's ProofBackendID")
|
|
)
|
|
|
|
// QuorumVerifierConfig pins the policy axes the verifier enforces. Kept
|
|
// separate from the cert so verification policy (what's allowed) and the
|
|
// cert (what's claimed) are not braided.
|
|
type QuorumVerifierConfig struct {
|
|
// AllowedSchemes is the set of per-signer schemes admissible on this
|
|
// chain. A record whose scheme is absent fails (ErrQCSchemeNotAllowed).
|
|
// Empty means "every scheme the registry knows" — convenient for tests;
|
|
// production callers pin an explicit set via the chain profile.
|
|
AllowedSchemes map[QuorumSchemeID]bool
|
|
|
|
// Context is the FIPS context string passed to every per-signer verify.
|
|
// It MUST match what signers bound at sign time. Length ≤ 255.
|
|
//
|
|
// Applied per-scheme: ML-DSA (FIPS 204) records verify under this context;
|
|
// SLH-DSA (FIPS 205) records ALWAYS verify under the empty context,
|
|
// because the only SLH-DSA sign path here (magnetar.ValidatorSign) binds
|
|
// the empty FIPS-205 context and the round-digest message already carries
|
|
// the full domain binding. See contextForScheme.
|
|
Context []byte
|
|
|
|
// MinThreshold is the chain's BFT quorum floor — the minimum voting
|
|
// weight any valid cert on this chain MUST assert. Verify FAILS CLOSED if
|
|
// MinThreshold is zero (unset): a certificate may not be the sole source
|
|
// of its own security parameter, so the verifier MUST pin the floor from
|
|
// the chain profile. A cert whose QuorumThreshold is below this floor is
|
|
// rejected (ErrQCThresholdBelowFloor) regardless of how many signatures it
|
|
// carries — this is the mandatory defence against sub-quorum finality
|
|
// forgery, independent of (and in addition to) the threshold's binding
|
|
// into the signed message.
|
|
MinThreshold uint64
|
|
}
|
|
|
|
// schemeAllowed reports whether scheme is admissible under this config.
|
|
func (c *QuorumVerifierConfig) schemeAllowed(scheme QuorumSchemeID) bool {
|
|
if len(c.AllowedSchemes) == 0 {
|
|
_, known := defaultQuorumVerifiers[scheme]
|
|
return known
|
|
}
|
|
return c.AllowedSchemes[scheme]
|
|
}
|
|
|
|
// Verify checks the certificate against the chain envelope under cfg. Returns
|
|
// nil iff every predicate clause holds; otherwise a typed error naming the
|
|
// first failure. NEVER panics and NEVER does unbounded work — an adversarial
|
|
// cert (unknown validator, bad signature, tampered weight, forged threshold)
|
|
// yields a clean error, not a node crash.
|
|
//
|
|
// envelope carries the chain's pinned posture axes (profile, hash suite,
|
|
// schemes, proof backend/format/verifier, network). Verify rebuilds the
|
|
// domain-separated signing message ITSELF from the envelope plus the cert's
|
|
// own consensus-position fields (via QuorumMessageForCert) — it NEVER trusts
|
|
// a caller-supplied opaque message. This closes the role-replay / position
|
|
// mismatch surface: a cert whose signers signed a different
|
|
// (chain_id, epoch, height, round, value_hash, qc_type, validator_set_root,
|
|
// quorum_threshold) fails the per-signer FIPS check for every record, and a
|
|
// cert that lies about any of those fails because the rebuilt message no
|
|
// longer matches the signatures.
|
|
//
|
|
// Verify additionally asserts the envelope's proof-backend axis equals the
|
|
// cert's own ProofBackendID(), so a cert produced under the direct
|
|
// weighted-quorum backend cannot be verified under a different backend's
|
|
// envelope (and vice versa).
|
|
func (c *WeightedQuorumCert) Verify(envelope QuorumMessageEnvelope, cfg QuorumVerifierConfig) error {
|
|
if c == nil {
|
|
return ErrQCNil
|
|
}
|
|
// Bind the verification envelope to the cert's trust model: the caller's
|
|
// envelope axis MUST name the same proof backend this cert is produced
|
|
// under. Folds the round-digest proof-backend axis check into Verify so a
|
|
// cross-backend envelope is rejected before any message is built.
|
|
if envelope.ProofBackend != c.ProofBackendID() {
|
|
return fmt.Errorf("%w: envelope=0x%02x cert=0x%02x",
|
|
ErrQCProofBackendMismatch, uint8(envelope.ProofBackend), uint8(c.ProofBackendID()))
|
|
}
|
|
// Rebuild the signing message from the envelope + the cert's own position
|
|
// fields. The verifier owns message construction — it does not trust a
|
|
// caller-supplied message.
|
|
message, err := QuorumMessageForCert(envelope, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.verifyWithMessage(message, cfg)
|
|
}
|
|
|
|
// verifyWithMessage is the core weighted-quorum predicate over an already-
|
|
// built domain-separated message. It is INTERNAL: production callers MUST use
|
|
// Verify, which constructs the message itself from the chain envelope so a
|
|
// caller can never substitute an opaque message that skips the position /
|
|
// threshold / proof-backend binding. This helper carries only the predicate
|
|
// clauses + the mandatory threshold floor; it is exercised directly by tests
|
|
// that need to drive a deliberately-mismatched message.
|
|
func (c *WeightedQuorumCert) verifyWithMessage(message []byte, cfg QuorumVerifierConfig) error {
|
|
if c == nil {
|
|
return ErrQCNil
|
|
}
|
|
if c.Version != QuorumCertVersion {
|
|
return fmt.Errorf("%w: got %d want %d", ErrQCVersion, c.Version, QuorumCertVersion)
|
|
}
|
|
// Mandatory threshold floor (fail-closed). A cert may not be the sole
|
|
// source of its own security parameter: the verifier MUST pin the chain's
|
|
// BFT quorum floor, and a cert asserting a threshold below it is rejected
|
|
// regardless of signature count — the defence-in-depth that closes
|
|
// sub-quorum finality forgery even if a signed-message binding were
|
|
// somehow bypassed.
|
|
if cfg.MinThreshold == 0 {
|
|
return ErrQCMinThresholdUnset
|
|
}
|
|
if c.QuorumThreshold == 0 {
|
|
return ErrQCThresholdZero
|
|
}
|
|
if c.QuorumThreshold < cfg.MinThreshold {
|
|
return fmt.Errorf("%w: threshold %d floor %d",
|
|
ErrQCThresholdBelowFloor, c.QuorumThreshold, cfg.MinThreshold)
|
|
}
|
|
if c.SignerCount == 0 {
|
|
return ErrQCNoSigners
|
|
}
|
|
if len(c.Signers) == 0 {
|
|
// Compact cert: records were stripped to the DA layer. Cannot verify
|
|
// the predicate without them. Caller must re-attach records first.
|
|
return ErrQCCompactNoRecords
|
|
}
|
|
if uint32(len(c.Signers)) != c.SignerCount {
|
|
return fmt.Errorf("%w: records=%d SignerCount=%d",
|
|
ErrQCSignerCountMismatch, len(c.Signers), c.SignerCount)
|
|
}
|
|
|
|
var total uint64
|
|
var prev [32]byte
|
|
havePrev := false
|
|
|
|
for i := range c.Signers {
|
|
rec := &c.Signers[i]
|
|
|
|
// Clause (d): strictly increasing validator_ids. Distinct +
|
|
// canonical order; closes duplicate-signer double counting and
|
|
// signer-set malleability (re-ordering a valid cert).
|
|
if havePrev && !bytesLess(prev[:], rec.ValidatorID[:]) {
|
|
return fmt.Errorf("%w: record %d", ErrQCNotStrictlyIncreasing, i)
|
|
}
|
|
prev = rec.ValidatorID
|
|
havePrev = true
|
|
|
|
// Clause (b): scheme in the allowed set.
|
|
if !cfg.schemeAllowed(rec.Scheme) {
|
|
return fmt.Errorf("%w: record %d scheme %s", ErrQCSchemeNotAllowed, i, rec.Scheme)
|
|
}
|
|
|
|
// ParamSetID must equal the scheme byte — the leaf commits to the
|
|
// parameter set, so a record cannot claim ML-DSA-65 while its leaf
|
|
// pins ML-DSA-44 (cross-parameter confusion).
|
|
if rec.ParamSetID != uint8(rec.Scheme) {
|
|
return fmt.Errorf("%w: record %d param=0x%02x scheme=0x%02x",
|
|
ErrQCParamSetMismatch, i, rec.ParamSetID, uint8(rec.Scheme))
|
|
}
|
|
|
|
// Public-key length must match the scheme before any dispatch.
|
|
wantLen, known := expectedPubKeyLen(rec.Scheme)
|
|
if !known {
|
|
return fmt.Errorf("%w: record %d scheme %s", ErrQCSchemeNotAllowed, i, rec.Scheme)
|
|
}
|
|
if len(rec.PublicKey) != wantLen {
|
|
return fmt.Errorf("%w: record %d scheme %s have=%d want=%d",
|
|
ErrQCPubKeyLen, i, rec.Scheme, len(rec.PublicKey), wantLen)
|
|
}
|
|
|
|
// Clause (a): Merkle inclusion under validator_set_root. This is
|
|
// where an unknown validator / pubkey-mismatch / out-of-set record
|
|
// is rejected — its reconstructed leaf is not in the committed tree.
|
|
if !VerifyWeightedInclusion(c.ValidatorSetRoot, c.Epoch, rec.leaf(), rec.MerklePath) {
|
|
return fmt.Errorf("%w: record %d", ErrQCMerkleInclusion, i)
|
|
}
|
|
|
|
// Clause (c): unmodified FIPS verify. The dispatch is a thin call
|
|
// into the stock FIPS 204 / 205 verifier for the scheme. The FIPS
|
|
// context is selected PER SCHEME (contextForScheme): ML-DSA records
|
|
// verify under cfg.Context; SLH-DSA records verify under the empty
|
|
// FIPS-205 context, matching magnetar.ValidatorSign's empty-ctx sign.
|
|
// Without this, a mixed ML-DSA ∧ SLH-DSA cert would only verify when
|
|
// cfg.Context is nil (the SLH-DSA leg would reject a non-empty ctx).
|
|
verify := defaultQuorumVerifiers[rec.Scheme]
|
|
if verify == nil || !verify(rec.PublicKey, message, contextForScheme(cfg, rec.Scheme), rec.Signature) {
|
|
return fmt.Errorf("%w: record %d scheme %s", ErrQCSigInvalid, i, rec.Scheme)
|
|
}
|
|
|
|
// Accumulate weight (overflow-checked).
|
|
if total+rec.VotingWeight < total {
|
|
return ErrQCWeightOverflow
|
|
}
|
|
total += rec.VotingWeight
|
|
}
|
|
|
|
// Σ weight ≥ quorum_threshold.
|
|
if total < c.QuorumThreshold {
|
|
return fmt.Errorf("%w: have %d need %d", ErrQCBelowThreshold, total, c.QuorumThreshold)
|
|
}
|
|
|
|
// Σ weight == aggregate_weight (tamper check).
|
|
if total != c.AggregateWeight {
|
|
return fmt.Errorf("%w: recomputed %d claimed %d", ErrQCAggregateWeight, total, c.AggregateWeight)
|
|
}
|
|
|
|
// commit(signer_ids) == signer_commitment (signer-set malleability).
|
|
want := c.computeSignerCommitment()
|
|
if want != c.SignerCommitment {
|
|
return ErrQCSignerCommitment
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// computeSignerCommitment recomputes the cSHAKE256 commitment over the
|
|
// ordered signer set plus the cert's version and consensus position.
|
|
// Binding the position fields here means a cert that lies about its own
|
|
// (chain_id, epoch, height, round, value_hash, qc_type, validator_set_root,
|
|
// quorum_threshold) fails the commitment check even before the per-signer
|
|
// message binding catches it — defence-in-depth against header tampering.
|
|
//
|
|
// Per signer it binds validator_id AND the Merkle (leaf_index, leaf_count).
|
|
// The PRIMARY canonicality guarantee for (leaf_index, leaf_count) is enforced
|
|
// cryptographically in the weighted-Merkle commitment itself: leaf_count is
|
|
// folded into every leaf digest (computeWeightedLeafHash), so a record that
|
|
// relabels its count within a shape-class (e.g. (0,3)→(0,4), both shape "RR")
|
|
// recomputes a different leaf digest and FAILS the Merkle inclusion clause
|
|
// against validator_set_root — exactly one (leaf_index, leaf_count) verifies
|
|
// per committed tree (QUASAR-C5). Binding the pair here as well is
|
|
// defence-in-depth: it makes the commitment field itself total and tamper-
|
|
// evident over the Merkle position, so any position malleation is caught at
|
|
// the commitment check too, not only at the inclusion clause.
|
|
//
|
|
// Layout (TupleHash256, customization "QUASAR-WQC-SIGNERS-V1"):
|
|
//
|
|
// parts[0] = "Quasar/WQC/Signers"
|
|
// parts[1] = version (2 BE)
|
|
// parts[2] = chain_id (4 BE)
|
|
// parts[3] = epoch (8 BE)
|
|
// parts[4] = height (8 BE)
|
|
// parts[5] = round (4 BE)
|
|
// parts[6] = value_hash ([32]byte)
|
|
// parts[7] = qc_type (1)
|
|
// parts[8] = validator_set_root ([48]byte)
|
|
// parts[9] = quorum_threshold (8 BE)
|
|
// parts[10] = signer_count (4 BE)
|
|
// then, per record i in order, three parts:
|
|
// validator_id[i] ([32]byte)
|
|
// leaf_index[i] (4 BE)
|
|
// leaf_count[i] (4 BE)
|
|
func (c *WeightedQuorumCert) computeSignerCommitment() [48]byte {
|
|
var u16 [2]byte
|
|
var u32 [4]byte
|
|
var u64 [8]byte
|
|
|
|
binary.BigEndian.PutUint16(u16[:], c.Version)
|
|
verBytes := append([]byte(nil), u16[:]...)
|
|
binary.BigEndian.PutUint32(u32[:], c.ChainID)
|
|
chainBytes := append([]byte(nil), u32[:]...)
|
|
binary.BigEndian.PutUint64(u64[:], c.Epoch)
|
|
epochBytes := append([]byte(nil), u64[:]...)
|
|
binary.BigEndian.PutUint64(u64[:], c.Height)
|
|
heightBytes := append([]byte(nil), u64[:]...)
|
|
binary.BigEndian.PutUint32(u32[:], c.Round)
|
|
roundBytes := append([]byte(nil), u32[:]...)
|
|
binary.BigEndian.PutUint64(u64[:], c.QuorumThreshold)
|
|
threshBytes := append([]byte(nil), u64[:]...)
|
|
binary.BigEndian.PutUint32(u32[:], c.SignerCount)
|
|
countBytes := append([]byte(nil), u32[:]...)
|
|
|
|
parts := make([][]byte, 0, 11+3*len(c.Signers))
|
|
parts = append(parts,
|
|
[]byte(signerCommitmentProtocolTag),
|
|
verBytes,
|
|
chainBytes,
|
|
epochBytes,
|
|
heightBytes,
|
|
roundBytes,
|
|
c.ValueHash[:],
|
|
[]byte{c.QCType},
|
|
c.ValidatorSetRoot[:],
|
|
threshBytes,
|
|
countBytes,
|
|
)
|
|
for i := range c.Signers {
|
|
// Bind the Merkle position (leaf_index, leaf_count). A nil path (a
|
|
// structurally-incomplete record that will fail the Merkle clause
|
|
// anyway) binds the (0,0) sentinel so the commitment is still total.
|
|
var leafIdx, leafCount uint32
|
|
if p := c.Signers[i].MerklePath; p != nil {
|
|
leafIdx, leafCount = p.LeafIndex, p.LeafCount
|
|
}
|
|
var idxBytes, cntBytes [4]byte
|
|
binary.BigEndian.PutUint32(idxBytes[:], leafIdx)
|
|
binary.BigEndian.PutUint32(cntBytes[:], leafCount)
|
|
parts = append(parts,
|
|
c.Signers[i].ValidatorID[:],
|
|
append([]byte(nil), idxBytes[:]...),
|
|
append([]byte(nil), cntBytes[:]...),
|
|
)
|
|
}
|
|
|
|
out := tupleHash256RoundDigest(parts, 48, signerCommitmentCustomization)
|
|
var h [48]byte
|
|
copy(h[:], out)
|
|
return h
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Prover (permissionless — needs NO secrets)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// QuorumCertParams names the consensus position + policy a cert is built
|
|
// for. Separate from the records so the prover's two inputs (what we're
|
|
// certifying / who signed) are not braided.
|
|
type QuorumCertParams struct {
|
|
ChainID uint32
|
|
Epoch uint64
|
|
Height uint64
|
|
Round uint32
|
|
ValueHash [32]byte
|
|
QCType uint8
|
|
ValidatorSetRoot [48]byte
|
|
QuorumThreshold uint64
|
|
}
|
|
|
|
// BuildWeightedQuorumCert assembles a FULL (records-included) certificate
|
|
// from already-produced signer records. It is permissionless and
|
|
// deterministic: NO secrets, NO randomness, NO hidden state. Given the same
|
|
// records and params, the same cert bytes come out.
|
|
//
|
|
// The prover sorts records strictly-increasing by validator_id (rejecting
|
|
// duplicates), computes aggregate_weight, and computes the signer
|
|
// commitment. It does NOT verify the records' signatures — assembly is
|
|
// orthogonal to verification (a relaying node assembles; verifiers verify).
|
|
// It DOES reject a structurally impossible cert (no records, duplicate ids,
|
|
// zero threshold) so a malformed cert is never produced.
|
|
func BuildWeightedQuorumCert(params QuorumCertParams, records []QuorumSignerRecord) (*WeightedQuorumCert, error) {
|
|
if params.QuorumThreshold == 0 {
|
|
return nil, ErrQCThresholdZero
|
|
}
|
|
if len(records) == 0 {
|
|
return nil, ErrQCNoSigners
|
|
}
|
|
|
|
sorted := make([]QuorumSignerRecord, len(records))
|
|
copy(sorted, records)
|
|
// Defensive copy of variable-length fields so a later caller mutation
|
|
// cannot move the assembled cert underneath us.
|
|
for i := range sorted {
|
|
sorted[i].PublicKey = append([]byte(nil), sorted[i].PublicKey...)
|
|
sorted[i].Signature = append([]byte(nil), sorted[i].Signature...)
|
|
}
|
|
sortSignerRecords(sorted)
|
|
|
|
var total uint64
|
|
for i := range sorted {
|
|
if i > 0 && sorted[i].ValidatorID == sorted[i-1].ValidatorID {
|
|
return nil, fmt.Errorf("%w: %x", ErrWVSetDuplicateID, sorted[i].ValidatorID[:])
|
|
}
|
|
if total+sorted[i].VotingWeight < total {
|
|
return nil, ErrQCWeightOverflow
|
|
}
|
|
total += sorted[i].VotingWeight
|
|
}
|
|
|
|
cert := &WeightedQuorumCert{
|
|
Version: QuorumCertVersion,
|
|
ChainID: params.ChainID,
|
|
Epoch: params.Epoch,
|
|
Height: params.Height,
|
|
Round: params.Round,
|
|
ValueHash: params.ValueHash,
|
|
QCType: params.QCType,
|
|
ValidatorSetRoot: params.ValidatorSetRoot,
|
|
QuorumThreshold: params.QuorumThreshold,
|
|
AggregateWeight: total,
|
|
SignerCount: uint32(len(sorted)),
|
|
Signers: sorted,
|
|
}
|
|
cert.SignerCommitment = cert.computeSignerCommitment()
|
|
return cert, nil
|
|
}
|
|
|
|
// sortSignerRecords sorts records strictly by validator_id ascending.
|
|
func sortSignerRecords(recs []QuorumSignerRecord) {
|
|
// insertion-free stable sort via the std sort on a closure would alloc;
|
|
// validator IDs are public so a simple comparison sort is fine.
|
|
for i := 1; i < len(recs); i++ {
|
|
for j := i; j > 0 && bytesLess(recs[j].ValidatorID[:], recs[j-1].ValidatorID[:]); j-- {
|
|
recs[j], recs[j-1] = recs[j-1], recs[j]
|
|
}
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Compact ↔ full (DA hybrid mode)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// Compact returns a copy of the cert with the per-signer records stripped —
|
|
// the commitment-only form a block header carries. The raw signer records
|
|
// live in the data-availability layer; a verifier re-attaches them with
|
|
// WithRecords before calling Verify. The SignerCommitment + SignerCount are
|
|
// retained, so the header still binds exactly which signer set the cert
|
|
// claims (a DA layer cannot substitute a different record set without
|
|
// failing the commitment check on re-attach).
|
|
func (c *WeightedQuorumCert) Compact() *WeightedQuorumCert {
|
|
cp := *c
|
|
cp.Signers = nil
|
|
return &cp
|
|
}
|
|
|
|
// IsCompact reports whether the cert is in commitment-only form (no
|
|
// records). A compact cert cannot be verified until records are re-attached.
|
|
func (c *WeightedQuorumCert) IsCompact() bool {
|
|
return len(c.Signers) == 0 && c.SignerCount > 0
|
|
}
|
|
|
|
// WithRecords re-attaches signer records to a compact cert (records fetched
|
|
// from the DA layer). It does NOT trust the records: WithRecords binds them
|
|
// in, and a subsequent Verify recomputes the signer commitment — if the DA
|
|
// layer served a different record set than the header committed to, Verify
|
|
// returns ErrQCSignerCommitment. Returns a copy; the receiver is unmodified.
|
|
func (c *WeightedQuorumCert) WithRecords(records []QuorumSignerRecord) *WeightedQuorumCert {
|
|
cp := *c
|
|
cp.Signers = make([]QuorumSignerRecord, len(records))
|
|
copy(cp.Signers, records)
|
|
return &cp
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Wire codec — full and compact encodings
|
|
// ----------------------------------------------------------------------------
|
|
//
|
|
// Compact header layout (deterministic, big-endian):
|
|
//
|
|
// kind:1 = wqcKindCompact (0x00) | wqcKindFull (0x01)
|
|
// version:2
|
|
// chain_id:4
|
|
// epoch:8
|
|
// height:8
|
|
// round:4
|
|
// value_hash:32
|
|
// qc_type:1
|
|
// validator_set_root:48
|
|
// quorum_threshold:8
|
|
// aggregate_weight:8
|
|
// signer_commitment:48
|
|
// signer_count:4
|
|
//
|
|
// Full layout = compact header (kind=0x01) followed by signer_count signer
|
|
// records, each:
|
|
//
|
|
// validator_id:32
|
|
// scheme:1
|
|
// param_set_id:1
|
|
// key_version:4
|
|
// voting_weight:8
|
|
// pubkey_len:4 pubkey:pubkey_len
|
|
// sig_len:4 sig:sig_len
|
|
// merkle_leaf_index:4
|
|
// merkle_leaf_count:4
|
|
// merkle_step_count:4
|
|
// step[i]: flags:1 (bit0=promoted, bit1=sibling_is_right) [+ sibling:48 if !promoted]
|
|
|
|
const (
|
|
wqcKindCompact byte = 0x00
|
|
wqcKindFull byte = 0x01
|
|
)
|
|
|
|
// wqcHeaderSize is the fixed compact-header byte length.
|
|
const wqcHeaderSize = 1 + 2 + 4 + 8 + 8 + 4 + 32 + 1 + 48 + 8 + 8 + 48 + 4
|
|
|
|
// wqcMinRecordBytes is the SMALLEST possible on-wire size of one signer
|
|
// record: the fixed fields with a zero-length public key, a zero-length
|
|
// signature, and a zero-step Merkle path. Used to cap an attacker-controlled
|
|
// signer_count against the remaining buffer BEFORE any allocation, so a
|
|
// header claiming signer_count = 0xFFFFFFFF cannot force a multi-hundred-GB
|
|
// reservation (decode-DoS). Layout, per the full-record comment above:
|
|
//
|
|
// validator_id:32 scheme:1 param_set_id:1 key_version:4 voting_weight:8
|
|
// pubkey_len:4 sig_len:4 merkle_leaf_index:4 merkle_leaf_count:4
|
|
// merkle_step_count:4
|
|
const wqcMinRecordBytes = 32 + 1 + 1 + 4 + 8 + 4 + 4 + 4 + 4 + 4 // = 66
|
|
|
|
// ErrQCWireCorrupt is returned by the decoders on any structural defect.
|
|
var ErrQCWireCorrupt = errors.New("quasar: weighted quorum cert wire corrupt")
|
|
|
|
// MarshalBinary encodes the cert. If records are present it emits the FULL
|
|
// form; otherwise the COMPACT form. Deterministic: equal certs encode to
|
|
// equal bytes.
|
|
func (c *WeightedQuorumCert) MarshalBinary() ([]byte, error) {
|
|
if c == nil {
|
|
return nil, ErrQCNil
|
|
}
|
|
full := len(c.Signers) > 0
|
|
kind := wqcKindCompact
|
|
if full {
|
|
kind = wqcKindFull
|
|
}
|
|
|
|
buf := make([]byte, 0, wqcHeaderSize)
|
|
buf = append(buf, kind)
|
|
buf = appendU16(buf, c.Version)
|
|
buf = appendU32(buf, c.ChainID)
|
|
buf = appendU64(buf, c.Epoch)
|
|
buf = appendU64(buf, c.Height)
|
|
buf = appendU32(buf, c.Round)
|
|
buf = append(buf, c.ValueHash[:]...)
|
|
buf = append(buf, c.QCType)
|
|
buf = append(buf, c.ValidatorSetRoot[:]...)
|
|
buf = appendU64(buf, c.QuorumThreshold)
|
|
buf = appendU64(buf, c.AggregateWeight)
|
|
buf = append(buf, c.SignerCommitment[:]...)
|
|
buf = appendU32(buf, c.SignerCount)
|
|
|
|
if !full {
|
|
return buf, nil
|
|
}
|
|
|
|
for i := range c.Signers {
|
|
rec := &c.Signers[i]
|
|
buf = append(buf, rec.ValidatorID[:]...)
|
|
buf = append(buf, byte(rec.Scheme))
|
|
buf = append(buf, rec.ParamSetID)
|
|
buf = appendU32(buf, rec.KeyVersion)
|
|
buf = appendU64(buf, rec.VotingWeight)
|
|
buf = appendU32(buf, uint32(len(rec.PublicKey)))
|
|
buf = append(buf, rec.PublicKey...)
|
|
buf = appendU32(buf, uint32(len(rec.Signature)))
|
|
buf = append(buf, rec.Signature...)
|
|
|
|
if rec.MerklePath == nil {
|
|
return nil, fmt.Errorf("%w: record %d nil merkle path", ErrQCWireCorrupt, i)
|
|
}
|
|
buf = appendU32(buf, rec.MerklePath.LeafIndex)
|
|
buf = appendU32(buf, rec.MerklePath.LeafCount)
|
|
buf = appendU32(buf, uint32(len(rec.MerklePath.Steps)))
|
|
for _, st := range rec.MerklePath.Steps {
|
|
var flags byte
|
|
if st.Promoted {
|
|
flags |= 0x01
|
|
}
|
|
if st.SiblingIsRight {
|
|
flags |= 0x02
|
|
}
|
|
buf = append(buf, flags)
|
|
if !st.Promoted {
|
|
buf = append(buf, st.Sibling[:]...)
|
|
}
|
|
}
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
// UnmarshalWeightedQuorumCert is the inverse of MarshalBinary. Strict
|
|
// trailing-bytes policy: any byte after the declared frame rejects the
|
|
// input. Fail-closed on every short read.
|
|
func UnmarshalWeightedQuorumCert(data []byte) (*WeightedQuorumCert, error) {
|
|
r := &qcReader{buf: data}
|
|
kind, err := r.u8()
|
|
if err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if kind != wqcKindCompact && kind != wqcKindFull {
|
|
return nil, fmt.Errorf("%w: bad kind 0x%02x", ErrQCWireCorrupt, kind)
|
|
}
|
|
|
|
c := &WeightedQuorumCert{}
|
|
if c.Version, err = r.u16(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if c.ChainID, err = r.u32(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if c.Epoch, err = r.u64(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if c.Height, err = r.u64(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if c.Round, err = r.u32(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if err = r.read32(&c.ValueHash); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if c.QCType, err = r.u8(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if err = r.read48(&c.ValidatorSetRoot); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if c.QuorumThreshold, err = r.u64(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if c.AggregateWeight, err = r.u64(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if err = r.read48(&c.SignerCommitment); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if c.SignerCount, err = r.u32(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
|
|
if kind == wqcKindCompact {
|
|
if len(r.buf) != 0 {
|
|
return nil, fmt.Errorf("%w: %d trailing bytes (compact)", ErrQCWireCorrupt, len(r.buf))
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// Full: decode signer_count records. Cap signer_count against the
|
|
// remaining buffer BEFORE reserving capacity: each record occupies at
|
|
// least wqcMinRecordBytes, so a count whose minimum footprint exceeds the
|
|
// bytes that remain is structurally impossible. This rejects an
|
|
// adversarial header (signer_count = 0xFFFFFFFF → ~446 GB reservation) in
|
|
// O(1) with no allocation — mirrors the step_count cap below.
|
|
if uint64(c.SignerCount)*wqcMinRecordBytes > uint64(len(r.buf)) {
|
|
return nil, fmt.Errorf("%w: signer_count %d exceeds remaining buffer (%d bytes)",
|
|
ErrQCWireCorrupt, c.SignerCount, len(r.buf))
|
|
}
|
|
c.Signers = make([]QuorumSignerRecord, 0, c.SignerCount)
|
|
for i := uint32(0); i < c.SignerCount; i++ {
|
|
var rec QuorumSignerRecord
|
|
if err = r.read32(&rec.ValidatorID); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
sch, err := r.u8()
|
|
if err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
rec.Scheme = QuorumSchemeID(sch)
|
|
if rec.ParamSetID, err = r.u8(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if rec.KeyVersion, err = r.u32(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if rec.VotingWeight, err = r.u64(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if rec.PublicKey, err = r.lenPrefixed(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if rec.Signature, err = r.lenPrefixed(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
|
|
var path WeightedInclusionProof
|
|
if path.LeafIndex, err = r.u32(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
if path.LeafCount, err = r.u32(); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
stepCount, err := r.u32()
|
|
if err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
// Bound the step count: a Merkle path over N leaves is ≤ ceil(log2 N)
|
|
// + a small constant; cap at 64 (supports 2^64 leaves) so a malicious
|
|
// header cannot force a huge allocation.
|
|
if stepCount > 64 {
|
|
return nil, fmt.Errorf("%w: step count %d too large", ErrQCWireCorrupt, stepCount)
|
|
}
|
|
path.Steps = make([]WeightedProofStep, 0, stepCount)
|
|
for s := uint32(0); s < stepCount; s++ {
|
|
flags, err := r.u8()
|
|
if err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
step := WeightedProofStep{
|
|
Promoted: flags&0x01 != 0,
|
|
SiblingIsRight: flags&0x02 != 0,
|
|
}
|
|
if !step.Promoted {
|
|
if err = r.read48(&step.Sibling); err != nil {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
}
|
|
path.Steps = append(path.Steps, step)
|
|
}
|
|
rec.MerklePath = &path
|
|
c.Signers = append(c.Signers, rec)
|
|
}
|
|
|
|
if len(r.buf) != 0 {
|
|
return nil, fmt.Errorf("%w: %d trailing bytes (full)", ErrQCWireCorrupt, len(r.buf))
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// qcReader is a bounds-checked sequential reader for the cert codec.
|
|
type qcReader struct{ buf []byte }
|
|
|
|
func (r *qcReader) need(n int) bool { return len(r.buf) >= n }
|
|
|
|
func (r *qcReader) u8() (uint8, error) {
|
|
if !r.need(1) {
|
|
return 0, ErrQCWireCorrupt
|
|
}
|
|
v := r.buf[0]
|
|
r.buf = r.buf[1:]
|
|
return v, nil
|
|
}
|
|
|
|
func (r *qcReader) u16() (uint16, error) {
|
|
if !r.need(2) {
|
|
return 0, ErrQCWireCorrupt
|
|
}
|
|
v := binary.BigEndian.Uint16(r.buf[:2])
|
|
r.buf = r.buf[2:]
|
|
return v, nil
|
|
}
|
|
|
|
func (r *qcReader) u32() (uint32, error) {
|
|
if !r.need(4) {
|
|
return 0, ErrQCWireCorrupt
|
|
}
|
|
v := binary.BigEndian.Uint32(r.buf[:4])
|
|
r.buf = r.buf[4:]
|
|
return v, nil
|
|
}
|
|
|
|
func (r *qcReader) u64() (uint64, error) {
|
|
if !r.need(8) {
|
|
return 0, ErrQCWireCorrupt
|
|
}
|
|
v := binary.BigEndian.Uint64(r.buf[:8])
|
|
r.buf = r.buf[8:]
|
|
return v, nil
|
|
}
|
|
|
|
func (r *qcReader) read32(dst *[32]byte) error {
|
|
if !r.need(32) {
|
|
return ErrQCWireCorrupt
|
|
}
|
|
copy(dst[:], r.buf[:32])
|
|
r.buf = r.buf[32:]
|
|
return nil
|
|
}
|
|
|
|
func (r *qcReader) read48(dst *[48]byte) error {
|
|
if !r.need(48) {
|
|
return ErrQCWireCorrupt
|
|
}
|
|
copy(dst[:], r.buf[:48])
|
|
r.buf = r.buf[48:]
|
|
return nil
|
|
}
|
|
|
|
func (r *qcReader) lenPrefixed() ([]byte, error) {
|
|
n, err := r.u32()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if uint64(n) > uint64(len(r.buf)) {
|
|
return nil, ErrQCWireCorrupt
|
|
}
|
|
out := make([]byte, n)
|
|
copy(out, r.buf[:n])
|
|
r.buf = r.buf[n:]
|
|
return out, nil
|
|
}
|
|
|
|
// Equal reports structural equality of two certs (used in round-trip
|
|
// tests). Compares all fields including the per-signer records.
|
|
func (c *WeightedQuorumCert) Equal(o *WeightedQuorumCert) bool {
|
|
if c == nil || o == nil {
|
|
return c == o
|
|
}
|
|
if c.Version != o.Version || c.ChainID != o.ChainID || c.Epoch != o.Epoch ||
|
|
c.Height != o.Height || c.Round != o.Round || c.ValueHash != o.ValueHash ||
|
|
c.QCType != o.QCType || c.ValidatorSetRoot != o.ValidatorSetRoot ||
|
|
c.QuorumThreshold != o.QuorumThreshold || c.AggregateWeight != o.AggregateWeight ||
|
|
c.SignerCommitment != o.SignerCommitment || c.SignerCount != o.SignerCount ||
|
|
len(c.Signers) != len(o.Signers) {
|
|
return false
|
|
}
|
|
for i := range c.Signers {
|
|
a, b := &c.Signers[i], &o.Signers[i]
|
|
if a.ValidatorID != b.ValidatorID || a.Scheme != b.Scheme ||
|
|
a.ParamSetID != b.ParamSetID || a.KeyVersion != b.KeyVersion ||
|
|
a.VotingWeight != b.VotingWeight ||
|
|
!bytes.Equal(a.PublicKey, b.PublicKey) || !bytes.Equal(a.Signature, b.Signature) {
|
|
return false
|
|
}
|
|
if !equalProof(a.MerklePath, b.MerklePath) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func equalProof(a, b *WeightedInclusionProof) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
if a.LeafIndex != b.LeafIndex || a.LeafCount != b.LeafCount || len(a.Steps) != len(b.Steps) {
|
|
return false
|
|
}
|
|
for i := range a.Steps {
|
|
if a.Steps[i].Promoted != b.Steps[i].Promoted ||
|
|
a.Steps[i].SiblingIsRight != b.Steps[i].SiblingIsRight ||
|
|
a.Steps[i].Sibling != b.Steps[i].Sibling {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ProofBackendID returns the canonical wire ProofBackendID for a direct
|
|
// weighted quorum certificate. A caller assembling the round-digest
|
|
// envelope (quorum_message.go) MUST stamp this backend so the signed
|
|
// transcript binds the full-node-verifiable trust model — a cert produced
|
|
// under this backend cannot be re-presented under a STARK backend's
|
|
// envelope.
|
|
func (c *WeightedQuorumCert) ProofBackendID() config.ProofBackendID {
|
|
return config.ProofBackendDirectWeightedQuorum
|
|
}
|
|
|
|
// ProofFormatID returns the canonical wire ProofFormatID for the full
|
|
// (records-included) weighted quorum cert encoding.
|
|
func (c *WeightedQuorumCert) ProofFormatID() config.ProofFormatID {
|
|
return config.ProofFormatDirectWeightedQuorumV1
|
|
}
|
|
|
|
// VerifierID returns the canonical pinned VerifierID for the direct
|
|
// weighted quorum predicate.
|
|
func (c *WeightedQuorumCert) VerifierID() config.VerifierID {
|
|
return config.VerifierDirectWeightedQuorumPQ
|
|
}
|