mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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]>
407 lines
16 KiB
Go
407 lines
16 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
// catchup_convergence_test.go — proves the cert-carrying catch-up path:
|
||
//
|
||
// - LIVENESS: a validator stranded at height N converges to the network tip
|
||
// N+k by accepting fetched (block, cert) pairs through AcceptCatchupBlock,
|
||
// WITHOUT re-voting (no live quorum exists for an already-finalized height).
|
||
// - SAFETY: a forged / sub-quorum / below-α-floor cert delivered via catch-up
|
||
// is REJECTED and finalizes nothing — the cert-gate holds through catch-up
|
||
// exactly as it does on the live path. A node cannot be force-fed a chain.
|
||
// - ORDERING: even a VALID cert applied out of parent order is refused by the
|
||
// per-height guard (finality is contiguous), so the oldest-first invariant
|
||
// is ENFORCED, not merely assumed.
|
||
package chain
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/luxfi/consensus/engine/chain/block"
|
||
"github.com/luxfi/ids"
|
||
"github.com/luxfi/log"
|
||
)
|
||
|
||
// catchupVM is a FAITHFUL test VM: ParseBlock returns the SAME *verifyOnceBlock
|
||
// that was registered for those bytes, so block identity (ID/height/parent) and
|
||
// AcceptCalled tracking survive a round-trip through bytes — exactly as a real
|
||
// VM's deterministic codec does (unlike verifyOnceVM.ParseBlock, which discards
|
||
// identity). Unknown bytes parse to an error so AcceptCatchupBlock rejects them.
|
||
type catchupVM struct {
|
||
mu sync.Mutex
|
||
byBytes map[string]*verifyOnceBlock
|
||
byID map[ids.ID]*verifyOnceBlock
|
||
lastAcc ids.ID
|
||
}
|
||
|
||
func newCatchupVM() *catchupVM {
|
||
return &catchupVM{
|
||
byBytes: map[string]*verifyOnceBlock{},
|
||
byID: map[ids.ID]*verifyOnceBlock{},
|
||
}
|
||
}
|
||
|
||
func (m *catchupVM) register(blk *verifyOnceBlock) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
m.byBytes[string(blk.bytes)] = blk
|
||
m.byID[blk.id] = blk
|
||
}
|
||
|
||
func (m *catchupVM) BuildBlock(context.Context) (block.Block, error) {
|
||
return nil, errVerifiedAlready // a behind node never builds during catch-up
|
||
}
|
||
|
||
func (m *catchupVM) GetBlock(_ context.Context, id ids.ID) (block.Block, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if b, ok := m.byID[id]; ok {
|
||
return b, nil
|
||
}
|
||
return nil, errVerifiedAlready
|
||
}
|
||
|
||
func (m *catchupVM) ParseBlock(_ context.Context, bytes []byte) (block.Block, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if b, ok := m.byBytes[string(bytes)]; ok {
|
||
return b, nil
|
||
}
|
||
return nil, errVerifiedAlready
|
||
}
|
||
|
||
func (m *catchupVM) LastAccepted(context.Context) (ids.ID, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
return m.lastAcc, nil
|
||
}
|
||
|
||
func (m *catchupVM) SetPreference(_ context.Context, id ids.ID) error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
m.lastAcc = id
|
||
return nil
|
||
}
|
||
|
||
var _ BlockBuilder = (*catchupVM)(nil)
|
||
|
||
// newCatchupRuntime builds a STARTED stake-weighted multi-validator Runtime for
|
||
// validator `self`, wired with the test validator set (verifier + signer + stake),
|
||
// the faithful VM, and a recording gossiper (so a test can assert NO votes/certs
|
||
// are emitted during catch-up). It returns the runtime, its chainID, and the
|
||
// recorder. Stake-weighting is wired because the value (mainnet) chain that wedged
|
||
// is stake-weighted — catch-up must clear the SAME ⅔-of-stake predicate.
|
||
func newCatchupRuntime(t *testing.T, vs *testValidatorSet, self int, vm BlockBuilder) (*Runtime, ids.ID, *recordingGossiper) {
|
||
t.Helper()
|
||
chainID := ids.GenerateTestID()
|
||
rec := &recordingGossiper{}
|
||
rt := NewRuntime(NetworkConfig{
|
||
ChainID: chainID,
|
||
NetworkID: ids.GenerateTestID(),
|
||
NodeID: vs.nodeID(self),
|
||
Logger: log.Noop(),
|
||
Params: ptrParams(params5()), // K=5, α=3
|
||
VoteVerifier: vs,
|
||
VoteSigner: vs.signerFor(self),
|
||
StakeSource: vs, // equal unit weights → α-of-K count is also a ⅔-stake quorum
|
||
Gossiper: &certQuorumGossiper{rec: rec},
|
||
VM: vm,
|
||
})
|
||
if err := rt.Start(context.Background(), true); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = rt.Stop(context.Background()) })
|
||
return rt, chainID, rec
|
||
}
|
||
|
||
// catchupCertFor assembles a REAL finality cert for blk at the given chainID,
|
||
// signed by `voters` over blk's canonical position, asserting `threshold`. This is
|
||
// byte-identical to the cert an AHEAD node would have stored (CertForBlock) and
|
||
// gossiped at finalize time. Returns the marshaled cert bytes.
|
||
func catchupCertFor(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock, voters []int, threshold uint32) []byte {
|
||
t.Helper()
|
||
pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
|
||
votes := make([]SignedVote, 0, len(voters))
|
||
for _, i := range voters {
|
||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||
}
|
||
qc, err := AssembleQuorumCert(pos, Quasar, threshold, votes)
|
||
if err != nil {
|
||
t.Fatalf("assemble cert: %v", err)
|
||
}
|
||
b, err := qc.MarshalBinary()
|
||
if err != nil {
|
||
t.Fatalf("marshal cert: %v", err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// seedBehindAt strands the runtime at finalized height N with tip block `tip`,
|
||
// exactly as a node that fell behind the frontier (the incident: luxd-0 at
|
||
// 1082780). The gap blocks built afterward extend `tip`.
|
||
func seedBehindAt(t *testing.T, rt *Runtime, vm *catchupVM, tip *verifyOnceBlock) {
|
||
t.Helper()
|
||
vm.register(tip)
|
||
// Establish CERTIFIED finality at N (a node that legitimately finalized up to N
|
||
// in-process). Post-incident-1082814, SyncState is only a non-authoritative HINT —
|
||
// it no longer seeds certified finality — so the certified baseline is set through
|
||
// the real finalize fold (first-finalize seeds at tip.height). The distinct
|
||
// restart-from-hint recovery is covered by the incident regression suite.
|
||
if _, err := rt.Transitive.consensus.FinalizeBranch(tip.id, tip.height, ids.Empty); err != nil {
|
||
t.Fatalf("seed behind at height %d: %v", tip.height, err)
|
||
}
|
||
if fh, set := rt.Transitive.consensus.GetFinalizedHeight(); !set || fh != tip.height {
|
||
t.Fatalf("precondition: behind node must be finalized at N=%d, got (%d,%v)", tip.height, fh, set)
|
||
}
|
||
}
|
||
|
||
// buildGap returns k blocks N+1..N+k chained on `tip`, registered in vm. Each
|
||
// block's bytes are unique (keyed on height) so the VM registry never collides.
|
||
func buildGap(vm *catchupVM, tip *verifyOnceBlock, k int) []*verifyOnceBlock {
|
||
gap := make([]*verifyOnceBlock, 0, k)
|
||
parent := tip
|
||
for i := 1; i <= k; i++ {
|
||
h := tip.height + uint64(i)
|
||
blk := newTestBlock(h, parent.id, fmt.Sprintf("gap@%d", h))
|
||
vm.register(blk)
|
||
gap = append(gap, blk)
|
||
parent = blk
|
||
}
|
||
return gap
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// LIVENESS — the stranded node converges to the tip THROUGH the cert path, with
|
||
// ZERO re-voting. This is the fix for the wedge: the network will not re-vote an
|
||
// already-finalized height, so the only way back is accepting the certs it
|
||
// already assembled.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
func TestCatchup_BehindNodeConvergesViaCertPath(t *testing.T) {
|
||
vs := newTestValidatorSet(5)
|
||
vm := newCatchupVM()
|
||
rt, chainID, rec := newCatchupRuntime(t, vs, 0, vm)
|
||
|
||
// Strand the node at N=1082780 (the incident height) and build the 17-block gap
|
||
// up to the network tip N+17=1082797 (the incident delta).
|
||
const N = uint64(1082780)
|
||
const k = 17
|
||
tip := newTestBlock(N, ids.Empty, "tip@N")
|
||
seedBehindAt(t, rt, vm, tip)
|
||
gap := buildGap(vm, tip, k)
|
||
|
||
// Feed each (block, cert) oldest-first, as the node-side catch-up transport
|
||
// delivers fetched ancestors. Each cert is a genuine 4-of-5 (≥⅔ stake) witness.
|
||
for i, blk := range gap {
|
||
cert := catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 3)
|
||
if err := rt.AcceptCatchupBlock(context.Background(), blk.bytes, cert); err != nil {
|
||
t.Fatalf("gap[%d] (height %d) cert-accept failed: %v", i, blk.height, err)
|
||
}
|
||
if !rt.IsAccepted(blk.id) {
|
||
t.Fatalf("gap[%d] (height %d) not finalized via cert path", i, blk.height)
|
||
}
|
||
if got := blk.AcceptCalled(); got != 1 {
|
||
t.Fatalf("gap[%d] must VM.Accept exactly once, got %d", i, got)
|
||
}
|
||
}
|
||
|
||
// CONVERGENCE: the behind node advanced N → N+k purely through cert-accept.
|
||
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N+uint64(k) {
|
||
t.Fatalf("convergence FAILED: finalized height %d, want %d (N=%d + k=%d)", fh, N+uint64(k), N, k)
|
||
}
|
||
|
||
// THE DISTINGUISHING ASSERTION: convergence happened WITHOUT re-voting. The
|
||
// catch-up path never broadcasts a vote and never re-gossips a cert — it applies
|
||
// FINISHED certs. (If catch-up had fallen back to the voting Put path, the node
|
||
// would have broadcast votes for already-decided heights and still wedged.)
|
||
rec.mu.Lock()
|
||
votes, certs := len(rec.votes), len(rec.certs)
|
||
rec.mu.Unlock()
|
||
if votes != 0 {
|
||
t.Fatalf("catch-up must NOT broadcast votes (re-voting an already-finalized height), got %d", votes)
|
||
}
|
||
if certs != 0 {
|
||
t.Fatalf("catch-up must NOT re-gossip certs (it applies finished certs), got %d", certs)
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// SAFETY — a bad cert delivered via catch-up is REJECTED and finalizes nothing.
|
||
// The cert-gate (VerifyWeighted / α-floor) holds through the catch-up path with
|
||
// the SAME rigor as live finality. A node cannot be force-fed a bad chain.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
func TestCatchup_RejectsForgedAndSubQuorumCerts(t *testing.T) {
|
||
const N = uint64(1082780)
|
||
|
||
// Each sub-case strands a FRESH node at N and tries to push block N+1 with a
|
||
// DEFECTIVE cert. None may finalize. Fresh runtimes keep verifyOnceBlock.Verify
|
||
// single-shot and isolate the per-height ledger.
|
||
cases := []struct {
|
||
name string
|
||
cert func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte
|
||
}{
|
||
{
|
||
// FORGED signature: 4 voters (count ok, stake ok) but voter 0's slot
|
||
// carries voter 1's signature → clause-6 (signature) fails → no cert.
|
||
name: "forged-signature",
|
||
cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte {
|
||
pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
|
||
votes := []SignedVote{
|
||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(1, pos)}, // 0 claims, 1 signed
|
||
{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)},
|
||
}
|
||
qc, err := AssembleQuorumCert(pos, Quasar, 3, votes)
|
||
if err != nil {
|
||
t.Fatalf("assemble forged: %v", err)
|
||
}
|
||
b, _ := qc.MarshalBinary()
|
||
return b
|
||
},
|
||
},
|
||
{
|
||
// SUB-QUORUM by stake: 3 of 5 validators (count=3≥α=3 passes the COUNT
|
||
// predicate) but 3/5 = 60% ≤ ⅔ → VerifyWeighted's strict supermajority
|
||
// fails → no finality. This is the HIGH-3 headcount-vs-stake gap, enforced
|
||
// through catch-up.
|
||
name: "sub-quorum-stake",
|
||
cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte {
|
||
return catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2}, 3)
|
||
},
|
||
},
|
||
{
|
||
// BELOW α-FLOOR: a cert that asserts a LOWER threshold (1) than the chain's
|
||
// α (3). HandleIncomingCert rejects it at the MinThreshold floor even though
|
||
// its 4 signatures verify — sub-quorum finality-forgery defence.
|
||
name: "below-alpha-floor",
|
||
cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte {
|
||
return catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 1)
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
vs := newTestValidatorSet(5)
|
||
vm := newCatchupVM()
|
||
rt, chainID, _ := newCatchupRuntime(t, vs, 0, vm)
|
||
tip := newTestBlock(N, ids.Empty, "tip@N")
|
||
seedBehindAt(t, rt, vm, tip)
|
||
|
||
blk := newTestBlock(N+1, tip.id, "forced@N+1")
|
||
vm.register(blk)
|
||
bad := tc.cert(t, vs, chainID, blk)
|
||
|
||
err := rt.AcceptCatchupBlock(context.Background(), blk.bytes, bad)
|
||
if err == nil {
|
||
t.Fatalf("SAFETY VIOLATION: a %s cert was accepted via catch-up", tc.name)
|
||
}
|
||
if rt.IsAccepted(blk.id) {
|
||
t.Fatalf("SAFETY VIOLATION: %s cert finalized block N+1 (IsAccepted)", tc.name)
|
||
}
|
||
if got := blk.AcceptCalled(); got != 0 {
|
||
t.Fatalf("SAFETY VIOLATION: %s cert ran VM.Accept %d×", tc.name, got)
|
||
}
|
||
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N {
|
||
t.Fatalf("%s: finalized height moved off N=%d to %d on a bad cert", tc.name, N, fh)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// ORDERING — even a VALID cert applied OUT OF PARENT ORDER is refused. The
|
||
// per-height guard requires height == finalizedHeight+1 AND parent == finalizedTip,
|
||
// so the oldest-first invariant is ENFORCED by the engine, not merely assumed by
|
||
// the transport. After the gap is filled in order, the same block finalizes.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
func TestCatchup_OutOfOrderRefusedThenInOrderConverges(t *testing.T) {
|
||
vs := newTestValidatorSet(5)
|
||
vm := newCatchupVM()
|
||
rt, chainID, _ := newCatchupRuntime(t, vs, 0, vm)
|
||
|
||
const N = uint64(1082780)
|
||
tip := newTestBlock(N, ids.Empty, "tip@N")
|
||
seedBehindAt(t, rt, vm, tip)
|
||
gap := buildGap(vm, tip, 2) // N+1, N+2 (kept pristine for step 2)
|
||
|
||
// (1) Try to skip ahead: a DISTINCT block at height N+2 (parent = the real N+1)
|
||
// with a perfectly VALID 4-of-5 cert, applied while still finalized at N. The
|
||
// per-height guard refuses it (height N+2 != finalizedHeight+1 == N+1) — a valid
|
||
// cert does NOT license a non-contiguous finalize.
|
||
ooo := newTestBlock(N+2, gap[0].id, "ooo@N+2")
|
||
vm.register(ooo)
|
||
certOoo := catchupCertFor(t, vs, chainID, ooo, []int{0, 1, 2, 3}, 3)
|
||
if err := rt.AcceptCatchupBlock(context.Background(), ooo.bytes, certOoo); err == nil {
|
||
t.Fatal("ORDERING VIOLATION: a height-N+2 block accepted while finalized at N (contiguity guard bypassed)")
|
||
}
|
||
if rt.IsAccepted(ooo.id) {
|
||
t.Fatal("ORDERING VIOLATION: out-of-order block finalized")
|
||
}
|
||
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N {
|
||
t.Fatalf("out-of-order accept moved finalized height off N=%d to %d", N, fh)
|
||
}
|
||
|
||
// (2) Now apply N+1 then N+2 IN ORDER → both finalize. The earlier refusal was
|
||
// the contiguity guard, not a stuck path.
|
||
for i, blk := range gap {
|
||
cert := catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 3)
|
||
if err := rt.AcceptCatchupBlock(context.Background(), blk.bytes, cert); err != nil {
|
||
t.Fatalf("in-order gap[%d] (height %d) cert-accept failed: %v", i, blk.height, err)
|
||
}
|
||
}
|
||
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N+2 {
|
||
t.Fatalf("did not converge to N+2 after in-order apply, got %d", fh)
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// SERVE — the ahead side. A node that finalized a block retains and SERVES its
|
||
// cert (CertForBlock), and the served bytes are exactly what a behind node needs
|
||
// to finalize the same block. Closes the loop: store-on-finalize ⇄ serve.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) {
|
||
vs := newTestValidatorSet(5)
|
||
vm := newCatchupVM()
|
||
rt, chainID, _ := newCatchupRuntime(t, vs, 0, vm)
|
||
|
||
const N = uint64(100)
|
||
tip := newTestBlock(N, ids.Empty, "tip@N")
|
||
seedBehindAt(t, rt, vm, tip)
|
||
blk := buildGap(vm, tip, 1)[0] // N+1
|
||
|
||
// Before finalize: nothing to serve.
|
||
if _, ok := rt.CertForBlock(blk.id); ok {
|
||
t.Fatal("CertForBlock returned a cert for an unfinalized block")
|
||
}
|
||
|
||
cert := catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 3)
|
||
if err := rt.AcceptCatchupBlock(context.Background(), blk.bytes, cert); err != nil {
|
||
t.Fatalf("finalize N+1: %v", err)
|
||
}
|
||
|
||
// After finalize: the node serves a cert that decodes+verifies to the SAME
|
||
// finality witness — a peer can finalize on it with zero trust in this node.
|
||
served, ok := rt.CertForBlock(blk.id)
|
||
if !ok {
|
||
t.Fatal("CertForBlock did not serve the finalized block's cert")
|
||
}
|
||
qc, err := UnmarshalQuorumCert(served)
|
||
if err != nil {
|
||
t.Fatalf("served cert does not decode: %v", err)
|
||
}
|
||
if qc.Position.BlockID != blk.id || qc.Position.Height != blk.height {
|
||
t.Fatalf("served cert binds the wrong position: %+v", qc.Position)
|
||
}
|
||
if err := qc.VerifyWeighted(vs, vs, 0); err != nil {
|
||
t.Fatalf("served cert does not clear the ⅔-stake predicate: %v", err)
|
||
}
|
||
}
|