Overview

Consensus Verifiability

The primary purpose of cryptographic consensus protocols is to enable people to interact with strangers all over the world where a person's locally controlled software can provide safe, timely guarantees that some globally available state was updated through an interaction.

To do this, a person's software needs to be able to verify that a particular interaction resulted in a change to some globally available state and the resuling state is valid according to a set of pre-defined rules.

Unfortunately, "global state" is a useful fiction:

Objectivity and Subjectivity in Consensus

Objective Verifiability

Verifying nodes run software which verifies whether a particular ledge history is valid according to consensus rules, which all nodes must agree on to arrive at the same view of the network's ledger. In order to ensure any two nodes would arrive at the same decision as to whether a given history is valid or not, their decision must be objectively verifiable. Precisely:

An objectively verifiable property of a ledger history can be computed using only that ledger history as input.

Objectively verifiable properties include all of the ledger state that users care about: balances, when transfers occurred, what authorization criteria were fulfilled to enable a transfer, the current total supply, the supply totals for different shielded pools, how changes to balances and ownership were bundled into (objectively verifiable) transactions, and so forth.

By relying only on the ledger history, and not any other auxillary, node-specific information, a node ensures that a property it verifies is the same value any other node would verify from that same history. If nodes relied on non-objective input to their verification process, they would lose the guarantee that other nodes would arrive at the same conclusion for a given history.

The top-line objectively verifiable property of a ledger history is a boolean value: consensus-valid. By accurately excluding any ledger history which is not objectively verifiable as consensus-valid, nodes protect their users from large categories of malice and accident with respect to the ledger state.

Intersubjectivity

Nodes, like people, are immersed in subjectivity. Local data and code can compute values locally, which nodes can then rely on innately through the magic of computation, but there's no a priori guarantee any other entity has access to the the same input or calculated data. Furthermore, if presented with arbitrary data along with a claim that it is a conclusion or result of some kind of verification, a node can only rely on this claim by either performing their own verification locally, or relying on the third party as an authority (or anyone who can foil authentication attempts of that authority).

When performing local verification, a wonderful advancement Zcash in particular has leveraged are proofs-of-integrity which allow verifying a claim objectively without needing to reproduce all of the original input data and direct calculation steps.

However, none of this can

Rewards Distribution

Here we give an overview of how the Zcash protocol with Crosslink enabled distributes protocol rewards.

The ZEC Supply Policy

We conceptualize all abstract rules about the ZEC asset's ownership and accounting as the ZEC Supply Policy. This policy is not an emergent property of (any version of) the Zcash consensus protocol, and is instead an explicit part of the conceptual design which protocol implementations and changes are evaluated against.

We propose the following hold for existing Zcash mainnet (NU 6.3) as well as in this Crosslink design:

  1. ZEC must be scarce, with every unit accounted for by the consensus protocol, all adding up to the Max Supply Cap of 21,000,000.

  2. The consensus protocol constrains ZEC transfers, either between users, or between the protocol and users. All such transfers maintain Supply Integrity by ensuring the same number of units transferred away from a set of sources are distributed to a set of recipients.

  3. ZEC is divided between Unissued Supply versus Active Supply, with the total of these two categories being the Max Supply Cap.

  4. The Active Supply is all ZEC that is under a user's discretionary control, given any protocol constraints on those funds. For example, much of the active supply is ZEC which cannot be transferred without possession of a spending authority, but another example relevant to Crosslink is that staking bonds have restrictions on transfers, yet end users still have discretion within those constraints, and so both examples are considered part of the Active Supply.

  5. The consensus protocol may issue or unissue ZEC by accounting for those units being removed from the Unissues Supply in a transfer to the Active Supply (or vice versa for unissuance), with the following further constraints on issuance:

    The consensus protocol regularly issues ZEC as part of the network operation in order to incentivize successful operation of the network and establish a valuable network effect of ZEC scarcity among users.

    This new ZEC is issued by the protocol at a predictable constrained rate, the Issuance Schedule, which asymptotically approaches 0 over time with an approximate 4 year half-life.

    Issuance Rate Detail
    • Details:
      • The rate is time-approximate by relying on per-block accounting on the assumption that the block production rate is approximately constant.
      • The rate currently follows the Bitcoin-like "four year halving" schedule, although it may be adjusted by the Network Sustainability Mechanism or other changes which alter this "fine-grained" detail of the issuance rate.

Consensus Rewards Distribution

Given the concept of the ZEC Supply Policy, we can envision any version of a given consensus protocol as a component which can take newly issued ZEC and distribute it such that all of the consensus guarantees hold, including all of the ZEC Supply Policy.

In other words, one way to conceptualize the consensus protocol designers job is in terms of an operational budget:

Given that the ZEC Supply Policy provides you with new ZEC to distribute in each time period to protocol participants, design and deploy a self-funding protocol that supports all of the ZEC Supply Policy goals.

FIXME: What about transaction fees?

FIXME: What about "dev fund" / "human-governed funding" that is not direct "consensus operation funding"?

With that framework in mind, here are the concrete rewards distribution rules Crosslink follows:

  1. The consensus protocol distributes block rewards (= new issuance + transaction fees) in each block. Crosslink does not alter the distribution of transaction fees in any way, and their distribution is orthogonal to Crosslink.

  2. A chunk may come out for dev fund or other discretionary governed funding (FIXME: this is part of the policy that's out-of-scope for the consensus protocol; move this section).

  3. The remainder is called the Operational Consensus Rewards (aka OCR).

  4. 50% (rounding up) of the OCR is distributed as Mining Rewards to the current block miner in the same manner/mechanism as in Zcash NU 6.3. (Note: transaction fees are not altered by Crosslink, and thus accrue to the miner and/or NSM or any other current txn fee design when Crosslink activates.)

  5. 50% (rounding down) of the OCR is distributed as PoS Rewards as follows:

    a. 90% (rounding down) of PoS Rewards are distributed as Staking Returns. Staking returns are divided proportionally among all Bonds present in a block. b. 10% (rounding up) are distributed as Finalizer Comission Fees which are divided propotionally to the total stake weight of each active finalizer.

    FIXME: Should it be $F^{active}_i / \Sum F^{active}$ or $F^{active}_i / \Sum F^{total}$ ?

Staking Mechanics

The rewards distribution rules above depend on these staking mechanics:

  1. Users can modify their bonds with Crosslink-specific transaction fields. All interactions with bonds require that there are no txn inputs or outputs aside from: the bond actions, latest/greatest shielded pool actions, and transaction fee payments.

  2. Txns with bond actions may only be included in blocks at Staking Day height ranges (FIXME: define these heights).

  3. Bonds have a stateful lifecycle:

    • From any Shielded Pool ZEC: create action

    • From active state: redelegate or withdraw actions

    • From withdrawing w/ sufficient delay state: transfer action

      • FIXME: define "Sufficient delay", but it should be relatively simple and based on the height of the withdraw action (or maaaaybe an associated staking day on/off height?)

    Actions:

    • The create and withdraw actions may only occur during Staking Day heights.
    • The redelegate and transfer actions are not constrained by Staking Day height ranges.
    • The transfer action is akin to any other transfer transaction with the additional restriction that ther are no inputs/outputs/actions except for the transfer and the latest/greatest shielded pool actions, plus a transaction fee.

FIXME: consistent rigorous rounding for every apportioning of ZEC of entire accounting design.

Zcash Crosslink Design Overview

Project: Crosslink (Zcash)

Status: Living document — draft 10. Draft 7 plus material folded in from four working notes: slashing_constraints, network_design, bootstrap_and_stake_rewards, storing_crosslink_in_pow. Items marked [TBC] are unsettled or were cut off in dictation.

Related: Nikete's Mechanism Design Audit of Crosslink Zebra (recommended reading for Part IV).

Terminology used in the working notes and adopted here

  • TFC — the BFT certificate ("trailing finality certificate").
  • lockbox — the finalizer bank account of section 14.
  • NSM — Zcash's network sustainability mechanism (burn/reissue).
  • Tenderlink — the BFT (Tendermint-derived) node component and its networking; PoWLink — the PoW block side-channel.

CONTENTS

PART I — WHAT CROSSLINK IS AND WHY

1. PURPOSE

Zcash is a proof-of-work chain, and proof of work gives only probabilistic finality: reversing a block grows ever more expensive, but there is never a moment at which it is definitively settled. In practice the tip of the chain can thrash — small reorganisations near the head as competing blocks race.

Crosslink adds a proof-of-stake BFT (Byzantine fault tolerant) layer whose only job is to finalize the proof-of-work chain. It does not replace proof of work and it does not carry the ledger. It periodically reaches agreement that a specific PoW block is final, and the rest of the design is arranged so that:

  • this agreement has teeth (nodes will not reorg past it),
  • proof of work remains the primary system, so that if the BFT layer fails the network decays gracefully back to plain PoW,
  • stake is genuinely at risk, so finalizers can be held to account,
  • all of this is done privately, in keeping with Zcash, and
  • the whole thing is robust to malicious peers.

Most specific choices below are downstream of one of those five goals.

2. THE ONE-PARAGRAPH VERSION

There are two chains. The PoW chain gets a new field, the "fat pointer", that names a BFT certificate by hash and carries signatures attesting to it. The BFT chain is a sequence of certificates, each of which finalizes one PoW block and carries a few PoW headers above it as evidence of work. Each chain therefore points at the other. Nodes follow the "sticky choice" rule: most-work still picks the best chain, but once a finalized block is on your best chain you never reorg past it, so finality is a ratchet rather than an override. BFT participants are "finalizers"; anyone can delegate stake to them by creating anonymous, fixed-denomination bonds from shielded funds. Rewards are paid uniformly and only while the system is working; there is no in-protocol slashing, because the protocol cannot agree on who voted for what. Instead, slashing is a user-coordinated hard fork that burns all stake delegated to a named finalizer and jails it. All ledger state lives on the PoW chain, and every economic effect is computed from what the PoW chain can see through the fat pointer.

PART II — THE TWO CHAINS

3. THE PROOF-OF-WORK SIDE: THE FAT POINTER AND WHERE IT LIVES

What the fat pointer is

The PoW chain carries, per block, a "fat pointer" to the BFT chain:

  • a hash identifying a BFT certificate, and
  • a set of Ed25519 signatures attesting to that certificate.

The certificate is referenced by hash rather than embedded by value. A certificate contains PoW headers, so embedding it in a PoW block would be recursive. Referring by hash breaks the recursion at the cost of the "not yet known" state discussed in section 5.

The fat pointer exists because a certificate cannot carry its own signatures: the signatures are over the certificate, so they cannot be inside it. Several places for the signatures were considered; alongside the hash on the PoW side is the one settled on. Whether the signature set could be made fixed-size with a different scheme is an open question (Part VI).

All ledger state — balances, bonds, rosters — lives on the PoW chain. The BFT chain finalizes and holds no state of its own. Every payout, burn or slash is computed from what the PoW chain can observe about the BFT chain, and the fat pointer is the only window.

Where in the PoW block it lives

The current implementation modifies the block header directly and bumps the header version. (The version number is [TBC]; "version 5" was dictated but may be a confusion with transaction v5.) A ZIP call on 2025-08-05 with Jack "str4d" Grigg and Daira-Emma established that this is possible but "fractally difficult", and that the options below should be weighed first. Jack has offered to review early designs.

Why changing the header is hard

  • ASIC miners may have hardcoded header interpretation. Breaking most miners would structurally reduce PoW security.
  • The version field has been used inconsistently in the wild (e.g. big-endian 4 and other small numbers). If mining follows the Stratum protocol of ZIP 301, a strict version == 4 is already required and a plain bump would work; if not, the little-endian signed value must still be positive, so the top bit is available as a flag (the approach ZIP 202 uses for "overwintered"). [TODO: survey actual version-field use on chain.]
  • Exchanges and others may have custom parsers. SPV and lightwallet protocols are little used, and the mining-pool population is small enough to talk to individually, which may make this tractable.

Storage options, in increasing order of change required

  • a. Commit only. Do not store Crosslink data; add it to the tree behind the existing 32-byte commitment field whose meaning is versioned. Probably insufficient, since some data must actually be stored.
  • b. Coinbase sigscript. ~100 bytes, and the cap is far easier to raise than the header. Fits a 32-byte hash of the certificate but not signatures. Compatible with designs that ignore signatures on the PoW side, at the cost that a new PoW block's link cannot be verified as carrying the required votes without consulting an up-to-date PoS service.
  • c. Typed memo bundle on the coinbase transaction (all-zero key), committed to. Up to 16 KB — ample for the current signature format, perhaps not for anything post-quantum.
  • d. Modify the header directly (current approach). Daira-Emma's caution: unless notarization proofs are short and constant length they do not belong in the header, and putting them in the coinbase merges the indirection with one that is needed anyway to validate the block. A variable-length vector of 32-byte fields, length fixed by semantic version, is one shape.
  • e. Two-level header: a small fixed-size header committing to a variable-length non-transaction "sidecar" section that holds the Crosslink data and other things. Jack and Daira-Emma were both in favour if the pain of a breaking header change is being paid anyway; there is a backlog of things they would like to fix at the same time (promoting data currently back-doored through the commitment tree, etc.). Top-level headers should fit in a network MTU; if PoW is not in the fixed part, P2P may need care.

A caveat for any option that keeps the data outside the header: PoS blocks cannot then use the PoW headers they carry to directly reach back-references to earlier PoS blocks.

Further reading: ZIP 200 (network upgrade mechanism); zcash issues #172, #5755, #1040.

4. THE BFT SIDE: TFCs AND σ

The unit of the BFT chain is a TFC. Each non-genesis TFC carries headers_bc: exactly σ PoW headers, deepest first. The block it finalizes (its "snapshot") is not among them. It is the parent of the first header, named by that header's parent hash. So the σ headers are the confirmations above the snapshot, and the snapshot is σ-confirmed by construction.

σ is a protocol parameter. The prototype sets σ = 4. This value has not been checked for security or performance.

Two TFC validity rules concern headers_bc:

  • Tail Confirmation: the headers are the σ-block tail of a bc-valid chain.
  • Linearity: each snapshot is equal to or descends from the parent TFC's snapshot. Final snapshots only move forward along one PoW chain.

A validator must download and validate the PoW blocks under those headers, not just check the headers' work. Tail Confirmation requires a bc-valid chain, and a proposal whose snapshot the node cannot resolve cannot be validated yet. Headers alone do not establish validity. The trade-off is accepted: finalization waits on the validators receiving those blocks. Tail Confirmation is objective all the same: σ consecutive headers ending at a bc-valid block form that block's tail, whatever the validator's own best chain is.

Inclusion Depth

A PoW block at height P may cite (via context_bft) a TFC whose snapshot is at height F only if P >= F + σ + 1: the σ carried headers F+1 ..= F+σ, then the carrier. This is a bc-block validity rule, beside Valid Context, Extension and Last Final Snapshot. Checking it takes a PoW -> PoS -> PoW lookup: resolve the pointer to its TFC, take the TFC's snapshot, look up its height. A block whose snapshot is not yet known is deferred, not rejected. Block templates apply the same test, so a miner is never handed a TFC it could not include.

Rolling, not batch

An honest proposer carries the σ-block tail of its own best chain. If that tail would break Linearity (e.g. after a PoW reorg below the last final snapshot), it repeats its parent's headers instead. A decision at PoW tip T therefore finalizes T - σ. Each decision advances the snapshot by however many PoW blocks arrived since the last decision. It repeats the snapshot when no block arrived and jumps several blocks when decisions are slow. This rolling window is how the construction already works; it does not depend on incentives.

Deviations from the TFL Book's honest proposer:

  • Our proposer clamps the snapshot to at most 40 blocks above the previous final snapshot. When the clamp binds, the proposal is a window ending below the tip. That still satisfies Tail Confirmation. The clamp is a heuristic, not part of Crosslink 2.
  • Where the honest proposer would repeat its parent's headers, ours makes no proposal. It declines when the tail would break Linearity, when the candidate would not improve on the last final snapshot, and when a PoW reorg lands between reading the tip and reading the tail. How often a proposer should repeat instead is not yet decided. Both are proposer behavior, not validity rules: a validator cannot tell whether the headers were the proposer's best-chain tail.

Finality lag

By the Inclusion Depth rule, the first PoW block that can cite a decision at tip T (snapshot T - σ) is T + 1, and only if its template was built after the decision arrived. So local finality trails the best tip by at least σ + 1 blocks in steady state. Stale templates and slow decisions add more.

5. MUTUAL REFERENCE AND ITS CONSEQUENCES

Because each chain references the other, there is a serial dependency in both directions:

PoW depends on BFT

A PoW block pointing at a certificate the node has not seen cannot be validated until the certificate arrives.

BFT depends on PoW

A finalizer cannot vote on a proposal whose candidate it has not fully validated, which requires the candidate and its ancestry.

The third validity state

Conventionally a proposal or block is valid or invalid. Crosslink adds "not yet determinable" on both sides: the node is waiting for data before it can take a position. That data may never arrive — a malicious peer can reference a hash for which nothing exists — so "pending" must be a state that can expire or be abandoned, never one the node blocks on indefinitely. [TBC: the exact rule.]

Threat model

Robustness to malicious peers — dangling references, withheld data, attempts to wedge validation — is a standing constraint. Hash-only pointers, the third state, and tolerance for data that never arrives all follow from it.

6. THE BFT IMPLEMENTATION

The BFT layer (Tenderlink) is a reimplementation of Tendermint. Reuse was not possible because the ternary validity state must be encoded in the protocol itself.

One property shapes the whole economic design: peers reach consensus on the decision for a certificate (was it approved by two thirds of stake-weighted power?), but different peers may hold different subsets of the votes that made up that two thirds. The outcome is agreed; the exact signature set is not. The protocol therefore cannot use "who voted for what" as evidence for anything — not slashing, not per-vote payouts. See sections 11 and 16.

A second assumption of Tendermint matters for slashing: every finalizer must have an identical understanding of who is in the roster. Section 16 spells out what that forbids.

7. NETWORKING AND SYNC

What went wrong with the existing stack

Zcash's sync layer assumes one logical chain in which every block has one parent, so a suffix connecting to a known ancestor is enough to validate everything in it. It bulk-syncs infrequently, and any validation failure triggers a long timeout. Crosslink has two logical chains, each needing knowledge of the other to progress, which produces round-trips like:

receive a decided BFT block → query for its PoW blocks → header missing → PoWLink downloads the chain backwards by hash until Zebra recognises a block → submit PoW blocks in order, but they contain certificate changes that query PoS state → only now can the BFT block be processed.

In workshops at an increased block rate, sync was too slow: people diverged by over a hundred blocks and could not recover without a reset, and a side channel had to be added. Mempool sync also appeared not to happen when the sender is a miner placing the transaction directly in a block.

σ-based security depends on sufficiently fast sync, so this is a security requirement, not just a usability one.

Tenderlink networking is a custom, encrypted-from-the-ground-up (NOISE/Snow) datagram protocol. Keys rather than certificates provide identity and addressing. Packets are MTU-sized with application-level fragmentation and application-controlled (naive) resend and specific-peer targeting; there is no congestion control. Proposals span many packets, votes pack many into one, status messages are one-to-one.

PoWLink is a reliable-stream side channel that downloads PoW blocks and submits them to PoW state. It exploits finality directly to linearise and find the required chain — in effect "more frequent checkpoints" — and is "always up" rather than periodic. Its gain is discovery speed more than raw download speed.

The new networking stack

Requirements: "little and often" and "high-bandwidth serial beaming"; reliable and unreliable transport; large datagrams; multiple streams per connection; forward and backward secrecy; connection migration and rekeying; upgradeable-but-not-downgradable crypto; high bandwidth, high ping and high jitter; identical API over Nym mixnet and direct connections; and the recognition that networking is CPU work, not just I/O waiting. It is not expected to be compatible with Bitcoin-style sync. Requirements are being coordinated with Nym, ZF and Tachyon.

Three layers:

  • 1. Transport — use-case agnostic: congestion control (ECN, loss, bytes in flight), MTU discovery and BDP, bulk transfer with acking, minimally blocking. Data packets are all the same size for indistinguishability. Possible later: opt-in RaptorQ-style loss recovery, compression, "packlet" framing for small items.
  • 2. P2P — probably application-transparent: gossip, UDP hole punching via a third party. Hard-NAT clients (phone wallets) are expected to use a client-server model, which the protocol is designed to support well.
  • 3. Application — sensitive-metadata announcements over Nym (tx announcements; optionally BFT messages and newly mined blocks), BFT and PoW block sync, BFT votes, lightwalletd traffic, bootstrap (DNS?).

Non-goals for now: multiple NICs, one logical client in multiple physical locations, rapid reopen of identical connections, symmetric-NAT traversal, local-endpoint discovery, packet relay.

Why not QUIC: no Nym path; TLS is redundant with NOISE and brings certificate authorities and downgrade concerns; complexity and audit surface.

Why not libp2p: misaligned goals (NAT-poor, relay-heavy, broadcast only), too modular to use effectively, large code volume, pub-sub is the wrong model since every message is globally relevant and should always be gossiped, DHT is unneeded, and it would be a heavy non-Zcash supply-chain dependency for a key component.

Interleaving

The two streams are currently handled separately. Interleaving them in a serializable order is planned but not done.

8. BOOTSTRAP AND ACTIVATION

The BFT chain has no external genesis. It is started deterministically by every node from PoW state alone; nothing about genesis is received from the network or agreed by a separate process. This section records how, since the safety argument otherwise lives only in code.

Heights (as implemented)

BOOTSTRAP_ROSTER_HEIGHT = STAKING_PERIOD / 2 (call it h1)

BOOTSTRAP_ACTIVATION_HEIGHT = h1 + 200 (call it h2)

h1: The PoW block whose staking state supplies the roster that votes on BFT height 0.

h2: The PoW height at which a node walks back, finalizes h1, and starts BFT. Every PoW block at or below h2 must carry a nil fat pointer; the first non-nil pointer can appear only above h2.

Safety argument

There is a compile-time assertion that h2 - h1 > MAX_BLOCK_REORG_HEIGHT. By the time any node reaches h2, block h1 is below the reorg limit and therefore identical on every chain a node could be following. So every node computes the same roster from h1, constructs the same BFT genesis, and the Tendermint requirement that all finalizers share one view of the roster (section 6) holds from the first round without any coordination.

This is the bootstrap instance of the general rule in section 16: the roster is a pure function of finalized PoW state, and the finality in question here is reorg-depth finality rather than BFT finality.

Relation to the three-height plan in the notes

The rewards notes describe three heights: H1 (staking transactions activate), H2 (roster is determined), H3 (first block that may point at a certificate), with H2 and H3 fixed in one governance decision and H2 perhaps H3 minus 100,000. The code's h1 corresponds to the notes' H2 and the code's h2 to the notes' H3; the notes' H1 (activation of staking actions) is not a separate constant in the code as described. [TBC: reconcile naming, and confirm whether the 200-block gap is a development value versus the ~100,000 suggested for mainnet.]

At activation every block in [0, h1] becomes de facto finalized. Whether BFT genesis should point at the PoW genesis or at h1 remains open.

PART III — WHAT FINALITY MEANS HERE

9. STICKY FORK CHOICE

Every node, and in particular every miner, must choose a best chain given information from both chains. The spectrum:

Most work wins. A node can follow a heavier chain that excludes its own finalized block. The finalized point is then left on an abandoned branch.

Follow the latest BFT snapshot (the TFL Book's "Questions" rule)

The best chain must extend the snapshot of the newest final TFC in view. That point need not be on any chain the node has selected, nor σ-confirmed in one. The Book's author concludes "Probably not".

Sticky fork choice — our rule

The floor is the node's own fin (local_finalized_tip), not the BFT snapshot. A node switches from its current chain to a new one iff fin is on the new chain and the new chain has more work (ties broken by tip hash). Equivalently: best = heaviest chain that contains fin.

How fin moves

fin is never set from a BFT decision directly. On every change of best chain, the node computes

candidate(best) = lca(snapshot(LF(best)), prune_σ(best))

where LF(best) is the TFC the tip's context_bft points at, and prune_σ(C) is C with its last σ blocks removed. If fin is an ancestor of or equal to the candidate, fin := candidate. Otherwise fin stays put. So fin only advances along the node's own best chain, only to blocks the node has itself buried σ deep, and never backwards.

The ratchet, step by step

  • A decision arrives. It advances bft_final_snapshot (the snapshot of the newest decided TFC), which may be on a side chain. My fin does not move yet.
  • A PoW block on my best chain cites that decision in its context_bft. Once that block is my best tip, fin ratchets up to its candidate. I will never again switch to a chain lacking it.
  • A side chain carries newer decisions. I sync it anyway, store its blocks and decisions outside the finalized state, and track bonds along it (I need that for the roster, section 11). Under Linearity it contains my fin, so it stays eligible.
  • If it ever has more work than my chain, I switch, on work alone. fin then ratchets to that chain's candidate.
  • A heavier chain that forks below my fin: I refuse it, whatever its work. This refused switch is the only observable sign of a finality conflict. It is logged; a persisted hazard record is still TODO.

Caveat: Zebra also commits blocks at reorg depth (MAX_BLOCK_REORG_HEIGHT). If my best chain runs that far past the fork to bft_final_snapshot, the depth commit conflicts with it and I can never switch back. Under Linearity my branch then never finalizes again until I resync.

Why

BFT does not choose the chain. It only ratchets a floor under the work rule, and only to points my own work-selected chain already has σ deep. An advance of fin never causes a switch. The rule departs from raw work only when a heavier chain excludes fin. By construction that would displace a prefix σ-confirmed on my own earlier best chain. While BFT is stalled or withholding, fin is frozen and selection above it is plain most-work, so PoW stays the Schelling point.

What this does not buy:

  • No Stalled Mode, so the gap between fin and the tip is unbounded during a stall, and ordinary spends keep landing above fin.
  • A miner who dominates the best chain can withhold finality progress by never updating context_bft; that costs nothing in validity.
  • A partition in which only one side can finalize past the fork leaves that side permanently unwilling to switch to the other.
  • The TFL Book has no safety or liveness proof for this rule. Its liveness argument relies on unmodified fork choice.

10. KINDS OF FINALITY AND WHICH LEDGER STATE IS CURRENT

BFT finality (bft_final_snapshot)

The snapshot of the newest decided TFC. It is objective, and possibly on a chain the node does not consider best, for any length of time. "Crosslink finalized" in conversation usually means this.

Local finality (fin / local_finalized_tip)

The node will not reorg past this block. It is node-local and monotone, advanced only from candidate(best) as in section 9. It always lies on the best chain and is at or below bft_final_snapshot (under Linearity). It is persisted in the finalized database as its own hash.

Database finalized tip

Not finality. It is the higher of fin and Zebra's reorg-depth commit, so it can be above fin. It is a physical commit boundary, not a cache of fin. Never report it as Crosslink finality. Finality readers take fin.

No protocol quantity lies between the best tip and fin. A "confirmed" display is plain PoW confirmation depth, never final.

Consensus must never read fin. Honest nodes reach the same chain through different histories, so their fin values are only prefix-compatible, not equal. Anything every node must compute identically comes from objective chain data:

Roster for BFT height H

the bonds at snapshot(B_{H-1}), the snapshot of the decided TFC at H - 1. BFT agreement fixes that TFC, so every validator derives the same set. It is generally above fin and may be off the best chain, so it is read from the synced chain leading to bft_final_snapshot (section 11).

Staking rewards

an objective per-block trigger, not fin (section 14).

PoW-tip state

ordinary ledger state at the best tip. Staking transactions in a block are validated against their own chain.

PART IV — STAKE

11. FINALIZERS AND ROSTERS

Participants in the BFT layer are finalizers. A finalizer's voting power is the stake delegated to it (section 12). Every "percentage of finalizers" here is stake-weighted, never a headcount.

Active and inactive rosters

Finalizers are split into an active roster and an inactive roster. The split exists for performance and technical reasons — it bounds the number of parties per BFT round — and is not a reward or penalty mechanism. The roster for a given certificate must be a pure function of finalized PoW state plus the slashing config (section 16). The active roster is selected as the top N finalizers when sorted by voting power, where N is the active roster length (a fixed constant).

Finalizers opt in

A finalizer publishes an address that acts as a capability carrying its own signature consenting to act as a finalizer. Delegation requires this consent.

Who is behind a finalizer

Crosslink has no internal web of trust. The expectation is that Zcash organisations will attest outside the protocol that a finalizer is run by a particular provider or company, and that users can combine several attestations into a reasonable judgement.

A possible built-in feature: since finalizers already have keys, they could sign short, namespaced messages that nodes gossip at a rate limit of about one per active finalizer — "down for maintenance Tuesday; planned, not malice." Today such notices go out on forums or social media, unlinked to the on-chain identity. The most important use would be during a slash fork, where finalizers need to signal which side they will be on. [Idea only.]

Uniformity, and no automatic slashing

Because peers do not agree on exact vote sets (section 6), the protocol cannot reward or punish individual votes. So there is no automatic slashing, and all active finalizers are paid uniformly at the same time. Punishment is a social process (section 16).

12. DELEGATION BONDS

Delegation of stake from anyone (including finalizers themselves) to a finalizer is a primitive construct, and the way essentially all stake is assigned.

Creating a bond

A user spends shielded funds in a transaction carrying a staking action. "Create delegation bond" specifies:

  • an amount, which must be a power of ten ZEC — privacy quantization, so bonds cannot be fingerprinted by size;
  • a target finalizer, via its consent capability.

The result is a bond: a free-floating note controlled by an Ed25519 key. Holding the key is holding the bond, so it can be operated anonymously, decoupled from the funds that created it. The key is derived from the create action plus a salt, so repeated identical delegations yield distinct, untangled bonds.

The privacy trade-off: bonds are anonymous, but the total staked to each finalizer is public. That total is what voting weight is computed from, so it has to be.

Bonds are atomic

Bonds cannot be split; retarget, unbond and withdraw act on the whole bond.

Retargeting

A bond can be retargeted at any time. The transaction records both old and new target, so from the bond state at time t the retargets can be played backwards to recover the mapping at any earlier time. Slashing depends on this.

Withdrawing: unbond, then withdraw

Two actions on two different staking days (section 13), so minimum exit is two weeks.

  1. Unbond. Value depends on which block it lands in; once landed, the bond has a fixed numeric value.
  2. Withdraw. The transaction must state that value explicitly and match the chain exactly, or it is invalid.

Two reasons. Accounting clarity: before funds re-enter ordinary Zcash transaction land their amount must be an explicit number, not implicit ledger state. Security: the enforced delay is what puts stake genuinely at risk — if an attack is discovered there is time to slash before the funds escape into the shielded pool.

13. STAKING DAYS

One day per week is a staking day. Bond creation, unbond and withdraw are quantized to staking days.

  • Privacy: batching activity into one day per week removes timing information that could link actions to users.
  • Security: unbond and withdraw on successive staking days gives the two-week minimum exit.

The slash window (section 16) is also measured in staking days.

14. REWARDS

Design stance: smooth enforcement

The economic design leans on withheld income rather than destroyed funds. Uniform, conditional payouts make staking feel like mining: put money in, earn while the system works, and if the system is attacked or stalls you lose income, not principal. Missed income is a far smoother enforcement mechanism than burning, and it means stakers are rewarded or not for the mechanism as a whole working — never for particular finalizer or miner behaviour. Destruction of funds is reserved for the case where users identify a staker as having aided or been totally negligent in an attack, via a slash fork (Part V).

Proposed issuance split

Of post-dev-fund issuance (ignoring the initial 20% deduction):

48% miner subsidy 48% staking rewards (finalizers and stakers) 4% miner bounty for including a new finality

Within the 48% staking share, the split is 90/10:

90% to bonds, pro rata by size against total stake 10% to active finalizers, uniformly

The 90% includes finalizers staking to themselves. Rewards come from a fixed pool; there is no guaranteed percentage yield.

Proposed issuance rule

IF a PoW block points at a new certificate, AND that certificate finalizes a PoW block close to the tip, THEN stakers and finalizers receive the 48% staking reward and the miner receives the 4% bounty; OTHERWISE the 52% is NSM-burned.

"Close to the tip" is measured entirely on the PoW chain: this block points to a certificate, which points to a PoW block; the delta between those two PoW heights is the distance.

This one rule serves several purposes:

  • First-inclusion miner bounty: miners are paid to advance finality monotonically rather than reuse a stale pointer (audit item R2).
  • Anti-jackpot: rewards cannot accumulate while finality is stalled (R4). Because the reward is single-shot per block, nothing is ever accrued or recalculated; accumulated values need not be stored anywhere. Bounty sniping is not an issue in steady state and at most a flaky-finalizer concern.
  • Certificate aging by another route: instead of expiring old certificates, catch-up is incentivized by withholding rewards from finalizations far behind the tip (R6). During catch-up the current heuristic is roughly 40 blocks per certificate; finalizers are incentivized to coordinate quickly on a known shared prefix rather than skip ahead to unshared tips, because otherwise they earn nothing. This is an incentive, not a consensus constraint. (Aside: FlyClient-style proofs might help finalizers establish a shared prefix fast.)
  • Slash forks suspend payouts for their duration, since the pointer stops advancing. This does not disincentivize spring-cleaning forks: their BFT activation is u32::MAX, so they are a single point of cremation rather than a stall.

Bonds always earn

A bond accrues reward whether or not its finalizer is on the active roster. Backing an up-and-coming finalizer costs nothing — deliberate anti-centralization.

Open: is delegating to a non-active finalizer a "vote of no confidence" in the active roster, such that finalizer commission should shrink — i.e. should the 10% denominator be active-roster stake or total stake? ("Vote of no confidence" versus "pay for infrastructure".)

Finalizer lockboxes (bank accounts)

The 10% goes into a per-finalizer lockbox balance on the ledger. A staking action turns lockbox balance into a bond. The lockbox itself also behaves as a virtual bond delegated to its finalizer, so it accrues from both sources: from the 90% like any bond (per total), and from the 10% (per total, or per active-roster member on each new block — open). Ordinary bonds accrue only from the 90%.

Burns are an extra complication: normal burns come from transactions, whereas these would be implicit or a new data member, and different burn designs have different complexity costs.

Because the active roster is bounded, lockboxes should not need the acceleration structure of section 15, though computing "active" as well as "total" stake is a small addition that is needed for Tenderlink anyway.

15. ACCOUNTING AND THE ACCELERATION STRUCTURE

Why

Very many unmergeable bonds; finalizers, miners and wallets need fast answers (finalizer weight, what is final, what my bonds are worth). An acceleration structure over bonds is required, and it depends on payout details, so it follows them.

Defer everything

Bond creation is cheap; ongoing work is per-finalizer and global; an individual bond's value is computed only on query or at unbond. Single-shot rewards (section 14) help here: nothing accumulates.

Rounding

Integer zatoshis divided by an arbitrary total stake guarantee some rounding error. It is minor and should be apportioned sensibly, but the requirement is that every party, at every query resolution, agrees exactly on the value produced for each recipient. Determinism beats precision.

PART V — PUNISHMENT

16. SOCIAL SLASHING

There is no automatic slashing (sections 6, 11). Punishment of stake behind misbehaving finalizers is a user-coordinated hard fork.

Why social

The protocol cannot agree on vote sets, and "well behaved" is fuzzy: a stall can never be proven permanent. The judgement is left to users, aided by visualization tools that inform but cannot decide.

Hard constraints on how a slash may work

  1. The updated roster must be a pure function of finalized PoW state plus the slashing config. It may not depend on any PoW data not already implied by shared certificates. Tendermint assumes every finalizer has an identical view of the roster, and side chains may validly disagree about recent PoW, so:
  • the new roster cannot be derived from staking actions up to the activation tip, and
  • it cannot use "the highest certificate the PoW chain points at", since chains may disagree on what that is.
  1. Staking actions that have landed in the PoW chain must be respected. Justice: most are unrelated to any slashed finalizer. Practicality: reclaiming them would need extra machinery. PR: in a long stall, ignoring them would look like a huge reorg.

  2. PoS stores no ledger information of its own.

  3. Application must be robust to users, miners and finalizers adopting the config at different times; no accidental hard forks from unspecified race conditions.

Design virtues (soft constraints)

Users should preference-cascade to the same input values, so fewer degrees of freedom is better. Users should be able to decide at a suitable resolution. Negligent stakers should be slashed and non-negligent ones not. Miners and finalizers should not be over-weighted in the decision. Roster computation should be cheap in memory and CPU. Forks should be deferred where possible.

Triggering a slash

Node operators modify their config to list the finalizer(s) to slash and a PoW activation height. Every slash event is a hard fork; there is no default switch, and currently nothing nudges a node onto a fork (a warning system may be added).

Why a config, not something else

Two goals pulled in different directions: users should have a large amount of control, so the decision is not centralised; but it should also be easy for everyone to agree on the same thing and decide the same way, because divergent decisions mean multiple hard forks.

The alternative considered was having users edit the code directly. It was rejected because:

  • the space of possible edits is too large to reach a Schelling point on;
  • working through it exposed, as expected, many edge cases where it would be easy to produce a corrupt state, or one where an action on the PoW side rendered the PoS decisions meaningless — a tested implementation with a small set of inputs is far safer than something implemented and decided under time pressure;
  • a config lets non-programmers have meaningful input.

A config with few degrees of freedom satisfies both goals: users decide, but there are few enough choices that they converge.

Two components

The Incinerator (PoW chain)

Burns every bond delegated to the named finalizer(s) within a window reaching back two staking days from activation. Retarget history makes that set reconstructible. No partial slashing, no tunable amount.

The Sergeant-at-Arms (BFT chain)

Jails the named finalizer(s) from voting in current and subsequent rounds; their weight leaves the pool so the rest can reach two thirds.

Bridging the fork: the "do not include by" height

PoW must not be disrupted while nodes switch. BFT has stalled; old PoW blocks keep pointing at the last old certificate; once enough finalizers adopt the fork, the reduced set produces new certificates, which must not leak into PoW before activation. So each certificate carries a "do not include by" PoW height, monotonically non-decreasing, which may only increase in the first certificate of a forked chain — the one embedding the slash config. The forked set resumes immediately on the BFT side while PoW transitions at one agreed height.

How nodes on different configs see each other

Votes are cryptographically namespaced by config, so nodes on different configs diverge immediately, and currently the other side looks like malicious nonsense. This should be improved so a node can recognise "well behaved, different worldview". [TBC.]

A big stick

A slash fork needs buy-in from essentially every node. Obvious cases behave like a network upgrade; splinter groups would leave no apparent canonical chain. The intended dynamic is that misbehaviour is obvious early and honest stakers retarget away in time — the threat moves stake, the burn is the fallback.

Scenarios

Outright stall

BFT stops with identifiable offline participants. Available information stays consistent; probably the easy case — though one may actually be in the flaky case without knowing it.

Flaky stall

BFT stalls, resumes, stalls again; a gradient, not a category. Open: what happens if new certificates are produced between the creation of a slash config and its application?

Spring cleaning

No stall, but the stake-weighted share actually voting drifts down toward two thirds because some finalizers are permanently gone. From this perspective it may be fine for the fork to be merely a deadline by which stakers must have moved, rather than an actual punishment — i.e. analysis and application on a single block.

Other malicious behaviour

[TBC: not yet elaborated.]

PART VI — OPEN QUESTIONS

Header and encoding

  • Which storage option from section 3 to adopt; survey actual version-field use on chain; talk to miners, pools and exchanges.
  • Fixed-size fat pointer via a zero-knowledge proof over the signatures: feasibility and proving cost.
  • Exact encoding of the fat pointer and certificate, including the "do not include by" field.

Protocol details

  • Timeout/abandonment rule for "not yet determinable" (section 5).
  • Whether BFT genesis points at PoW genesis or h1 (section 8).
  • Reconcile the notes' H1/H2/H3 with the code's h1/h2, and the 200-block gap versus ~100,000 (section 8).
  • How the active roster is selected (section 11).
  • Other anti-tail-thrashing mechanisms (section 1).
  • Precise definition of "close to the tip" (section 14).
  • 10% denominator: active-roster stake or total (section 14).
  • Lockbox accrual from the 10%: per total or per active (section 14).
  • Burn representation for withheld rewards (section 14).
  • Acceleration structure design once payouts are fixed (section 15).
  • Non-liveness misbehaviour slashing should cover (section 16).
  • Certificates produced between slash-config creation and application (section 16).
  • Why the parameters are what they are: σ = 3, 48/48/4, 90/10, weekly cadence, two-staking-day window, power-of-ten sizes.

Networking

  • Interleaving the two sync streams in serializable order.
  • Bootstrap/peer discovery (DNS?).

Slash forks

  • Recognising peers on another fork as well-behaved (section 16).
  • Signed finalizer messaging channel (section 11).

Signature cryptography

  • Aggregate signatures (BLS or similar) to make the signature set fixed-size. Most such schemes assume equal-weight signers; weight by repeated voting scales badly. Weight-aware schemes may exist or be emerging. Parliament-style quantization is an alternative with its own centralization risk.
  • Post-quantum story for the fat pointer, bond keys and finalizer keys — not yet considered. Possible collaboration with the post-quantum cryptography team.

Wallet UX dependent on cryptography

  • Automating the withdraw step without a live wallet.
  • Scheduling a staking action from a non-staking day without relying on OS wake-up.

Loose ends

  • Andrew had one further point at the end of session 5 that slipped his mind.
  • Off-topic musing from the rewards notes: pay miners by block fullness rather than tx fees?

Crosslink finality semantics and Zebra policy boundaries

This document separates the three Crosslink 2 protocol quantities that Zebra's design retains from Zebra's irreversible state-commit boundary, legacy reorg-depth fallback, and consumer-specific meanings of "final".

It keeps three layers apart:

  • Book: Crosslink 2 as the pinned TFL Book specifies it (§1).
  • Zebra Crosslink: the behavior this tree implements. It is the Book's construction without Stalled Mode, with sticky fork choice, persisted fin, and every remaining CL2 validity rule. Statements in this layer are requirements, including where the code does not meet them yet.
  • Current tree: the code at this revision of the repository. Statements in this layer describe code that the implementation changes, and are not requirements.
sectionlayer
§1 ScopeBook, with Zebra Crosslink's parameter and Stalled Mode choices
§2 TerminologyZebra Crosslink; the Zebra-specific quantities note their current-tree form
§3 Crosslink 2 modelBook, with the consequences for Zebra Crosslink
§4.1 Raw fork choiceBook
§4.2 Finalized-prefix policya general policy, and its current-tree form
§4.3 Sticky fork choiceZebra Crosslink, ending with the current tree
§5 Implementation inventorycurrent tree
§6 Divergencescurrent tree, measured against Zebra Crosslink
§7 Names and consumer contractsZebra Crosslink
§8 Implementation status and pitfallscurrent-tree facts that constrain the implementation
§9 Open decisionsoutside this document's implementation work

Where a section mixes layers, a paragraph opens with its layer in bold. The ordered implementation work, and the questions that still need a design pass, are in IMPLEMENTATION.md.

A companion visual explanation is in FINALITY_DIAGRAM.html.

1. Scope and source maturity

Crosslink 2 is parameterized by a best-chain protocol Π_bc and a BFT protocol Π_bft; it is not intrinsically a PoW/PoS protocol. This tree's Zebra prototype instantiates the best-chain side with PoW and the BFT side with a stake-based protocol. The generic model below therefore uses bc and bft; implementation observations use PoW and PoS/BFT.

The primary design source is the original TFL Book at pinned revision daira/tfl-book@fe6e1d6. The adaptation in ShieldedLabs/zebra-crosslink is useful implementation context, but describes itself as an almost-direct paste that may be incomplete, confusing, or inconsistent and records Zebra-specific omissions such as Stalled Mode.

The pinned construction currently defines candidate(H) as written below. Its rationale nevertheless ends with a TODO to choose between that clamp and a stronger Last Final Snapshot rule based on proof and latency results (lines 510–517). This document treats the formula as the current construction, not as a settled protocol decision beyond that source revision.

Zebra Crosslink omits Stalled Mode. The Book builds bounded availability from three parts: the finalization gap bound L, the Finality Depth rule with its stalled-block exception, and the bounded-available client view ba_μ with its confirmation depth μ. This design has none of them; §3.3 derives why omitting Stalled Mode removes the other two, and what the remaining definitions guarantee.

Current tree. The prototype sets σ = 4 in librustzcash/zcash_primitives/src/bft.rs (PROTOTYPE_PARAMETERS). The source code explicitly warns that this value has not been verified as secure or performant. The Book's L is not a parameter of this design: it is absent from ZcashCrosslinkParameters, from the node configuration, and from the test format, whose parameter instruction writes a zero in its place.

Notation

prune_k(C) means C with its last k blocks removed, with genesis as the floor. It is the plain-text spelling of the Book's C ⌈bc^k. A ⪯ B means that A is an ancestor of or equal to B; A and B conflict when neither is an ancestor of the other.

The two chains have their own parent links. They also contain two cross-chain references:

  • each bc-block H has H.context_bft, which commits to a bft-block; and
  • each non-genesis bft-block has headers_bc, exactly σ bc-headers in deepest-first order; the block it finalizes is the parent of the first, named by that header's parent hash (§6.1).

2. Terminology and layers

The construction as adopted has one fork-choice input, one objective intermediate quantity, and one client view:

quantitydefinitionkind
bc_best / χhighest-score bc-valid chain in the node's viewraw fork-choice view
candidate(H)lca(snapshot(LF(H)), prune_σ(H))objective function of a block and its ancestry
finmonotone local state updated from candidate(bc_best)locally finalized client view

The Book's fourth quantity, the bounded-available chain ba_μ, is not part of this design (§3.3). No CL2 quantity lies between bc_best and fin.

Those three quantities do not exhaust the meanings carried by "final" in this tree. Zebra also has:

  • a canonical-finalized policy point, canonical_finalized_tip, that makes only chains containing that point eligible for local activation;
  • a physical database-commit boundary, the finalized database's tip, which advances only after the finalized-state write has succeeded; and
  • a legacy reorg-depth marker, roughly tip − MAX_BLOCK_REORG_HEIGHT. It has no consumer in the current tree, whose finality RPCs no longer substitute it when no Crosslink marker exists. It is listed here so that it is not reintroduced under a Crosslink name.

Protocol fin and these Zebra quantities must not share an undocumented storage slot. In raw CL2, fin can remain fixed on a branch that raw bc_best no longer contains. A finalized-prefix policy instead enforces canonical_finalized_tip ⪯ canonical_tip locally, which is an additional chain-activation and state policy (§4.2).

Current tree. The floor is fin, advanced where the best chain changes and committed as it advances, so the two quantities coincide (§5.2).

Zebra Crosslink. Under sticky fork choice (§4.3) the policy floor is fin itself, so canonical_finalized_tip and fin are one quantity. Two stored values remain: fin, persisted in the finalized database as its own block hash, and the database's finalized tip, which is the higher of fin and the block Zebra commits at reorg depth. The finalized tip equals fin while finality lags the best tip by less than about MAX_BLOCK_REORG_HEIGHT blocks. Past that lag the depth commit runs ahead of fin, and if it comes to lie on a branch that excludes bft_final_snapshot, the node opens a second chain state of the same shape for the BFT branch (§4.3, §7.1). fin is one quantity across both: every branch the node records contains it.

A fourth quantity is objective rather than node-local:

quantitydefinitionkind
bft_final_snapshotsnapshot(B) for the newest decided bft-block B in the node's viewthe bc-block Π_bft has most recently finalized

A BFT decision finalizes bft_final_snapshot in the sense of Π_bft, and "Crosslink finalized" in conversation usually means this point. A node's fin reaches it only through the node's own best chain, by the update rule of §3.2. Under sticky fork choice, and after each update:

candidate(bc_best) ⪯ fin ⪯ bc_best
fin ⪯ bft_final_snapshot                       (under Linearity and Π_bft Final Agreement)

fin equals candidate(bc_best) except after a reorganization that moved the candidate back. bft_final_snapshot need not be on bc_best, and can stay off it for any length of time: it is on a chain the node switches to only when that chain has more work (§4.3). The first line holds exactly under fork-choice rules that keep fin ⪯ bc_best; under raw work-based fork choice both of its relations can fail.

3.1 snapshot, LF, and candidate

snapshot(B)  := O_bc                         if B.headers_bc = ∅
             := parent(B.headers_bc[0])      otherwise
LF(H)        := bft-last-final(H.context_bft)
candidate(H) := lca(snapshot(LF(H)), prune_σ(H))

The walk is bc → bft → bft → bc, followed by the last-common-ancestor clamp.

bft-last-final(B) is the last final ancestor of B, B included. In Zebra, in the current tree and in Zebra Crosslink alike, Π_bft decides each bft-block individually, and a decided block is final. A bc-block's context_bft is a fat pointer, and a node resolves it only against the node's own store of decided bft-blocks; a pointer that does not resolve defers the bc-block (§6.2, Extension). Every context a node accepts is therefore final, and bft-last-final is the identity on them, so LF(H) is the bft-block that H.context_bft points at. Zebra Crosslink keeps that store in zebra-state beside the chain it is resolved against (§7.1); in the current tree it is BftChain::blocks (§5.5).

The clamp puts candidate(H) on H's own chain and no later than prune_σ(H). The Book says, “This ensures that the candidate is at least σ‑confirmed” (lines 510–517). The source immediately identifies a possible alternative rule, so this rationale is evidence for the current formula rather than evidence that the design choice is final.

3.2 fin: node-local monotone memory

When a node's bc-best-chain view changes, it runs the locally-finalized-chain update:

N := candidate(bc_best)
if fin ⪯ N:
    fin := N
else:
    keep fin
    if N conflicts with fin:
        record a finalization safety hazard

A candidate that moves behind fin during a reorg leaves fin unchanged and is not itself a hazard; the Book gives that reorg case as the reason fin needs local state. A candidate on a conflicting fork also leaves fin unchanged and must produce the specified hazard record, which carries bc_best and the fin history back to the last update that was an ancestor of N. fin is therefore a node-local time series, not a pure function of the current tip.

The Book's Local fin-depth lemma bounds where that series can be:

for node i honest at time t, there is a time r ≤ t with fin_i^t ⪯ prune_σ(bc_best_i^r)

Take r as the last time fin changed, or genesis if it never has. At r > 0, fin was set to candidate(bc_best^r), and candidate(H) ⪯ prune_σ(H) because an lca is an ancestor of both of its arguments. At genesis both sides are O_bc, since pruning O_bc yields O_bc. The Book combines the lemma with Π_bc Prefix Agreement at depth σ to argue Assured Finality; that use is why candidate clamps to prune_σ(H) rather than using snapshot(LF(H)) alone (line 513).

When the prune_σ clamp binds. When the σ headers of LF(H) are all ancestors of H, the last of them is at or below parent(H), so snapshot(LF(H)) is at least σ + 1 blocks below H and the clamp does not bind. It binds when the headers lie on another chain that extends past H's own chain: for example a short side chain whose blocks sit just above snapshot(B) and cite a bft-block B whose σ headers continue on a different, longer chain. Last Final Snapshot admits such a block, since snapshot(B) is its ancestor. Without the clamp, a node whose best chain is that side chain would finalize snapshot(B) while its own chain buried that block only one or two deep.

That matters only when Π_bft is subverted. A subverted Π_bft can decide a bft-block whose headers come from any chain with valid PoW, including one the adversary mined privately, and so can name as its snapshot a block that an honest node has seen only shallowly on a branch about to be abandoned. With the clamp, a node finalizes a block only once its own best chain has buried it σ deep, so under Π_bc Prefix Consistency every honest best chain keeps that block and honest fin values stay compatible without any assumption about Π_bft. Without it, a subverted Π_bft alone could give honest nodes conflicting fin values.

Assured Finality requires honest nodes' fin values at arbitrary times to be prefix-compatible. It does not require those values to be equal at the same wall-clock time.

3.3 No bounded availability

The Book's bounded availability is one mechanism in three parts:

  • the Finality Depth rule admits a bc-block H with height(H) − height(snapshot(LF(H))) > L only if H is a stalled block;
  • Stalled Mode defines stalled blocks (proposed for Zcash as coinbase-only), so that Π_bc keeps producing blocks while no user transaction lands more than L blocks past the snapshot; and
  • the bounded-available chain ba_μ := prune_μ(bc_best) if fin ⪯ prune_μ(bc_best), else fin is the client view whose distance ahead of fin that bound limits.

Without Stalled Mode, the other two parts have no role:

  1. Finality Depth. With no stalled blocks the rule would reduce to height(H) − height(snapshot(LF(H))) ≤ L. During a BFT stall no context can lower that depth, so Π_bc would halt L blocks past the snapshot. That is still bounded availability, in its strictest form. The Book also rejects it as a design: it calls stopping the chain a naive approach with serious security problems under PoW, and its liveness analysis says any loss of Π_bc liveness would be a bug because it allows tail-thrashing attacks. L and is_stalled_block have no other use.
  2. ba_μ and μ. ba_μ differs from a plain confirmation depth only in its fallback to fin, and that fallback exists to keep fin ⪯ ba_μ. Without a bound there is nothing for that view to bound.

The same liveness analysis states that the Finality Depth rule is technically independent of the rest of Crosslink 2: without it, the protocol keeps its advantages over Snap-and-Chat, but the incentive to pull the finalization point forward is weaker. The concrete consequences for the remaining definitions are:

  • candidate(bc_best) can lag without any validity cost. The Extension rule permits LF(H) = LF(parent(H)), and the Valid Context and Last Final Snapshot rules are always satisfiable by reusing the parent's context_bft. With no depth bound, a chain that never updates its context stays valid at any height. Progress of fin while Π_bft is live therefore depends on bc-block producers following the honest context-selection procedure (§3.4), which is not a validity rule. A producer with enough hash rate to dominate bc_best can withhold finality progress; under bounded availability it would have been confined to stalled blocks after L.
  • The finality gap is unbounded. During a finalization stall, bc_best keeps accepting ordinary spending transactions at any distance past fin. The rollback exposure of those transactions grows with the gap. This is the outcome the Book's bounded-availability argument was written to avoid; omitting Stalled Mode accepts it.
  • No client view is both available and guaranteed to extend fin. An application that does not want to stop with finality reads bc_best or a confirmation prefix prune_k(bc_best). Those are Π_bc views, not CL2 quantities, and their security is Π_bc's own Prefix Consistency. The Book's "Prefix Consistency of ba" theorem has no subject. A prefix prune_k(bc_best) can be an ancestor of fin even when every assumption holds; the Book gives difficulty adjustment after a reorg as the reason, which is why ba_μ had its fallback. It can conflict with fin only if Prefix Consistency at σ has failed: fin ⪯ prune_σ(χ^r) for some earlier r, so Prefix Consistency at σ gives fin ⪯ bc_best, and every prefix of bc_best is then comparable with fin.
  • The application choice changes. The Book framed it as fin (stop immediately) versus ba_μ (continue for at most L blocks). Here it is fin versus a bc_best view that never stops and has no bound on how much can be rolled back to fin.

In the current tree, the collapse onto each decided block (§4.2) locally forces canonical_finalized_tip ⪯ canonical_tip; in Zebra Crosslink, sticky fork choice (§4.3) keeps fin ⪯ bc_best. Either restores, as a chain-selection policy, a prefix relation that ba_μ provided by definition. Neither limits the finality gap, and both cost local liveness whenever the dominant chain excludes the floor (§4.3).

3.4 Validity rules and honest production

In addition to inherited rules, the bc-block validity rules are:

  • Valid context: H.context_bft is bft-block-valid.
  • Extension: LF(parent(H)) ⪯bft LF(H).
  • Last Final Snapshot: snapshot(LF(H)) ⪯bc H.

The Book's fourth rule, Finality Depth, is omitted with Stalled Mode (§3.3).

The separately stated bft-proposal and bft-block validity rules add:

  • Linearity: snapshot(parent(B)) ⪯bc snapshot(B).
  • Tail Confirmation: B.headers_bc form the σ-block tail of a bc-valid chain.

Zebra Crosslink enforces all five rules above, and so does the current tree (§6.2).

Tail Confirmation is objective: σ consecutive headers ending at a bc-valid block are the tail of the chain that ends at that block, whatever the validator's own best chain. The Book separately defines what an honest proposer puts in that field.

Honest proposal. An honest proposer of a bft-proposal P:

  • sets P.headers_bc to the σ-block tail of its own bc_best, if that satisfies Linearity against P's parent;
  • otherwise sets P.headers_bc to its parent's headers_bc, repeating the parent's snapshot; and
  • makes no proposals until its bc_best is at least σ + 1 blocks long.

A proposal is therefore always possible once the chain is long enough. The Linearity rationale depends on that: liveness of the underlying BFT protocol can require honest proposers to propose at a minimum rate. Honest proposal is a behavior, not a validity rule: a validator cannot tell whether the carried tail was the proposer's best chain. An honest validator first downloads the bc-blocks for P.headers_bc and checks their bc-block validity.

Zebra Crosslink. A proposer clamps its candidate height to at most 40 blocks above the previous final snapshot. When the clamp binds, P.headers_bc is a window of bc_best ending below its tip rather than its tail, which breaks honest proposal. The clamp is a design heuristic, not part of the Crosslink 2 specification. The window still satisfies Tail Confirmation.

Linearity and bc reorganizations. Let B be the newest final bft-block. Linearity requires every later final snapshot to extend snapshot(B). When a node's bc_best reorganizes onto a branch that forks below snapshot(B), the tail of that branch fails Linearity, so honest proposers repeat B.headers_bc. Last Final Snapshot admits a block H on that branch only if snapshot(LF(H)) lies on the branch, so H cannot cite B or any later final bft-block, and candidate(H) stays at or below the fork point. Finality for nodes on that branch resumes when a chain containing snapshot(B) becomes their best chain again. Under honest proposal at every bc-block, snapshot(B) sits about σ blocks below the proposer's tip, so a reorganization slightly deeper than σ reaches this case.

Finality lag under honest production. This follows from the Book's honest proposal and applies to Zebra Crosslink. A proposer at tip T carries headers T − σ + 1 through T, so the decided block's snapshot is T − σ. The first bc-block that can cite that decision is T + 1, and only if its template was built after the decision arrived; then candidate(T + 1) = T − σ. In steady state fin therefore trails the best tip by at least σ + 1 blocks. Every bc-block built from a template that predates the latest decision cites an older bft-block and adds one more block of lag, and a decision that takes longer than a bc-block interval adds more.

The Book's informal safety argument uses Linearity and Last Final Snapshot as follows:

  • Linearity with Π_bft Final Agreement makes the snapshots of final bft-blocks bc-linear, which the Book says implies Assured Finality without any Π_bc safety assumption (lines 587–594).
  • Last Final Snapshot with the σ carried headers is the basis of the other half of that sketch: each candidate final snapshot is a σ-confirmed prefix of the observer's best chain, so Π_bc Prefix Agreement gives safety without any Π_bft assumption (same lines).
  • The two together remove the sanitization of ledgers that Snap-and-Chat and Crosslink 1 needed (potential changes, lines 320–371). That section's security analysis starts from the observation that neither rule affects the evolution of Π_bc unless its Prefix Consistency or Prefix Agreement would be violated, and breaks off mid-sentence.

The Book's safety section is marked as not updated for Crosslink 2, and a later edit says Linearity so far contributes to security only heuristically. These arguments are sketches, not proofs.

Two facts used in §4.3 follow from the definitions:

  • candidate(H) ⪯ snapshot(LF(H)) and candidate(H) ⪯ prune_σ(H) hold for every H, because an lca is an ancestor of both of its arguments.
  • With Last Final Snapshot, snapshot(LF(H)) and prune_σ(H) both lie on H, so candidate(H) is the lower of the two. Without it, snapshot(LF(H)) can lie on another branch, and candidate(H) is then the lower of prune_σ(H) and the point where that branch leaves H.

Beyond satisfying validity rules, the explicit BFT-context selection procedure chooses H.context_bft: among eligible bft-valid tips it chooses a longest chain, then breaks ties by final-snapshot score and hash (lines 705–716). BFT-derived data therefore affects block validity as well as this selection procedure. With no Finality Depth rule, the procedure is the only thing that makes a producer advance its context (§3.3).

3.5 Prefix Consistency and client exposure

Prefix Consistency at depth σ is a property assumed of qualifying executions of the best-chain protocol:

prune_σ(χ_i^t) ⪯ χ_j^u    for honest observations at t ≤ u

It is not a Crosslink checkpoint rule and is not enforced by fin. If a later best chain displaces an earlier σ-confirmed prefix, an argument that assumes Prefix Consistency no longer applies to that execution.

The Book recommends baking in a BFT checkpoint and withholding fin from clients until the checkpoint precedes LF(bc_best), its snapshot precedes fin, and fin is recent. This is a sync-safety recommendation, not a block-validity or consensus rule. The Book applies it to fin and ba_μ; here it covers fin only, and says nothing about exposing bc_best. Zebra Crosslink marks with an @Todo where the condition applies and does not implement it; the current tree has neither.

4. Raw fork choice and finalized-prefix policy

4.1 What raw CL2 selects

Raw CL2 leaves the underlying rule in place: choose a highest-score bc-valid chain. In the Zebra instantiation, score is accumulated PoW. Crosslink constrains individual blocks through the validity rules carried by their own BFT context; it does not require raw bc_best to contain the observer's current fin.

The evidence chain is:

  1. The generic best-chain model chooses a highest-score bc-valid chain (construction lines 245–260).
  2. The CL2 validity rules constrain a block relative to the BFT context that block carries (lines 686–693). A chain can retain an older, still-valid BFT context.
  3. Honest bc-block production says producers “must not use information from the BFT protocol” beyond the specified consensus rules when selecting a bc-valid chain (lines 705–716).
  4. The Questions chapter analyzes the stronger rule requiring bc_best to extend the latest final BFT snapshot in the node's view and says it breaks the current safety and liveness arguments (lines 11–26).

Its exact conclusion is:

“Probably not. I don’t know how to repair the safety and liveness arguments.”

— Questions about Crosslink, lines 44–52

The Questions page is partly historical. It analyzes the Last Final Snapshot rule on its own and defers the combination with Linearity to the potential-changes section, which says the Questions argument against that rule was made for a protocol without Linearity. The fork-choice change and its "Probably not" belong to the same pre-Linearity discussion, and part of that discussion relies on the Finality Depth rule and Stalled Mode, which this design omits. The Book does not revisit the fork-choice change with Linearity in place. The reason it gives for its conclusion is that an analysis that treats Π_bft as possibly subverted can say nothing useful about snapshot(B).

The per-block rule and the fork-choice constraint remain distinct. A block can satisfy snapshot(LF(H)) ⪯ H using stale BFT context; the fork-choice constraint would constrain the selected best chain by the newest final BFT snapshot in the observer's view. §4.3 compares that constraint with sticky fork choice.

4.2 The additional Zebra policy

A finalized-prefix eligibility rule would be:

eligible_i(C, t) := bc_valid(C) and local_finalized_tip_i^t ⪯ C
bc_best_i^t      := highest_score({ C | eligible_i(C, t) })

This rule preserves the finalized prefix for each node that enforces it. It does not by itself prove global agreement, network progress, or Prefix Consistency for unfinalized blocks. If an enforcing node has no eligible progressing chain, local liveness must yield.

Current tree. The floor is fin, and the rule is sticky fork choice (§4.3): committing fin discards the non-finalized branches that do not hold it, which makes the policy physical. The CL2 construction does not mandate that.

Omitting Stalled Mode changes what the policy constrains. The Book pairs raw fork choice with Stalled Mode, which confines a dominant unfinalizable branch to stalled blocks after L. Without it, raw fork choice lets that branch carry ordinary spends without limit, all of them past fin and unfinalizable under Linearity. A finalized-prefix policy keeps an enforcing node off such a branch, at the cost of that node's liveness whenever the dominant chain excludes its finalized point.

4.3 Sticky fork choice

Sticky fork choice is the fork-choice rule Zebra Crosslink implements. It selects bc_best so that a node's best chain never excludes its own fin. It is temporal: the result depends on the node's current best chain and its current fin, which is node-local memory (§3.2), not only on the set of chains in view.

A node holds current, its best chain, and fin. When a bc-valid chain new is in view, the node switches from current to new iff:

fin ⪯ new
and ( work(new) > work(current)
      or ( work(new) = work(current) and tip_hash(new) > tip_hash(current) ) )

After every change of best chain, fin is updated from candidate(bc_best) by the rule in §3.2. Equal-work chains are ordered by tip hash, which is Zebra's existing tiebreak: Chain::cmp in zebra-state/src/service/non_finalized_state/chain.rs orders equal-work chains by tip hash bytes, and NonFinalizedState::best_chain takes the greatest. That doc comment records that the Zcash protocol specification instead prefers the block received first.

Relation to §4.2. fin only ever advances to candidate(bc_best), and candidate(bc_best) ⪯ bc_best, so the current chain always contains fin. A chain that contains fin now also contained every earlier fin, so it was eligible when it appeared and would have been switched to then if it were greater. The pairwise switch condition therefore selects the greatest chain, by work and then tip hash, among the chains that contain fin. That is the eligibility rule of §4.2 with local_finalized_tip := fin. The temporal behavior comes entirely from fin: the eligible set shrinks each time fin advances.

Relation to the Book's fork-choice discussion

The nearest relative of sticky fork choice in the Book is the fork-choice change on the Questions page (§4.1): bc_best must extend snapshot(B) for the newest final bft-block B in the node's view. The two rules differ in their floor:

  • The Questions rule uses snapshot(B). That point need not lie on any chain the node has selected, nor be σ-confirmed in one.
  • Sticky fork choice uses fin. It advances only to candidate(bc_best), a point on the node's own best chain at or below prune_σ(bc_best).

Under Π_bft Final Agreement and Linearity, fin ⪯ snapshot(B): fin is at or below the snapshot of some final bft-block (§3.4), and the snapshots of final bft-blocks are bc-linear. Every chain containing snapshot(B) then contains fin, so sticky fork choice refuses a subset of the chains the Questions rule refuses. Without Linearity the two floors can conflict, and neither set of refused chains contains the other.

If the Last Final Snapshot rule holds as well, every chain that sticky fork choice refuses ends in a block whose last final bft-block is strictly older than the one from which fin was last advanced. Suppose fin was advanced from a chain with last final bft-block F, so fin ⪯ snapshot(F). A chain new with F ⪯bft LF(new) has snapshot(F) ⪯ snapshot(LF(new)) by Linearity, and snapshot(LF(new)) ⪯ new by Last Final Snapshot, so new contains fin. The refused chains are therefore stale-context chains, as in the adversary strategy the Questions page describes.

The Book makes three statements that bear on a change of this kind:

  • The honest-production instruction excludes using Π_bft information beyond the consensus rules to choose among bc-valid chains (§4.1, item 3). Sticky fork choice uses fin.
  • The liveness analysis attributes its tractability to leaving Π_bc fork choice unmodified, in contrast with Casper FFG, whose fork choice follows the justified checkpoint. Under sticky fork choice a node never switches to a chain with less work, and an advance of fin never causes a switch, because fin advances only along the current chain. A node can instead refuse a switch. The Book has no liveness argument for that.
  • The reason given for "Probably not" is that nothing useful can be said about snapshot(B) once Π_bft may be subverted. Under sticky fork choice a subverted Π_bft still cannot place fin off the node's best chain or above prune_σ(bc_best). It can choose when to finalize: a σ-confirmed prefix finalized before a Prefix Consistency failure would displace it is the branch the node keeps. The Book has no safety argument for that either.

Properties

These follow from the definitions of candidate and fin (§3.1, §3.2) and the switch condition, and hold in Zebra Crosslink. The current tree does not compute fin (§6.1).

  • fin ⪯ bc_best holds on the node at all times. The raw-CL2 state in which fin stays fixed on a branch that bc_best no longer contains (§4.1) does not arise.
  • The conflicting-candidate case of the §3.2 update cannot occur: candidate(bc_best) and fin both lie on bc_best, so they are comparable. The §3.2 hazard record is therefore never written. Its observable counterpart is a refused switch, meaning a chain in view with more work than current that excludes fin.
  • The rule selects a different chain from raw work-based fork choice only when a chain with more work than current excludes fin. By the Local fin-depth lemma (§3.2), fin was part of prune_σ of this node's best chain at some earlier time. The raw choice in that situation would displace a prefix that was σ-confirmed in the node's own earlier best chain. Where no such chain is in view, the two rules select the same chain.
  • candidate(H) ⪯ prune_σ(H) for every H, because an lca is an ancestor of both of its arguments. Π_bft can therefore move fin only to blocks the node had already selected by work and σ-confirmed; it cannot move the node onto a chain it did not select. While Π_bft is stalled or withholding, fin is frozen and selection above it is the raw work rule.
  • The node never switches to a chain with less work than current.

Behavior by situation

Each case compares sticky fork choice with raw work-based fork choice. Statements under With Linearity assume Π_bft Final Agreement and enforcement of the Linearity rule, as the abstract outcome in FINALITY_DIAGRAM §4 does; Zebra Crosslink enforces Linearity, so they describe it. Statements under Without Linearity show what the rule prevents.

  • Candidate regression with fin still on the heavier chain (FINALITY_DIAGRAM §3). Both rules switch to the heavier chain.
  • Heavier chain forked below fin (FINALITY_DIAGRAM §4). Under raw fork choice the node follows the heavier chain and fin stays behind on the other branch. Under sticky fork choice the node stays on the branch containing fin, and its tip advances only as fast as hash rate on that branch extends it. No amount of work on the other branch changes this; only a change to fin from outside the protocol would.
    • With Linearity: no final snapshot can move onto the heavier branch past the branch point. Under raw fork choice the node's fin stays frozen while that branch is its best chain. Under sticky fork choice fin can keep advancing if Π_bft finalizes the branch the node holds.
    • Without Linearity: Π_bft can finalize snapshots on the heavier branch. Under raw fork choice candidate(bc_best) then conflicts with fin, the node records the §3.2 hazard, and fin stays frozen. Under sticky fork choice candidate(bc_best) is at or below the branch point, so fin freezes without a hazard record; the event is visible only as the refused switch.
  • Partition while fin is frozen on every node. Every chain extending the common fin is eligible, so selection is by work on both sides. After the partition heals, nodes converge on the heavier chain under either rule, provided no node's fin moved past the fork point.
  • Partition in which one side advances fin. Side A holds enough stake for Π_bft to decide and advances fin past the fork point; side B does not. After the partition heals, A-side nodes never switch to B's chain, whatever its work. B-side nodes switch to A's chain once it has more work than theirs, since it contains their fin. While B's branch has more work, the nodes stay split along the partition. B's blocks cannot advance B-side fin past the fork point using A's decisions: with Last Final Snapshot they cannot carry that context, and without it their candidate is clamped to the fork point. How often this arises, and how many nodes land on each side, depends on how stake and hash rate are distributed across the partition. Under raw fork choice all nodes follow the heavier branch.
    • With Linearity: later final snapshots stay on A's branch, so B-side fin never passes the fork point and B-side nodes can always still switch to A. Under raw fork choice, if B is heavier, finality stays stalled until B's branch is abandoned. A partition lasting more than MAX_BLOCK_REORG_HEIGHT blocks puts the fork point below the B-side depth commit, which is the case the second finalized state of the implementation below exists to serve: the B-side node records both branches and switches without an operator.
    • Without Linearity: if Π_bft later finalizes a snapshot on B's branch, B-side nodes advance fin past the fork point on B. From then on neither side switches, whatever the work, and neither records a hazard, because each side's candidate from the other branch is clamped below its own fin. Under raw fork choice every node follows the heavier branch, and nodes whose fin lies on the other branch record the hazard once the candidate passes the branch point.
  • Order of observation. Suppose a lighter branch carries final snapshots past the fork point and a heavier branch does not. A node that processes the lighter branch first, for example during sync, advances fin into it and then refuses the heavier branch. A node that processes the heavier branch first keeps fin at or below the fork and does not switch to the lighter branch until it has more work. Under raw fork choice both nodes end on the heavier branch.
    • With Linearity: the premise persists: no later final snapshot can move onto the heavier branch past the fork point.
    • Without Linearity: the heavier branch can later gain final snapshots past the fork point as well. A node that processed it first then advances fin into it, and the two nodes stay on different branches whatever the work.
  • Conflicting finality. Each fin lies on prune_σ of its own node's earlier best chain, so two conflicting fin values require those best chains to have diverged at depth σ, a Prefix Consistency failure. Under raw fork choice both nodes follow the heavier chain, and the node whose fin it excludes records the §3.2 hazard once the candidate passes the branch point. Under sticky fork choice each node keeps the branch containing its own fin, whatever the work on the other, and neither records a hazard.
    • With Linearity: conflicting fin values also require a Final Agreement failure, because every fin is at or below the snapshot of a final bft-block (§3.4) and those snapshots are bc-linear.
    • Without Linearity: conflicting fin values need no Final Agreement failure; the partition case above is an example.

These are requirements; §5 describes the current tree. The rule is implemented through the finalized database rather than as a separate chain filter:

  • On every change of bc_best, the node computes N := candidate(bc_best). If fin ⪯ N and N ≠ fin, it finalizes up to N and stores N as fin. A candidate at or below fin changes nothing. The candidate computation and both writes belong to the code that changed the best chain, so they are one synchronous sequence rather than a request that can fail partway (§7.1).

  • The commit discards every non-finalized chain that does not contain N, and Zebra rejects blocks that fork below its finalized tip. Chains that exclude fin therefore never enter the node's view, which is the switch condition above. That rejection is the refused switch; the node reports it on stdout, and a persisted hazard record is an @Todo.

  • fin is stored in the finalized database as its own block hash, so the floor survives a restart. The database's finalized tip is the higher of fin and the reorg-depth commit (next bullet), so finality readers take fin, never the finalized tip.

  • A BFT decision does not change the finalized state. It advances bft_final_snapshot (§2), which can lie on a chain that is not bc_best. Only a later bc_best change moves fin, and only by the rule above.

  • The node syncs the chain leading to bft_final_snapshot whether or not it is bc_best, and it never needs a resync to do so. While that chain forks above the depth-committed block it is a chain of the non-finalized state, exempt from the pruning that drops the lowest-work chains past MAX_NON_FINALIZED_CHAIN_FORKS, and it survives a restart through the non-finalized backup. Every chain carries its aggregated stakes per block, beside the per-block bond_rewards and finalizer_commissions it already unwinds, so the validator set at any held block is a lookup, on either side of a finalized tip (§7.3). Under Linearity that chain contains fin, so it remains eligible, and the node switches to it once it has more work. Bc-block validity, bft-block validity and roster computation are therefore one synchronous domain in zebra-state (§7.1).

  • Zebra also commits the root of the best chain to the finalized database once the chain is longer than MAX_BLOCK_REORG_HEIGHT (from zcash_protocol::consensus, applied in zebra-state/src/service/write.rs). The value in the current tree is 99. Upstream Zebra raised it to 999, and that change was lost when this tree was rebased onto new Zebra, so 999 is the intended value; the depths of 99 written elsewhere in this document follow the tree. Chains forking below that point are no longer in view of that database. On a Zebra node the effective floor is the higher of fin and that depth-committed block.

    The depth commit is never held back indefinitely. A node whose BFT has stalled, for any length of time or forever, keeps committing its PoW best chain and remains a working PoW node with a frozen fin. If bc_best then runs more than that depth past the point where the chain to bft_final_snapshot forks from it, the depth commit writes a block that conflicts with bft_final_snapshot, and one finalized database cannot be rewound to take the other branch. The node must nevertheless keep syncing and recording both branches for as long as both grow, validating bft-blocks and computing rosters along the BFT branch, and switch its served best chain to that branch when the rule above says to. That is a second finalized state:

    • The PoW state P is today's Zebra: raw work fork choice and the depth commit. Its finalized state keeps, at a height at or below fin, a snapshot of itself from which an independent, writable copy can be opened while P keeps writing. The snapshot is retaken as fin advances, and during a stall it stays valid, only staler. Every chain containing fin forks at or above it.
    • When a bc-block arrives that forks below P's finalized tip but above fin, the node opens the Crosslink state C from that snapshot, replays P's own stored blocks from the snapshot height to the fork point into it, and from there feeds C the conflicting chain from peers. C's fork-choice floor is bft_final_snapshot and C never depth-commits. Both states keep syncing and committing; the served best chain is chosen across both by the switch rule above. C is dropped once fin passes the fork, and P's branch is recorded for as long as blocks arrive on it.
    • How the snapshot is taken belongs to the storage engine, and it sets the cost of a conflict rather than whether the node survives one: a hard-linked checkpoint or a filesystem reflink clone is milliseconds, a persistent savepoint plus a file clone needs P's writer paused for the copy, and a logical copy into a fresh database costs a full database of time and disk. The last is the portable floor and is acceptable, because what it pays for is a network partition deeper than MAX_BLOCK_REORG_HEIGHT.

    Until the second state exists, the node holds P's depth commit at the fork point while a conflict is live, up to CONFLICT_HOLD_DEPTH blocks past the fork, then commits and reports on stdout that it can no longer follow bft_final_snapshot; that path carries an @Todo naming the second state. The hold is an interim and never the design, because under a permanent conflict it is the same wall CONFLICT_HOLD_DEPTH blocks later. A node never requires a resync to resume bft-block validation.

Sticky fork choice and Linearity constrain different points. Sticky fork choice keeps fin on bc_best; Linearity keeps each final snapshot on or after the previous one. fin lies at or below the newest final snapshot, so a reorganization that forks between the two is admitted by sticky fork choice and then leaves finality on the new branch waiting (§3.4, Linearity and bc reorganizations).

Current tree

  • The rule needs protocol fin, which the current tree does not compute (§6.1). Its collapse onto a BFT-decided branch (§4.2, §6.3) is a related rule with a different floor: the stored marker, taken directly from a decided BFT block when it is decided rather than from candidate(bc_best). With that floor, the invariant above does not follow: the marker need not lie on the node's best chain when it advances, and a known side-chain hash becomes canonical (§5.2).
  • It enforces Linearity and Last Final Snapshot (§6.2).

5. Current tree: implementation inventory

Everything in this section describes the current tree, not requirements. It refers to symbols in the tree this document ships with; symbol names are preferred over brittle working tree line numbers.

5.1 The two finality markers and their write paths

zebra-crosslink/zebra-state/src/new_network/fin.rs holds fin, the block this node has finalized. It is a cache of the single row of the crosslink_fin column family, loaded by fin::load at startup and advanced only by fin::advance, which runs on the sync thread that owns the block writer, so the marker has exactly one writer. fin::candidate computes candidate(bc_best), and WriteBlockWorkerTask::crosslink_update_fin applies the §3.2 rule where a commit changes the best chain.

zebra-crosslink/zebra-state/src/new_network/bft.rs holds the other marker, BftChain::bft_final_snapshot: the snapshot of the last decided bft-block, which is what Π_bft has finalized rather than what this node has (§3.2). BftRunner::decide assigns it, and BftRunner::restore replays it from the stored chain. Its readers are the BFT proposal path, the +40 clamp, the reorg-depth conflict hold, and the non-finalized pruning exemption — all inside zebra-state, because a block it names is not final for this node until fin reaches it (§2).

Both markers take snapshot(new_block) = parent(new_block.headers[0]) from BftBlock::snapshot_block_hash, the one accessor through which every reader derives the finalized block (§8.1).

When fin is absent, the finality RPCs return None. They previously substituted a Zebra reorg-depth location derived from the state block locator, so that the API changed semantics depending on whether Crosslink had produced a value; that substitution and its helper have been removed. Removing it was safe because the substitution reached only the RPC-facing readers of the marker: the finalized-tip, block-status and transaction-status answers. Every consensus-, state-, and GUI-side reader takes fin directly and never saw the substituted value.

The Crosslink service no longer carries a second copy of the marker: current_bc_final, which was written during startup restore and read nowhere, has been deleted.

5.2 Irreversible commitment and ordering

BftRunner::decide assigns bft_final_snapshot and stores the decision. It commits nothing: the finalized state follows fin, which WriteBlockWorkerTask::handle_commit advances where the best chain changes. Tenderlink therefore no longer waits on a finalized-state write to start the next round, and no reader can observe a fin whose block is not committed, because fin::advance runs after handle_crosslink_finalize returns and writes the row before it publishes.

The state behavior depends on whether the hash is known:

  • new_network accepts a hash found in any non-finalized chain or in the finalized database. NonFinalizedState::crosslink_finalize retains the chain containing a known side-chain hash, but crosslink_update_fin passes it only a hash already on the best chain, so finalizing no longer makes a branch canonical.
  • a hash the state does not know never reaches the finalize call: candidate answers None while the snapshot is a block this node does not hold, and the bft-block naming it is refused by validate for the same reason.
  • the restore path makes a KnownBlock lookup for the last decided BFT block. The decided chain and the blocks it finalizes are rows of one database, written together, so a database that cannot resolve that block is damaged rather than merely behind: restore says so and ends the process instead of panicking. The replay-watermark loop just above it tolerates the case.

The stored fin row is therefore both a fin implementation and a lower bound on the finalized database's tip: it is written only after the commit it names has succeeded, so a crash leaves it at or below that tip and never naming a block the database lacks.

5.3 Consumers

fin reaches:

  • the get_tfl_final_block_*, block-finality and transaction-finality RPC methods, through the CrosslinkFinalizedTip, CrosslinkBlockFinality and CrosslinkTxFinality read requests;
  • the GUI's finalized row and its visualization paging lower bound; and
  • the notification subscribers of fin::fin_change_rx, reached through CrosslinkFinalizedTipChange.

bft_final_snapshot reaches irreversible state commitment only indirectly, through the conflict hold that delays it (§4.3). Its other readers are the BFT proposal path's +40 clamp, the pruning exemption, and the GUI's terminated-finalizer display, which passes it to terminated_finalizers_at as the finalized bc-height so that the display and the consensus roster share one derivation.

BftChain::roster is a write target, populated from the aggregated stakes at the decided snapshot: the chain holding that block answers if it is still non-finalized, and the finalized database answers otherwise (§8.1). That read is the node's own and is separate from any commit.

fin::fin_change_rx is a tokio::sync::watch, published beside the chain-tip channels and handed out by the CrosslinkFinalizedTipChange read request; the RPC notification methods in zebra-crosslink/zebra-rpc/src/methods.rs wait on it. Every fin::advance publishes on that channel after the database row is written, so a notification names a block this node has already committed. A watch rather than a broadcast: fin is monotone, so a subscriber that fell behind wants the latest value and nothing else.

5.4 Current staking rewards

At the end of Chain::push in zebra-crosslink/zebra-state/src/service/non_finalized_state/chain.rs:

  • a block that does not pay (see the payout rule below) pushes empty bond_rewards and finalizer_commissions entries and mints nothing; the empty entries keep positional reorg reversal aligned;
  • if no bond is active, the same empty entries are pushed and no staking reward is minted; and
  • otherwise it distributes the fixed POS_BLOCK_REWARD_ZATS for that PoW block, increases staking_bonded_amount by the same total, and records the per-bond rewards for exact reorg reversal.

update_bonds_with_pos_issuance in zebra-crosslink/zebra-state/src/service.rs allocates the total pro rata with integer division, gives the remainder to the largest active bond (then smallest key on a tie), and adds rewards to bond principal. Rewards therefore compound.

The variable payout rule

Issuance is not paid per PoW block. A block P pays exactly when it advances finality and does so promptly:

payout(P)  iff  cert(P) != cert(parent(P))  and  height(P) - F <= σ + FINALITY_LIVENESS_ALLOWANCE

where cert(P) is the BFT block named by P.context_bft (compared by BFT block hash, not by the whole fat pointer: two honest nodes can carry different signature sets for the same decision) and F is the height of the PoW block that certificate finalizes — its snapshot. FINALITY_LIVENESS_ALLOWANCE = 3, in librustzcash/zcash_primitives/src/bft.rs.

Both inputs are objective functions of committed chain data, so every node computes the same answer for the same block, as §9.1 requires. The fat-pointer check (§6.2) already refuses any P below F + σ + 1, so height(P) − F is at least σ + 1: with σ = 4 the paying gaps are 5, 6 and 7, i.e. 4, 5 or 6 blocks strictly between F and P, and a seventh earns nothing.

The decision is made in new_network::bft::admit_fat_pointer (zebra-crosslink/zebra-state/src/new_network/bft.rs), which is the one place that can resolve both facts, and travels with the block as SemanticallyVerifiedBlock::pos_payout → ContextuallyVerifiedBlock::pos_payout → Chain::push. Paths that never run that check (checkpoint sync, tests, blocks rebuilt from raw bytes) carry pos_payout: false and mint nothing.

Because the verdict cannot be recovered from the block bytes, it is persisted in the non-finalized state backup alongside the deferred pool change (zebra-state/src/service/non_finalized_state/backup.rs), and restored with the block. Without that, a node that restarts re-enters its non-finalized blocks through SemanticallyVerifiedBlock::from(Arc<Block>), which defaults to pos_payout: false: the restarted node mints nothing for blocks every other node has already paid. That is not a local accounting slip. It changes the bonded stake, the bonded stake is the voting power, and the roster derived from it then differs between nodes — which in a two-node roster is enough to make both nodes believe they are the proposer, prevote different values forever and stall finality permanently. This was observed on a dilated two-node testnet: node two restarted, restored 4 backed-up blocks, and came back exactly 4 × POS_BLOCK_REWARD_ZATS short, after which BFT never decided another block.

The same per-block calculation is replayed by the wallet projection path in zebra-crosslink/zebra-crosslink/src/lib.rs, which recomputes the rule from committed data in block_pays_pos_issuance, and by fixup_aggregated_stakes in zebra-crosslink/zebra-state/src/service/stake_fixup.rs (reached through the --fixup-db-stake entry point). Any future consensus change must keep all three paths identical.

The repair tool is the one path that cannot evaluate the rule in full: it has the PoW database and nothing else, and F lives inside the BFT block. It applies the half it can see — a block that does not advance the certificate pays nothing — and assumes an advancing block was prompt. That is correct whenever BFT kept up. When it did not, the replay disagrees with the rows already stored and its existing cross-check refuses to write anything, so the failure mode is a repair that declines, never a repair that corrupts.

The decided bft-chain, the tenderlink engine and every reader of them live in zebra-state, in zebra-crosslink/zebra-state/src/new_network/bft.rs. What is left of zebra-crosslink/zebra-crosslink is about 1,000 lines in four files: lib.rs, holding the service and the wallet, faucet and staking arms; service.rs, which constructs it; viz2.rs, the GUI feed; and test_format.rs, the .zeccltf test driver. It exposes a tower Service over TFLServiceRequest whose handlers take a tokio Mutex on TFLServiceInternal, which now holds only the BFT message and error counters and the two BFT connection strings. tfl_service_main_loop starts the visualizer and the test driver and then sleeps, because zebrad treats the service task's exit as a node shutdown.

Nothing on a consensus path calls that service, and nothing calls into it from zebra-state. Its callers outside the crate are zebra-crosslink/zebra-rpc/src/methods.rs — the staking, wallet and faucet commands — and one site in zebra-crosslink/zebrad/src/lightwalletd.rs, which asks for Faucet. No finality answer reaches it. The roster, the block template's fat pointer, the recency status, activation, and every finality answer are ReadStateService requests (CrosslinkRoster, CrosslinkFatPointerToBftChainTip, CrosslinkRecencyStatus, CrosslinkIsActivated, CrosslinkFinalizedTip, CrosslinkFinalizedTipChange, CrosslinkBlockFinality, CrosslinkTxFinality).

BftChain in new_network::bft is the decided bft-chain: blocks and hash_to_height, fat_pointer_to_tip, roster — the validator set read at the snapshot of the previous decided bft-block — bft_final_snapshot, the marker of §5.1, and is_activated. It sits behind one RwLock; the new_network::sync thread is its only writer, and every reader outside that thread takes the read lock without an asynchronous call. Recency status is a tokio::sync::watch the bft_access closure publishes.

BftRunner, owned by the sync loop, holds the tenderlink side. The five closures of tenderlink::entry_point each send one BftRequest and await one reply; the loop drains that channel where it used to sleep, so propose, validate and decide run on the thread that holds the best chain, the non-finalized state, the finalized database and the commit path. A decision commits nothing, so nothing on that path can reject one or hold up the round (§5.2).

admit_fat_pointer answers the Extension, Last Final Snapshot and σ-confirmation verdicts and the pos_payout flag (§5.4, §6.2) as a pure function of the BftChain and a ReadStateService, called under the read lock from the retain loop that filters blocks about to be committed.

The decided bft-chain is persisted in the finalized database, in three column families keyed by BFT height: bft_block_by_height, bft_fat_pointer_by_height and bft_proposal_sigs_by_height. BftRunner::finish_decision writes all three in one batch once the decision's snapshot has committed, so a crash in between leaves the chain one height short and that height is decided again on the next run. BftRunner::restore reads them back at startup to rebuild the chain and tenderlink's ingest_startup_data, recomputing each height's roster from the bonds at its snapshot and the watermark prev_finalized_bc_height from the same lookup. A database past the activation height holding no decided chain, or a decided chain whose snapshot the database does not hold, ends the process with a message rather than being migrated or re-bootstrapped (§5.2).

force_feed_bft_block injects a decided bft-block without Π_bft, as a message to the sync thread. Its only caller is test_format.rs, through TFLServiceCalls::force_feed_pos.

fin::block_finality answers block status inside one read request, against fin and the best chain taken from the same state snapshot, so the answer cannot straddle a reorganization. A block the node does not hold is not in its best chain, which is the answer it gives for one.

Each item states a current-tree fact and, where it is not evident, the Zebra Crosslink behavior it departs from.

6.1 Derivation and update trigger

  • Header order is enforced by validation, not by the type. Honest proposal construction issues FindBlockHeaders with the snapshot block as the sole known hash. That request returns the headers following the intersection, ascending, so the proposal carries the σ blocks above the snapshot, deepest-first, and parent(headers[0]) is the snapshot. σ headers suffice, because headers[0] carries the snapshot's hash in its parent field, and a validator must hold the snapshot block to validate the certificate anyway. BftRunner::validate enforces that order as part of Tail Confirmation, rejecting a block whose headers do not each name the one below as parent (§6.2). The type does not: BftBlock::try_from checks only the header count and logs that its documented validations are unimplemented, and the deserialization path used for network and stored blocks does not call try_from at all. The snapshot is named by hash only; a consumer that needs its height asks the chain, and the fat-pointer check is handed a height lookup for that purpose (§6.2).
  • The candidate height is clamped, and the clamp is not prune_σ. The proposal path computes tip − σ and then takes min(tip − σ, bft_final_snapshot + 40). Only when that clamp does not bind is the stored marker prune_σ(tip), i.e. σ confirmations. Whenever tip − σ > marker + 40, which is the normal regime during catch-up after a restart or a BFT stall, the candidate is marker + 40 and the block is finalized far deeper than σ. Any statement of the form "the proposal path finalizes at tip − σ" is true only in the unclamped regime.
  • The improvement test runs before the clamp. is_improved_final compares the proposal's snapshot height, tip − σ, against the stored marker, so every new PoW block is proposable at once. The clamp can only lower the snapshot to marker + 40, so it never turns an admitted proposal into a non-improving one.
  • Missing hazard record. crosslink_update_fin holds fin where the candidate regresses, and refuses to move it onto a chain that does not hold it, but a refused switch is only reported on stdout: the safety incident of §3.2 is not distinguished from the benign regression in anything that survives a restart.

6.2 Validity rules

  • The Last Final Snapshot rule is enforced on bc-block admission, beside the Extension rule in new_network::bft::admit_fat_pointer, with the same defer/reject split: a snapshot the state cannot yet place on a branch defers, and one it places off the block's own ancestry is rejected permanently. Ancestry is read with ReadStateService::is_ancestor_of, across every chain the state holds rather than the best chain alone.
  • The Finality Depth rule and Stalled Mode are omitted by design (§3.3). The 512-block log threshold is diagnostic, not consensus.
  • BFT validation enforces Linearity and Tail Confirmation in BftRunner::validate. Tail Confirmation is checked as the three things it is: exactly σ headers, each naming the one below it, and the block at the topmost header known to this state — which, given the linkage, carries the bc-validity of the whole tail, since a block the state holds has been validated along with its ancestry. Linearity compares the parent bft-block's snapshot against this block's through the same ancestry read. A block either check cannot resolve yet returns Indeterminate with the hash it needs, as a missing snapshot already did.
  • The confirmation depth is enforced on inclusion. A PoW block at height P may carry a fat pointer to a BFT block whose snapshot is at height F only when P ≥ F + σ + 1: the σ carried headers F+1 ..= F+σ, then the carrier. Admitting a PoW block therefore requires a PoW → PoS → PoW lookup: resolve the pointer to its BFT block, take that block's snapshot hash, and ask the state for its height. new_network::bft::admit_fat_pointer resolves the pointer in the decided chain it holds and asks the ReadStateService beside it for the height, searching every chain the state holds, and defers rather than rejects while the snapshot is unknown here. The block-template path applies the same test, so a miner is never handed a certificate that could not be committed. The inequality bounds depth only; whether F is an ancestor of P is the Last Final Snapshot rule, which the same read answers.
  • The proposal path departs from honest proposal (§3.4) in two ways. When the +40 candidate clamp in §6.1 binds, headers_bc is a window ending at marker + 40 + σ, not the tail of the proposer's bc_best; the window still satisfies Tail Confirmation. The clamp is a Zebra Crosslink design heuristic (§3.4). Where honest proposal repeats the parent's headers_bc, the path makes no proposal instead: when is_improved_final fails, when the candidate's snapshot would fail Linearity against the parent bft-block's, and when the tail the chain returns is short or does not link to the candidate. The two reads behind the tail run on the writer thread, so no commit falls between them. How often a node should repeat its parent's headers is an open implementation question, so declining is what it does until that is settled.
  • Bc-block production follows the honest context-selection procedure (§3.4) as far as validity requires: the block template's FatPointerToBFTChainTip request cites the newest decided bft-block whose do_not_include_until_bc_height admits the proposed height, whose snapshot is deep enough for the σ-confirmation rule, and whose snapshot lies on the chain the template extends. It does not implement the Book's longest-chain-then-score-then-hash tie-break, which is a selection among bft-valid tips this tree does not hold: the decided chain is linear here. When no decided block qualifies, the template repeats the parent block's own context_bft, which always does; reverting to the null pointer would break the Extension rule.
  • Block templates lag BFT decisions. Time-accelerated and realtime tests show miners producing two consecutive bc-blocks with the same fat pointer, each of which adds a block of finality lag (§3.4). Any rule keyed to finalization at exactly σ + 1 below the tip misses those blocks.
  • The Extension rule is implemented by new_network::bft::admit_fat_pointer, including its defer/reject distinction.

6.3 State-finalization and fork-choice policy

Committing up to fin leaves the non-finalized state holding only chains that contain it, so a higher-score conflicting chain cannot become canonical. This locally enforces a finalized-prefix activation policy; raw CL2 does not impose that rule. The test crosslink_pow_follows_the_heaviest_chain_until_fin_moves_to_the_decided_branch documents the behavior: a decision alone moves nothing, and the floor appears only once fin does.

Zebra Crosslink names separately:

  • protocol local_finalized_tip (fin), which under sticky fork choice is also the Zebra policy floor canonical_finalized_tip (§2, §4.3); and
  • the finalized tip of the PoW state's database, the higher of fin and the reorg-depth commit; a second state opened for a conflicting BFT branch (§4.3) has its own.

6.4 Unbounded finality gap

Nothing in consensus bounds the finality gap or restricts which transactions a block far past the snapshot may carry. This follows from omitting Stalled Mode (§3.3). It has two practical consequences:

  • The diagnostic warning at a hardcoded gap is the only signal of a long finalization stall. Any response to one, such as alerts, wallet warnings, or operator action, is outside consensus.
  • A best chain that has forked below fin (§3.5) can carry ordinary spending transactions for as long as it dominates. Under raw CL2 fork choice nothing limits that activity. Sticky fork choice keeps a node off such a branch (§4.3).

6.5 Client exposure and API semantics

There is no checkpoint/recency sync condition on client exposure. Before the Crosslink marker exists, finality RPCs now report no value rather than silently exposing the legacy reorg-depth fallback; get_tfl_final_block_hash and get_tfl_final_block_height_and_hash return null, and the block- and transaction-finality methods collapse their error to null as well. Existing GUI and RPC surfaces still conflate raw tip, confirmation, and finalization instead of defining each endpoint's contract. What these methods return once the marker does exist is fin itself.

6.6 Ordering and notification

  • The visible marker advances, and the fin watch channel's subscribers are notified, only after the commit it names has succeeded (§5.2).
  • bft_final_snapshot advances without any commit, and nothing outside zebra-state reads it, so a decision is not visible as finality until fin reaches it (§5.1).

6.7 Placement

Zebra Crosslink keeps finality state in zebra-state (§7.1). The decided bft-chain, the tenderlink engine, the admission check, fin and the persisted chain are all there now (§5.5); what remains of the placement divergence is smaller:

  • The decided bft-chain is held in memory as well as in the database, and every reader takes the in-memory copy; the database is read only at startup (§5.5).
  • The GUI reaches fin and the bft-chain as a crate-level import rather than through the state service, which is the move of §7.2.

7. Names and consumer contracts

This section is Zebra Crosslink. The protocol names encode their definitions:

protocol quantityvalue identifieroptional newtype
bc_bestbc_best_tipBcBestTip
candidate(H)finalization_candidateFinalizationCandidate
finlocal_finalized_tipLocalFinalizedTip
bft_final_snapshotbft_final_snapshotBftFinalSnapshot

The database's finalized tip has a name distinct from fin (§6.3). The legacy reorg-depth value keeps a name that says it is a reorg-depth marker, not Crosslink finality.

7.1 Where finality state lives

Finality state is chain state. The quantities of §2 are computed from blocks the node holds — fin from its own history of them (§3.2) — and every consumer of one needs the bc-chain as it stood at the same instant. Zebra Crosslink keeps all of it in zebra-state, beside the finalized database and the non-finalized state, reachable without an asynchronous call:

  • fin is a column of the finalized database, written in or after the batch that commits the block it names (§8.1).
  • The decided bft-chain — its blocks, their fat pointers, and the proposal signatures that accompany them — is stored the way bc-blocks are stored, in the same database.
  • LF(H), snapshot(LF(H)), candidate(H), bft_final_snapshot and the σ-confirmation test are computed from those two by the code that also holds the chains they are compared against.
  • The validator set is derived from bonds on the chain the decided bft-block names, by the same function that computes the finalized aggregate (§7.3).

Π_bft is not chain state. The tenderlink crate owns the vote rounds, the message transport, and the rule that a height decides before the next one starts; none of that is a function of the chain, and it stays where it is. It reaches its host through the arguments of tenderlink::entry_point, which are the whole interface:

argumentwhat the host answers with
propose_closurea bft-proposal built from the host's bc_best, or nothing (§3.4)
validate_closurea verdict on a proposed bft-block, or Indeterminate with the block it still needs
push_block_closureacceptance of a decision, answered with the roster and vote namespace for the next height
peer_cmd_closurethe peer addresses of the current roster
bft_access_closurea copy of round state for display; it decides nothing

Three of the five need the bc-chain: a proposal is the σ-block tail of bc_best, validation is Tail Confirmation and Linearity against the chains the node holds, and the roster is the bonds at a bc-block. The host side therefore belongs in zebra-state, which already depends on tenderlink and already runs its transport (tenderlink::stp, tenderlink::native_sockets) for bc-block propagation. Nothing sits between the two: a crate that relays between tenderlink and zebra-state can only reintroduce the boundary that makes these quantities race.

The closures stay asynchronous, because tenderlink awaits them. What moves is the side of the boundary the reads happen on: a closure sends one message to the block writer and awaits one reply, and every chain read behind that reply is local and synchronous. There is no path back out, so there is no lock ordering to respect (§6.7). Current tree: this is how the five closures are built, in BftRunner.

Finality state is chain state, and there can be two chain states at once. When the depth commit and bft_final_snapshot come to lie on different branches, the BFT branch is a second finalized database plus non-finalized state of the same shape as the first, opened from a snapshot of the first at or below fin (§4.3). zebra-state routes blocks and reads between the two and chooses the served best chain across both. How that snapshot is taken belongs to the storage engine, not to the protocol.

By fate. What the Crosslink service crate held is four kinds of thing, and only the first is irreducible. The moves marked "moved" are done (§5.5); the rest are ahead:

current treefate
tenderlink and its entry_point interfaceunchanged; zebra-state constructs the five closures
bft_blocks, bft_block_hash_to_height, fat_pointer_to_tip, finalizers_at_current_heightmoved: BftChain in new_network::bft, still in memory rather than in the database
latest_final_blockgone: split into BftChain::bft_final_snapshot and fin in new_network::fin, the latter with one writer, the §3.2 update rule and a database row (§5.1)
propose_new_bft_block, BftRunner::validate, handle_new_decided_bft_block, call_from_state_to_crosslink_to_ask_about_fat_pointersmoved: BftRunner::{propose, validate, decide} and admit_fat_pointer, each reading one consistent view on the writer thread
the PoS store filegone; its records are database rows keyed by BFT height, and the roster is recomputed from bonds rather than stored
TFLServiceInternal, tfl_service_main_loop, TFLServiceHandle, the tower service over TFLServiceRequest, and the reentrancy constraint it imposesthe reentrancy constraint is gone; the rest die; every finality arm is answered by the state service or by a channel it publishes
the wallet, faucet and staking armssurvive as calls rather than as a service: they relay to the wallet crate and read no finality quantity
viz2.rssurvives as a view over the state service and a published diagnostic snapshot, holding no state of its own; where it lives is a packaging question
test_format.rs and force_feed_possurvive as test support beside the tests they drive; injection is a message to the block writer

Once the moves above are done, what remains holds no finality state and sits on no consensus path, so where it lives is a packaging question and not a finality one. The GUI feed is the largest such piece, and it is a view in the sense of the §7.2 table: it reads the state service and the published round-state snapshot, answers the renderer's requests from them, and is free to lag or to be absent without any consequence for consensus.

7.2 Consumer contracts

No protocol view lies between the best tip and the finalized tip, so no CL2 quantity is a default for "confirmed" presentation. Each consumer needs a contract:

consumer or endpointvaluecontract or unresolved work
raw best-tip displaybc_best_tipcurrent fork-choice result
confirmed displaybc_best_tip at a stated confirmation depthΠ_bc confirmation only; can be an ancestor of local_finalized_tip, or conflict with it after a Prefix Consistency failure (§3.3); never present it as final
final displaylocal_finalized_tipnode-local monotone CL2 view
get_tfl_final_block_hash and get_tfl_final_block_height_and_hashlocal_finalized_tipno value before the first fin; the exposure condition of §3.5 is an @Todo
block statuslocal_finalized_tip and bc_best_tipFinalized if the block is an ancestor of or equal to local_finalized_tip; InBestChain { confirmations } if it is on bc_best above that; NotInBestChain otherwise, including unknown blocks
transaction statusstatus of the block containing itthe block status of its mined block under the same three states; a mempool transaction has no block status
finality-change notificationslocal_finalized_tip transitionssent after fin is persisted; the exposure condition of §3.5 is an @Todo
visualization pagingoperational paging cursordo not overload a finality value merely to bound a window
canonical state activationfinsticky fork choice floor (§4.3)
physical database statusdatabase finalized tiphigher of fin and the reorg-depth commit, in the PoW state; a second state for a conflicting BFT branch has its own; never reported as Crosslink finality
staking rewardsobjective per-block sourcenever use node-local fin; see §9.1
validator roster and hardfork membershipbonds at snapshot(B_{H−1})objective; see below
block-template BFT contextnewest qualifying decided bft-blockthe σ-confirmation and Last Final Snapshot tests are the ones bc-block admission runs, so a template never carries a certificate its own chain would refuse (§6.2)
BFT round diagnosticstenderlink round statedisplay only; a published snapshot, never an input to consensus
visualization feedbc_best_tip, local_finalized_tip, the decided bft-chain, round diagnosticsa view: it holds no state and decides nothing
wallet, faucet and staking commandsnone of the abovenot finality; they relay to the wallet crate

Every row is served by zebra-state: a read request against the finalized database and the non-finalized state, or a watch channel published beside the existing chain-tip channels. No row is served by a component that keeps its own copy of the value (§7.1). Block and transaction status read fin and the best chain together, so they cannot report two different moments.

Current tree. Every row keyed on local_finalized_tip is served by a ReadStateService request, except the GUI's, which still reaches fin as a crate-level import (§6.7). The finality RPCs return no value while fin is unset. TFLBlockFinality carries no confirmation count with its middle state, because the count would change the test format.

7.3 Consensus-sensitive roster and hardfork inputs

The validator roster, voting power, and hardfork-driven membership changes are consensus-sensitive. They must not read node-local fin unless there is a proof that every validator derives the same value at the same BFT height. Prefix compatibility between honest fin values is insufficient.

The two quantities are separate:

  • Canonical ledger state is selected by sticky fork choice with floor fin (§4.3).
  • The validator set for BFT height H (roster, voting power, and hardfork-driven membership) is read from the bonds at snapshot(B_{H−1}), where B_{H−1} is the decided bft-block at height H − 1. Π_bft agreement fixes B_{H−1}, its snapshot is a function of its headers_bc, and the bonds at a bc-block are a function of that block's ancestry, so every validator that has those blocks derives the same set. terminated_finalizers_at takes the height of the same block.

snapshot(B_{H−1}) generally lies above fin, because candidate(H) ⪯ snapshot(LF(H)) and fin advances only once a bc-block citing the bft-block is best. It need not lie on bc_best at all, and can stay off it for any length of time (§2, §4.3). Its bonds are therefore read from the chain leading to bft_final_snapshot, which the node syncs and stores independently of its best chain: as a non-finalized chain carrying its aggregated stakes per block, or, once it forks below the depth commit, as the second state of §4.3. The aggregate at a block is the same function whichever side of a finalized tip the block is on, so a block committed later yields the identical row. An honest validator has downloaded that chain while validating B_{H−1} (§3.4).

Validation on that chain is interdependent but well-founded. Validating bft-block B_H needs the validator set from the bonds at snapshot(B_{H−1}) and the bc-blocks under B_H.headers_bc; validating those bc-blocks needs the bft-blocks their fat pointers cite, all of which were decided before them. Processing decisions in BFT height order, each after the bc-blocks up to its headers, satisfies every dependency.

8. Implementation status and pitfalls

The ordered implementation work is in IMPLEMENTATION.md. This section records the current-tree facts that work starts from.

Current tree. The final-block accessor returns only the stored Crosslink value and never substitutes the legacy reorg-depth marker; tfl_reorg_final_block_height_hash and tfl_final_block_height_hash_pre_locked no longer exist. Before Crosslink produces a value, finality queries return None. There is no test harness for these RPC methods, so the absent and present cases are covered at the ReadStateService request level, by crosslink_finality_reads_before_and_after_the_first_decision; a JSON-RPC harness remains separate work. fin::advance publishes every marker write on the fin watch channel, after state commitment.

Current tree. fin is computed by candidate(bc_best), moved only where the best chain changes, held where the candidate regresses, and persisted, so it is the CL2 quantity and local_finalized_tip is an accurate name for it. Its readers reach it through the state service (§7.2), except the GUI's (§6.7). What is left is the hazard record a refused switch should leave (§6.1).

8.1 Implementation pitfalls

These are current-tree facts, and they hold for any change to how the marker is derived, stored, or consumed.

  • The derivation has one accessor. BftBlock::snapshot_block_hash is the only place parent(headers[0]) is computed. BftRunner::decide, the BFT validation path, the restore path and its replay watermark prev_finalized_bc_height, test_format.rs, and viz2.rs (both the live viz response and VizScene) read it, and the finality-diagram tests in zebrad/tests/crosslink.rs and viz2::scene_tests assert marker positions derived from it. A second derivation makes the node, the GUI, and the tests disagree about which block is final.
  • The roster is a function of a block, not of committing it. BftChain::roster is filled from FinalizedState::db::aggregated_stakes at snapshot(B_{H−1}), on the decide path and on the restore path alike, and terminated_finalizers_at takes that same block's height. Nothing reads stakes from the reply to handle_crosslink_finalize, which returns the hash alone, so the roster does not depend on a decision and a commit being one event. The read answers on either side of the finalized tip: Chain carries the aggregate per block, appended by Chain::push beside bond_rewards and finalizer_commissions and popped with them, from the same function prepare_aggregated_stakes_batch uses, so a block held only in the non-finalized state yields the row the database will hold once it commits. A block on no chain the node holds is the case the second state of §4.3 removes.
  • The BFT genesis snapshot is below the bootstrap roster height. Bootstrap genesis carries headers starting at BOOTSTRAP_ROSTER_HEIGHT, so its snapshot, and the roster for BFT height 1, is the block below that height.
  • A derivation change is a network-wide consensus change. Nodes running two derivations disagree on the finalized block and on the roster. Databases written under the old derivation are deleted, not migrated. A stored decision holds the BftBlock, the fat pointer and the proposal signatures; restore recomputes both the roster and the replay watermark prev_finalized_bc_height from each stored block's snapshot, so an old database loaded by new code yields rosters that disagree with the stored votes, which travel by roster index.
  • fin moves only forward. candidate(bc_best) falls below fin after a benign reorg (§3.2), and WriteBlockWorkerTask::handle_crosslink_finalize returns success for a hash the database already holds, so a caller that stored whatever it committed would move fin backwards. The fin ⪯ N check is at the caller, in crosslink_update_fin; fin::advance aborts on a regression rather than recording one.
  • fin is written no earlier than its commit. A persisted fin above the finalized tip can name a block that was only in non-finalized state, which does not survive a restart. Writing fin in the commit's batch, or after it, keeps fin at or below the finalized tip.
  • The database finalized tip is not fin. Past MAX_BLOCK_REORG_HEIGHT of lag it is the reorg-depth commit. RPC, GUI, and notification readers take the persisted fin.
  • Last Final Snapshot constrains block templates. A template must cite a bft-block whose snapshot lies on the template's parent chain, or the mined block is invalid (§6.2).
  • Tail Confirmation needs the whole tail. Validation checks all σ carried headers and the bc-validity of their blocks, which a validator may first have to download (§3.4).
  • A σ window is a chain, and its top must be present. Enforcing Tail Confirmation invalidated two fixtures in crosslink_test_basic_finality that predate it: a window taken as a slice of a block list that holds a fork carried two siblings rather than a chain, and the last window's topmost header named a block the test never loaded, which now defers with NeedsBlock instead of validating. Any test that builds a certificate from a slice has to take its blocks from one branch and load the block at the top of the window.
  • Reward logic has three copies. Chain::push with update_bonds_with_pos_issuance, fixup_aggregated_stakes in stake_fixup.rs, and the wallet projection in lib.rs must change together (§5.4).
  • Header order is a property of the honest producer. BftBlock::try_from checks only the header count, and the network and stored-block deserialization path does not call it. Code that reads headers[0] as the deepest header relies on the producer, not on validation.
  • NonFinalizedState holds less than the finalized chain needs. It lives in memory, it drops the lowest-work chain past MAX_NON_FINALIZED_CHAIN_FORKS (10), and it drops chains that do not contain the finalized tip, including those forking below a reorg-depth commit. The chain to bft_final_snapshot must survive all three. Its BFT decisions are database rows beside the bc-chain (§7.1); the chain holding bft_final_snapshot is exempt from the lowest-work pruning and is restored by the non-finalized backup; and a fork below the depth commit is the second state of §4.3, never a reason to stop following the chain.
  • A conflict below the depth commit is a second database, not a resync. The Wall of Death (410d99ed) was this failure with no conflict in it: a decision's snapshot lay on the committed chain, deep, and the roster could not be read there, so a node that had mined past it could never resume BFT. Storing aggregated stakes per committed block fixed that, and the resyncs it had forced on feature-testnet operators are what the fix existed to end. The conflict case is the same failure one branch over, and the same answer applies: the node recovers from what it already holds, without an operator.
  • Switching to the BFT branch can be a reorganization deeper than MAX_BLOCK_REORG_HEIGHT. The wallet's REWIND_DISTANCE and CHECKPOINTS_N derive from that constant, so a client of a node that can switch to a second state needs checkpoints back to fin.
  • A switch onto the finalized chain replaces bond state. Bonds tracked along that chain while it was a side chain must agree with what the chain produces once it becomes bc_best; one implementation of the bond update serves both (§5.4).
  • The decided bft-chain is stored in the finalized database, without its roster. The rows carry the block, the fat pointer and the proposal signatures; restore recomputes each height's roster from the bonds at that height's snapshot. Storing the roster instead would be the second derivation the first bullet of this list warns against: a roster that disagreed with the stored votes would re-index every one of them through seats that never voted (§5.5).
  • MAX_BLOCK_REORG_HEIGHT is asserted against the bootstrap gap. ZcashCrosslinkParameters::bootstrap_is_valid requires activation_height − roster_height > MAX_BLOCK_REORG_HEIGHT, and a const _: () = assert! on PROTOTYPE_PARAMETERS checks it while compiling. The prototype gap is 200 blocks, so raising the constant past that stops the workspace building until the bootstrap heights move with it.
  • Aborts kill the node. The build uses panic=abort. The decide path unwraps block_height_from_hash on the decided header, so a decided block whose header is unknown to state terminates the process, as does every assert! on that path.
  • fin is a time series, not a function of the tip (§3.2). Recomputing it from candidate(bc_best) after a restart reproduces only the current candidate, which is why fin is persisted.
  • Zebra's depth commit is a second floor, per state. Blocks deeper than MAX_BLOCK_REORG_HEIGHT on the best chain are written to the PoW state's finalized database regardless of fin (§4.3, Implementation in Zebra), and a fork-choice rule above fin operates only within that window in one database. A second state is what lets the rule reach past it; holding the commit back is not, because the hold has to end.
  • The +40 candidate clamp breaks honest proposal (§6.2). It stays, as a design heuristic outside the specification (§3.4). With it, one bft-block's snapshot advances by at most 40 bc-blocks; the commit, the roster lookup, and terminated_finalizers_at handle steps of any size regardless.
  • Block status never reports bft_final_snapshot as finalized. A block at or below bft_final_snapshot but above local_finalized_tip is InBestChain or NotInBestChain, because fin is the view Assured Finality covers (§2).
  • σ comes from ZcashCrosslinkParameters. The GUI's apply_viz_op hardcodes it as TMP_SIGMA, which matches only while PROTOTYPE_PARAMETERS is unchanged.
  • Last Final Snapshot is testable only because deciding no longer commits. A violation needs a block whose ancestry omits the snapshot of the bft-block it cites, which needs that snapshot to survive on a branch the block is not on. While the decide path committed every snapshot as it was decided, the state collapsed onto that branch and refused forks below it, so such a block was refused for the wrong reason before it could be offered. The test is crosslink_reject_pow_block_citing_a_snapshot_off_its_own_chain. The rule is what makes the σ carried headers confirmations of the admitting chain. Linearity and Tail Confirmation are tested, in crosslink_reject_pos_block_that_regresses_the_snapshot, crosslink_reject_pos_block_with_lt_sigma_headers and crosslink_reject_pos_block_with_unlinked_headers.
  • Crosslink node tests and viz_gui. Tests run through phest.bat zebra-crosslink, and phargo.bat enables viz_gui for that project, which puts winit on the main thread. The node tests in zebrad/tests/crosslink.rs run headless, so they run with PH_NO_VIZ_GUI set, which leaves the feature out of an otherwise identical build.

9. Open decisions

Payout design belongs to separate work, recorded here for context. Implementation questions that need a design pass are in IMPLEMENTATION.md.

9.1 Objective reward trigger and reward economics

Consensus issuance cannot depend on node-local fin. Honest nodes can reach the same chain through different best-chain and reorg histories, so they need not observe the same sequence of fin transitions. They must nevertheless compute identical value pools for the same chain.

An objective per-block event can instead be derived from block data, for example:

payout boundary at H  iff  candidate(H) != candidate(parent(H))

Implemented. The prototype now takes this trigger, with a liveness bound added to it: a block pays iff its certificate differs from its parent's and the certificate is at most σ + FINALITY_LIVENESS_ALLOWANCE blocks behind it. §5.4 states the rule and where each path evaluates it. The consequences listed below under "payout amount" are the ones this choice accepts: a flat reward per advance, so a BFT stall lowers issuance for as long as it lasts and never pays the missed blocks back.

This is not literally the event "local fin advanced." It is a block-local event that would permit fin to advance if H were observed as best and its candidate were ahead of that node's current fin. snapshot(LF(H)) is another objective candidate source. The selected function must be monotone along a chain under the enforced validity rules. Extension makes LF(H) monotone along a chain; Linearity then makes snapshot(LF(H)) monotone, and candidate(H) follows because prune_σ(H) is monotone and an lca of two monotone arguments is monotone. The current tree enforces both Extension and Linearity (§6.2). Without a Finality Depth rule, a bc-block producer can also keep a stale context_bft at no validity cost (§3.3), so an objective advance trigger lets whoever dominates bc_best delay payouts while Π_bft is live.

Payout amount is a separate decision:

  • A flat POS_BLOCK_REWARD_ZATS per objective advance lowers issuance during a BFT stall and can leave it permanently lower if advances never resume.
  • A deferred amount based on elapsed PoW height can catch up only when a later payout occurs and only under an explicit accrual rule. Issuance is still lower at intermediate heights, remains lower after a permanent stall, and intervals with no active bonds need a rule: drop, burn, or carry their nominal reward.
  • Current rewards increase bond principal every rewarded block. A lump delays that compounding and allocates the whole amount among bonds active at payout time. Individual allocations therefore change, even if aggregate eventual base issuance is preserved under stated assumptions.
  • Non-payout blocks must still append empty bond_rewards entries so positional reorg reversal remains aligned.

The choice of objective trigger does not depend on resolving the amount formula. Conversely, the amount decision must not obscure the already-settled requirement that a consensus trigger be replayable from the chain alone. Detailed reward economics should live in a separate decision document once a concrete policy is proposed.

10. Source appendix

Every current-tree statement describes the monolith tree this document ships with. Code can move without this file being updated, so re-check the cited symbols before using this document to plan changes.

A Visual Tour of Zcash Crosslink

Zcash PoW

-Currently the Zcash Mainnet uses consensus rules defined by Network Upgrade 6.1. This relies on a Bitcoin-like PoW consensus mechanism, which enables partition-tolerant high availability at the cost of forks / rollbacks. Here's a conceptual diagram of PoW blocks pointing to their parents (via prevhash header fields) which shows two objectively-verifiable histories leading back from PoW blocks B₃ and B₂', with B₃ being the longer: +Currently the Zcash Mainnet uses consensus rules defined by Network Upgrade 6.3. This relies on a Bitcoin-like PoW consensus mechanism, which enables partition-tolerant high availability at the cost of forks / rollbacks. Here's a conceptual diagram of PoW blocks pointing to their parents (via prevhash header fields) which shows two objectively-verifiable histories leading back from PoW blocks B₃ and B₂', with B₃ being the longer:

graph TD
     B3([B₃]):::pow --> B2([B₂]):::pow
     B2 --> B1([B₁]):::pow
     B1 --> B0([B₀]):::pow
 
     %% A split history:
     B2b([B₂']):::powAlt --> B1b([B₁']):::powAlt
     B1b --> B0
 
     %% Styles defined in mermaid-common-styles.js and mermaid-styles.md
     %% Define PoW block style with dark blue border and oval shape
     classDef pow fill:#fff,stroke:#01579b,stroke-width:3px,color:#000
     %% Define alternative PoW block style with orange fill for caution
     classDef powAlt fill:#ffcc80,stroke:#01579b,stroke-width:3px,color:#000

A Visual Lexicon for TFL Book

The Last Final Snapshot Rule

Last Final Snapshot rule: .

For a bft‑block or bft‑proposal , define

Note that the type of in the quoted definition is a BFT Finality Certificate.

graph TD
  %% Node Categories
  classDef protocol stroke-width:1px
  class powProto,bftProto protocol;

  classDef pow stroke-width:1px
  classDef powAlt stroke:grey,stroke-width:1px,stroke-dasharray:2,2
  classDef bft stroke-width:2px
  classDef elidedNode stroke-width:0px,fill:none

  %% Nodes
  subgraph powProto [Proof of Work blocks]
    B0(["`$$B_0$$`"]):::pow
    B1(["`$$B_1$$`"]):::pow
    B2(["`$$B_2$$`"]):::pow
    B3(["`$$B_3$$`"]):::pow

    B2alt(["`$$B_2'$$`"]):::powAlt
    B1alt(["`$$B_1'$$`"]):::powAlt
  end

  subgraph bftProto [BFT Finality Certificates]
    FIN_0("`$$FIN_0$$`"):::bft
    FIN_1("`$$FIN_1$$`"):::bft
    FIN_2("`$$FIN_2$$`"):::bft
  end

  %% "Out of view" node indicators:
  powDots(["…"]):::elidedNode
  bftDots(["…"]):::elidedNode

  %% PoW Edges
  B3 --> B2
  B2 --> B1
  B1 --> B0
  B0 --> powDots

  B2alt --> B1alt
  B1alt --> B0

  %% Finality certificate sequence
  FIN_2 --> FIN_1
  FIN_1 --> FIN_0
  FIN_0 --> bftDots

  %% Finality snapshots
  FIN_2 == snapshot ==> B2
  FIN_1 == snapshot ==> B0
  FIN_0 == snapshot ==> powDots

For a bc‑block , define

Mermaid Common Styles

This file contains reusable Mermaid style definitions for diagrams throughout the book.

How to Use

Copy the relevant style definitions from this page into your Mermaid diagrams. While the styles are also defined in mermaid-common-styles.js for potential programmatic use, Mermaid requires classDef statements to be included within each diagram block.

PoW Block Styles

For Proof-of-Work blockchain diagrams:

%% Define PoW block style with dark blue border and oval shape
classDef pow fill:#fff,stroke:#01579b,stroke-width:3px,color:#000
%% Define alternative PoW block style with orange fill for caution
classDef powAlt fill:#ffcc80,stroke:#01579b,stroke-width:3px,color:#000

Usage Example

graph TD
    Block_0([B₀]):::pow
    Block_1([B₁]):::pow
    Block_1_alt([B₁']):::powAlt

    Block_1 --> Block_0
    Block_1_alt --> Block_0

    %% Define PoW block style with dark blue border and oval shape
    classDef pow fill:#fff,stroke:#01579b,stroke-width:3px,color:#000
    %% Define alternative PoW block style with orange fill for caution
    classDef powAlt fill:#ffcc80,stroke:#01579b,stroke-width:3px,color:#000

Node Type Styles

Standard node types used across diagrams:

classDef input fill:#e1f5ff,stroke:#01579b,stroke-width:3px,color:#000
classDef compute fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#000
classDef aggregate fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#000
classDef output fill:#e8f5e9,stroke:#1b5e20,stroke-width:3px,color:#000
classDef storage fill:#fce4ec,stroke:#880e4f,stroke-width:2px,color:#000