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:
2026-07-10 03:58:20 -07:00
co-authored by Hanzo Dev
parent 4f09496454
commit a664c82534
30 changed files with 1484 additions and 330 deletions
+94 -88
View File
@@ -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())
}
+1 -1
View File
@@ -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
}
+14 -11
View File
@@ -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))
}
})
}
+2 -2
View File
@@ -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
View File
@@ -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")
}
+9 -2
View File
@@ -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
}
+4 -4
View File
@@ -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
View File
@@ -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
+17 -27
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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)},
+15 -17
View File
@@ -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.
+18 -14
View File
@@ -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)},
+1 -1
View File
@@ -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)
}
+5 -5
View File
@@ -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")
+50
View File
@@ -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
+8 -5
View File
@@ -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)
}
}
+497
View File
@@ -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
View File
@@ -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
+12 -12
View File
@@ -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)
+2 -2
View File
@@ -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 {
+4 -4
View File
@@ -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)},
+36 -10
View File
@@ -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
View File
@@ -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)")
}
+1 -1
View File
@@ -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)
}
}
+39 -9
View File
@@ -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)")
}
+7 -3
View File
@@ -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
+3 -3
View File
@@ -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)
}