mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
Durable fix for the finality-equivocation bug class. Root cause: finality was
defined over the OUTER proposervm envelope id, and the in-memory finality ledger
was SEEDED from vm.LastAccepted on boot. Two outer envelopes wrapping the SAME
inner execution block looked like a fork -> fatal EQUIVOCATION (reportCertEquivocation
-> Logger.Crit -> Fatal -> os.Exit), halting mainnet 43h. They were duplicates.
Two layers, shipped together (atomic roll, all validators upgrade at once):
PART A - seed semantics. ChainConsensus.SyncState (the vm.LastAccepted reconcile)
now writes a NON-AUTHORITATIVE recovery HINT, never certified finality.
FinalityLedger separates a certified frontier (advanced ONLY by a verified QC or
bootstrap frontier-trust) from a hint anchor. GetFinalizedHeight is certified-only;
GetFinalizedTip/BuildAnchor return the higher of {certified tip, hint} for the VM
to build on. A locally-seeded sibling can never manufacture a finalized entry, so
it can never equivocate. The equal-height "different block => equivocation" check
in SyncState was the bug and is deleted.
PART B - QCv2 canonical cert. Finality is keyed on the inner EXECUTION commitment,
not the envelope. VotePosition carries both outer transport ids {BlockID, ParentID}
and canonical {CanonicalID, ParentCanonicalID, ExecutionStateRoot, PayloadRoot}.
byHeight indexes the CANONICAL id. Two certs at one height conflict IFF canonical
differs; same canonical / different envelope = duplicate alias (idempotent no-op).
CanonicalVoteMessage (tag LUX/chain/vote/v2, QuorumCertVersion=3) binds the
canonical identity and EXCLUDES the envelope, so validators that executed the same
inner block sign byte-identical messages even under different envelopes -> their
votes interoperate. The non-wrapped degrade (Empty canonical => bind BlockID) is
resolved in ONE place (canonicalVoteMessageFor). Canonical id is plumbed from
proposervm via the optional canonicalCommitter block iface + setCanonicalFromVM /
canonicalIDOf (falls back to b.ID for a bare block).
HARD GATE: the only writers of certified finalizedTip/byHeight are the cert fold
(Finalize, via ApplyCert/FinalizeBranch) and bootstrap frontier-trust
(AcceptBootstrapBlock). SyncState writes only the hint; preferredTip/build anchor
are build-only, never finality.
Proof suite (all green): 6-row regression matrix + invariant (incident_1082814_test.go),
canonical inversions (cert_canonical_inversion_test.go), two fuzz targets
(cert_fuzz_test.go: codec round-trip/malformed + finalize-decision invariant),
-race clean, go vet clean, staticcheck adds zero findings. 335 engine/chain tests
pass. Pre-fix repro captured (seed A + cert B at one height returned
ErrHeightAlreadyFinalized; now finalizes the canonical block, no equivocation).
No deploy/push/merge/tag — review-pending.
Co-authored-by: Hanzo Dev <[email protected]>
264 lines
7.9 KiB
Go
264 lines
7.9 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
// cert_codec.go — deterministic wire codec for the engine-level
|
||
// QuorumCert. A finality cert is gossiped so followers can verify α-of-K
|
||
// finality without re-collecting votes themselves; this is its on-wire form.
|
||
//
|
||
// All multi-byte integers are big-endian. Decoding is fail-closed on every
|
||
// short read and rejects trailing bytes, and caps the attacker-controlled vote
|
||
// count against the remaining buffer BEFORE any allocation (decode-DoS guard).
|
||
package chain
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/luxfi/ids"
|
||
)
|
||
|
||
// ErrQCWireCorrupt is returned by the decoder on any structural defect.
|
||
var ErrQCWireCorrupt = errors.New("chain: quorum cert wire corrupt")
|
||
|
||
// Wire layout (big-endian). The wire carries BOTH the canonical execution
|
||
// identity (the signed primary object) and the outer transport ids (the
|
||
// non-authoritative cache key used for block lookup/gossip):
|
||
//
|
||
// version:2
|
||
// type:1
|
||
// chain_id:32
|
||
// height:8
|
||
// round:4
|
||
// block_id:32 (OUTER/envelope — transport cache key; NOT signed)
|
||
// parent_id:32 (OUTER/envelope parent — transport; NOT signed)
|
||
// canonical_block_id:32 (inner execution commitment — PRIMARY; signed)
|
||
// parent_canonical_id:32 (inner parent commitment — signed)
|
||
// execution_state_root:32 (post-execution state root — signed)
|
||
// payload_root:32 (tx/payload root — signed)
|
||
// validator_set_root:32 (epoch/weighted-set commitment; Empty = unbound; signed)
|
||
// threshold:4
|
||
// vote_count:4
|
||
// then vote_count records, each:
|
||
// node_id:20 (ids.NodeID is a 20-byte value)
|
||
// accept:1
|
||
// sig_len:4 sig:sig_len
|
||
//
|
||
// The outer ids are encoded so a receiver can locate the block, but they are
|
||
// NOT part of CanonicalVoteMessage — a signature commits only to the canonical
|
||
// identity, so a cert is identical regardless of which envelope wrapped the
|
||
// inner block.
|
||
|
||
// qcVoteFixed is the fixed-size prefix of one encoded vote (node_id + accept +
|
||
// sig_len). Used to cap vote_count against the buffer before allocation.
|
||
const qcVoteFixed = ids.NodeIDLen + 1 + 4
|
||
|
||
// qcHeaderSize is the fixed header byte length preceding the vote records.
|
||
// (version + type + chain_id + height + round + block_id + parent_id +
|
||
// canonical_block_id + parent_canonical_id + execution_state_root + payload_root +
|
||
// validator_set_root + threshold + vote_count.)
|
||
const qcHeaderSize = 2 + 1 + 32 + 8 + 4 + 32 + 32 + 32 + 32 + 32 + 32 + 32 + 4 + 4
|
||
|
||
// MarshalBinary encodes the cert deterministically. Equal certs encode to equal
|
||
// bytes (votes are kept in the strictly-increasing order Assemble produced).
|
||
func (c *QuorumCert) MarshalBinary() ([]byte, error) {
|
||
if c == nil {
|
||
return nil, ErrQCNil
|
||
}
|
||
buf := make([]byte, 0, qcHeaderSize)
|
||
var u16 [2]byte
|
||
binary.BigEndian.PutUint16(u16[:], c.Version)
|
||
buf = append(buf, u16[:]...)
|
||
buf = append(buf, byte(c.Type))
|
||
buf = append(buf, c.Position.ChainID[:]...)
|
||
var u64 [8]byte
|
||
binary.BigEndian.PutUint64(u64[:], c.Position.Height)
|
||
buf = append(buf, u64[:]...)
|
||
var u32 [4]byte
|
||
binary.BigEndian.PutUint32(u32[:], c.Position.Round)
|
||
buf = append(buf, u32[:]...)
|
||
buf = append(buf, c.Position.BlockID[:]...)
|
||
buf = append(buf, c.Position.ParentID[:]...)
|
||
buf = append(buf, c.Position.CanonicalID[:]...)
|
||
buf = append(buf, c.Position.ParentCanonicalID[:]...)
|
||
buf = append(buf, c.Position.ExecutionStateRoot[:]...)
|
||
buf = append(buf, c.Position.PayloadRoot[:]...)
|
||
buf = append(buf, c.Position.ValidatorSetRoot[:]...)
|
||
binary.BigEndian.PutUint32(u32[:], c.Threshold)
|
||
buf = append(buf, u32[:]...)
|
||
binary.BigEndian.PutUint32(u32[:], uint32(len(c.Votes)))
|
||
buf = append(buf, u32[:]...)
|
||
|
||
for i := range c.Votes {
|
||
v := &c.Votes[i]
|
||
buf = append(buf, v.NodeID[:]...)
|
||
if v.Accept {
|
||
buf = append(buf, 0x01)
|
||
} else {
|
||
buf = append(buf, 0x00)
|
||
}
|
||
binary.BigEndian.PutUint32(u32[:], uint32(len(v.Signature)))
|
||
buf = append(buf, u32[:]...)
|
||
buf = append(buf, v.Signature...)
|
||
}
|
||
return buf, nil
|
||
}
|
||
|
||
// UnmarshalQuorumCert is the inverse of MarshalBinary. Strict trailing-bytes
|
||
// policy and fail-closed on every short read.
|
||
func UnmarshalQuorumCert(data []byte) (*QuorumCert, error) {
|
||
r := &qcWireReader{buf: data}
|
||
c := &QuorumCert{}
|
||
|
||
v16, err := r.u16()
|
||
if err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
c.Version = v16
|
||
t8, err := r.u8()
|
||
if err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
c.Type = QCType(t8)
|
||
if err = r.readIDInto(&c.Position.ChainID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if c.Position.Height, err = r.u64(); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if c.Position.Round, err = r.u32(); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.BlockID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.ParentID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.CanonicalID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.ParentCanonicalID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.ExecutionStateRoot); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.PayloadRoot); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.ValidatorSetRoot); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if c.Threshold, err = r.u32(); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
voteCount, err := r.u32()
|
||
if err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
|
||
// Cap vote_count against the remaining buffer BEFORE reserving capacity:
|
||
// each vote occupies at least qcVoteFixed bytes, so a count whose minimum
|
||
// footprint exceeds the bytes that remain is structurally impossible.
|
||
// Rejects an adversarial header (vote_count = 0xFFFFFFFF) in O(1).
|
||
if uint64(voteCount)*uint64(qcVoteFixed) > uint64(len(r.buf)) {
|
||
return nil, fmt.Errorf("%w: vote_count %d exceeds remaining buffer (%d bytes)",
|
||
ErrQCWireCorrupt, voteCount, len(r.buf))
|
||
}
|
||
c.Votes = make([]SignedVote, 0, voteCount)
|
||
for i := uint32(0); i < voteCount; i++ {
|
||
var sv SignedVote
|
||
if err = r.readNodeIDInto(&sv.NodeID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
ab, err := r.u8()
|
||
if err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
sv.Accept = ab != 0
|
||
if sv.Signature, err = r.lenPrefixed(); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
c.Votes = append(c.Votes, sv)
|
||
}
|
||
|
||
if len(r.buf) != 0 {
|
||
return nil, fmt.Errorf("%w: %d trailing bytes", ErrQCWireCorrupt, len(r.buf))
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
// qcWireReader is a bounds-checked sequential reader for the cert codec.
|
||
type qcWireReader struct{ buf []byte }
|
||
|
||
func (r *qcWireReader) need(n int) bool { return len(r.buf) >= n }
|
||
|
||
func (r *qcWireReader) u8() (uint8, error) {
|
||
if !r.need(1) {
|
||
return 0, ErrQCWireCorrupt
|
||
}
|
||
v := r.buf[0]
|
||
r.buf = r.buf[1:]
|
||
return v, nil
|
||
}
|
||
|
||
func (r *qcWireReader) 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 *qcWireReader) 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 *qcWireReader) 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 *qcWireReader) readIDInto(dst *ids.ID) error {
|
||
if !r.need(ids.IDLen) {
|
||
return ErrQCWireCorrupt
|
||
}
|
||
copy(dst[:], r.buf[:ids.IDLen])
|
||
r.buf = r.buf[ids.IDLen:]
|
||
return nil
|
||
}
|
||
|
||
func (r *qcWireReader) readNodeIDInto(dst *ids.NodeID) error {
|
||
if !r.need(ids.NodeIDLen) {
|
||
return ErrQCWireCorrupt
|
||
}
|
||
copy(dst[:], r.buf[:ids.NodeIDLen])
|
||
r.buf = r.buf[ids.NodeIDLen:]
|
||
return nil
|
||
}
|
||
|
||
func (r *qcWireReader) 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
|
||
}
|