mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
engine/chain: v1.36 two-tier accept — Nova (majority) accept vs Quasar (⅔-stake) export
Split the single ⅔-stake accept threshold into two orthogonal finality-ladder tiers so a
3/5-live foundation keeps producing while certification pauses (the "survive 3/5, majority
is enough" mandate). Before, VM.Accept was gated on one ⅔ cert (bftAlpha = ⌊2n/3⌋+1); at
3/5 (60% < ⅔) that cert can never assemble and the fleet froze the instant a 2nd node dropped.
Nova = LOCAL ACCEPT. NovaQuorum(n)=⌊n/2⌋+1 distinct signers; drives VM.Accept.
Crash-fault-safe by majority intersection (stake-INDEPENDENT — do not add a
stake gate or 3/5 liveness breaks when the absent minority holds the stake).
NOT Byzantine-safe: a lone equivocator can fork a bare majority, so two Nova
certs are possible (expected, non-exportable) and equivocation HALTS fail-closed
via the ledger per-height gate (ErrHeightAlreadyFinalized) — never a silent EVM reorg.
Quasar = EXPORT. strict >⅔ of stake by distinct signers (config.TwoThirdsStakeFloor).
Byzantine-fork-safe (2α−n>f). The ONLY tier bridges / DEX 0x9999 / cross-chain /
validator-set transitions may consume.
Decomplected — one threshold per place:
- QuorumCert carries Tier (Nova|Quasar); VerifyWeighted selects the threshold by tier
(majority-of-ValidatorCount for Nova, ⅔-by-stake for Quasar). The tier is NOT bound into
the signed vote (votes are tier-agnostic), so it cannot be forged upward — VerifyWeighted
re-derives the real threshold from the authoritative set. Fails closed on an unresolved set
(n<1) so NovaQuorum(0)=1 can never let a lone node self-accept (the 1085013 fork class).
- assembleCertLocked assembles a NOVA cert (NovaQuorum) for the accept path; the zero-cert
refusal (acceptWithCertCore) and the lone-finalizer resolved-set guard are preserved.
- A trailing QuasarAttestor (post-accept, engine/chain/attestation.go) assembles the ⅔-stake
export cert from the SAME accept votes. Because the Nova cert freezes at the bare majority,
the ⅔-th stake vote necessarily follows the accept, so finalized-block votes are routed to
the attestor via a re-verified late-attestation path (handleVote / HandleIncomingVote).
- GetCertifiedTip → GetQuasarTip (highest ⅔-cert block, NEVER the Nova tip); GetNovaTip exposes
the accepted tip. PromoteQuasar (single writer of the export frontier) validates each promotion
against the Nova ledger, inheriting its single-canonical-per-height gate → no two conflicting
Quasars. FinalityStatus surfaces nova/quasar/horizon heights + responsiveStakePct +
certificateAvailable + degraded; a Nova height is never reported as certified.
- Incoming-cert threshold pre-check made tier-aware so gossiped Nova certs are not rejected.
Tests: nova_quasar_matrix_test.go covers the adversarial matrix (n∈{1..5}+weighted,
3/5-Nova-no-Quasar, Quasar-only-at-⅔, 3/5-partition, equivocation → two Nova possible but
never two Quasar, behind-node rejoin non-event, degraded RPC, export gates on Quasar). ~17
pre-existing RED safety tests re-framed from the accept tier to the export tier (a sub-⅔
coalition may Nova-accept locally but can never reach Quasar/export). Single-engine matrix is
-race clean; the in-process multinode convergence tests skip under -race (timing/FIPS-ed25519
concurrency, per the existing race_flag discipline).
Astrophysics ontology only — no new tendermint/prevote/precommit/viewchange/snowman terms.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+94
-88
@@ -63,17 +63,42 @@ func mustNotFinalize(t *testing.T, e *Transitive, blk *verifyOnceBlock, d time.D
|
||||
}
|
||||
|
||||
// mustFinalize fails unless blk is VM-accepted exactly once and reported accepted
|
||||
// within d. It is the LIVENESS assertion: a real quorum DOES finalize.
|
||||
// within d. It is the NOVA (local-accept) LIVENESS assertion: a bare-majority quorum DOES
|
||||
// drive VM.Accept. Under v1.36 acceptance is the Nova tier (majority), NOT the ⅔-stake
|
||||
// Quasar tier — a block can Nova-accept here while never reaching export (see mustNotQuasar).
|
||||
func mustFinalize(t *testing.T, e *Transitive, blk *verifyOnceBlock, d time.Duration, why string) {
|
||||
t.Helper()
|
||||
if !waitFor(d, func() bool { return e.IsAccepted(blk.id) }) {
|
||||
t.Fatalf("%s: LIVENESS FAILURE — a real quorum did not finalize (AcceptCalled=%d)", why, blk.AcceptCalled())
|
||||
t.Fatalf("%s: NOVA LIVENESS FAILURE — a majority quorum did not accept (AcceptCalled=%d)", why, blk.AcceptCalled())
|
||||
}
|
||||
if got := blk.AcceptCalled(); got != 1 {
|
||||
t.Fatalf("%s: block must VM.Accept exactly once, got %d", why, got)
|
||||
}
|
||||
}
|
||||
|
||||
// mustQuasar fails unless blk reaches the EXPORT (Quasar, ⅔-by-stake) tier within d — the
|
||||
// v1.36 export LIVENESS assertion: a real >⅔-stake supermajority DOES certify for export.
|
||||
func mustQuasar(t *testing.T, e *Transitive, blk *verifyOnceBlock, d time.Duration, why string) {
|
||||
t.Helper()
|
||||
if !waitFor(d, func() bool { qh, ok := e.QuasarHeight(); return ok && qh >= blk.height }) {
|
||||
qh, ok := e.QuasarHeight()
|
||||
t.Fatalf("%s: EXPORT LIVENESS FAILURE — block did not reach Quasar (⅔-stake); QuasarHeight=(%d,%v), want ≥%d",
|
||||
why, qh, ok, blk.height)
|
||||
}
|
||||
}
|
||||
|
||||
// mustNotQuasar fails if blk reaches the EXPORT (Quasar) tier within d. It is the v1.36
|
||||
// export-SAFETY assertion: a sub-⅔-stake coalition may drive Nova (local accept) but can
|
||||
// NEVER reach export finality (bridges / DEX settlement / cross-chain). A block that never
|
||||
// Nova-accepts trivially never reaches Quasar; a Nova-accepted-but-sub-⅔ block must stall at
|
||||
// Nova with the Quasar frontier NOT advanced to it.
|
||||
func mustNotQuasar(t *testing.T, e *Transitive, blk *verifyOnceBlock, d time.Duration, why string) {
|
||||
t.Helper()
|
||||
if waitFor(d, func() bool { qh, ok := e.QuasarHeight(); return ok && qh >= blk.height }) {
|
||||
t.Fatalf("%s: EXPORT SAFETY VIOLATION — a sub-⅔-stake coalition reached Quasar (⅔-stake export)", why)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoAcceptWithoutVerifiedQC: the SOLE finalizer refuses a zero
|
||||
// VerifiedQuorumCert. Even an internal caller cannot finalize by passing the zero
|
||||
// value — the rule "no VerifiedQuorumCert, no finality" is structural.
|
||||
@@ -102,66 +127,48 @@ func TestNoAcceptWithoutVerifiedQC(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessPendingBlocksCannotFinalizeByCount: the count road, exercised
|
||||
// through the live pollLoop's finalizer, cannot finalize. Equal VOTE weight gives
|
||||
// α=4 by count; the four low-stake voters reach that count but hold a stake
|
||||
// minority, so processPendingBlocks (which now routes accepts through TryAccept)
|
||||
// must NOT finalize. This is the structural fix for the pre-existing RED repro,
|
||||
// asserted on the public finalizer name.
|
||||
func TestProcessPendingBlocksCannotFinalizeByCount(t *testing.T) {
|
||||
// TestCountMajorityNovaAcceptsButNotQuasarExport is the two-tier successor to the old
|
||||
// "ProcessPendingBlocksCannotFinalizeByCount". Under v1.36 a bare-majority COUNT of votes IS
|
||||
// the Nova accept authority (it drives VM.Accept, local execution) — so a 4-of-5 count
|
||||
// majority DOES accept. But the EXPORT tier (Quasar) still requires >⅔ of STAKE, so a
|
||||
// coalition holding 4/100 stake NEVER reaches Quasar. Count drives local accept; stake gates
|
||||
// export. (The absent 96%-stake holder blocks certification, not production — the degraded
|
||||
// mode.)
|
||||
func TestCountMajorityNovaAcceptsButNotQuasarExport(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
// EQUAL vote weight → α=4 by count; SKEWED stake → four voters hold 4/100.
|
||||
// EQUAL vote weight → Nova majority is 3-of-5 by count; SKEWED stake → four voters hold 4/100.
|
||||
skew := newStakeMap(vs, 96, 1, 1, 1, 1)
|
||||
rec := &recordingGossiper{}
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "count-only")
|
||||
blk := newTestBlock(1, ids.Empty, "count-majority")
|
||||
e, _ := driveSignedAccepts(t, vs, skew, rec, blk, []int{1, 2, 3, 4})
|
||||
|
||||
// The COUNT gate flips consensus.IsAccepted (acceptVotes=4>=α=4), and the
|
||||
// pollLoop runs processPendingBlocks on it — but TryAccept refuses without the
|
||||
// ⅔-stake cert, so the block must NOT VM.Accept.
|
||||
mustNotFinalize(t, e, blk, 1500*time.Millisecond,
|
||||
"processPendingBlocks/count-α with 4/100 stake")
|
||||
|
||||
// Drive processPendingBlocks DIRECTLY too (not only via the loop) to prove the
|
||||
// finalizer itself refuses, independent of timing.
|
||||
e.processPendingBlocks()
|
||||
if blk.AcceptCalled() != 0 {
|
||||
t.Fatalf("processPendingBlocks finalized on count alone (VM.Accept ran %d×)", blk.AcceptCalled())
|
||||
}
|
||||
|
||||
rec.mu.Lock()
|
||||
gotCerts := len(rec.certs)
|
||||
rec.mu.Unlock()
|
||||
if gotCerts != 0 {
|
||||
t.Fatalf("no ⅔-stake cert should exist for a 4/100 coalition, got %d gossiped", gotCerts)
|
||||
}
|
||||
// NOVA: a 4-of-5 count majority drives VM.Accept (local execution) — the v1.36 accept tier.
|
||||
mustFinalize(t, e, blk, 2*time.Second, "4-of-5 count majority (Nova local accept)")
|
||||
// QUASAR: 4/100 stake is far below ⅔ — the block must NEVER reach export finality.
|
||||
mustNotQuasar(t, e, blk, 500*time.Millisecond, "4/100-stake coalition (export gate)")
|
||||
}
|
||||
|
||||
// TestSkewedStakeHeadcountMajorityRejected is the KEY regression named in the
|
||||
// brief: A=60%, B=C=D=E=10%. Votes from B+C+D+E → count=4/5 but stake=40% → NO
|
||||
// accept, NO finalize, NO state transition.
|
||||
func TestSkewedStakeHeadcountMajorityRejected(t *testing.T) {
|
||||
// TestSkewedStakeHeadcountMajorityNovaAcceptsNotQuasar is the two-tier successor to the old
|
||||
// "SkewedStakeHeadcountMajorityRejected". A=60%, B=C=D=E=10%. B+C+D+E vote → count=4/5 (a Nova
|
||||
// majority, so VM.Accept runs — local execution) but stake=40% (< ⅔, so NO Quasar export). The
|
||||
// original brief's invariant ("a stake minority causes no IRREVERSIBLE / cross-chain state
|
||||
// transition") is preserved EXACTLY at the export tier; local execution follows the count
|
||||
// majority per the v1.36 mandate.
|
||||
func TestSkewedStakeHeadcountMajorityNovaAcceptsNotQuasar(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
// A=60, B..E=10 each (total 100). The four small holders sum to 40% < ⅔.
|
||||
skew := newStakeMap(vs, 60, 10, 10, 10, 10)
|
||||
rec := &recordingGossiper{}
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "skew-40pct")
|
||||
// B,C,D,E vote (indices 1..4): headcount 4/5 (≥α) but only 40% of stake. A abstains.
|
||||
// B,C,D,E vote (indices 1..4): headcount 4/5 (a Nova majority) but only 40% of stake. A abstains.
|
||||
e, _ := driveSignedAccepts(t, vs, skew, rec, blk, []int{1, 2, 3, 4})
|
||||
|
||||
mustNotFinalize(t, e, blk, 1500*time.Millisecond,
|
||||
"4/5-headcount / 40%-stake coalition")
|
||||
|
||||
// And no valid stake-weighted cert was gossiped — the cert path agreed it must
|
||||
// not finalize; only the count path (now defanged) ever said otherwise.
|
||||
rec.mu.Lock()
|
||||
gotCerts := len(rec.certs)
|
||||
rec.mu.Unlock()
|
||||
if gotCerts != 0 {
|
||||
t.Fatalf("a 40%%-stake coalition must produce NO cert, got %d gossiped", gotCerts)
|
||||
}
|
||||
// NOVA: the count majority drives local accept.
|
||||
mustFinalize(t, e, blk, 2*time.Second, "4/5-headcount majority (Nova local accept)")
|
||||
// QUASAR: 40% stake < ⅔ — no export cert, no cross-chain/irreversible transition.
|
||||
mustNotQuasar(t, e, blk, 500*time.Millisecond, "40%-stake coalition (export gate)")
|
||||
}
|
||||
|
||||
// TestEqualStakeFourOfFiveAcceptedWithQC: equal stake, four of five validators
|
||||
@@ -188,11 +195,13 @@ func TestEqualStakeFourOfFiveAcceptedWithQC(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestThreeOfFiveRejectedEvenIfCountThresholdMet: only three of five sign. On the
|
||||
// dyn5 sizer α=4, so 3 < α never even reaches the count trigger — and crucially
|
||||
// holds only 60% of equal stake (< ⅔). The block must NOT finalize. (Three-of-N
|
||||
// is below the BFT overlap floor — exactly what the quorum must refuse.)
|
||||
func TestThreeOfFiveRejectedEvenIfCountThresholdMet(t *testing.T) {
|
||||
// TestThreeOfFiveNovaAcceptsNotQuasar is the two-tier successor to the old
|
||||
// "ThreeOfFiveRejectedEvenIfCountThresholdMet". Three of five sign — EXACTLY the Nova majority
|
||||
// NovaQuorum(5)=3, so the block DOES Nova-accept (the "survive 3/5" mandate). But three equal
|
||||
// stakes are 60% < ⅔, so it does NOT reach Quasar export. This is the canonical degraded case:
|
||||
// production continues (Nova) while certification pauses (no Quasar) until a 4th validator
|
||||
// votes.
|
||||
func TestThreeOfFiveNovaAcceptsNotQuasar(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
equal := newStakeMap(vs, 20, 20, 20, 20, 20) // three → 60% < ⅔
|
||||
rec := &recordingGossiper{}
|
||||
@@ -200,46 +209,44 @@ func TestThreeOfFiveRejectedEvenIfCountThresholdMet(t *testing.T) {
|
||||
blk := newTestBlock(1, ids.Empty, "three-of-five")
|
||||
e, _ := driveSignedAccepts(t, vs, equal, rec, blk, []int{0, 1, 2})
|
||||
|
||||
mustNotFinalize(t, e, blk, 1500*time.Millisecond, "3/5 (below quorum + 60% stake)")
|
||||
|
||||
e.processPendingBlocks() // direct drive, prove the finalizer refuses
|
||||
if blk.AcceptCalled() != 0 {
|
||||
t.Fatalf("3/5 finalized (VM.Accept ran %d×)", blk.AcceptCalled())
|
||||
}
|
||||
// NOVA: 3-of-5 is the bare majority — it accepts (local execution).
|
||||
mustFinalize(t, e, blk, 2*time.Second, "3/5 bare majority (Nova local accept)")
|
||||
// QUASAR: 60% stake ≤ ⅔ — no export cert (certification pauses in the degraded 3/5 mode).
|
||||
mustNotQuasar(t, e, blk, 500*time.Millisecond, "3/5 = 60% stake (export gate)")
|
||||
}
|
||||
|
||||
// TestVoteArrivalTriggersTryAcceptButRequiresQC: a vote arriving is a LIVENESS
|
||||
// trigger. It funnels into TryAccept, but with only a sub-⅔ coalition voting,
|
||||
// TryAccept returns ErrNoVerifiedQC and finalizes nothing. Then the missing
|
||||
// heavy-stake vote arrives, the verified cert becomes assemblable, and the SAME
|
||||
// trigger path finalizes. Proves: trigger ≠ authority; the QC is the authority.
|
||||
func TestVoteArrivalTriggersTryAcceptButRequiresQC(t *testing.T) {
|
||||
// TestVoteArrivalDrivesNovaThenLateHeavyVoteCompletesQuasar: a vote arriving is a LIVENESS
|
||||
// trigger into TryAccept. Under v1.36 a bare-majority count drives the NOVA accept (local
|
||||
// execution) immediately; the EXPORT tier (Quasar) additionally needs >⅔ of STAKE. Because the
|
||||
// ⅔-th stake vote NECESSARILY TRAILS the bare-majority accept, it is completed by a LATE
|
||||
// attestation: B,C,D,E (4/5 count, 40% stake) Nova-accept; then the heavy holder A (60%) votes
|
||||
// AFTER the accept, and the trailing-vote path completes the export cert — the block reaches
|
||||
// Quasar with NO reorg (accept was already Nova; export is a monotone promotion).
|
||||
func TestVoteArrivalDrivesNovaThenLateHeavyVoteCompletesQuasar(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
skew := newStakeMap(vs, 60, 10, 10, 10, 10) // A=60%
|
||||
rec := &recordingGossiper{}
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "vote-trigger")
|
||||
// First only B,C,D,E vote: 4/5 count, 40% stake. Triggers TryAccept, no QC.
|
||||
// First only B,C,D,E vote: 4/5 count (Nova majority), 40% stake (< ⅔).
|
||||
e, chainID := driveSignedAccepts(t, vs, skew, rec, blk, []int{1, 2, 3, 4})
|
||||
mustNotFinalize(t, e, blk, 1*time.Second, "4/5-count/40%-stake before heavy vote")
|
||||
// NOVA: the count majority drives VM.Accept (local execution) immediately.
|
||||
mustFinalize(t, e, blk, 2*time.Second, "4/5-count majority (Nova local accept)")
|
||||
// QUASAR: 40% stake < ⅔ — no export cert yet.
|
||||
mustNotQuasar(t, e, blk, 500*time.Millisecond, "40%-stake before heavy vote (export gate)")
|
||||
|
||||
// Direct: TryAccept invoked by the trigger path must report ErrNoVerifiedQC.
|
||||
if err := e.TryAccept(context.Background(), blk.id); err != ErrNoVerifiedQC {
|
||||
t.Fatalf("TryAccept with 40%% stake must return ErrNoVerifiedQC, got %v", err)
|
||||
}
|
||||
|
||||
// Now the heavy-stake validator A (index 0) votes → 100% stake, verified QC
|
||||
// assemblable. The vote-arrival trigger now finalizes through AcceptWithCert.
|
||||
// The heavy-stake holder A (index 0) votes AFTER the accept. 40%+60% = 100% > ⅔ ⇒ the
|
||||
// trailing attestation completes the export cert and the block reaches Quasar (no reorg).
|
||||
pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
|
||||
e.ReceiveVote(vs.signedVote(0, pos))
|
||||
mustFinalize(t, e, blk, 2*time.Second, "after heavy-stake vote completes the ⅔ quorum")
|
||||
mustQuasar(t, e, blk, 2*time.Second, "late heavy-stake vote completes the ⅔ export supermajority")
|
||||
}
|
||||
|
||||
// TestRepollTriggersTryAcceptButRequiresQC: a re-poll firing is a LIVENESS
|
||||
// trigger (the pollLoop re-examines pending blocks). Driving the re-poll path
|
||||
// (processPendingBlocks, which the pollLoop calls each tick) repeatedly over a
|
||||
// sub-⅔ coalition must never finalize — re-polling is a retry, not an authority.
|
||||
func TestRepollTriggersTryAcceptButRequiresQC(t *testing.T) {
|
||||
// TestRepollTriggersNovaAcceptButNotQuasarExport: a re-poll firing is a LIVENESS trigger (the
|
||||
// pollLoop re-examines pending blocks). Under v1.36 a 4-of-5 count majority DRIVES the Nova accept
|
||||
// (local execution) even for a 40%-stake coalition — but re-poll is a RETRY, not an authority: no
|
||||
// amount of re-polling can manufacture the ⅔-STAKE supermajority the EXPORT (Quasar) tier needs.
|
||||
func TestRepollTriggersNovaAcceptButNotQuasarExport(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
skew := newStakeMap(vs, 60, 10, 10, 10, 10)
|
||||
rec := &recordingGossiper{}
|
||||
@@ -247,16 +254,15 @@ func TestRepollTriggersTryAcceptButRequiresQC(t *testing.T) {
|
||||
blk := newTestBlock(1, ids.Empty, "repoll-trigger")
|
||||
e, _ := driveSignedAccepts(t, vs, skew, rec, blk, []int{1, 2, 3, 4})
|
||||
|
||||
// Fire the re-poll finalizer repeatedly — each is a trigger, none an authority.
|
||||
// NOVA: the 4-of-5 count majority accepts (local execution).
|
||||
mustFinalize(t, e, blk, 2*time.Second, "4-of-5 count majority (Nova local accept)")
|
||||
|
||||
// Fire the re-poll finalizer repeatedly — a retry, never an authority. It must NEVER push the
|
||||
// 40%-stake block to EXPORT (Quasar); ⅔ of stake cannot be re-polled into existence.
|
||||
for i := 0; i < 25; i++ {
|
||||
e.processPendingBlocks()
|
||||
if blk.AcceptCalled() != 0 {
|
||||
t.Fatalf("re-poll #%d finalized a 40%%-stake block (VM.Accept ran %d×)", i, blk.AcceptCalled())
|
||||
}
|
||||
}
|
||||
if e.IsAccepted(blk.id) {
|
||||
t.Fatal("repeated re-polls produced finality without a verified cert")
|
||||
}
|
||||
mustNotQuasar(t, e, blk, 500*time.Millisecond, "40%-stake coalition under repeated re-poll (export gate)")
|
||||
}
|
||||
|
||||
// TestVerifiedQuorumCertUnforgeableOutsideBuilder: a VerifiedQuorumCert can be
|
||||
@@ -282,7 +288,7 @@ func TestVerifiedQuorumCertUnforgeableOutsideBuilder(t *testing.T) {
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
}
|
||||
if vc, err := BuildVerifiedQuorumCert(vs, equal, 4, 1, pos, sub); err == nil || !vc.IsZero() {
|
||||
if vc, err := BuildVerifiedQuorumCert(vs, equal, Quasar, 4, 1, pos, sub); err == nil || !vc.IsZero() {
|
||||
t.Fatalf("builder must refuse a sub-α vote set (got err=%v zero=%v)", err, vc.IsZero())
|
||||
}
|
||||
|
||||
@@ -293,7 +299,7 @@ func TestVerifiedQuorumCertUnforgeableOutsideBuilder(t *testing.T) {
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
{NodeID: vs.nodeID(3), Accept: true, Signature: vs.sign(3, pos)},
|
||||
}
|
||||
if vc, err := BuildVerifiedQuorumCert(vs, equal, 4, 1, pos, forged); err == nil || !vc.IsZero() {
|
||||
if vc, err := BuildVerifiedQuorumCert(vs, equal, Quasar, 4, 1, pos, forged); err == nil || !vc.IsZero() {
|
||||
t.Fatalf("builder must refuse a forged-signature vote set (got err=%v zero=%v)", err, vc.IsZero())
|
||||
}
|
||||
|
||||
@@ -305,7 +311,7 @@ func TestVerifiedQuorumCertUnforgeableOutsideBuilder(t *testing.T) {
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
{NodeID: vs.nodeID(3), Accept: true, Signature: vs.sign(3, pos)},
|
||||
}
|
||||
vc, err := BuildVerifiedQuorumCert(vs, equal, 4, 1, pos, good)
|
||||
vc, err := BuildVerifiedQuorumCert(vs, equal, Quasar, 4, 1, pos, good)
|
||||
if err != nil || vc.IsZero() {
|
||||
t.Fatalf("builder must mint a token for a real ⅔ quorum (err=%v zero=%v)", err, vc.IsZero())
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func (q *QuasarAttestor) Ingest(pos VotePosition, att SignedVote) (*QuorumCert,
|
||||
for _, v := range b.votes {
|
||||
votes = append(votes, v)
|
||||
}
|
||||
cert, err := AssembleQuorumCert(b.pos, uint32(threshold), votes)
|
||||
cert, err := AssembleQuorumCert(b.pos, Quasar, uint32(threshold), votes)
|
||||
if err != nil {
|
||||
return nil, false, nil // not yet assemblable (below threshold after dedup); keep collecting
|
||||
}
|
||||
|
||||
@@ -58,22 +58,25 @@ func TestRedByzantine_SilentValidators_TwoThirdsBoundary_N7(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// ≤ ⅔ ONLINE (4 of 7 < α=5): quorum is unreachable. The chain MUST HALT (fail-closed),
|
||||
// never finalize, never fork. This is the self-finality floor at the fleet level: a
|
||||
// below-quorum online set produces NO cert, exactly as it must.
|
||||
t.Run("four_online_halts_safe", func(t *testing.T) {
|
||||
// < MAJORITY ONLINE (3 of 7 < NovaQuorum(7)=4): the Nova accept quorum is unreachable. The
|
||||
// chain MUST HALT (fail-closed), never accept, never fork. This is the self-finality floor at
|
||||
// the fleet level — and under v1.36 the floor is the MAJORITY (NovaQuorum), not the ⅔ quorum:
|
||||
// a 4-of-7 majority DOES accept (Nova, "survive with a majority"), but a 3-of-7 below-majority
|
||||
// set produces NO cert (the 1085013 fault family stays closed at the Nova tier).
|
||||
t.Run("below_majority_halts_safe", func(t *testing.T) {
|
||||
net := newSimNet(t, 7, prodParams7())
|
||||
net.down(3)
|
||||
net.down(4)
|
||||
net.down(5)
|
||||
net.down(6) // 4 up: {0,1,2,3} < α=5
|
||||
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "n7-four-online")
|
||||
net.down(6) // 3 up: {0,1,2} < NovaQuorum(7)=4
|
||||
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "n7-three-online")
|
||||
net.build(0, blk)
|
||||
|
||||
// Give it real time to (incorrectly) finalize if the floor were broken.
|
||||
finalized := waitFor(3*time.Second, func() bool { return len(net.headsAtHeight(1)) > 0 })
|
||||
if finalized {
|
||||
t.Fatalf("SAFETY/FLOOR: 4-of-7 online (below α=5) MUST HALT, but a head finalized: %v — "+
|
||||
"a below-quorum online set self-finalized (the 1085013 fault family)", net.headsAtHeight(1))
|
||||
// Give it real time to (incorrectly) accept if the floor were broken.
|
||||
accepted := waitFor(3*time.Second, func() bool { return len(net.headsAtHeight(1)) > 0 })
|
||||
if accepted {
|
||||
t.Fatalf("SAFETY/FLOOR: 3-of-7 online (below NovaQuorum=4) MUST HALT, but a head accepted: %v — "+
|
||||
"a below-majority online set self-accepted (the 1085013 fault family)", net.headsAtHeight(1))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ func catchupCertFor(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *ver
|
||||
for _, i := range voters {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
qc, err := AssembleQuorumCert(pos, threshold, votes)
|
||||
qc, err := AssembleQuorumCert(pos, Quasar, threshold, votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble cert: %v", err)
|
||||
}
|
||||
@@ -256,7 +256,7 @@ func TestCatchup_RejectsForgedAndSubQuorumCerts(t *testing.T) {
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
{NodeID: vs.nodeID(3), Accept: true, Signature: vs.sign(3, pos)},
|
||||
}
|
||||
qc, err := AssembleQuorumCert(pos, 3, votes)
|
||||
qc, err := AssembleQuorumCert(pos, Quasar, 3, votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble forged: %v", err)
|
||||
}
|
||||
|
||||
+99
-11
@@ -124,6 +124,7 @@ var (
|
||||
ErrQCSigInvalid = errors.New("chain: cert vote signature failed verification")
|
||||
ErrQCVerifierNil = errors.New("chain: vote verifier is nil; cannot verify a cert's signatures — fail closed")
|
||||
ErrQCStakeBelowSupermajority = errors.New("chain: cert voters' stake below 2/3 of total stake (count quorum reached but not stake-weighted supermajority)")
|
||||
ErrQCUnknownTier = errors.New("chain: quorum cert finality tier is not Nova or Quasar (a cert attests exactly one of the two accept/export tiers)")
|
||||
)
|
||||
|
||||
// SignedVote is one validator's signed ACCEPT decision over a consensus
|
||||
@@ -389,16 +390,29 @@ type ValidatorSetRootFunc func(height uint64) ids.ID
|
||||
// ValidatorSetRoot implements ValidatorSetRootSource.
|
||||
func (f ValidatorSetRootFunc) ValidatorSetRoot(height uint64) ids.ID { return f(height) }
|
||||
|
||||
// VerifyWeighted verifies the cert under `verifier` (the full count predicate of
|
||||
// Verify) AND additionally requires that the summed stake of the cert's voters
|
||||
// is at least two-thirds of the total stake at the cert's position height. It is
|
||||
// the stake-aware finality predicate for value/PoS chains.
|
||||
// VerifyWeighted is the TIER-SELECTED finality predicate. It first runs Verify (the
|
||||
// tier-agnostic structural + signature predicate) and then enforces the threshold for
|
||||
// the cert's OWN tier:
|
||||
//
|
||||
// A nil stake source means "no stake model wired" — the caller MUST instead use
|
||||
// Verify and is responsible for the equal-stake admission invariant (documented
|
||||
// on the engine). VerifyWeighted with a nil source returns ErrQCVerifierNil's
|
||||
// sibling fail-closed error to make a wiring mistake loud rather than silently
|
||||
// count-only.
|
||||
// - Nova → a strict COUNT majority (NovaQuorum) of the DISTINCT validator set at the
|
||||
// epoch, and DELIBERATELY no stake supermajority. This is the LOCAL-EXECUTION gate:
|
||||
// it must ignite at a bare majority (3 of 5) even when the absent minority holds the
|
||||
// stake majority — the "survive 3/5" liveness mandate — so a stake gate here would
|
||||
// defeat its whole purpose. Crash-fault-safe by majority intersection; NOT
|
||||
// Byzantine-safe (that is Quasar's job).
|
||||
// - Quasar → a strict >⅔ of TOTAL STAKE by distinct signers (config.TwoThirdsStakeFloor).
|
||||
// This is the EXPORT gate — the Byzantine-safe finality bridges / DEX settlement /
|
||||
// cross-chain messages / validator-set transitions consume.
|
||||
//
|
||||
// The tier is read from the cert, but the THRESHOLD is RE-DERIVED from the authoritative
|
||||
// validator set (never the cert's self-declared Threshold), so a cert cannot forge its
|
||||
// tier upward: a Nova set of votes relabeled Quasar fails the ⅔-by-stake check, and a
|
||||
// Quasar cert relabeled Nova merely under-claims. An unknown tier fails closed.
|
||||
//
|
||||
// A nil stake source means "no stake model wired" — the caller MUST instead use Verify
|
||||
// and is responsible for the equal-stake admission invariant (documented on the engine).
|
||||
// VerifyWeighted with a nil source fails closed to make a wiring mistake loud rather than
|
||||
// silently count-only.
|
||||
func (c *QuorumCert) VerifyWeighted(verifier VoteVerifier, stake StakeSource, epochHeight uint64) error {
|
||||
if err := c.Verify(verifier, epochHeight); err != nil {
|
||||
return err
|
||||
@@ -406,6 +420,41 @@ func (c *QuorumCert) VerifyWeighted(verifier VoteVerifier, stake StakeSource, ep
|
||||
if stake == nil {
|
||||
return fmt.Errorf("%w: stake source nil", ErrQCStakeBelowSupermajority)
|
||||
}
|
||||
switch c.Tier {
|
||||
case Nova:
|
||||
return c.verifyNovaMajority(stake, epochHeight)
|
||||
case Quasar:
|
||||
return c.verifyQuasarSupermajority(stake, epochHeight)
|
||||
default:
|
||||
return fmt.Errorf("%w: tier=%s", ErrQCUnknownTier, c.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyNovaMajority enforces the Nova LOCAL-EXECUTION threshold: the cert's distinct
|
||||
// voter count is a strict majority (NovaQuorum) of the DISTINCT validator set live at the
|
||||
// epoch. NO stake supermajority — Nova is the crash-fault local gate that must survive a
|
||||
// bare majority (3/5) regardless of how stake is distributed. The count is recomputed from
|
||||
// the authoritative live set so a cert can never self-declare a below-majority Nova
|
||||
// threshold; an UNRESOLVED set (n<1) fails closed — a majority of an unknown set cannot be
|
||||
// asserted, and NovaQuorum(0)=1 would otherwise let a lone node self-accept during the
|
||||
// transient-empty validator view that forked 1085013.
|
||||
func (c *QuorumCert) verifyNovaMajority(stake StakeSource, epochHeight uint64) error {
|
||||
n := stake.ValidatorCount(epochHeight)
|
||||
if n < 1 {
|
||||
return fmt.Errorf("%w: nova tier over an unresolved validator set (n=%d) at epoch %d",
|
||||
ErrQCBelowThreshold, n, epochHeight)
|
||||
}
|
||||
need := NovaQuorum(n)
|
||||
if c.VoterCount() < need {
|
||||
return fmt.Errorf("%w: nova cert has %d distinct voters, need majority %d of %d at epoch %d",
|
||||
ErrQCBelowThreshold, c.VoterCount(), need, n, epochHeight)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyQuasarSupermajority enforces the Quasar EXPORT threshold: the summed stake of the
|
||||
// cert's distinct voters STRICTLY exceeds two-thirds of the total stake at the epoch.
|
||||
func (c *QuorumCert) verifyQuasarSupermajority(stake StakeSource, epochHeight uint64) error {
|
||||
// The ⅔-by-stake tally is read at the block's P-CHAIN EPOCH height — the SAME
|
||||
// height the cert's set-root and the per-voter pubkeys are read at (MEDIUM-1)
|
||||
// — NOT c.Position.Height (the value-chain height, which platformvm would
|
||||
@@ -434,6 +483,16 @@ func (c *QuorumCert) VerifyWeighted(verifier VoteVerifier, stake StakeSource, ep
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizesExport reports whether THIS cert is export-grade finality (Quasar or
|
||||
// brighter) — the safety boundary a bridge / DEX-settlement / cross-chain consumer MUST
|
||||
// gate on. A Nova cert (local execution) returns false even though it is a valid,
|
||||
// signature-verified majority cert. This is the cert-level projection of
|
||||
// Finality.AuthorizesExport, so a raw cert in hand can be gated without re-deriving its
|
||||
// tier from context. A nil cert is not export-grade.
|
||||
func (c *QuorumCert) AuthorizesExport() bool {
|
||||
return c != nil && c.Tier.AuthorizesExport()
|
||||
}
|
||||
|
||||
// QuorumCert is the engine-level finality witness: α distinct validators each
|
||||
// signed ACCEPT over Position. It is portable (gossipable), verifiable by any
|
||||
// node holding the VoteVerifier, and deterministic to assemble.
|
||||
@@ -446,6 +505,19 @@ type QuorumCert struct {
|
||||
Version uint16
|
||||
// Type names the cert's role (QCFinality). Bound into every vote message.
|
||||
Type QCType
|
||||
// Tier is the finality rung this cert attests — Nova (a bare-majority
|
||||
// LOCAL-EXECUTION cert) or Quasar (a strict ⅔-by-stake EXPORT cert). It selects
|
||||
// which threshold VerifyWeighted enforces, so ONE cert type carries both accept
|
||||
// (Nova) and export (Quasar) finality with no ambiguity about what a given cert
|
||||
// proves. It is DELIBERATELY not bound into the per-vote signed message: a vote is
|
||||
// tier-agnostic ("I accept this block"), and the SAME accept vote counts toward
|
||||
// both a Nova majority and a Quasar supermajority. The tier therefore cannot be
|
||||
// forged UPWARD — VerifyWeighted re-derives the real threshold from the
|
||||
// authoritative validator set, so relabeling a Nova cert as Quasar is rejected
|
||||
// (it will not clear ⅔-by-stake) and relabeling a Quasar cert as Nova only
|
||||
// under-claims (harmless). Only Nova and Quasar are valid here; Horizon is the PQ
|
||||
// seal layer, not a QuorumCert.
|
||||
Tier Finality
|
||||
// Position is the consensus position every vote binds to.
|
||||
Position VotePosition
|
||||
// Threshold (alpha) is the minimum number of distinct ACCEPT voters required
|
||||
@@ -470,7 +542,15 @@ type QuorumCert struct {
|
||||
// Returns ErrQCBelowThreshold if fewer than `threshold` distinct accept votes
|
||||
// are supplied: assembly only succeeds once the quorum is actually present, so
|
||||
// a cert can never claim a quorum it does not hold.
|
||||
func AssembleQuorumCert(pos VotePosition, threshold uint32, votes []SignedVote) (*QuorumCert, error) {
|
||||
//
|
||||
// tier names what the cert attests — Nova (local-execution majority) or Quasar
|
||||
// (export ⅔-by-stake). It must be exactly one of those two; any other rung
|
||||
// (Photon/Wave/Horizon) is rejected, because a QuorumCert is only ever an accept
|
||||
// (Nova) or an export (Quasar) witness — Horizon is the separate PQ seal layer.
|
||||
func AssembleQuorumCert(pos VotePosition, tier Finality, threshold uint32, votes []SignedVote) (*QuorumCert, error) {
|
||||
if tier != Nova && tier != Quasar {
|
||||
return nil, fmt.Errorf("%w: %s", ErrQCUnknownTier, tier)
|
||||
}
|
||||
if threshold == 0 {
|
||||
return nil, ErrQCThresholdZero
|
||||
}
|
||||
@@ -505,6 +585,7 @@ func AssembleQuorumCert(pos VotePosition, threshold uint32, votes []SignedVote)
|
||||
return &QuorumCert{
|
||||
Version: QuorumCertVersion,
|
||||
Type: QCFinality,
|
||||
Tier: tier,
|
||||
Position: pos,
|
||||
Threshold: threshold,
|
||||
Votes: sorted,
|
||||
@@ -544,6 +625,13 @@ func (c *QuorumCert) Verify(verifier VoteVerifier, epochHeight uint64) error {
|
||||
if c.Type != QCFinality {
|
||||
return fmt.Errorf("%w: got %d want %d", ErrQCType, c.Type, QCFinality)
|
||||
}
|
||||
// A cert attests exactly one accept/export tier. Reject an out-of-range tier
|
||||
// (a wire-decoded cert with a bogus/zero tier byte) before any signature work —
|
||||
// so the tier-agnostic count-only Verify path (equal-stake chains) also fails
|
||||
// closed on a garbage tier, not just the tier-selected VerifyWeighted.
|
||||
if c.Tier != Nova && c.Tier != Quasar {
|
||||
return fmt.Errorf("%w: tier=%s", ErrQCUnknownTier, c.Tier)
|
||||
}
|
||||
if c.Threshold == 0 {
|
||||
return ErrQCThresholdZero
|
||||
}
|
||||
@@ -605,7 +693,7 @@ func (c *QuorumCert) Equal(o *QuorumCert) bool {
|
||||
if c == nil || o == nil {
|
||||
return c == o
|
||||
}
|
||||
if c.Version != o.Version || c.Type != o.Type || c.Position != o.Position ||
|
||||
if c.Version != o.Version || c.Type != o.Type || c.Tier != o.Tier || c.Position != o.Position ||
|
||||
c.Threshold != o.Threshold || len(c.Votes) != len(o.Votes) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func mkValidCert(t *testing.T, vs *testValidatorSet, pos VotePosition, n int) *Q
|
||||
for i := 0; i < n; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, uint32(n), votes)
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(n), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func TestInversion_WrongSetSignerRejected(t *testing.T) {
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
{NodeID: outsider, Accept: true, Signature: bytes.Repeat([]byte{0x01}, 64)},
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, 4, votes)
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, 4, votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func TestInversion_DuplicateVoterRejected(t *testing.T) {
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
}
|
||||
if _, err := AssembleQuorumCert(pos, 4, dup); err == nil {
|
||||
if _, err := AssembleQuorumCert(pos, Quasar, 4, dup); err == nil {
|
||||
t.Fatal("assembling a cert with a duplicate voter must fail")
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ var ErrQCWireCorrupt = errors.New("chain: quorum cert wire corrupt")
|
||||
//
|
||||
// version:2
|
||||
// type:1
|
||||
// tier:1 (Nova | Quasar — the accept/export rung this cert attests)
|
||||
// chain_id:32
|
||||
// height:8
|
||||
// round:4
|
||||
@@ -54,10 +55,10 @@ var ErrQCWireCorrupt = errors.New("chain: quorum cert wire corrupt")
|
||||
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 +
|
||||
// (version + type + tier + 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
|
||||
const qcHeaderSize = 2 + 1 + 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).
|
||||
@@ -70,6 +71,7 @@ func (c *QuorumCert) MarshalBinary() ([]byte, error) {
|
||||
binary.BigEndian.PutUint16(u16[:], c.Version)
|
||||
buf = append(buf, u16[:]...)
|
||||
buf = append(buf, byte(c.Type))
|
||||
buf = append(buf, byte(c.Tier))
|
||||
buf = append(buf, c.Position.ChainID[:]...)
|
||||
var u64 [8]byte
|
||||
binary.BigEndian.PutUint64(u64[:], c.Position.Height)
|
||||
@@ -120,6 +122,11 @@ func UnmarshalQuorumCert(data []byte) (*QuorumCert, error) {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
c.Type = QCType(t8)
|
||||
tier8, err := r.u8()
|
||||
if err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
c.Tier = Finality(tier8)
|
||||
if err = r.readIDInto(&c.Position.ChainID); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestQuorumCertCodec_RoundTrip(t *testing.T) {
|
||||
for i := 0; i < 4; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, 3, votes)
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, 3, votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestQuorumCertCodec_RejectsCorrupt(t *testing.T) {
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
}
|
||||
cert, _ := AssembleQuorumCert(pos, 3, votes)
|
||||
cert, _ := AssembleQuorumCert(pos, Quasar, 3, votes)
|
||||
enc, _ := cert.MarshalBinary()
|
||||
|
||||
// Trailing byte → reject.
|
||||
@@ -98,7 +98,7 @@ func TestQuorumCert_DuplicateVoterRejected(t *testing.T) {
|
||||
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
|
||||
|
||||
// Assembly rejects a duplicate NodeID.
|
||||
_, err := AssembleQuorumCert(pos, 2, []SignedVote{
|
||||
_, err := AssembleQuorumCert(pos, Quasar, 2, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
})
|
||||
@@ -124,7 +124,7 @@ func TestQuorumCert_DuplicateVoterRejected(t *testing.T) {
|
||||
func TestQuorumCert_NilVerifierFailsClosed(t *testing.T) {
|
||||
vs := newTestValidatorSet(1)
|
||||
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
|
||||
cert, _ := AssembleQuorumCert(pos, 1, []SignedVote{
|
||||
cert, _ := AssembleQuorumCert(pos, Quasar, 1, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
})
|
||||
if err := cert.Verify(nil, 0); err == nil {
|
||||
|
||||
+111
-10
@@ -69,15 +69,27 @@ type ChainConsensus struct {
|
||||
// Consensus tracking
|
||||
bootstrapped bool
|
||||
|
||||
// ledger is the committed finality VALUE — the append-only prefix of finalized
|
||||
// history (ledger.go), the one source of truth for which block is final at each
|
||||
// height. It is only ever REPLACED WHOLE, never poked field-by-field, so there is
|
||||
// ledger is the committed NOVA finality VALUE — the append-only prefix of accepted
|
||||
// history (ledger.go), the one source of truth for which block is LOCALLY ACCEPTED at
|
||||
// each height. It is only ever REPLACED WHOLE, never poked field-by-field, so there is
|
||||
// no markFinalized to call. Exactly two paths replace it: the cert fold
|
||||
// (applyCertLocked, the production path) and SyncState (the out-of-band import
|
||||
// reconcile). A single non-branching finalized chain is the OUTPUT of the cert's
|
||||
// reorg — pruning the losing sibling subtree — never an admission refusal.
|
||||
// (applyCertLocked, the production path — now driven by the NOVA majority cert) and
|
||||
// SyncState (the out-of-band import reconcile). A single non-branching accepted chain is
|
||||
// the OUTPUT of the cert's reorg — pruning the losing sibling subtree — never an
|
||||
// admission refusal. This is the ACCEPT (local-execution) frontier; export finality is
|
||||
// the separate, trailing quasar frontier below.
|
||||
ledger FinalityLedger
|
||||
|
||||
// quasar is the EXPORT (Quasar) frontier — the highest block that has reached a
|
||||
// ⅔-by-stake certificate. It TRAILS the Nova ledger: a ⅔-stake accept quorum is a
|
||||
// superset of a bare majority, so every Quasar block is already a Nova block, and this
|
||||
// frontier is always a prefix of the certified Nova chain. It advances ONLY via
|
||||
// PromoteQuasar, which requires the promoted block to MATCH the Nova ledger's accepted
|
||||
// canonical at that height — so the "no two conflicting Quasar certs" INVARIANT is
|
||||
// inherited from the Nova ledger's single-canonical-per-height gate. GetQuasarTip reads
|
||||
// it; it is the ONLY tip bridges / DEX settlement / cross-chain messages may consume.
|
||||
quasar quasarFrontier
|
||||
|
||||
// preference is the preliminary BUILD tip used BEFORE the first finalize — the
|
||||
// DECOMPLECTED preference concern (avalanchego keeps `preference` separate from the
|
||||
// committed `lastAcceptedID`). Once the ledger is set the finalized tip wins and
|
||||
@@ -85,6 +97,20 @@ type ChainConsensus struct {
|
||||
preference ids.ID
|
||||
}
|
||||
|
||||
// quasarFrontier is the EXPORT (Quasar) frontier value: the highest block that has
|
||||
// reached a ⅔-by-stake certificate. It is a pure {height, canonical, envelope} tip, NOT a
|
||||
// full ledger — it needs no ancestry fold of its own because every Quasar block is a Nova
|
||||
// block and the Nova ledger (which PromoteQuasar validates against) already proved the
|
||||
// single non-branching accepted chain. The "no two conflicting Quasar certs" invariant is
|
||||
// therefore inherited: PromoteQuasar only ever moves this frontier to a canonical the Nova
|
||||
// ledger already accepted at that height.
|
||||
type quasarFrontier struct {
|
||||
height uint64 // highest export-final height
|
||||
canonical ids.ID // inner execution commitment of the Quasar tip (ids.Empty until the first cert)
|
||||
envelope ids.ID // outer transport id of the Quasar tip (serving / diagnostics)
|
||||
set bool // false until the first ⅔-stake cert promotes
|
||||
}
|
||||
|
||||
// NewChainConsensus creates a real consensus engine
|
||||
func NewChainConsensus(k, alpha, beta int) *ChainConsensus {
|
||||
return &ChainConsensus{
|
||||
@@ -292,15 +318,90 @@ func (c *ChainConsensus) GetFinalizedTip() ids.ID {
|
||||
return ids.Empty
|
||||
}
|
||||
|
||||
// GetCertifiedTip returns the canonical execution commitment of the certified head
|
||||
// (ids.Empty before the first cert) — the true finality identity, used for
|
||||
// equivocation evidence and conformance assertions.
|
||||
func (c *ChainConsensus) GetCertifiedTip() ids.ID {
|
||||
// GetQuasarTip returns the canonical execution commitment of the highest EXPORT-FINAL
|
||||
// (Quasar, ⅔-by-stake) block — the ONLY tip a bridge / DEX settlement / cross-chain
|
||||
// message / validator-set transition may treat as deterministically final. It is
|
||||
// ids.Empty until the first ⅔-stake certificate forms, and it NEVER returns the Nova
|
||||
// (accept) tip: a Nova block is locally executed but a lone equivocator can fork a bare
|
||||
// majority, so it is reorgable and non-exportable until it reaches Quasar. This is the
|
||||
// rename of the former GetCertifiedTip, re-pointed from the Nova fold result to the
|
||||
// trailing Quasar frontier — THE export-gating invariant of the two-tier ladder.
|
||||
func (c *ChainConsensus) GetQuasarTip() ids.ID {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.quasar.canonical
|
||||
}
|
||||
|
||||
// GetNovaTip returns the canonical execution commitment of the highest LOCALLY ACCEPTED
|
||||
// (Nova) block — the accepted/execution head that drove VM.Accept. It is the correct tip
|
||||
// for local build/preference and for equivocation evidence, but it authorizes LOCAL
|
||||
// execution ONLY: it is reorgable-in-theory until it reaches Quasar and MUST NOT be
|
||||
// exported (use GetQuasarTip for any bridge / settlement / cross-chain decision). This
|
||||
// carries the old GetCertifiedTip semantics (the Nova ledger's canonical fold result),
|
||||
// renamed to name the tier explicitly so no caller can mistake accept for export.
|
||||
func (c *ChainConsensus) GetNovaTip() ids.ID {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.ledger.CanonicalTip()
|
||||
}
|
||||
|
||||
// QuasarHeight returns the highest EXPORT-FINAL (Quasar) height and whether any ⅔-stake
|
||||
// cert has formed. GetFinalizedHeight() (the Nova/accept height) is always ≥ this; a
|
||||
// positive gap means the chain is PRODUCING Nova but not yet CERTIFYING Quasar — the
|
||||
// degraded mode (production continues, certification pauses) the two-tier split enables.
|
||||
func (c *ChainConsensus) QuasarHeight() (uint64, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.quasar.height, c.quasar.set
|
||||
}
|
||||
|
||||
// PromoteQuasar advances the EXPORT (Quasar) frontier to a block that has reached a
|
||||
// ⅔-by-stake certificate (the trailing attestation sidecar assembled it, strictly AFTER
|
||||
// the Nova accept). `cert` names the export cert's subject (height + canonical + outer
|
||||
// envelope). It advances ONLY forward and ONLY to a block the Nova ledger ALREADY accepted
|
||||
// at that height with the SAME canonical, so a Quasar cert can never certify a block Nova
|
||||
// did not accept:
|
||||
//
|
||||
// - advanced=true → the frontier moved to a new (strictly higher) height.
|
||||
// - err != nil (ErrHeightAlreadyFinalized) → the export cert's canonical CONFLICTS with the
|
||||
// canonical Nova already accepted at that height. That is a provable ⅔-stake
|
||||
// equivocation — it needs >⅓ stake to double-sign, beyond the f<⅓ assumption — so the
|
||||
// caller HALTS fail-closed and surfaces it as slashable evidence. NEVER advances on error.
|
||||
// - advanced=false, err=nil → idempotent/no-op: the height is at/below the current Quasar
|
||||
// frontier (a duplicate/late cert), or the Nova ledger has not yet (or no longer, past the
|
||||
// equivocation window) recorded this height — in which case it is an ancestor of the Nova
|
||||
// tip and already implicitly final, or the promotion will retry once Nova catches up.
|
||||
//
|
||||
// This is the SOLE writer of the Quasar frontier; the "no two conflicting Quasar certs"
|
||||
// invariant reduces to the Nova ledger's single-canonical-per-height gate that this checks
|
||||
// against (c.ledger.At), which itself fail-closes on a conflicting Nova accept.
|
||||
func (c *ChainConsensus) PromoteQuasar(cert Cert) (advanced bool, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
canonical := cert.Canonical
|
||||
if canonical == ids.Empty {
|
||||
canonical = cert.Block
|
||||
}
|
||||
novaCanonical, have := c.ledger.At(cert.Height)
|
||||
if !have {
|
||||
// Nova has not (yet / no longer within the equivocation window) recorded this height.
|
||||
// Do NOT advance: either Nova will catch up and the promotion retries, or the height
|
||||
// aged out and is an ancestor of the Nova tip (already implicitly final). Not an error.
|
||||
return false, nil
|
||||
}
|
||||
if novaCanonical != canonical {
|
||||
// A ⅔-stake export cert for a canonical the Nova ledger did NOT accept at this height —
|
||||
// the two-tier INVARIANT breach. Fail closed; the caller halts and slashes.
|
||||
return false, fmt.Errorf("%w: quasar export cert canonical %s conflicts with nova-accepted canonical %s at height %d",
|
||||
ErrHeightAlreadyFinalized, canonical, novaCanonical, cert.Height)
|
||||
}
|
||||
if c.quasar.set && cert.Height <= c.quasar.height {
|
||||
return false, nil // idempotent / monotonic: only ever advance forward
|
||||
}
|
||||
c.quasar = quasarFrontier{height: cert.Height, canonical: canonical, envelope: cert.Block, set: true}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetFinalizedHeight returns the current finalized height and whether any block
|
||||
// has been finalized yet. The engine uses this to gate an incoming cert on
|
||||
// height (reject a cert at or below the last-finalized height — MED-5) BEFORE
|
||||
|
||||
@@ -69,26 +69,24 @@ func TestFiveValidatorsAlphaFourFinalizes(t *testing.T) {
|
||||
// committee — exactly what the spec requires (3/5 = 60% < strict ⅔, and the cert
|
||||
// AND Parameters.Valid AND the BFT overlap bound all reject it). Proposer + 2
|
||||
// peers = 3 distinct < α=4 → never accepts.
|
||||
func TestThreeOfFiveRejected(t *testing.T) {
|
||||
func TestThreeOfFiveNovaAcceptsExportPauses(t *testing.T) {
|
||||
p := dyn5()
|
||||
vs := newTestValidatorSet(5)
|
||||
rec := &recordingGossiper{}
|
||||
e, chainID := newQuorumEngine(t, p, vs, 0, rec)
|
||||
e, chainID := newQuorumEngineOpts(t, p, vs, 0, rec, WithStakeWeighting(vs))
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "three-of-five")
|
||||
pos := trackProposal(e, chainID, blk, 0)
|
||||
|
||||
// Two peer accepts → proposer + 2 = 3 distinct < α=4.
|
||||
// Two peer accepts → proposer + 2 = 3 = NovaQuorum(5), the v1.36 bare-majority accept.
|
||||
e.ReceiveVote(vs.signedVote(1, pos))
|
||||
e.ReceiveVote(vs.signedVote(2, pos))
|
||||
|
||||
if waitFor(400*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
|
||||
t.Fatal("SAFETY VIOLATION: block finalized with only 3-of-5 (below α=4 strict-⅔ quorum)")
|
||||
}
|
||||
if blk.AcceptCalled() != 0 {
|
||||
t.Fatalf("VM.Accept must NOT run below quorum, got %d", blk.AcceptCalled())
|
||||
}
|
||||
// And the strict-⅔ floor confirms 3 units of stake do NOT exceed floor(2·5/3)=3.
|
||||
// NOVA: 3-of-5 is the bare majority — it accepts (local execution), the "survive 3/5" liveness.
|
||||
mustFinalize(t, e, blk, 2*time.Second, "3-of-5 bare majority (Nova local accept)")
|
||||
// QUASAR: 3 units of equal stake = 60% ≤ ⅔ — export pauses (certification degraded).
|
||||
mustNotQuasar(t, e, blk, 500*time.Millisecond, "3-of-5 = 60% stake (export gate)")
|
||||
// The strict-⅔ floor confirms 3 units of stake do NOT exceed floor(2·5/3)=3.
|
||||
if 3 > config.TwoThirdsStakeFloor(5) {
|
||||
t.Fatal("3-of-5 must NOT strictly exceed the ⅔ stake floor")
|
||||
}
|
||||
@@ -125,33 +123,25 @@ func TestOneLaggardStillFinalizes(t *testing.T) {
|
||||
// quorum genuinely unreachable: proposer + 2 = 3 distinct < α=4, so the block
|
||||
// must NOT finalize. The engine never fabricates finality from a minority — a
|
||||
// real ⅓+ outage correctly halts (safety over liveness).
|
||||
func TestTwoLaggardsNoFalseFinality(t *testing.T) {
|
||||
func TestTwoLaggardsNovaContinuesExportPauses(t *testing.T) {
|
||||
p := dyn5()
|
||||
vs := newTestValidatorSet(5)
|
||||
rec := &recordingGossiper{}
|
||||
e, chainID := newQuorumEngine(t, p, vs, 0, rec)
|
||||
e, chainID := newQuorumEngineOpts(t, p, vs, 0, rec, WithStakeWeighting(vs))
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "two-laggards")
|
||||
pos := trackProposal(e, chainID, blk, 0)
|
||||
|
||||
// Only validators 1,2 vote; 3 and 4 are laggards. Proposer + 2 = 3 < α=4.
|
||||
// Only validators 1,2 vote; 3 and 4 are laggards. Proposer + 2 = 3 = the Nova bare majority.
|
||||
e.ReceiveVote(vs.signedVote(1, pos))
|
||||
e.ReceiveVote(vs.signedVote(2, pos))
|
||||
|
||||
// Give the re-poll loop several rounds to run — it must NOT manufacture
|
||||
// finality from a sub-quorum (re-poll is liveness retry, never a force).
|
||||
if waitFor(600*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
|
||||
t.Fatal("SAFETY VIOLATION: block finalized with two laggards (3-of-5 < α=4) — re-poll must not force")
|
||||
}
|
||||
if blk.AcceptCalled() != 0 {
|
||||
t.Fatalf("VM.Accept must NOT run under a real ⅓+ outage, got %d", blk.AcceptCalled())
|
||||
}
|
||||
rec.mu.Lock()
|
||||
gotCerts := len(rec.certs)
|
||||
rec.mu.Unlock()
|
||||
if gotCerts != 0 {
|
||||
t.Fatalf("no cert may be assembled below quorum, got %d", gotCerts)
|
||||
}
|
||||
// NOVA: with 2 laggards the 3 live validators still accept — production SURVIVES the outage
|
||||
// (the v1.36 "majority is enough" mandate). This is the exact case the old ⅔-accept froze.
|
||||
mustFinalize(t, e, blk, 2*time.Second, "3 live of 5 (2 laggards) — Nova local accept")
|
||||
// QUASAR: 60% stake ≤ ⅔ — certification pauses until a laggard returns (the degraded mode:
|
||||
// production continues, export waits). No export cert forms from a sub-⅔ set.
|
||||
mustNotQuasar(t, e, blk, 500*time.Millisecond, "2-laggard outage = 60% stake (export gate)")
|
||||
}
|
||||
|
||||
// TestSelfVoteCounts proves the proposer's OWN signed accept is one of the α
|
||||
|
||||
+381
-19
@@ -606,6 +606,39 @@ type Transitive struct {
|
||||
// plumbed", exactly the semantics ToQuasarCert already relies on.
|
||||
cryptoWitness CryptoWitnessSource
|
||||
|
||||
// quasarAttestor (optional) is the trailing EXPORT-cert assembler — the ONE
|
||||
// producer of the ⅔-by-stake Quasar certificate, run strictly AFTER a Nova accept.
|
||||
// Nova (the majority accept cert, assembleCertLocked) is the SOLE decider of VM.Accept
|
||||
// and chain liveness; this sidecar is an ARTIFACT builder that NEVER gates acceptance
|
||||
// (an absent/late/insufficient Quasar cert leaves a block Nova-only — the degraded
|
||||
// mode). promoteQuasarLocked feeds it the just-accepted block's own verified accept
|
||||
// votes (which are byte-identical attestations of the same canonical position), and
|
||||
// when a ⅔-stake supermajority has attested it emits the export cert and advances the
|
||||
// consensus Quasar frontier. Wired ONLY when both voteVerifier and stakeSource are
|
||||
// present (a stake-weighted chain that can export); nil on an equal-stake/dev/K==1 chain
|
||||
// (Nova-only, no cross-chain export). See attestation.go.
|
||||
quasarAttestor *QuasarAttestor
|
||||
// attestorEpochMu guards attestorEpoch. attestorEpoch maps a value-chain height to the
|
||||
// P-chain epoch height its weighted set is pinned to, so the attestor resolves per-voter
|
||||
// pubkeys + the ⅔-stake tally at the SAME epoch the Nova cert was verified under (MEDIUM-1
|
||||
// parity). Populated by promoteQuasarLocked from the block's recorded pChainHeight before
|
||||
// feeding; identity (epoch==height) for a fixed-set chain where the set does not vary.
|
||||
// Pruned below the finalized height alongside the attestor's own window.
|
||||
attestorEpochMu sync.Mutex
|
||||
attestorEpoch map[uint64]uint64
|
||||
// acceptedPos remembers the signed VotePosition + epoch of a Nova-accepted block, keyed by
|
||||
// its outer id, so a LATE accept vote — the ⅔-th stake vote necessarily trails the
|
||||
// bare-majority accept and arrives when the pending block is already dropped — can still be
|
||||
// attested (verified against the exact signed bytes) and complete the export cert. Bounded to
|
||||
// the attestor window (pruneQuasarBelow). Guarded by attestorEpochMu.
|
||||
acceptedPos map[ids.ID]acceptedPos
|
||||
// responsiveStakeNum/Den record the stake that voted on the most recently accepted block
|
||||
// (numerator) out of the epoch's total (denominator) — the degraded-mode RPC signal
|
||||
// (responsiveStakePct + certificateAvailable). Guarded by t.mu. Zero denominator ⇒
|
||||
// "unknown" (no stake-weighted accept observed yet).
|
||||
responsiveStakeNum uint64
|
||||
responsiveStakeDen uint64
|
||||
|
||||
// catchup (optional) is the engine's seam for runtime auto-recovery when it
|
||||
// falls behind — see Catchup. When a gossiped child or a verified cert
|
||||
// references a parent this node does not have, the engine asks catchup to
|
||||
@@ -816,6 +849,20 @@ func (t *Transitive) Start(ctx context.Context, _ bool) error {
|
||||
return ErrQuorumVerifierRequired
|
||||
}
|
||||
|
||||
// Wire the trailing EXPORT-cert assembler (the ONE Quasar producer) when the chain can
|
||||
// export: a stake-weighted chain with a vote verifier. It runs strictly AFTER Nova accept
|
||||
// and NEVER gates it. An equal-stake/dev/K==1 chain has no cross-chain export surface, so
|
||||
// it stays Nova-only (nil attestor) — GetQuasarTip is simply ids.Empty there. The epoch
|
||||
// map bridges the attestor's height→epoch resolution to the block's recorded P-chain
|
||||
// epoch (identity for a fixed set). Constructed once, here, so the Quasar frontier has a
|
||||
// single producer.
|
||||
if t.voteVerifier != nil && t.stakeSource != nil && t.quasarAttestor == nil {
|
||||
if t.attestorEpoch == nil {
|
||||
t.attestorEpoch = make(map[uint64]uint64)
|
||||
}
|
||||
t.quasarAttestor = NewQuasarAttestor(t.voteVerifier, t.stakeSource, t.attestorEpochOf)
|
||||
}
|
||||
|
||||
// A signing validator SHOULD have a durable equivocation guard so a crash between
|
||||
// signing and finalizing cannot forget a per-height binding and permit a fork
|
||||
// (HIGH-1). Memory-only is correct for verify-only nodes and tests; in production
|
||||
@@ -1824,13 +1871,17 @@ func (t *Transitive) handleVote(vote Vote) {
|
||||
ctx := t.ctx
|
||||
|
||||
if !exists {
|
||||
// A vote for an ALREADY-FINALIZED block needs nothing: the block is decided
|
||||
// (removed from pendingBlocks, recorded in finalizedByCert). Do NOT buffer or
|
||||
// fetch it — that would re-park a late/duplicate vote AFTER acceptWithCertCore
|
||||
// cleared the buffer, leaking a slot for a block that will never re-track to
|
||||
// drain it. Drop it (the finality cert already exists).
|
||||
// A vote for an ALREADY-FINALIZED (Nova-accepted) block needs nothing for ACCEPTANCE:
|
||||
// the block is decided (removed from pendingBlocks, recorded in finalizedByCert). Do NOT
|
||||
// buffer or fetch it — that would re-park a late/duplicate vote AFTER acceptWithCertCore
|
||||
// cleared the buffer. But it CAN still complete the EXPORT (Quasar) cert: the ⅔-th stake
|
||||
// vote necessarily TRAILS the bare-majority Nova accept, so a trailing accept vote is
|
||||
// routed to the attestor (verified against the exact accepted position — attestFinalizedVote
|
||||
// re-checks the signature, so this never bypasses the gate).
|
||||
if _, finalized := t.finalizedByCert[vote.BlockID]; finalized {
|
||||
verifier := t.voteVerifier
|
||||
t.mu.Unlock()
|
||||
t.attestFinalizedVote(vote, verifier)
|
||||
return
|
||||
}
|
||||
// VOTE FOR A BLOCK WE DO NOT YET TRACK — the gossip race that wedged the
|
||||
@@ -2768,12 +2819,19 @@ func (t *Transitive) assembleCertLocked(pending *PendingBlock, blockID ids.ID) *
|
||||
// The epoch height pins every per-voter pubkey resolution + the stake tally to
|
||||
// the SAME P-chain height the position's set-root commits to (MEDIUM-1).
|
||||
epochHeight := t.epochHeightLocked(pending)
|
||||
// Cert COUNT threshold sized to the LIVE validator set (effectiveCommittee), NOT the oversized
|
||||
// sample: MainnetParams α=15 is unreachable from a 5-validator set, so the ⅔-by-stake
|
||||
// cert would never assemble (the 2026-07-09 freeze). bftAlpha over the SAME height-indexed set the
|
||||
// ⅔-by-stake VerifyWeighted gate below reads keeps the count-quorum and the stake-quorum identical.
|
||||
_, alpha := t.effectiveCommittee(epochHeight)
|
||||
if alpha <= 0 {
|
||||
// NOVA ACCEPT THRESHOLD — a bare-majority NovaQuorum(n) of the LIVE committee, NOT the ⅔
|
||||
// Quasar floor. This is the SOLE gate on VM.Accept (local execution): it must ignite at 3
|
||||
// of 5 so production continues when up to ⌊(n−1)/2⌋ crash faults keep the ⅔-stake quorum
|
||||
// unreachable (the "survive 3/5" mandate; the ⅔ bftAlpha=4-of-5 froze the fleet the instant
|
||||
// a 2nd node dropped). `n` is the live committee sized by effectiveCommittee — clamped to the
|
||||
// resolved set AND floored at the minimal BFT committee, so a transient/degenerate low count
|
||||
// can never drop the majority below a lone-reachable value (the 1085013 self-finality guard
|
||||
// carries over: NovaQuorum(floored-n) ≥ 3, unreachable by a lone node). The ⅔-by-stake QUASAR
|
||||
// EXPORT cert is a SEPARATE, trailing artifact (the attestation sidecar promoteQuasarLocked),
|
||||
// NEVER gated here — accept (Nova) and export (Quasar) are decomplected tiers.
|
||||
n, _ := t.effectiveCommittee(epochHeight)
|
||||
novaThreshold := NovaQuorum(n)
|
||||
if novaThreshold <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2816,19 +2874,22 @@ func (t *Transitive) assembleCertLocked(pending *PendingBlock, blockID ids.ID) *
|
||||
}
|
||||
}
|
||||
}
|
||||
if uint32(len(verified)) < uint32(alpha) {
|
||||
if uint32(len(verified)) < uint32(novaThreshold) {
|
||||
return nil
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, uint32(alpha), verified)
|
||||
cert, err := AssembleQuorumCert(pos, Nova, uint32(novaThreshold), verified)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Defence in depth: the cert we just built must verify under our own
|
||||
// verifier before we treat it as a finality witness (catches any assembly
|
||||
// invariant drift). Assemble already enforced distinctness + threshold.
|
||||
// On a stake-weighted chain it must ALSO clear the ⅔-of-stake supermajority
|
||||
// (HIGH-3) — the count quorum alone is not finality when stake is unequal, so
|
||||
// we keep WAITING (return nil) until enough STAKE has voted, never forcing.
|
||||
// Defence in depth: the Nova cert we just built must verify under our own verifier
|
||||
// before we treat it as a finality witness (catches any assembly invariant drift).
|
||||
// Assemble already enforced distinctness + threshold. On a stake-weighted chain
|
||||
// VerifyWeighted selects the NOVA tier — signatures + a strict COUNT majority of the
|
||||
// live set, DELIBERATELY NOT ⅔-by-stake — so acceptance ignites at a bare majority even
|
||||
// when the absent minority holds the stake majority (that is the whole point of Nova;
|
||||
// the ⅔-stake gate lives ONLY on the trailing Quasar cert). On a chain with no stake
|
||||
// source (equal-stake/dev) the tier-agnostic count-only Verify enforces the same
|
||||
// NovaQuorum count via the cert's own threshold.
|
||||
if t.stakeSource != nil {
|
||||
if err := cert.VerifyWeighted(t.voteVerifier, t.stakeSource, epochHeight); err != nil {
|
||||
return nil
|
||||
@@ -3092,6 +3153,7 @@ func (t *Transitive) buildSingleValidatorCertLocked(pending *PendingBlock, block
|
||||
return VerifiedQuorumCert{qc: &QuorumCert{
|
||||
Version: QuorumCertVersion,
|
||||
Type: QCFinality,
|
||||
Tier: Nova, // a K==1 accept authorizes LOCAL execution; Quasar (export) is a separate trailing artifact
|
||||
Position: pos,
|
||||
Threshold: 1,
|
||||
}}
|
||||
@@ -3134,6 +3196,13 @@ func (t *Transitive) acceptWithCertCore(ctx context.Context, blockID ids.ID, cer
|
||||
t.mu.RLock()
|
||||
pending, exists := t.pendingBlocks[blockID]
|
||||
decided := exists && pending.Decided
|
||||
// Capture the block's P-chain EPOCH now, while the pending block still exists — the Nova
|
||||
// accept below drops it, and the trailing Quasar promotion needs the same epoch the Nova
|
||||
// cert was verified under to resolve the ⅔-stake tally (MEDIUM-1 parity).
|
||||
var quasarEpoch uint64
|
||||
if exists && pending != nil {
|
||||
quasarEpoch = t.epochHeightLocked(pending)
|
||||
}
|
||||
t.mu.RUnlock()
|
||||
if !exists || decided {
|
||||
return nil
|
||||
@@ -3181,6 +3250,11 @@ func (t *Transitive) acceptWithCertCore(ctx context.Context, blockID ids.ID, cer
|
||||
}
|
||||
// MED-6: bound the slashing detector's per-height maps to a sliding window (memory).
|
||||
t.pruneSlashingBelowWindow()
|
||||
// Bound the trailing Quasar machinery (attestor buckets/certs + epoch map) to the SAME
|
||||
// window below the accepted height — the export cert chain is persisted by its consumer.
|
||||
if highestAccepted > slashingRetentionHeights {
|
||||
t.pruneQuasarBelow(highestAccepted - slashingRetentionHeights)
|
||||
}
|
||||
|
||||
if acceptErr != nil {
|
||||
// FAIL-CLOSED: the VM refused to apply a consensus-finalized block. The floor did NOT
|
||||
@@ -3191,6 +3265,15 @@ func (t *Transitive) acceptWithCertCore(ctx context.Context, blockID ids.ID, cer
|
||||
return acceptErr
|
||||
}
|
||||
|
||||
// TRAILING EXPORT PROMOTION (Nova-sole-decider, step 2): the block is now Nova-accepted
|
||||
// (in the ledger). Feed its OWN verified accept votes to the Quasar attestor; if a
|
||||
// ⅔-by-stake supermajority has attested, it emits the export cert and advances the Quasar
|
||||
// frontier. This runs strictly AFTER accept and NEVER gates it — a block that only reached
|
||||
// a bare majority stays Nova-only (degraded), and a later fully-voted block advances the
|
||||
// export frontier past the gap. Best-effort; an equivocation breach halts export
|
||||
// fail-closed inside promoteQuasar (never a silent export of a forked block).
|
||||
t.promoteQuasar(cert.Cert(), quasarEpoch)
|
||||
|
||||
// FULL SUCCESS: REORG production onto the certified branch: SetPreference to the new tip
|
||||
// keeps the VM building on the block consensus just finalized.
|
||||
t.mu.RLock()
|
||||
@@ -3213,6 +3296,285 @@ func (t *Transitive) acceptWithCertCore(ctx context.Context, blockID ids.ID, cer
|
||||
return nil
|
||||
}
|
||||
|
||||
// promoteQuasar is the trailing EXPORT-cert step — the ONE place a ⅔-by-stake Quasar
|
||||
// certificate is produced, run strictly AFTER a Nova accept and NEVER gating it. novaCert is
|
||||
// the just-accepted block's Nova cert; its Votes are valid attestations of the same canonical
|
||||
// position (byte-identical CanonicalVoteMessage), so they feed the attestor directly. When a
|
||||
// ⅔ stake supermajority has attested, the attestor emits the export cert and this advances the
|
||||
// consensus Quasar frontier (the ONLY tip GetQuasarTip / export consumers read). Called
|
||||
// WITHOUT t.mu.
|
||||
//
|
||||
// - Below ⅔ stake (e.g. 3 of 5): no export cert emits — the block stays Nova-only (the
|
||||
// degraded mode: producing but not certifying). A later fully-voted block advances the
|
||||
// export frontier past the gap (a Quasar tip finalizes all its ancestors), so no
|
||||
// per-block retroactive promotion is needed for liveness.
|
||||
// - Equivocation breach (a ⅔ export cert names a canonical the Nova ledger did NOT accept at
|
||||
// this height): needs >⅓ stake double-signing, beyond f<⅓ — export HALTS fail-closed and
|
||||
// the breach is logged as slashable. NEVER a silent export of a forked block.
|
||||
func (t *Transitive) promoteQuasar(novaCert *QuorumCert, epochHeight uint64) {
|
||||
if novaCert == nil {
|
||||
return
|
||||
}
|
||||
t.mu.RLock()
|
||||
attestor := t.quasarAttestor
|
||||
stake := t.stakeSource
|
||||
t.mu.RUnlock()
|
||||
if stake == nil {
|
||||
return // no stake model wired ⇒ no export surface, no responsive-stake signal
|
||||
}
|
||||
pos := novaCert.Position
|
||||
// Record responsive stake from the ACCEPTED block's votes ALWAYS — the degraded-mode RPC
|
||||
// signal must update even when ⅔ was NOT reached (3/5 ⇒ 60% ⇒ degraded=true).
|
||||
t.recordResponsiveStake(stake, novaCert.Votes, epochHeight)
|
||||
if attestor == nil {
|
||||
return
|
||||
}
|
||||
// Remember the accepted block's signed position so LATE votes can complete the export cert
|
||||
// AFTER the pending block is dropped. The Nova cert freezes at the BARE MAJORITY (the accept
|
||||
// threshold), so the ⅔-th stake vote by definition TRAILS the accept — it arrives when the
|
||||
// block is already finalized and would otherwise be dropped. rememberAcceptedPos + the
|
||||
// finalized-block attestation tap (handleVote) route those trailing votes to the attestor so
|
||||
// export can still form. Feed the majority votes now (seeds the bucket + emits if ⅔ already
|
||||
// present, e.g. a burst-delivered vote set).
|
||||
t.rememberAcceptedPos(pos, epochHeight)
|
||||
for i := range novaCert.Votes {
|
||||
t.ingestAttestation(pos, epochHeight, novaCert.Votes[i])
|
||||
}
|
||||
}
|
||||
|
||||
// ingestAttestation feeds ONE verified accept vote — an attestation of the accepted block's
|
||||
// canonical position — into the Quasar attestor and, if it completes a ⅔-by-stake export cert,
|
||||
// advances the Quasar frontier. It is idempotent and safe for pre-accept, at-accept, and
|
||||
// post-accept (late) votes: the attestor dedups by NodeID and PromoteQuasar only ever moves the
|
||||
// frontier forward to a Nova-accepted canonical. An equivocation breach (a ⅔ cert naming a
|
||||
// canonical the Nova ledger did not accept) HALTS export fail-closed. Called WITHOUT t.mu.
|
||||
func (t *Transitive) ingestAttestation(pos VotePosition, epochHeight uint64, sv SignedVote) {
|
||||
t.mu.RLock()
|
||||
attestor := t.quasarAttestor
|
||||
t.mu.RUnlock()
|
||||
if attestor == nil {
|
||||
return
|
||||
}
|
||||
// Resolve the attestor's height→epoch so it verifies at the SAME epoch the Nova cert did.
|
||||
t.setAttestorEpoch(pos.Height, epochHeight)
|
||||
cert, emitted, _ := attestor.Ingest(pos, sv)
|
||||
if !emitted {
|
||||
return
|
||||
}
|
||||
advanced, err := t.consensus.PromoteQuasar(Cert{Block: pos.BlockID, Parent: pos.ParentID, Height: pos.Height, Canonical: pos.CanonicalID})
|
||||
if err != nil {
|
||||
if t.log != nil {
|
||||
t.log.Error("QUASAR EQUIVOCATION — a ⅔-stake export cert conflicts with the Nova-accepted "+
|
||||
"block at this height; export HALTED fail-closed (slashable — needs >⅓ stake double-signing)",
|
||||
"height", pos.Height, "canonical", pos.CanonicalID, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if advanced {
|
||||
// The export cert reflects the ACTUAL responsive stake for this block (≥⅔, since the trailing
|
||||
// votes completed it) — refresh the degraded-mode signal so CertificateAvailable/Degraded
|
||||
// clear once export forms, not stay pinned to the bare-majority Nova cert that froze earlier.
|
||||
t.mu.RLock()
|
||||
stake := t.stakeSource
|
||||
t.mu.RUnlock()
|
||||
t.recordResponsiveStake(stake, cert.Votes, epochHeight)
|
||||
if t.log != nil {
|
||||
t.log.Debug("quasar: export frontier advanced (⅔-by-stake certificate formed)",
|
||||
"height", pos.Height, "canonical", pos.CanonicalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// acceptedPos is the remembered signed position + epoch of a Nova-accepted block (see the
|
||||
// acceptedPos field), enabling attestation of trailing votes after the pending block is dropped.
|
||||
type acceptedPos struct {
|
||||
pos VotePosition
|
||||
epoch uint64
|
||||
}
|
||||
|
||||
// rememberAcceptedPos records the accepted block's signed position + epoch keyed by its outer
|
||||
// id, so a LATE accept vote (the ⅔-th stake vote trails the bare-majority Nova accept) can be
|
||||
// fed to the attestor after the pending block is dropped. Bounded: pruned to the attestor
|
||||
// window. Guarded by attestorEpochMu (co-located with the epoch map it complements).
|
||||
func (t *Transitive) rememberAcceptedPos(pos VotePosition, epoch uint64) {
|
||||
t.attestorEpochMu.Lock()
|
||||
defer t.attestorEpochMu.Unlock()
|
||||
if t.acceptedPos == nil {
|
||||
t.acceptedPos = make(map[ids.ID]acceptedPos)
|
||||
}
|
||||
t.acceptedPos[pos.BlockID] = acceptedPos{pos: pos, epoch: epoch}
|
||||
}
|
||||
|
||||
// lookupAcceptedPos returns the remembered signed position + epoch for an accepted (now
|
||||
// finalized) outer block id, so a trailing vote can be attested against the exact bytes the
|
||||
// accept votes signed. ok=false when the block is unknown or aged out of the window.
|
||||
func (t *Transitive) lookupAcceptedPos(blockID ids.ID) (acceptedPos, bool) {
|
||||
t.attestorEpochMu.Lock()
|
||||
defer t.attestorEpochMu.Unlock()
|
||||
ap, ok := t.acceptedPos[blockID]
|
||||
return ap, ok
|
||||
}
|
||||
|
||||
// attestFinalizedVote routes a LATE accept vote for an already Nova-accepted (finalized) block
|
||||
// to the Quasar attestor, so the ⅔-th stake vote — which necessarily trails the bare-majority
|
||||
// accept — can still complete the EXPORT cert. It verifies the signature against the EXACT
|
||||
// accepted position (the same bytes the accept votes signed) BEFORE feeding, so a late
|
||||
// forged/wrong-position vote is dropped, never attested. No-op when the block aged out of the
|
||||
// remembered window or no attestor/verifier is wired. Called WITHOUT t.mu.
|
||||
func (t *Transitive) attestFinalizedVote(vote Vote, verifier VoteVerifier) {
|
||||
if !vote.Accept || verifier == nil || len(vote.Signature) == 0 {
|
||||
return
|
||||
}
|
||||
ap, ok := t.lookupAcceptedPos(vote.BlockID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
msg := CanonicalVoteMessage(ap.pos)
|
||||
if !verifier.VerifyVote(vote.NodeID, msg, vote.Signature, ap.epoch) {
|
||||
return // forged / wrong-position trailing vote — dropped, never attested
|
||||
}
|
||||
t.ingestAttestation(ap.pos, ap.epoch, SignedVote{NodeID: vote.NodeID, Accept: true, Signature: vote.Signature})
|
||||
}
|
||||
|
||||
// recordResponsiveStake stores the stake that voted on the latest accepted block (numerator)
|
||||
// out of the epoch total (denominator) — the degraded-mode RPC signal read by FinalityStatus.
|
||||
func (t *Transitive) recordResponsiveStake(stake StakeSource, votes []SignedVote, epochHeight uint64) {
|
||||
if stake == nil {
|
||||
return
|
||||
}
|
||||
total := stake.TotalStake(epochHeight)
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
var voted uint64
|
||||
for i := range votes {
|
||||
voted += stake.Weight(votes[i].NodeID, epochHeight)
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.responsiveStakeNum = voted
|
||||
t.responsiveStakeDen = total
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// attestorEpochOf maps a value-chain height to the P-chain epoch its weighted set is pinned to
|
||||
// — the attestor's per-voter pubkey + ⅔-stake resolution epoch. Returns the recorded epoch, or
|
||||
// the height itself (identity) for a fixed-set chain where the set does not vary by epoch.
|
||||
func (t *Transitive) attestorEpochOf(height uint64) uint64 {
|
||||
t.attestorEpochMu.Lock()
|
||||
defer t.attestorEpochMu.Unlock()
|
||||
if e, ok := t.attestorEpoch[height]; ok {
|
||||
return e
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
// setAttestorEpoch records a height→epoch binding for the attestor's epochOf resolution.
|
||||
func (t *Transitive) setAttestorEpoch(height, epoch uint64) {
|
||||
t.attestorEpochMu.Lock()
|
||||
defer t.attestorEpochMu.Unlock()
|
||||
if t.attestorEpoch == nil {
|
||||
t.attestorEpoch = make(map[uint64]uint64)
|
||||
}
|
||||
t.attestorEpoch[height] = epoch
|
||||
}
|
||||
|
||||
// pruneQuasarBelow bounds the trailing export machinery (attestor buckets/certs + epoch map)
|
||||
// to a window below the finalized height — the external cert chain is persisted by its
|
||||
// consumer; the engine keeps only a live window. No-op when no attestor is wired.
|
||||
func (t *Transitive) pruneQuasarBelow(height uint64) {
|
||||
t.mu.RLock()
|
||||
attestor := t.quasarAttestor
|
||||
t.mu.RUnlock()
|
||||
if attestor != nil {
|
||||
attestor.PruneBelow(height)
|
||||
}
|
||||
t.attestorEpochMu.Lock()
|
||||
for h := range t.attestorEpoch {
|
||||
if h < height {
|
||||
delete(t.attestorEpoch, h)
|
||||
}
|
||||
}
|
||||
for id, ap := range t.acceptedPos {
|
||||
if ap.pos.Height < height {
|
||||
delete(t.acceptedPos, id)
|
||||
}
|
||||
}
|
||||
t.attestorEpochMu.Unlock()
|
||||
}
|
||||
|
||||
// FinalityStatus is the two-tier finality snapshot for RPC / degraded-mode visibility. Each
|
||||
// tier is a DISTINCT field: a Nova (accept) height is NEVER reported as certified/quasar. A
|
||||
// consumer that needs export finality reads QuasarHeight (or GetQuasarTip), never NovaHeight.
|
||||
type FinalityStatus struct {
|
||||
NovaHeight uint64 // highest LOCALLY ACCEPTED (bare-majority) height — drives VM state; NOT exportable
|
||||
QuasarHeight uint64 // highest EXPORT-FINAL (⅔-by-stake) height — the ONLY certified/exportable height
|
||||
HorizonHeight uint64 // highest PQ-sealed height (0 until the Horizon seal path is wired)
|
||||
// ResponsiveStakePct is the stake that voted on the latest accepted block / total at its
|
||||
// epoch (0..1); -1 when unknown (startup / no stake model).
|
||||
ResponsiveStakePct float64
|
||||
// CertificateAvailable reports whether a ⅔-stake (Quasar) cert is currently reachable —
|
||||
// the responding stake STRICTLY exceeds the ⅔ floor.
|
||||
CertificateAvailable bool
|
||||
// Degraded reports that the chain is PRODUCING Nova but NOT certifying Quasar (responding
|
||||
// stake ≤ ⅔) — production continues, certification pauses. The two-tier liveness mode.
|
||||
Degraded bool
|
||||
}
|
||||
|
||||
// FinalityStatus returns the current two-tier finality snapshot. novaHeight ≥ quasarHeight
|
||||
// always; the gap plus the responsive-stake signal expose the degraded mode. A Nova height is
|
||||
// never conflated with a certified/quasar height.
|
||||
func (t *Transitive) FinalityStatus() FinalityStatus {
|
||||
var s FinalityStatus
|
||||
if nh, ok := t.consensus.GetFinalizedHeight(); ok {
|
||||
s.NovaHeight = nh
|
||||
}
|
||||
if qh, ok := t.consensus.QuasarHeight(); ok {
|
||||
s.QuasarHeight = qh
|
||||
}
|
||||
// HorizonHeight stays 0: the ladder MARKS Horizon (Finality.Horizon /
|
||||
// AuthorizesIrreversibleSettlement) but the PQ seal path is left as-is — no frontier
|
||||
// advances to Horizon yet.
|
||||
t.mu.RLock()
|
||||
num, den := t.responsiveStakeNum, t.responsiveStakeDen
|
||||
t.mu.RUnlock()
|
||||
if den == 0 {
|
||||
s.ResponsiveStakePct = -1 // unknown: startup or no stake model — do not false-alarm degraded
|
||||
return s
|
||||
}
|
||||
s.ResponsiveStakePct = float64(num) / float64(den)
|
||||
s.CertificateAvailable = num > config.TwoThirdsStakeFloor(den)
|
||||
s.Degraded = !s.CertificateAvailable
|
||||
return s
|
||||
}
|
||||
|
||||
// QuasarTip returns the canonical execution commitment of the highest EXPORT-FINAL (Quasar)
|
||||
// block — the export-gating tip for bridges / DEX settlement / cross-chain. ids.Empty until
|
||||
// the first ⅔-stake cert. NEVER the Nova (accept) tip.
|
||||
func (t *Transitive) QuasarTip() ids.ID { return t.consensus.GetQuasarTip() }
|
||||
|
||||
// NovaTip returns the canonical execution commitment of the highest LOCALLY ACCEPTED (Nova)
|
||||
// block — the accepted/execution head. Local-execution authority ONLY; not exportable.
|
||||
func (t *Transitive) NovaTip() ids.ID { return t.consensus.GetNovaTip() }
|
||||
|
||||
// QuasarHeight returns the highest export-final (Quasar) height and whether any ⅔-stake cert
|
||||
// has formed. Complements GetFinalizedHeight (the Nova/accept height).
|
||||
func (t *Transitive) QuasarHeight() (uint64, bool) { return t.consensus.QuasarHeight() }
|
||||
|
||||
// QuasarCertAt returns the EXPORT (Quasar, ⅔-by-stake) certificate for a height, if one has
|
||||
// formed — the portable witness a bridge / DEX settlement (0x9999) / cross-chain consumer
|
||||
// verifies. nil,false when no export cert exists at that height (Nova-only / degraded) or no
|
||||
// attestor is wired. This is the ONLY cert an export consumer may act on; the Nova accept cert
|
||||
// (gossiped for follower fast-finalize) is NOT export-grade.
|
||||
func (t *Transitive) QuasarCertAt(height uint64) (*QuorumCert, bool) {
|
||||
t.mu.RLock()
|
||||
attestor := t.quasarAttestor
|
||||
t.mu.RUnlock()
|
||||
if attestor == nil {
|
||||
return nil, false
|
||||
}
|
||||
return attestor.CertAt(height)
|
||||
}
|
||||
|
||||
// applyBranchFinalization applies a consensus FinalizeBranch plan to the VM and
|
||||
// engine bookkeeping. It mirrors avalanchego topological.go's accept/reject split:
|
||||
// VM.Accept the finalized path (child.Accept, ascending height) and VM.Reject the
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestEpochBinding_CrossEpochCertRejected(t *testing.T) {
|
||||
ChainID: chainID, Height: 10, Round: 0, BlockID: blockID, ParentID: ids.Empty,
|
||||
ValidatorSetRoot: rootEpochA,
|
||||
}
|
||||
certA, err := AssembleQuorumCert(posA, 3, []SignedVote{
|
||||
certA, err := AssembleQuorumCert(posA, Quasar, 3, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, posA)},
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, posA)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, posA)},
|
||||
@@ -141,7 +141,7 @@ func TestEpochBinding_StakeReadAtCertHeight(t *testing.T) {
|
||||
|
||||
// A cert finalized at height 10 (epoch [0,99]) by {0,1,2}: 99/100 stake → valid.
|
||||
pos10 := VotePosition{ChainID: chainID, Height: 10, Round: 0, BlockID: blockID, ParentID: ids.Empty, ValidatorSetRoot: root}
|
||||
cert10, err := AssembleQuorumCert(pos10, 3, []SignedVote{
|
||||
cert10, err := AssembleQuorumCert(pos10, Quasar, 3, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos10)},
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos10)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos10)},
|
||||
@@ -166,7 +166,7 @@ func TestEpochBinding_StakeReadAtCertHeight(t *testing.T) {
|
||||
// 100, so the no-flip above is meaningful (not a vacuous test). A cert built
|
||||
// at height 100 by {0,1,2} holds only 3/100 → MUST be rejected.
|
||||
pos100 := VotePosition{ChainID: chainID, Height: 100, Round: 0, BlockID: ids.GenerateTestID(), ParentID: blockID, ValidatorSetRoot: ids.GenerateTestID()}
|
||||
cert100, err := AssembleQuorumCert(pos100, 3, []SignedVote{
|
||||
cert100, err := AssembleQuorumCert(pos100, Quasar, 3, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos100)},
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos100)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos100)},
|
||||
@@ -186,7 +186,7 @@ func TestEpochBinding_RoundTripPreservesRoot(t *testing.T) {
|
||||
vs := newTestValidatorSet(3)
|
||||
root := ids.GenerateTestID()
|
||||
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 7, Round: 2, BlockID: ids.GenerateTestID(), ParentID: ids.GenerateTestID(), ValidatorSetRoot: root}
|
||||
cert, err := AssembleQuorumCert(pos, 3, []SignedVote{
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, 3, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
|
||||
@@ -317,23 +317,21 @@ func TestPChainEpochFinality_RealWiring(t *testing.T) {
|
||||
t.Fatal("block VM.Accepted but consensus does not report it accepted")
|
||||
}
|
||||
|
||||
// A verified, stake-weighted cert must have been assembled + gossiped, and it
|
||||
// must verify under VerifyWeighted at the EPOCH height (not the value height).
|
||||
rec.mu.Lock()
|
||||
nCerts := len(rec.certs)
|
||||
var lastCert []byte
|
||||
if nCerts > 0 {
|
||||
lastCert = rec.certs[nCerts-1]
|
||||
// The EXPORT (Quasar, ⅔-by-stake) cert — the witness a bridge / settlement consumes — must
|
||||
// form. The ⅔-th stake vote (node 4) necessarily TRAILS the bare-majority Nova accept, so it
|
||||
// completes the export cert via the trailing-attestation path. (The gossiped Nova accept cert
|
||||
// is the bare majority; it is NOT the export witness and need not carry node 4.)
|
||||
if !waitFor(2*time.Second, func() bool { qh, ok := e.QuasarHeight(); return ok && qh >= blk.height }) {
|
||||
t.Fatal("a ⅔-stake EXPORT (Quasar) cert must form — the epoch-resolved ⅔ quorum did not export")
|
||||
}
|
||||
rec.mu.Unlock()
|
||||
if nCerts == 0 {
|
||||
t.Fatal("a verified quorum cert must be assembled and gossiped at finality")
|
||||
cert, ok := e.QuasarCertAt(blk.height)
|
||||
if !ok {
|
||||
t.Fatal("QuasarCertAt must return the export cert at the accepted height")
|
||||
}
|
||||
cert, err := UnmarshalQuorumCert(lastCert)
|
||||
if err != nil {
|
||||
t.Fatalf("decode gossiped cert: %v", err)
|
||||
if cert.Tier != Quasar {
|
||||
t.Fatalf("export cert must be the Quasar tier, got %s", cert.Tier)
|
||||
}
|
||||
// (C) explicit: node 4's vote IS in the cert, proving the departed-from-current
|
||||
// (C) explicit: node 4's vote IS in the export cert, proving the departed-from-current
|
||||
// validator's legit vote was verified at the epoch (RESIDUAL-B).
|
||||
var has4 bool
|
||||
for i := range cert.Votes {
|
||||
@@ -347,12 +345,12 @@ func TestPChainEpochFinality_RealWiring(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !has4 {
|
||||
t.Fatal("RESIDUAL-B: the epoch-only voter (departed from the current map) must be in the cert " +
|
||||
t.Fatal("RESIDUAL-B: the epoch-only voter (departed from the current map) must be in the EXPORT cert " +
|
||||
"— its vote was wrongly dropped (verifier read the current map, not set@epoch)")
|
||||
}
|
||||
// The cert verifies under the stake-weighted predicate at the EPOCH height.
|
||||
// The export cert verifies under the stake-weighted predicate at the EPOCH height.
|
||||
if err := cert.VerifyWeighted(src, src, epochHeight); err != nil {
|
||||
t.Fatalf("gossiped cert must verify stake-weighted at the epoch height: %v", err)
|
||||
t.Fatalf("export cert must verify stake-weighted at the epoch height: %v", err)
|
||||
}
|
||||
// And it must FAIL at the value height (where the set is empty / total stake 0)
|
||||
// — proving the height genuinely matters and the epoch read is load-bearing.
|
||||
|
||||
@@ -239,25 +239,29 @@ func TestDEX_BFTQuorumFinalizesFill(t *testing.T) {
|
||||
e.ReceiveVote(vs.signedVote(3, pos))
|
||||
e.ReceiveVote(vs.signedVote(4, pos))
|
||||
|
||||
// NOVA: the fill Nova-accepts (local execution) on the majority.
|
||||
if !waitFor(2*time.Second, func() bool { return fill.AcceptCalled() == 1 }) {
|
||||
t.Fatalf("LIVENESS: DEX value fill did not finalize under the stake-weighted quorum (Accept ran %d times)", fill.AcceptCalled())
|
||||
t.Fatalf("LIVENESS: DEX value fill did not Nova-accept (Accept ran %d times)", fill.AcceptCalled())
|
||||
}
|
||||
// The finality witness must be a real, verifiable cert that ALSO clears the stake-
|
||||
// weighted supermajority (the value-DEX finality rule).
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
if len(rec.certs) == 0 {
|
||||
t.Fatal("DEX value fill finality must produce a verifiable quorum cert")
|
||||
// QUASAR: a DEX-SETTLEABLE fill requires the EXPORT (⅔-by-stake) cert — the ONLY tier the
|
||||
// 0x9999 settlement receipt may consume. It forms from the trailing votes (the ⅔-th stake
|
||||
// vote follows the bare-majority accept).
|
||||
mustQuasar(t, e, fill, 2*time.Second, "DEX fill must reach the ⅔-stake export tier")
|
||||
|
||||
// The export witness must be a real, verifiable Quasar cert that clears the ⅔-stake
|
||||
// supermajority (the value-DEX settlement rule) — NOT the bare-majority Nova accept cert.
|
||||
cert, ok := e.QuasarCertAt(fill.height)
|
||||
if !ok {
|
||||
t.Fatal("DEX value fill export must produce a Quasar (⅔-stake) certificate")
|
||||
}
|
||||
cert, err := UnmarshalQuorumCert(rec.certs[len(rec.certs)-1])
|
||||
if err != nil {
|
||||
t.Fatalf("decode fill cert: %v", err)
|
||||
if cert.Tier != Quasar {
|
||||
t.Fatalf("DEX settlement cert must be the EXPORT (Quasar) tier, got %s", cert.Tier)
|
||||
}
|
||||
if err := cert.VerifyWeighted(vs, vs, cert.Position.Height); err != nil {
|
||||
t.Fatalf("fill cert must verify under the stake-weighted supermajority: %v", err)
|
||||
t.Fatalf("export fill cert must verify under the stake-weighted supermajority: %v", err)
|
||||
}
|
||||
if cert.VoterCount() < 4 {
|
||||
t.Fatalf("fill cert must carry >= the ⅔-stake supermajority (4 of 5) distinct voters, got %d", cert.VoterCount())
|
||||
t.Fatalf("export fill cert must carry >= the ⅔-stake supermajority (4 of 5) distinct voters, got %d", cert.VoterCount())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +314,7 @@ func TestDEX_ByzantineProposerFakeFillRejected(t *testing.T) {
|
||||
|
||||
// (c) a hand-forged sub-quorum cert presented to a follower is rejected by
|
||||
// Verify (threshold floor + only 1 real signer).
|
||||
subCert, err := AssembleQuorumCert(pos, 1, []SignedVote{
|
||||
subCert, err := AssembleQuorumCert(pos, Quasar, 1, []SignedVote{
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -378,7 +382,7 @@ func TestLiveness_DroppedProposerChitsStillFinalizeViaPeerCert(t *testing.T) {
|
||||
chainID := ids.GenerateTestID()
|
||||
blk := newTestBlock(9, ids.Empty, "cert-relayed")
|
||||
pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
|
||||
cert, err := AssembleQuorumCert(pos, 3, []SignedVote{
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, 3, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
|
||||
@@ -42,7 +42,7 @@ func buildCertAtRound(t *testing.T, vs *testValidatorSet, chainID, blockID, pare
|
||||
for i := 0; i < n; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, uint32(n), votes)
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(n), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble cert (round %d): %v", round, err)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func canonicalCert(t *testing.T, vs *testValidatorSet, chainID, outerID, parentO
|
||||
for i := 0; i < n; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, uint32(n), votes)
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(n), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble canonical cert: %v", err)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func TestMatrix_Row1and5_SeedHintNeverFinalizes(t *testing.T) {
|
||||
if h, set := c.GetFinalizedHeight(); set || h != 0 {
|
||||
t.Fatalf("Row5: vm.LastAccepted created finality (%d,%v) — must be (0,false)", h, set)
|
||||
}
|
||||
if got := c.GetCertifiedTip(); got != ids.Empty {
|
||||
if got := c.GetNovaTip(); got != ids.Empty {
|
||||
t.Fatalf("Row5: certified tip non-empty from a seed: %s", got)
|
||||
}
|
||||
if _, ok := c.FinalizedBlockAtHeight(100); ok {
|
||||
@@ -154,7 +154,7 @@ func TestMatrix_Row2_OneCertCanonicalFinalize(t *testing.T) {
|
||||
if h, set := c.GetFinalizedHeight(); !set || h != 100 {
|
||||
t.Fatalf("Row2: certified height (%d,%v) want (100,true)", h, set)
|
||||
}
|
||||
if got := c.GetCertifiedTip(); got != innerC {
|
||||
if got := c.GetNovaTip(); got != innerC {
|
||||
t.Fatalf("Row2: certified canonical tip %s want innerC %s", got, innerC)
|
||||
}
|
||||
if fin, ok := c.FinalizedBlockAtHeight(100); !ok || fin != innerC {
|
||||
@@ -447,7 +447,7 @@ func TestE2E_ForgedConflictingCertSlashesNobody(t *testing.T) {
|
||||
Signature: bytes.Repeat([]byte{byte(i + 1)}, 64),
|
||||
})
|
||||
}
|
||||
forged, err := AssembleQuorumCert(forgedPos, 4, junk)
|
||||
forged, err := AssembleQuorumCert(forgedPos, Quasar, 4, junk)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble forged cert: %v", err)
|
||||
}
|
||||
@@ -507,7 +507,7 @@ func TestE2E_DeferredValidCertTriggersFetch(t *testing.T) {
|
||||
for i := 0; i < 4; i++ {
|
||||
bad = append(bad, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: bytes.Repeat([]byte{0xEE}, 64)})
|
||||
}
|
||||
badCert, _ := AssembleQuorumCert(badPos, 4, bad)
|
||||
badCert, _ := AssembleQuorumCert(badPos, Quasar, 4, bad)
|
||||
badBytes, _ := badCert.MarshalBinary()
|
||||
if rt.HandleIncomingCert(badBytes) {
|
||||
t.Fatal("a forged cert must not finalize")
|
||||
|
||||
@@ -482,6 +482,19 @@ func (n *simNode) finalizedTip() (ids.ID, uint64, bool) {
|
||||
return n.rt.FinalizedLedger()
|
||||
}
|
||||
|
||||
// quasarHeight returns this node's EXPORT (Quasar, ⅔-by-stake) height + whether any export cert
|
||||
// has formed. It is always ≤ the node's Nova/accept height.
|
||||
func (n *simNode) quasarHeight() (uint64, bool) { return n.rt.QuasarHeight() }
|
||||
|
||||
// quasarTip returns the canonical of this node's highest EXPORT-final (Quasar) block (ids.Empty
|
||||
// until the first ⅔-stake cert). novaTip returns its highest LOCALLY ACCEPTED (Nova) canonical.
|
||||
func (n *simNode) quasarTip() ids.ID { return n.rt.QuasarTip() }
|
||||
func (n *simNode) novaTip() ids.ID { return n.rt.NovaTip() }
|
||||
|
||||
// finalityStatus returns this node's two-tier finality snapshot (nova/quasar heights, responsive
|
||||
// stake, degraded flag) for the degraded-mode visibility assertions.
|
||||
func (n *simNode) finalityStatus() FinalityStatus { return n.rt.FinalityStatus() }
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// simNet — a set of N validators sharing one bus and one test validator set.
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -612,6 +625,43 @@ func (net *simNet) headsAtHeight(h uint64) map[ids.ID]int {
|
||||
return heads
|
||||
}
|
||||
|
||||
// quasarEverywhere reports whether EVERY up node has EXPORTED (reached Quasar) at least to blk's
|
||||
// height with blk's canonical as its export tip, and whether any up node exported a DIFFERENT
|
||||
// canonical as its export tip (an export fork — THE INVARIANT breach). For a single-block test
|
||||
// the export tip at blk.height IS blk's canonical, so comparing quasarTip is exact.
|
||||
func (net *simNet) quasarEverywhere(blk *simBlock) (all bool, fork bool) {
|
||||
all = true
|
||||
for _, n := range net.nodes {
|
||||
if !n.reachable() {
|
||||
continue
|
||||
}
|
||||
qh, ok := n.quasarHeight()
|
||||
if !ok || qh < blk.height {
|
||||
all = false
|
||||
continue
|
||||
}
|
||||
if tip := n.quasarTip(); tip != blk.ID() && tip != ids.Empty {
|
||||
fork = true
|
||||
}
|
||||
}
|
||||
return all, fork
|
||||
}
|
||||
|
||||
// anyQuasarAtHeight reports the distinct EXPORT (Quasar) tips any up node has reached at >= h —
|
||||
// used to assert that NO two conflicting export tips ever coexist (THE INVARIANT).
|
||||
func (net *simNet) quasarTips() map[ids.ID]int {
|
||||
tips := map[ids.ID]int{}
|
||||
for _, n := range net.nodes {
|
||||
if !n.reachable() {
|
||||
continue
|
||||
}
|
||||
if qh, ok := n.quasarHeight(); ok && qh > 0 {
|
||||
tips[n.quasarTip()]++
|
||||
}
|
||||
}
|
||||
return tips
|
||||
}
|
||||
|
||||
// upCount returns how many nodes are currently up.
|
||||
func (net *simNet) upCount() int {
|
||||
c := 0
|
||||
|
||||
@@ -254,20 +254,23 @@ func TestMultiNode_NoDoubleFinalize_LateSiblingRejected(t *testing.T) {
|
||||
// fails-before: lower α below the live-up count (e.g. α=3) → the 3 up nodes
|
||||
// finalize → RED (proving the assertion actually depends on the threshold).
|
||||
// -----------------------------------------------------------------------------
|
||||
func TestMultiNode_SubQuorumNeverFinalizes(t *testing.T) {
|
||||
func TestMultiNode_BelowMajorityNeverAccepts(t *testing.T) {
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
net.down(2)
|
||||
net.down(3)
|
||||
net.down(4) // only 3 of 5 remain — below α=4
|
||||
net.down(4) // only 2 of 5 remain — below the Nova majority NovaQuorum(5)=3
|
||||
|
||||
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "subquorum-h1")
|
||||
net.build(1, blk)
|
||||
|
||||
// Re-solicit far past any backoff; a sub-quorum must STILL never finalize.
|
||||
// Re-solicit far past any backoff; a BELOW-MAJORITY online set must STILL never accept. Under
|
||||
// v1.36 the accept floor is the MAJORITY (a 3-of-5 majority DOES accept — "survive 3/5"), so
|
||||
// the fail-closed floor moves to 2-of-5: below the majority, no Nova cert, no acceptance.
|
||||
if waitFor(2*time.Second, func() bool { _, ok := net.nodes[1].rt.FinalizedBlockAtHeight(1); return ok }) {
|
||||
t.Fatal("SAFETY VIOLATION: a sub-quorum (3 of 5, below α=4) finalized — the liveness retry lowered the threshold.")
|
||||
t.Fatal("SAFETY VIOLATION: a below-majority set (2 of 5, below NovaQuorum=3) accepted — the retry lowered the threshold.")
|
||||
}
|
||||
if heads := net.headsAtHeight(1); len(heads) != 0 {
|
||||
t.Fatalf("no block may finalize below α; heads=%v", heads)
|
||||
t.Fatalf("no block may accept below the Nova majority; heads=%v", heads)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// nova_quasar_matrix_test.go — the v1.36 TWO-TIER adversarial matrix (the go-live gate).
|
||||
//
|
||||
// One ⅔ accept threshold is split into two orthogonal tiers on the finality ladder:
|
||||
//
|
||||
// NOVA = LOCAL ACCEPT — NovaQuorum(n)=⌊n/2⌋+1 distinct signers. Drives VM.Accept.
|
||||
// Crash-fault-safe (majority intersection), NOT Byzantine-safe.
|
||||
// QUASAR = EXPORT — strict >⅔ of STAKE by distinct signers. Byzantine-fork-safe.
|
||||
// The ONLY tier bridges / DEX settlement / cross-chain consume.
|
||||
//
|
||||
// THE INVARIANT (asserted throughout): across every scenario below, no two CONFLICTING
|
||||
// blocks ever both reach QUASAR at one height. Nova may fork under equivocation (expected,
|
||||
// non-exportable); Quasar may not (that needs >⅓ stake double-signing, beyond f<⅓).
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// skipMultinodeUnderRace skips a timing-sensitive in-process MULTINODE convergence test under
|
||||
// the race detector. The detector's ~10x, highly-variable slowdown makes the sim's gossip
|
||||
// latency exceed any bounded convergence settle window — a condition that never occurs on a
|
||||
// real network (gossip ms, settle hundreds of ms) — and also stresses the FIPS-140 ed25519
|
||||
// module's concurrent Sign/Verify (all crypto/ed25519 routes through it in Go 1.26) shared by
|
||||
// the convergence goroutine and the vote handlers. The TWO-TIER SAFETY invariants asserted here
|
||||
// (Nova at the majority, Quasar only at ⅔, no two conflicting Quasars, export gating) are ALSO
|
||||
// asserted by the SINGLE-ENGINE matrix tests (Threshold / WeightedStake / SingleValidator /
|
||||
// Equivocation / DegradedRPC), which are deterministic and DO run under -race. Mirrors the
|
||||
// convergence-storm gate discipline in race_flag_race_test.go.
|
||||
func skipMultinodeUnderRace(t *testing.T) {
|
||||
t.Helper()
|
||||
if underRace {
|
||||
t.Skip("timing-sensitive in-process multinode convergence test skipped under -race " +
|
||||
"(the detector slowdown violates the sim's gossip<settle timing assumption; the two-tier " +
|
||||
"safety invariants run -race-clean in the single-engine matrix tests)")
|
||||
}
|
||||
}
|
||||
|
||||
// novaQ is NovaQuorum(n) — the bare-majority Nova accept count.
|
||||
func novaQ(n int) int { return n/2 + 1 }
|
||||
|
||||
// twoThirdsCount is the minimum DISTINCT equal-stake voter count that strictly exceeds ⅔ of
|
||||
// total stake — the Quasar export threshold for an equal-weight set of n.
|
||||
func twoThirdsCount(n int) int { return int(config.TwoThirdsStakeFloor(uint64(n))) + 1 }
|
||||
|
||||
// nMatrixParams builds K=n consensus params for the single-engine threshold matrix so the live
|
||||
// committee is exactly n (no BFT floor clamp — the floor only binds when the live count is BELOW
|
||||
// the preset, which is the transient-restart guard, not this deterministic matrix).
|
||||
func nMatrixParams(n int) config.Parameters {
|
||||
p := dyn5()
|
||||
p.K = n
|
||||
if n >= 1 {
|
||||
p.AlphaPreference = twoThirdsCount(n)
|
||||
p.AlphaConfidence = twoThirdsCount(n)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// (e) n ∈ {2..5} weighted+equal: Nova at majority; Quasar only at strict >⅔ stake.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestNovaQuasarMatrix_Threshold_NovaMajority_QuasarTwoThirds is the core threshold ladder for
|
||||
// n ∈ {2,3,4,5}: exactly NovaQuorum(n) distinct signers drive the Nova accept; the export tier
|
||||
// forms only once a strict >⅔-stake DISTINCT-signer supermajority has attested. For odd n the
|
||||
// two thresholds differ (a degraded window where the chain produces Nova but does not certify
|
||||
// Quasar); for even n they coincide.
|
||||
func TestNovaQuasarMatrix_Threshold_NovaMajority_QuasarTwoThirds(t *testing.T) {
|
||||
for n := 2; n <= 5; n++ {
|
||||
t.Run(fmt.Sprintf("n=%d_equal", n), func(t *testing.T) {
|
||||
vs := newTestValidatorSet(n)
|
||||
rec := &recordingGossiper{}
|
||||
e, chainID := newQuorumEngineOpts(t, nMatrixParams(n), vs, 0, rec, WithStakeWeighting(vs))
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "n-matrix")
|
||||
pos := trackProposal(e, chainID, blk, 0) // node 0's own signed accept = 1 vote
|
||||
|
||||
nq, tt := novaQ(n), twoThirdsCount(n)
|
||||
// Deliver peers up to the Nova majority (self already = 1).
|
||||
for i := 1; i < nq; i++ {
|
||||
e.ReceiveVote(vs.signedVote(i, pos))
|
||||
}
|
||||
mustFinalize(t, e, blk, 2*time.Second, fmt.Sprintf("n=%d: NovaQuorum=%d votes → Nova accept", n, nq))
|
||||
|
||||
if nq < tt {
|
||||
// Degraded window: a bare majority is below ⅔ stake — no export yet.
|
||||
mustNotQuasar(t, e, blk, 400*time.Millisecond, fmt.Sprintf("n=%d: majority %d < ⅔ %d → no export", n, nq, tt))
|
||||
for i := nq; i < tt; i++ {
|
||||
e.ReceiveVote(vs.signedVote(i, pos))
|
||||
}
|
||||
}
|
||||
mustQuasar(t, e, blk, 2*time.Second, fmt.Sprintf("n=%d: ⅔ count=%d → Quasar export", n, tt))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNovaQuasarMatrix_WeightedStake_NovaCountQuasarStake proves the tiers are orthogonal on the
|
||||
// stake axis: with UNEQUAL stake, Nova ignites on a bare COUNT majority regardless of how little
|
||||
// stake it holds (the "survive 3/5 even if the absent minority holds the stake majority"
|
||||
// mandate), while Quasar forms ONLY once >⅔ of STAKE by distinct signers has attested — the
|
||||
// heavy-stake holder's vote is required and, because it trails the bare-majority accept, arrives
|
||||
// as a late attestation.
|
||||
func TestNovaQuasarMatrix_WeightedStake_NovaCountQuasarStake(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
// Node 0 holds 60% of stake and ABSTAINS; nodes 1..4 hold 10% each.
|
||||
skew := newStakeMap(vs, 60, 10, 10, 10, 10)
|
||||
rec := &recordingGossiper{}
|
||||
e, chainID := newQuorumEngineOpts(t, dyn5(), vs, 0, rec, WithStakeWeighting(skew))
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "weighted")
|
||||
// Track as a NON-own block so node 0 (the 60% holder) does NOT auto-vote — the accepting
|
||||
// coalition is exactly the four 10%-stake nodes.
|
||||
cb := &Block{id: blk.id, parentID: blk.parentID, height: blk.height, timestamp: blk.timestamp.Unix(), data: blk.bytes}
|
||||
_ = e.consensus.AddBlock(context.Background(), cb)
|
||||
e.mu.Lock()
|
||||
e.pendingBlocks[blk.id] = &PendingBlock{ConsensusBlock: cb, VMBlock: blk, ProposedAt: time.Now(), Round: 0}
|
||||
e.mu.Unlock()
|
||||
pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
|
||||
|
||||
// Nodes 1..4 vote: 4/5 count majority holding only 40% of stake.
|
||||
for _, i := range []int{1, 2, 3, 4} {
|
||||
e.ReceiveVote(vs.signedVote(i, pos))
|
||||
}
|
||||
// NOVA: the bare count majority accepts (local execution) despite holding a stake minority.
|
||||
mustFinalize(t, e, blk, 2*time.Second, "count majority / 40% stake → Nova accept")
|
||||
// QUASAR: 40% stake < ⅔ — no export until the 60% holder attests.
|
||||
mustNotQuasar(t, e, blk, 400*time.Millisecond, "40% stake → no export")
|
||||
|
||||
// The 60%-stake holder votes (late). 40%+60% = 100% > ⅔ ⇒ Quasar export completes.
|
||||
e.ReceiveVote(vs.signedVote(0, pos))
|
||||
mustQuasar(t, e, blk, 2*time.Second, "heavy-stake late vote → ⅔ export")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// n=1 self-ignites (NovaQuorum(1)=1); the single-validator path still works.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestNovaQuasarMatrix_SingleValidatorSelfIgnites proves n=1 self-ignites Nova (NovaQuorum(1)=1)
|
||||
// through the single-validator path, and — since the sole validator IS a ⅔-stake supermajority —
|
||||
// also reaches Quasar. No peer exists to fork against, so this is sound.
|
||||
func TestNovaQuasarMatrix_SingleValidatorSelfIgnites(t *testing.T) {
|
||||
vs := newTestValidatorSet(1)
|
||||
rec := &recordingGossiper{}
|
||||
p := dyn5()
|
||||
p.K = 1
|
||||
e, _ := newQuorumEngineOpts(t, p, vs, 0, rec, WithStakeWeighting(vs))
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "solo")
|
||||
_ = trackProposal(e, chainID(e), blk, 0)
|
||||
// Drive the accept funnel (no peer votes arrive to trigger it on a solo chain).
|
||||
_ = e.TryAccept(context.Background(), blk.id)
|
||||
// The sole validator self-ignites: its own accept is the entire NovaQuorum(1)=1.
|
||||
mustFinalize(t, e, blk, 2*time.Second, "n=1 self-ignites Nova")
|
||||
// The sole validator is 100% stake > ⅔, so the block is export-final too.
|
||||
mustQuasar(t, e, blk, 2*time.Second, "n=1 sole validator is a ⅔ supermajority → Quasar")
|
||||
}
|
||||
|
||||
// chainID recovers the engine's chain id (set via WithQuorumCert) for tests that did not capture
|
||||
// it from the constructor.
|
||||
func chainID(e *Transitive) ids.ID { return e.chainID }
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// (a)(b)(d) multinode: 3/5 produces Nova, Quasar only at ⅔, export gates on Quasar.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar is gate (a): with 2 of 5 validators DOWN, the 3
|
||||
// live validators PRODUCE (Nova accepts + converges everywhere), but the export tier does NOT
|
||||
// advance (3/5 = 60% stake ≤ ⅔). Gate (d): the Quasar tip stays Empty (export consumers see no
|
||||
// finalized block) even though the Nova/accept tip has advanced. This is the owner's mandate:
|
||||
// "survive 3/5, majority is enough — production continues, certification pauses."
|
||||
func TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar(t *testing.T) {
|
||||
skipMultinodeUnderRace(t)
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
net.down(3)
|
||||
net.down(4) // 3 up: {0,1,2}
|
||||
|
||||
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "three-of-five-nova")
|
||||
net.build(0, blk)
|
||||
|
||||
// NOVA: the 3 live validators accept and converge (production continues under the outage).
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
all, fork := net.finalizedEverywhere(blk)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("NOVA LIVENESS: 3-of-5 must accept+converge %s, heads=%v up=%d", blk.ID(), net.headsAtHeight(1), net.upCount())
|
||||
}
|
||||
// QUASAR: certification pauses — no up node exports, and the export tip stays Empty.
|
||||
if waitFor(2*time.Second, func() bool {
|
||||
for _, n := range net.nodes {
|
||||
if !n.reachable() {
|
||||
continue
|
||||
}
|
||||
if qh, ok := n.quasarHeight(); ok && qh >= blk.height {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}) {
|
||||
t.Fatal("EXPORT SAFETY: 3-of-5 (60% stake ≤ ⅔) must NOT reach Quasar — certification must pause")
|
||||
}
|
||||
// Gate (d): the export tip a bridge/DEX reads is Empty; the Nova/accept tip is NOT.
|
||||
for i, n := range net.nodes {
|
||||
if !n.reachable() {
|
||||
continue
|
||||
}
|
||||
if tip := n.quasarTip(); tip != ids.Empty {
|
||||
t.Fatalf("node %d exposes a non-empty export (Quasar) tip %s under 3/5 — export must gate on Quasar", i, tip)
|
||||
}
|
||||
if n.novaTip() == ids.Empty {
|
||||
t.Fatalf("node %d has an empty Nova/accept tip under 3/5 — production must have continued", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNovaQuasarMatrix_FourOfFive_ExportsQuasar is gate (b): with 4 of 5 up (80% stake > ⅔), the
|
||||
// block reaches Quasar and every up node exports the SAME block (no export fork).
|
||||
func TestNovaQuasarMatrix_FourOfFive_ExportsQuasar(t *testing.T) {
|
||||
skipMultinodeUnderRace(t)
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
net.down(4) // 4 up: {0,1,2,3}
|
||||
|
||||
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "four-of-five-quasar")
|
||||
net.build(0, blk)
|
||||
|
||||
// NOVA accept converges.
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
all, fork := net.finalizedEverywhere(blk)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("NOVA LIVENESS: 4-of-5 must accept+converge %s, heads=%v", blk.ID(), net.headsAtHeight(1))
|
||||
}
|
||||
// QUASAR export forms and converges to the SAME block (no export fork).
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
all, fork := net.quasarEverywhere(blk)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("EXPORT LIVENESS: 4-of-5 (80%% stake > ⅔) must export %s to every up node, tips=%v", blk.ID().String(), net.quasarTips())
|
||||
}
|
||||
// THE INVARIANT: exactly one export tip across the fleet.
|
||||
if tips := net.quasarTips(); len(tips) != 1 {
|
||||
t.Fatalf("INVARIANT: divergent export tips at 4-of-5: %v", tips)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// (f) 3/5 partition, BOTH sides: majority side produces Nova; neither exports Quasar;
|
||||
// on heal a single Quasar chain (no two conflicting Quasars).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports splits a 5-net into a 3-side
|
||||
// {0,1,2} and a 2-side {3,4}. The majority side PRODUCES (Nova advances); NEITHER side reaches
|
||||
// Quasar (3/5 = 60% ≤ ⅔ on one side, 2/5 below even the Nova majority on the other). On heal,
|
||||
// once all 5 are up a fresh block reaches Quasar — a SINGLE export chain, never two conflicting
|
||||
// Quasars.
|
||||
func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T) {
|
||||
skipMultinodeUnderRace(t)
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
// Partition: isolate {3,4} from the bus (model as down for delivery on both sides).
|
||||
net.down(3)
|
||||
net.down(4)
|
||||
|
||||
blk1 := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "partition-h1")
|
||||
net.build(0, blk1)
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
all, fork := net.finalizedEverywhere(blk1)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("majority side must PRODUCE Nova during the partition, heads=%v", net.headsAtHeight(1))
|
||||
}
|
||||
// Neither side exports (the 3-side holds 60% ≤ ⅔).
|
||||
if tips := net.quasarTips(); len(tips) != 0 {
|
||||
t.Fatalf("no side may export during the partition (neither holds ⅔), got export tips=%v", tips)
|
||||
}
|
||||
|
||||
// HEAL: bring {3,4} back. They missed height 1, so the first post-heal blocks pull them up via
|
||||
// catch-up (fetch the missing ancestor). Build a short run so the fleet re-converges and the
|
||||
// 4/5-of-5 supermajority forms an export cert on the SINGLE chain.
|
||||
net.nodes[3].setUp(true)
|
||||
net.nodes[4].setUp(true)
|
||||
parentID := blk1.ID()
|
||||
parentStateRoot := blk1.stateRoot
|
||||
var last *simBlock
|
||||
for h := uint64(2); h <= 4; h++ {
|
||||
blk := newHonestBlock(parentID, parentStateRoot, h, "partition-heal-h")
|
||||
net.build(int(h%5), blk)
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
all, fork := net.finalizedEverywhere(blk)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("after heal, height %d must re-converge (Nova) across the healed fleet, heads=%v", h, net.headsAtHeight(h))
|
||||
}
|
||||
parentID = blk.ID()
|
||||
parentStateRoot = blk.stateRoot
|
||||
last = blk
|
||||
}
|
||||
// A post-heal block reaches Quasar (all 5 up ⇒ 80%+ stake > ⅔) on a SINGLE export chain.
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
all, fork := net.quasarEverywhere(last)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("after heal, the fleet must export a post-heal block to a SINGLE Quasar chain, tips=%v", net.quasarTips())
|
||||
}
|
||||
// THE INVARIANT: one export tip across the healed fleet — never two conflicting Quasars.
|
||||
if tips := net.quasarTips(); len(tips) != 1 {
|
||||
t.Fatalf("INVARIANT after heal: divergent export tips %v (two conflicting Quasars would be a breach)", tips)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// (h) behind-node / restart (the luxd-4 case): a NON-EVENT — majority keeps producing
|
||||
// Nova; the rejoining node catches up; export resumes. No stall.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent kills one of five nodes, proves the remaining 4
|
||||
// keep PRODUCING (Nova) across several heights (the majority never stalls), then brings the node
|
||||
// back and proves it CATCHES UP to the tip AND the fleet resumes EXPORTING (Quasar) — the
|
||||
// pulling-luxd-4 scenario is a non-event. (That 4-of-5 exports during the outage is proven by
|
||||
// TestNovaQuasarMatrix_FourOfFive_ExportsQuasar; here the focus is Nova production + rejoin.)
|
||||
func TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent(t *testing.T) {
|
||||
skipMultinodeUnderRace(t)
|
||||
net := newSimNet(t, 5, prodParams5Fast())
|
||||
net.down(4) // node 4 is behind/down
|
||||
|
||||
parentID := ids.Empty
|
||||
parentStateRoot := simGenesisRoot()
|
||||
var last *simBlock
|
||||
const heights = 4
|
||||
for h := uint64(1); h <= heights; h++ {
|
||||
blk := newHonestBlock(parentID, parentStateRoot, h, "rejoin-h")
|
||||
net.build(int((h-1)%4), blk) // rotate the builder across the 4 up nodes
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
all, fork := net.finalizedEverywhere(blk)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("NOVA HALT at height %d with node 4 down (4-of-5 up) — the majority must keep producing, heads=%v", h, net.headsAtHeight(h))
|
||||
}
|
||||
parentID = blk.ID()
|
||||
parentStateRoot = blk.stateRoot
|
||||
last = blk
|
||||
}
|
||||
|
||||
// REJOIN: node 4 comes back and must CATCH UP to the tip (the fetch-missing-ancestor path),
|
||||
// finalizing the block the fleet already accepted — no stall.
|
||||
net.nodes[4].setUp(true)
|
||||
// Nudge production so gossip/catch-up flows to node 4.
|
||||
next := newHonestBlock(last.ID(), last.stateRoot, heights+1, "rejoin-after")
|
||||
net.build(0, next)
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
got, ok := net.nodes[4].rt.FinalizedBlockAtHeight(last.height)
|
||||
return ok && got == last.ID()
|
||||
}) {
|
||||
gotTip, gotH, _ := net.nodes[4].finalizedTip()
|
||||
t.Fatalf("REJOIN STALL: node 4 did not catch up to height %d block %s after rejoining (its tip=%s@%d) — "+
|
||||
"the behind-node rejoin must be a non-event", last.height, last.ID(), gotTip, gotH)
|
||||
}
|
||||
// The rejoin is a NON-EVENT: Nova never stalled while node 4 was down, and node 4 caught up on
|
||||
// return. Export RESUMPTION at ⅔ and the no-two-conflicting-Quasars INVARIANT are proven
|
||||
// (deterministically, -race-clean) by TestNovaQuasarMatrix_FourOfFive_ExportsQuasar and
|
||||
// TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar.
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// (c)(g) THE INVARIANT under equivocation: two Nova certs possible, NEVER two Quasars.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar constructs the equivocation at the cert
|
||||
// layer (the deterministic form of the storm): two conflicting blocks A and B at one height. With
|
||||
// k equivocators, a second NOVA (bare-majority) cert for B is reachable at a LOWER k than a second
|
||||
// QUASAR (⅔) cert — Nova is crash-fault-safe only, so a second Nova cert under equivocation is
|
||||
// EXPECTED and non-exportable. THE INVARIANT: a second QUASAR cert requires k ≥ 2α−n = >⅓ stake
|
||||
// double-signing, so below that NO two conflicting export certs ever coexist.
|
||||
func TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar(t *testing.T) {
|
||||
const n = 5
|
||||
const alpha = 4 // ⅔ export count for n=5
|
||||
vs := newTestValidatorSet(n)
|
||||
e, cid := newQuorumEngine(t, params5Prod(), vs, 0, &recordingGossiper{})
|
||||
|
||||
A := newTestBlock(1, ids.Empty, "equiv-A")
|
||||
B := newTestBlock(1, ids.Empty, "equiv-B")
|
||||
_ = trackProposal(e, cid, A, 0) // engine (node 0) self-signs A
|
||||
_ = trackProposal(e, cid, B, 0) // vote-guard refuses node 0's self-vote for B
|
||||
|
||||
addRaw := func(blockID ids.ID, voters ...int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
pb := e.pendingBlocks[blockID]
|
||||
pos := e.blockPositionLocked(pb, blockID)
|
||||
for _, i := range voters {
|
||||
e.recordCertVoteLocked(pb, Vote{BlockID: blockID, NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos), ParentID: pos.ParentID})
|
||||
}
|
||||
}
|
||||
// Count how many distinct verified signers a block holds (both tiers assemble from these).
|
||||
verifiedVotes := func(blockID ids.ID) []SignedVote {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
pb := e.pendingBlocks[blockID]
|
||||
pos := e.blockPositionLocked(pb, blockID)
|
||||
out := make([]SignedVote, 0, len(pb.certVotes))
|
||||
for _, v := range pb.certVotes {
|
||||
if e.voteVerifier.VerifyVote(v.NodeID, CanonicalVoteMessage(pos), v.Signature, 0) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
novaCertOK := func(blockID ids.ID) bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
_, ok := e.assembleVerifiedCertLocked(e.pendingBlocks[blockID], blockID)
|
||||
return ok
|
||||
}
|
||||
quasarCertOK := func(blockID ids.ID) bool {
|
||||
votes := verifiedVotes(blockID)
|
||||
if len(votes) < alpha {
|
||||
return false
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
pos := e.blockPositionLocked(e.pendingBlocks[blockID], blockID)
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(alpha), votes)
|
||||
return err == nil && cert.Verify(e.voteVerifier, 0) == nil
|
||||
}
|
||||
|
||||
// A reaches an EXPORTABLE quorum honestly: {0,1,2,3}.
|
||||
addRaw(A.id, 1, 2, 3)
|
||||
if !quasarCertOK(A.id) {
|
||||
t.Fatal("A must hold a valid ⅔ (Quasar) export cert")
|
||||
}
|
||||
|
||||
// Give B exactly 2 equivocators {1,2} (who also signed A) + 1 honest-B voter {4}. That is a
|
||||
// NOVA majority (3) for B → a SECOND Nova cert exists (expected under equivocation).
|
||||
addRaw(B.id, 1, 2, 4)
|
||||
if !novaCertOK(B.id) {
|
||||
t.Fatal("with 2 equivocators + 1 honest, B must reach a second NOVA cert (bare majority) — expected, non-exportable")
|
||||
}
|
||||
// THE INVARIANT: B has only 3 distinct signers (< α=4), so NO second QUASAR (export) cert can
|
||||
// form. Two conflicting export certs would need k ≥ 2α−n = 3 equivocators (>⅓ stake).
|
||||
if quasarCertOK(B.id) {
|
||||
t.Fatal("INVARIANT BREACH: a second conflicting QUASAR (export) cert formed with only 2 equivocators (<⅓ stake)")
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// (i) degraded-mode RPC visibility.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestNovaQuasarMatrix_DegradedRPC asserts the two-tier RPC snapshot never reports a Nova height
|
||||
// as certified, and flips `Degraded`/`CertificateAvailable` exactly at the ⅔-stake boundary:
|
||||
// 3-of-5 (60% ≤ ⅔) is degraded (producing Nova, quasarHeight behind); a 4th vote (80% > ⅔) clears
|
||||
// it and advances quasarHeight.
|
||||
func TestNovaQuasarMatrix_DegradedRPC(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
rec := &recordingGossiper{}
|
||||
e, cid := newQuorumEngineOpts(t, dyn5(), vs, 0, rec, WithStakeWeighting(vs))
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "degraded")
|
||||
pos := trackProposal(e, cid, blk, 0) // self = 1 vote
|
||||
e.ReceiveVote(vs.signedVote(1, pos))
|
||||
e.ReceiveVote(vs.signedVote(2, pos)) // 3 of 5 = 60% stake
|
||||
|
||||
// DEGRADED: Nova accepted, but ⅔ is not reachable — quasarHeight trails novaHeight.
|
||||
if !waitFor(2*time.Second, func() bool { return e.IsAccepted(blk.id) }) {
|
||||
t.Fatal("precondition: 3/5 must Nova-accept")
|
||||
}
|
||||
if !waitFor(time.Second, func() bool { s := e.FinalityStatus(); return s.Degraded && !s.CertificateAvailable }) {
|
||||
s := e.FinalityStatus()
|
||||
t.Fatalf("3/5 must report degraded (producing Nova, not certifying Quasar): %+v", s)
|
||||
}
|
||||
s := e.FinalityStatus()
|
||||
if s.NovaHeight < blk.height {
|
||||
t.Fatalf("novaHeight must have advanced to the accepted block, got %d", s.NovaHeight)
|
||||
}
|
||||
if s.QuasarHeight >= blk.height {
|
||||
t.Fatalf("quasarHeight must NOT report a Nova-only block as certified, got %d", s.QuasarHeight)
|
||||
}
|
||||
if s.ResponsiveStakePct <= 0 || s.ResponsiveStakePct > 0.61 {
|
||||
t.Fatalf("responsiveStakePct must be ~0.60 at 3/5, got %v", s.ResponsiveStakePct)
|
||||
}
|
||||
|
||||
// RECOVER: a 4th vote (80% > ⅔) clears degraded and advances the export height.
|
||||
e.ReceiveVote(vs.signedVote(3, pos))
|
||||
if !waitFor(2*time.Second, func() bool {
|
||||
s := e.FinalityStatus()
|
||||
return !s.Degraded && s.CertificateAvailable && s.QuasarHeight >= blk.height
|
||||
}) {
|
||||
s := e.FinalityStatus()
|
||||
t.Fatalf("after the 4th vote (80%% > ⅔) the chain must certify (not degraded, quasarHeight advanced): %+v", s)
|
||||
}
|
||||
}
|
||||
+21
-26
@@ -95,24 +95,20 @@ func TestRED_INT_LowStakeCoalition_LiveVotePath_DoesNotFinalize(t *testing.T) {
|
||||
"not reach the engine, so this test would be vacuous (count=%d)", blk.AcceptCalled())
|
||||
}
|
||||
|
||||
// SAFETY: despite count ≥ α, the ⅔-STAKE gate in assembleCertLocked must refuse.
|
||||
if waitFor(800*time.Millisecond, func() bool { return rt.IsAccepted(blk.id) }) {
|
||||
t.Fatalf("SAFETY VIOLATION: a 4%%-stake coalition finalized a block via the LIVE vote path "+
|
||||
"(VM.Accept=%d) — assembleCertLocked's VerifyWeighted gate was bypassed from the "+
|
||||
"count/Gossip ingestion road", blk.AcceptCalled())
|
||||
}
|
||||
if got := blk.AcceptCalled(); got != 0 {
|
||||
t.Fatalf("VM.Accept must not run for a sub-⅔-stake coalition, got %d", got)
|
||||
}
|
||||
|
||||
// LIVENESS CONTROL (anti-vacuity): once the 96-stake node ALSO votes, stake is
|
||||
// 100/100 > ⅔ and the SAME block finalizes — so the refusal above was the stake
|
||||
// gate, not a stuck engine.
|
||||
rt.ReceiveVote(vs.signedVote(4, pos))
|
||||
// v1.36 TWO-TIER: the 4-of-5 COUNT majority IS the NOVA accept authority — it drives VM.Accept
|
||||
// (local execution), crash-fault-safe by majority intersection. The ⅔-STAKE gate did not
|
||||
// disappear; it moved to the EXPORT tier (Quasar).
|
||||
if !waitFor(2*time.Second, func() bool { return rt.IsAccepted(blk.id) }) {
|
||||
t.Fatalf("liveness: block did not finalize once the 96-stake node voted (stake>⅔) — "+
|
||||
"VerifyWeighted is over-strict (Accept=%d)", blk.AcceptCalled())
|
||||
t.Fatalf("NOVA LIVENESS: the 4-of-5 count majority did not accept via the live vote path (Accept=%d)", blk.AcceptCalled())
|
||||
}
|
||||
// EXPORT SAFETY: despite the count majority, a 4/100-stake coalition must NEVER reach Quasar
|
||||
// (⅔-by-stake) — the export gate holds across the live channel path.
|
||||
mustNotQuasar(t, rt.Transitive, blk, 500*time.Millisecond, "4%-stake coalition (export gate, live path)")
|
||||
|
||||
// LIVENESS CONTROL (anti-vacuity): once the 96-stake node ALSO votes, stake is 100/100 > ⅔ and
|
||||
// the block reaches QUASAR — so the refusal above was the stake gate, not a stuck engine.
|
||||
rt.ReceiveVote(vs.signedVote(4, pos))
|
||||
mustQuasar(t, rt.Transitive, blk, 2*time.Second, "96-stake node completes the ⅔ export supermajority")
|
||||
}
|
||||
|
||||
// RED-INT-2 — STAKE GATE HOLDS THROUGH THE GOSSIP HandleIncomingVote ENTRY.
|
||||
@@ -159,14 +155,16 @@ func TestRED_INT_LowStakeCoalition_GossipVoteEntry_DoesNotFinalize(t *testing.T)
|
||||
if !waitFor(2*time.Second, func() bool { return rt.HasEnoughResponsesForRetry(blk.id) }) {
|
||||
t.Fatalf("precondition: COUNT predicate never tripped via the Gossip vote entry (vacuous)")
|
||||
}
|
||||
if waitFor(800*time.Millisecond, func() bool { return rt.IsAccepted(blk.id) }) {
|
||||
t.Fatalf("SAFETY VIOLATION: a 4%%-stake coalition finalized via the Gossip HandleIncomingVote "+
|
||||
"entry (VM.Accept=%d) — the newly-routed Gossip path is a count-only finality road",
|
||||
blk.AcceptCalled())
|
||||
// v1.36 TWO-TIER: the 4-of-5 COUNT majority Nova-accepts (local execution) via the Gossip
|
||||
// entry; the ⅔-STAKE gate moved to the EXPORT tier (Quasar).
|
||||
if !waitFor(2*time.Second, func() bool { return rt.IsAccepted(blk.id) }) {
|
||||
t.Fatalf("NOVA LIVENESS: the 4-of-5 count majority did not accept via the Gossip vote entry (Accept=%d)", blk.AcceptCalled())
|
||||
}
|
||||
// EXPORT SAFETY: a 4/100-stake coalition must NEVER reach Quasar via the Gossip path either.
|
||||
mustNotQuasar(t, rt.Transitive, blk, 500*time.Millisecond, "4%-stake coalition (export gate, gossip path)")
|
||||
|
||||
// Anti-vacuity: the 96-stake node's vote (via the SAME Gossip entry) tips stake
|
||||
// over ⅔ and finalizes.
|
||||
// Anti-vacuity: the 96-stake node's vote (via the SAME Gossip entry) tips stake over ⅔ and the
|
||||
// block reaches QUASAR (export).
|
||||
vb4, err := encodeSignedVote(vs.nodeID(4), vs.sign(4, pos))
|
||||
if err != nil {
|
||||
t.Fatalf("encode node4 vote: %v", err)
|
||||
@@ -174,10 +172,7 @@ func TestRED_INT_LowStakeCoalition_GossipVoteEntry_DoesNotFinalize(t *testing.T)
|
||||
if !rt.HandleIncomingVote(blk.id, vb4) {
|
||||
t.Fatalf("the 96-stake node's genuine vote must verify via the Gossip entry")
|
||||
}
|
||||
if !waitFor(2*time.Second, func() bool { return rt.IsAccepted(blk.id) }) {
|
||||
t.Fatalf("liveness: block did not finalize once >⅔ stake voted via the Gossip entry (Accept=%d)",
|
||||
blk.AcceptCalled())
|
||||
}
|
||||
mustQuasar(t, rt.Transitive, blk, 2*time.Second, "96-stake node completes the ⅔ export via the Gossip entry")
|
||||
}
|
||||
|
||||
// RED-INT-3 — DOUBLE-COUNT UNDER STAKE isolates the replay/double-count attack
|
||||
|
||||
@@ -225,9 +225,10 @@ func TestProposerLiveness_LateHonestVote_FinalizesViaPersistentRePoll(t *testing
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestProposerSafety_SubQuorumNeverFinalizes_EvenWithPersistentRePoll proves the
|
||||
// liveness retry does not weaken safety: 3 of 5 (< α=4) is re-solicited forever and
|
||||
// STILL never finalizes. A genuine minority cannot finalize no matter how long it
|
||||
// re-polls.
|
||||
// liveness retry does not weaken safety: a SUB-NOVA-MAJORITY count (2 of 5, below the v1.36
|
||||
// accept threshold NovaQuorum(5)=3) is re-solicited forever and STILL never accepts. A genuine
|
||||
// sub-majority cannot Nova-accept no matter how long it re-polls (re-poll re-SOLICITS, it never
|
||||
// manufactures a vote or lowers the majority threshold).
|
||||
func TestProposerSafety_SubQuorumNeverFinalizes_EvenWithPersistentRePoll(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
params := params5Prod()
|
||||
@@ -237,15 +238,14 @@ func TestProposerSafety_SubQuorumNeverFinalizes_EvenWithPersistentRePoll(t *test
|
||||
e.SetProposer(newReSolicitProbe())
|
||||
|
||||
blk := newTestBlock(3, ids.Empty, "minority")
|
||||
pos := trackProposal(e, chainID, blk, 0) // self = 1
|
||||
e.ReceiveVote(vs.signedVote(1, pos)) // 2
|
||||
e.ReceiveVote(vs.signedVote(2, pos)) // 3 of 5 < α=4
|
||||
pos := trackProposal(e, chainID, blk, 0) // self = 1 vote
|
||||
e.ReceiveVote(vs.signedVote(1, pos)) // 2 of 5 < Nova majority (NovaQuorum(5)=3)
|
||||
|
||||
driveRePoll(e, 60) // re-solicit far past the old abandon cap
|
||||
|
||||
if waitFor(500*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
|
||||
t.Fatal("SAFETY VIOLATION: a sub-quorum (3 of 5, below α=4) finalized — persistent re-poll " +
|
||||
"must re-SOLICIT only; it must never lower the α-of-K threshold.")
|
||||
t.Fatal("SAFETY VIOLATION: a sub-majority (2 of 5, below NovaQuorum=3) accepted — persistent " +
|
||||
"re-poll must re-SOLICIT only; it must never lower the Nova majority threshold.")
|
||||
}
|
||||
rec.mu.Lock()
|
||||
certs := len(rec.certs)
|
||||
@@ -258,7 +258,8 @@ func TestProposerSafety_SubQuorumNeverFinalizes_EvenWithPersistentRePoll(t *test
|
||||
// TestProposerSafety_ForgedAndOutOfSetVotes_NeverCount proves re-solicitation
|
||||
// cannot be exploited: an out-of-set signer and a real validator's signature lifted
|
||||
// to the WRONG position are both dropped, so even re-solicited forever the block
|
||||
// stays at its 3 genuine votes (< α) and never finalizes.
|
||||
// stays at its 2 genuine votes (< the Nova majority NovaQuorum(5)=3) and never accepts —
|
||||
// forged votes never push a sub-majority over the accept threshold.
|
||||
func TestProposerSafety_ForgedAndOutOfSetVotes_NeverCount(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
params := params5Prod()
|
||||
@@ -268,9 +269,8 @@ func TestProposerSafety_ForgedAndOutOfSetVotes_NeverCount(t *testing.T) {
|
||||
e.SetProposer(newReSolicitProbe())
|
||||
|
||||
blk := newTestBlock(9, ids.Empty, "forged-attack")
|
||||
pos := trackProposal(e, chainID, blk, 0) // self = 1
|
||||
e.ReceiveVote(vs.signedVote(1, pos)) // 2
|
||||
e.ReceiveVote(vs.signedVote(2, pos)) // 3 (genuine)
|
||||
pos := trackProposal(e, chainID, blk, 0) // self = 1 genuine vote
|
||||
e.ReceiveVote(vs.signedVote(1, pos)) // 2 genuine (< Nova majority NovaQuorum(5)=3)
|
||||
|
||||
// (a) out-of-set signer: keys unknown to vs.
|
||||
outsider := newTestValidatorSet(1)
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestQuasarBridge_RealCertVerifies(t *testing.T) {
|
||||
pos := VotePosition{ChainID: chainID, Height: 100, Round: 0, BlockID: blockID, ParentID: ids.Empty}
|
||||
|
||||
// Engine cert with 3 distinct signed (engine-auth) accept votes.
|
||||
engineCert, err := AssembleQuorumCert(pos, 3, []SignedVote{
|
||||
engineCert, err := AssembleQuorumCert(pos, Quasar, 3, []SignedVote{
|
||||
{NodeID: nodeIDs[0], Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: nodeIDs[1], Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: nodeIDs[2], Accept: true, Signature: vs.sign(2, pos)},
|
||||
@@ -226,7 +226,7 @@ func TestQuasarBridge_RealCertVerifies(t *testing.T) {
|
||||
func TestQuasarBridge_FailsClosedWithoutMaterial(t *testing.T) {
|
||||
vs := newTestValidatorSet(1)
|
||||
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
|
||||
cert, err := AssembleQuorumCert(pos, 1, []SignedVote{
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, 1, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -49,7 +49,7 @@ func certFor(t *testing.T, vs *testValidatorSet, pos VotePosition, voters ...int
|
||||
for _, i := range voters {
|
||||
sv = append(sv, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, uint32(len(voters)), sv)
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(len(voters)), sv)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble cert %v: %v", voters, err)
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func decidesViaRuntimeCert(t *testing.T, n int, weights []uint64, alpha int, vot
|
||||
for _, i := range voters {
|
||||
sv = append(sv, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, uint32(len(voters)), sv)
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(len(voters)), sv)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble cert (voters=%v): %v", voters, err)
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func TestBlue_QuorumMatrix_DuplicateAndEquivocation(t *testing.T) {
|
||||
pos := VotePosition{ChainID: chainID, Height: 1, Round: 0, BlockID: blk.id, ParentID: ids.Empty}
|
||||
|
||||
// node 0 signing "twice" — the same NodeID repeated. Dedup ⇒ 1 distinct voter, ⅓ stake.
|
||||
dup, err := AssembleQuorumCert(pos, 2, []SignedVote{
|
||||
dup, err := AssembleQuorumCert(pos, Quasar, 2, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
})
|
||||
@@ -240,7 +240,7 @@ func TestBlue_QuorumMatrix_DuplicateAndEquivocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// the genuine 2-of-3 (nodes 0,1 = ⅔... need >⅔: nodes 0,1,2) finalizes.
|
||||
real3, err := AssembleQuorumCert(pos, 3, []SignedVote{
|
||||
real3, err := AssembleQuorumCert(pos, Quasar, 3, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
|
||||
@@ -163,11 +163,31 @@ func TestRedIndep_ByzantineForkThreshold(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
certOK := func(e *Transitive, blockID ids.ID) bool {
|
||||
// quasarCertOK reports whether an EXPORTABLE (Quasar, ⅔) certificate can be assembled for the
|
||||
// block from its collected votes. Under the v1.36 two tiers, the EXPORT cert — not the Nova
|
||||
// accept cert — is the Byzantine-safe artifact whose fork threshold is 2α−n. (The Nova accept
|
||||
// cert, assembleVerifiedCertLocked, is a bare majority and forks more easily by design; it
|
||||
// authorizes LOCAL execution only and is never exported, so a second Nova cert is expected
|
||||
// under equivocation and is NOT a safety violation.)
|
||||
quasarCertOK := func(e *Transitive, blockID ids.ID) bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
_, ok := e.assembleVerifiedCertLocked(e.pendingBlocks[blockID], blockID)
|
||||
return ok
|
||||
pb := e.pendingBlocks[blockID]
|
||||
pos := e.blockPositionLocked(pb, blockID)
|
||||
votes := make([]SignedVote, 0, len(pb.certVotes))
|
||||
for _, v := range pb.certVotes {
|
||||
if e.voteVerifier.VerifyVote(v.NodeID, CanonicalVoteMessage(pos), v.Signature, 0) {
|
||||
votes = append(votes, v)
|
||||
}
|
||||
}
|
||||
if len(votes) < alpha {
|
||||
return false
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(alpha), votes)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return cert.Verify(e.voteVerifier, 0) == nil
|
||||
}
|
||||
|
||||
for k := 1; k <= 3; k++ {
|
||||
@@ -188,20 +208,26 @@ func TestRedIndep_ByzantineForkThreshold(t *testing.T) {
|
||||
addRaw(e, B.id, vs, equiv...)
|
||||
addRaw(e, B.id, vs, 4)
|
||||
|
||||
if !certOK(e, A.id) {
|
||||
t.Fatalf("A must hold a valid alpha-of-K cert (4 signers)")
|
||||
if !quasarCertOK(e, A.id) {
|
||||
t.Fatalf("A must hold a valid ⅔ (Quasar) EXPORT cert (4 signers)")
|
||||
}
|
||||
gotFork := certOK(e, B.id)
|
||||
// THE INVARIANT (export tier): a SECOND conflicting EXPORTABLE (Quasar) cert requires
|
||||
// k ≥ 2α−n = 3 equivocators — i.e. >⅓ of stake double-signing, BEYOND the f<⅓ budget.
|
||||
// Below that, NO two Quasar certs can coexist, so bridges / DEX settlement / cross-chain
|
||||
// consumers never see two conflicting finalized blocks at one height.
|
||||
gotFork := quasarCertOK(e, B.id)
|
||||
wantFork := k >= (2*alpha - n) // 2*4-5 = 3
|
||||
if gotFork != wantFork {
|
||||
t.Fatalf("k=%d equivocators: B second-cert=%v, want %v (fork threshold is 2*alpha-n=%d)",
|
||||
t.Fatalf("k=%d equivocators: B second QUASAR (export) cert=%v, want %v (Byzantine fork threshold 2α−n=%d)",
|
||||
k, gotFork, wantFork, 2*alpha-n)
|
||||
}
|
||||
if gotFork {
|
||||
t.Logf("k=%d >= 3: SECOND conflicting cert exists — fork reachable, but only with f=%d >= n/3 "+
|
||||
"(BFT budget is f<=1). This is BEYOND the guarantee, as expected.", k, k)
|
||||
t.Logf("k=%d >= 3: a SECOND conflicting EXPORT cert exists — fork reachable, but only with "+
|
||||
"f=%d >= n/3 (BFT budget is f<=1). BEYOND the guarantee, as expected.", k, k)
|
||||
} else {
|
||||
t.Logf("k=%d < 3: no second cert — safety holds (f=%d, within/at margin of the f<n/3 budget).", k, k)
|
||||
t.Logf("k=%d < 3: no second EXPORT cert — the export INVARIANT holds (f=%d, within the f<n/3 budget). "+
|
||||
"A second NOVA (accept) cert may exist at this k — that is expected (Nova is crash-fault, not "+
|
||||
"Byzantine-safe) and non-exportable.", k, k)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+10
-18
@@ -70,23 +70,15 @@ func TestRED_ProcessPendingFinalizesSubTwoThirdsStake(t *testing.T) {
|
||||
e.ReceiveVote(vs.signedVote(3, pos))
|
||||
e.ReceiveVote(vs.signedVote(4, pos))
|
||||
|
||||
// The COUNT gate flips IsAccepted=true (acceptVotes=4>=α=4) with NO stake check.
|
||||
// The cert path (tryFinalizeBlock) correctly refuses (VerifyWeighted: 4 ≤ 66).
|
||||
// SAFETY REQUIREMENT: the block must NOT be VM-accepted — 4/100 stake is not a
|
||||
// ⅔ supermajority. If the pollLoop's processPendingBlocks accepts it, that is a
|
||||
// finalize-without-cert / sub-⅔-stake finality bug.
|
||||
if waitFor(2*time.Second, func() bool { return blk.AcceptCalled() >= 1 }) {
|
||||
t.Fatalf("CRITICAL SAFETY VIOLATION: block finalized (VM.Accept ran %d×) with a "+
|
||||
"4/100-stake coalition — processPendingBlocks accepted on count-α IsAccepted "+
|
||||
"without the ⅔-stake cert. accepted=%v", blk.AcceptCalled(), e.IsAccepted(blk.id))
|
||||
}
|
||||
// v1.36 TWO-TIER: a 4-of-5 COUNT majority drives the NOVA accept (local execution) even for a
|
||||
// 4/100-stake coalition — count is the accept authority, crash-fault-safe by majority
|
||||
// intersection. But the EXPORT authority (Quasar) is >⅔ of STAKE, which this coalition is
|
||||
// nowhere near. So the block Nova-accepts (VM.Accept) yet NEVER reaches Quasar: the original
|
||||
// invariant ("a stake minority causes no IRREVERSIBLE / cross-chain transition") is preserved
|
||||
// EXACTLY at the export tier.
|
||||
mustFinalize(t, e, blk, 2*time.Second, "4-of-5 count majority (Nova local accept)")
|
||||
|
||||
// And confirm NO valid stake-weighted cert was ever gossiped (proving the cert
|
||||
// path agreed this should not finalize — only the count path disagreed).
|
||||
rec.mu.Lock()
|
||||
gotCerts := len(rec.certs)
|
||||
rec.mu.Unlock()
|
||||
if gotCerts != 0 {
|
||||
t.Fatalf("no ⅔-stake cert should exist for a 4/100 coalition, got %d gossiped", gotCerts)
|
||||
}
|
||||
// SAFETY REQUIREMENT (unchanged, moved to the export tier): a 4/100-stake coalition must NEVER
|
||||
// reach Quasar — no bridge / DEX-settlement / cross-chain consumer can treat it as final.
|
||||
mustNotQuasar(t, e, blk, 500*time.Millisecond, "4/100-stake coalition (export gate)")
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestSyncState_DifferentBlockIsHintNotEquivocation(t *testing.T) {
|
||||
if h, set := c.GetFinalizedHeight(); !set || h != 100 {
|
||||
t.Fatalf("certified height moved on a hint import: got (%d,%v) want (100,true)", h, set)
|
||||
}
|
||||
if got := c.GetCertifiedTip(); got != h100 {
|
||||
if got := c.GetNovaTip(); got != h100 {
|
||||
t.Fatalf("certified tip changed on a hint import: got %s want %s", got, h100)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,9 +102,30 @@ func (rt *Runtime) HandleIncomingVote(blockID ids.ID, voteBytes []byte) bool {
|
||||
}
|
||||
t.mu.RUnlock()
|
||||
|
||||
if !exists || verifier == nil {
|
||||
if verifier == nil {
|
||||
return false
|
||||
}
|
||||
if !exists {
|
||||
// The block is not pending. It may be a FINALIZED (Nova-accepted) block whose trailing
|
||||
// ⅔-stake vote is still arriving — the ⅔-th stake vote necessarily follows the
|
||||
// bare-majority accept — so route a verified accept for it to the late-attestation path
|
||||
// (handleVote → attestFinalizedVote → the Quasar attestor) to complete the EXPORT cert.
|
||||
// Verify against the REMEMBERED accepted position (the exact bytes the accept votes signed).
|
||||
// An unknown / aged-out block drops (unchanged behaviour).
|
||||
ap, remembered := t.lookupAcceptedPos(blockID)
|
||||
if !remembered || !verifier.VerifyVote(nodeID, CanonicalVoteMessage(ap.pos), sig, ap.epoch) {
|
||||
return false
|
||||
}
|
||||
t.ReceiveVote(Vote{
|
||||
BlockID: blockID,
|
||||
NodeID: nodeID,
|
||||
Accept: true,
|
||||
Signature: sig,
|
||||
ParentID: ap.pos.ParentID,
|
||||
Round: ap.pos.Round,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Verify the signature against OUR position for this block, resolving the
|
||||
// voter's pubkey at the block's P-CHAIN epoch height (RESIDUAL-B). A vote that
|
||||
@@ -164,16 +185,25 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
if verifier == nil {
|
||||
return false
|
||||
}
|
||||
// The cert's threshold MUST meet our own α floor — a cert that asserts a
|
||||
// LOWER threshold than this chain requires is rejected even if its internal
|
||||
// signatures verify (sub-quorum finality forgery defence, mirrors quasar's
|
||||
// MinThreshold floor).
|
||||
if alpha := t.consensus.Alpha(); alpha > 0 && cert.Threshold < uint32(alpha) {
|
||||
// The cert's threshold MUST meet our own floor FOR ITS TIER — a cert that asserts a
|
||||
// LOWER threshold than this chain requires for that tier is rejected even if its
|
||||
// internal signatures verify (a cheap sub-quorum forgery filter; the AUTHORITATIVE gate
|
||||
// is verifyCert → VerifyWeighted below, which re-derives the tier threshold from the
|
||||
// validator set at the cert's epoch). A gossiped NOVA cert legitimately carries a
|
||||
// bare-majority threshold BELOW the ⅔ Quasar floor, so the floor is tier-selected:
|
||||
// NovaQuorum(K) for Nova, the ⅔ count Alpha() for Quasar. K/Alpha track the live
|
||||
// committee (construction clamp + reclampCommitteeLocked), so both floors follow the
|
||||
// live set. An unknown tier is left to verifyCert (Verify rejects it fail-closed).
|
||||
floor := t.consensus.Alpha() // Quasar ⅔ count floor
|
||||
if cert.Tier == Nova {
|
||||
floor = NovaQuorum(t.consensus.K()) // Nova bare-majority floor
|
||||
}
|
||||
if floor > 0 && cert.Threshold < uint32(floor) {
|
||||
if !rt.config.Logger.IsZero() {
|
||||
rt.config.Logger.Warn("incoming cert: threshold below chain alpha floor",
|
||||
rt.config.Logger.Warn("incoming cert: threshold below chain floor for its tier",
|
||||
log.Stringer("blockID", cert.Position.BlockID),
|
||||
log.Uint32("certThreshold", cert.Threshold),
|
||||
log.Int("alphaFloor", alpha))
|
||||
log.Int("tierFloor", floor))
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -488,7 +518,7 @@ func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.I
|
||||
rebasedPos.BlockID = localID
|
||||
rebasedPos.ParentID = localParentID
|
||||
|
||||
rebased, err := AssembleQuorumCert(rebasedPos, cert.Threshold, cert.Votes)
|
||||
rebased, err := AssembleQuorumCert(rebasedPos, cert.Tier, cert.Threshold, cert.Votes)
|
||||
if err != nil {
|
||||
return ids.Empty, false
|
||||
}
|
||||
|
||||
@@ -135,35 +135,33 @@ func TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// TestFix1_FlooredCommittee_HaltsWithoutRealQuorum is the downstream proof: a floored
|
||||
// K=4/α=3 committee backed by a 5-validator stake source NEVER advances finality
|
||||
// (never VM.Accept, never advance the decided floor) until a real 4-of-5 ⅔-stake
|
||||
// supermajority signs — a lone self-vote, and even a 3-of-5 count-quorum, HALT.
|
||||
func TestFix1_FlooredCommittee_HaltsWithoutRealQuorum(t *testing.T) {
|
||||
// TestFix1_FlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds is the downstream proof under
|
||||
// the v1.36 two tiers: a floored K=4/α=3 committee backed by a 5-validator stake source
|
||||
// - HALTS a LONE self-vote (below the Nova majority NovaQuorum=3 — the 1085013 self-finality
|
||||
// guard the floor exists for is preserved: a single node can never accept),
|
||||
// - NOVA-ACCEPTS a 3-of-5 bare majority (local execution — the "survive 3/5" liveness), but
|
||||
// - reaches the EXPORT (Quasar) tier ONLY at a real 4-of-5 ⅔-stake supermajority.
|
||||
func TestFix1_FlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds(t *testing.T) {
|
||||
vs := newTestValidatorSet(5) // equal unit stake ⇒ ⅔-of-5 needs 4 voters
|
||||
rec := &recordingGossiper{}
|
||||
params := config.LocalBFTParams() // K=4/α=3 — the committee the transient floor produces
|
||||
e, chainID := newQuorumEngineOpts(t, params, vs, 0, rec, WithStakeWeighting(vs))
|
||||
|
||||
floorBefore := e.consensus.GetDecidedFloor()
|
||||
|
||||
blk := newTestBlock(1, ids.Empty, "floored-halt")
|
||||
pos := trackProposal(e, chainID, blk, 0) // inserts own proposal + records THIS node's (node 0) signed accept
|
||||
|
||||
// 1 self-vote — 1/5 = 20% stake. Must HALT (self-finality is exactly this).
|
||||
mustNotFinalize(t, e, blk, 1200*time.Millisecond, "lone self-vote (20% stake)")
|
||||
// 1 self-vote — below the Nova majority (NovaQuorum(4)=3). Must HALT: lone self-finality is
|
||||
// exactly the 1085013 fork the floor prevents, and it stays prevented at the Nova tier.
|
||||
mustNotFinalize(t, e, blk, 1200*time.Millisecond, "lone self-vote (below Nova majority)")
|
||||
|
||||
// 3 votes {0,1,2} — reaches the α=3 COUNT quorum but only 3/5 = 60% stake. Must HALT:
|
||||
// a count quorum is a liveness signal, not finality; the ⅔-stake cert is the authority.
|
||||
// 3 votes {0,1,2} — the bare majority NovaQuorum=3. NOVA ACCEPTS (local execution). But 3/5 =
|
||||
// 60% stake ≤ ⅔, so NO Quasar export yet (the degraded mode).
|
||||
e.ReceiveVote(vs.signedVote(1, pos))
|
||||
e.ReceiveVote(vs.signedVote(2, pos))
|
||||
mustNotFinalize(t, e, blk, 1200*time.Millisecond, "3-of-5 count quorum but 60% stake")
|
||||
mustFinalize(t, e, blk, 2*time.Second, "3-of-5 bare majority (Nova local accept)")
|
||||
mustNotQuasar(t, e, blk, 500*time.Millisecond, "3-of-5 = 60% stake (export gate)")
|
||||
|
||||
if got := e.consensus.GetDecidedFloor(); got != floorBefore {
|
||||
t.Fatalf("decided floor advanced (%d→%d) WITHOUT a real 4-of-5 cert", floorBefore, got)
|
||||
}
|
||||
|
||||
// 4th vote {0,1,2,3} — 4/5 = 80% > ⅔. Now a real cert assembles and the block finalizes.
|
||||
// 4th vote {0,1,2,3} — 4/5 = 80% > ⅔. The EXPORT (Quasar) cert now forms.
|
||||
e.ReceiveVote(vs.signedVote(3, pos))
|
||||
mustFinalize(t, e, blk, 3*time.Second, "4-of-5 ⅔-stake supermajority")
|
||||
mustQuasar(t, e, blk, 3*time.Second, "4-of-5 ⅔-stake supermajority (Quasar export)")
|
||||
}
|
||||
|
||||
@@ -68,9 +68,12 @@ func (v VerifiedQuorumCert) Cert() *QuorumCert { return v.qc }
|
||||
//
|
||||
// verifier — the chain's VoteVerifier (BLS / ML-DSA / secp256k1). nil ⇒ fail closed.
|
||||
// stake — the chain's StakeSource. Non-nil ⇒ stake-weighted (VerifyWeighted,
|
||||
// strict >⅔). nil ⇒ count-only Verify (equal-stake chains only; the
|
||||
// tier-selected). nil ⇒ count-only Verify (equal-stake chains only; the
|
||||
// chain MUST enforce the equal-stake admission invariant).
|
||||
// alpha — the α-of-K count floor.
|
||||
// tier — Nova (local-execution majority) or Quasar (export ⅔-by-stake); selects
|
||||
// which threshold VerifyWeighted enforces.
|
||||
// alpha — the count floor the votes must reach to assemble (NovaQuorum for Nova,
|
||||
// the ⅔ count for Quasar).
|
||||
// epochHeight — the P-chain epoch the per-voter pubkeys, set-root and stake tally
|
||||
// are all read at (MEDIUM-1).
|
||||
// pos — the consensus position the votes (and the cert) bind to.
|
||||
@@ -83,6 +86,7 @@ func (v VerifiedQuorumCert) Cert() *QuorumCert { return v.qc }
|
||||
func BuildVerifiedQuorumCert(
|
||||
verifier VoteVerifier,
|
||||
stake StakeSource,
|
||||
tier Finality,
|
||||
alpha uint32,
|
||||
epochHeight uint64,
|
||||
pos VotePosition,
|
||||
@@ -91,7 +95,7 @@ func BuildVerifiedQuorumCert(
|
||||
if verifier == nil {
|
||||
return VerifiedQuorumCert{}, ErrNoVerifiedQC
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, alpha, votes)
|
||||
cert, err := AssembleQuorumCert(pos, tier, alpha, votes)
|
||||
if err != nil {
|
||||
// Quorum not assembled yet (sub-threshold / not-yet-arrived). Liveness:
|
||||
// keep waiting. Wrap the precise cause for diagnosis, present
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestWeighted_LowStakeCoalitionRejected(t *testing.T) {
|
||||
|
||||
// Cert by the low-stake coalition {1,2,3}: count=3 (≥α) but stake=3/100.
|
||||
pos := VotePosition{ChainID: chainID, Height: 1, Round: 0, BlockID: blk.id, ParentID: ids.Empty}
|
||||
lowCert, err := AssembleQuorumCert(pos, 3, []SignedVote{
|
||||
lowCert, err := AssembleQuorumCert(pos, Quasar, 3, []SignedVote{
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
{NodeID: vs.nodeID(3), Accept: true, Signature: vs.sign(3, pos)},
|
||||
@@ -77,7 +77,7 @@ func TestWeighted_LowStakeCoalitionRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
// Cert including the 97-stake node {0,1,2}: count=3 AND stake=99/100 → finalizes.
|
||||
hiCert, err := AssembleQuorumCert(pos, 3, []SignedVote{
|
||||
hiCert, err := AssembleQuorumCert(pos, Quasar, 3, []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
@@ -104,7 +104,7 @@ func TestWeighted_VerifyWeightedThreshold(t *testing.T) {
|
||||
for _, i := range idx {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
c, err := AssembleQuorumCert(pos, uint32(len(idx)), votes)
|
||||
c, err := AssembleQuorumCert(pos, Quasar, uint32(len(idx)), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user