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:
-
ZECmust be scarce, with every unit accounted for by the consensus protocol, all adding up to the Max Supply Cap of 21,000,000. -
The consensus protocol constrains
ZECtransfers, 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. -
ZECis divided between Unissued Supply versus Active Supply, with the total of these two categories being the Max Supply Cap. -
The Active Supply is all
ZECthat is under a user's discretionary control, given any protocol constraints on those funds. For example, much of the active supply isZECwhich 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. -
The consensus protocol may issue or unissue
ZECby 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
ZECas part of the network operation in order to incentivize successful operation of the network and establish a valuable network effect ofZECscarcity among users.This new
ZECis 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.
- Details:
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
ZECto 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"?
Crosslink Rewards Distribution Rules
With that framework in mind, here are the concrete rewards distribution rules Crosslink follows:
-
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.
-
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).
-
The remainder is called the Operational Consensus Rewards (aka OCR).
-
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.)
-
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:
-
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.
-
Txns with bond actions may only be included in blocks at Staking Day height ranges (FIXME: define these heights).
-
Bonds have a stateful lifecycle:
-
From any Shielded Pool
ZEC:createaction -
From
activestate:redelegateorwithdrawactions -
From
withdrawing w/ sufficient delay state:transferaction- 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
createandwithdrawactions may only occur during Staking Day heights. - The
redelegateandtransferactions are not constrained by Staking Day height ranges. - The
transferaction 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
- Part II — The two chains
- Part III — What finality means here
- Part IV — Stake
- Part V — Punishment
- Part VI — Open questions
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.
Interim solution: Tenderlink networking and PoWLink
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:
Raw work (Crosslink 2's unmodified fork choice)
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. Myfindoes not move yet. - A PoW block on my best chain cites that decision in its
context_bft. Once that block is my best tip,finratchets 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.
finthen 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
finand the tip is unbounded during a stall, and ordinary spends keep landing abovefin. - 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.
- Unbond. Value depends on which block it lands in; once landed, the bond has a fixed numeric value.
- 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
- 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.
-
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.
-
PoS stores no ledger information of its own.
-
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/H3with the code'sh1/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.
| section | layer |
|---|---|
| §1 Scope | Book, with Zebra Crosslink's parameter and Stalled Mode choices |
| §2 Terminology | Zebra Crosslink; the Zebra-specific quantities note their current-tree form |
| §3 Crosslink 2 model | Book, with the consequences for Zebra Crosslink |
| §4.1 Raw fork choice | Book |
| §4.2 Finalized-prefix policy | a general policy, and its current-tree form |
| §4.3 Sticky fork choice | Zebra Crosslink, ending with the current tree |
| §5 Implementation inventory | current tree |
| §6 Divergences | current tree, measured against Zebra Crosslink |
| §7 Names and consumer contracts | Zebra Crosslink |
| §8 Implementation status and pitfalls | current-tree facts that constrain the implementation |
| §9 Open decisions | outside 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
HhasH.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:
| quantity | definition | kind |
|---|---|---|
bc_best / χ | highest-score bc-valid chain in the node's view | raw fork-choice view |
candidate(H) | lca(snapshot(LF(H)), prune_σ(H)) | objective function of a block and its ancestry |
fin | monotone 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:
| quantity | definition | kind |
|---|---|---|
bft_final_snapshot | snapshot(B) for the newest decided bft-block B in the node's view | the 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. Crosslink 2 model
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
Hwithheight(H) − height(snapshot(LF(H))) > Lonly ifHis a stalled block; - Stalled Mode
defines stalled blocks (proposed for Zcash as coinbase-only), so that
Π_bckeeps producing blocks while no user transaction lands more thanLblocks past the snapshot; and - the bounded-available chain
ba_μ := prune_μ(bc_best) if fin ⪯ prune_μ(bc_best), else finis the client view whose distance ahead offinthat bound limits.
Without Stalled Mode, the other two parts have no role:
- 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Π_bcwould haltLblocks 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Π_bcliveness would be a bug because it allows tail-thrashing attacks.Landis_stalled_blockhave no other use. ba_μandμ.ba_μdiffers from a plain confirmation depth only in its fallback tofin, and that fallback exists to keepfin ⪯ 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 permitsLF(H) = LF(parent(H)), and the Valid Context and Last Final Snapshot rules are always satisfiable by reusing the parent'scontext_bft. With no depth bound, a chain that never updates its context stays valid at any height. Progress offinwhileΠ_bftis 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 dominatebc_bestcan withhold finality progress; under bounded availability it would have been confined to stalled blocks afterL.- The finality gap is unbounded. During a finalization stall,
bc_bestkeeps accepting ordinary spending transactions at any distance pastfin. 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 readsbc_bestor a confirmation prefixprune_k(bc_best). Those areΠ_bcviews, not CL2 quantities, and their security isΠ_bc's own Prefix Consistency. The Book's "Prefix Consistency ofba" theorem has no subject. A prefixprune_k(bc_best)can be an ancestor offineven when every assumption holds; the Book gives difficulty adjustment after a reorg as the reason, which is whyba_μhad its fallback. It can conflict withfinonly if Prefix Consistency atσhas failed:fin ⪯ prune_σ(χ^r)for some earlierr, so Prefix Consistency atσgivesfin ⪯ bc_best, and every prefix ofbc_bestis then comparable withfin. - The application choice changes. The Book framed it as
fin(stop immediately) versusba_μ(continue for at mostLblocks). Here it isfinversus abc_bestview that never stops and has no bound on how much can be rolled back tofin.
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_bftis 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_bcform 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_bcto theσ-block tail of its ownbc_best, if that satisfies Linearity againstP's parent; - otherwise sets
P.headers_bcto its parent'sheaders_bc, repeating the parent's snapshot; and - makes no proposals until its
bc_bestis at leastσ + 1blocks 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
Π_bftFinal Agreement makes the snapshots of final bft-blocks bc-linear, which the Book says implies Assured Finality without anyΠ_bcsafety 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Π_bcPrefix Agreement gives safety without anyΠ_bftassumption (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
Π_bcunless 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))andcandidate(H) ⪯ prune_σ(H)hold for everyH, because an lca is an ancestor of both of its arguments.- With Last Final Snapshot,
snapshot(LF(H))andprune_σ(H)both lie onH, socandidate(H)is the lower of the two. Without it,snapshot(LF(H))can lie on another branch, andcandidate(H)is then the lower ofprune_σ(H)and the point where that branch leavesH.
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:
- The generic best-chain model chooses a highest-score bc-valid chain (construction lines 245–260).
- 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.
- 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).
- The Questions chapter analyzes the stronger rule requiring
bc_bestto 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.”
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 tocandidate(bc_best), a point on the node's own best chain at or belowprune_σ(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
Π_bftinformation beyond the consensus rules to choose among bc-valid chains (§4.1, item 3). Sticky fork choice usesfin. - The liveness analysis
attributes its tractability to leaving
Π_bcfork 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 offinnever causes a switch, becausefinadvances 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Π_bftmay be subverted. Under sticky fork choice a subvertedΠ_bftstill cannot placefinoff the node's best chain or aboveprune_σ(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_bestholds on the node at all times. The raw-CL2 state in whichfinstays fixed on a branch thatbc_bestno longer contains (§4.1) does not arise.- The conflicting-candidate case of the §3.2 update cannot occur:
candidate(bc_best)andfinboth lie onbc_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 thancurrentthat excludesfin. - The rule selects a different chain from raw work-based fork choice only when a chain with
more work than
currentexcludesfin. By the Local fin-depth lemma (§3.2),finwas part ofprune_σ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 everyH, because an lca is an ancestor of both of its arguments.Π_bftcan therefore movefinonly to blocks the node had already selected by work andσ-confirmed; it cannot move the node onto a chain it did not select. WhileΠ_bftis stalled or withholding,finis 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
finstill 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 andfinstays behind on the other branch. Under sticky fork choice the node stays on the branch containingfin, 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 tofinfrom 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
finstays frozen while that branch is its best chain. Under sticky fork choicefincan keep advancing ifΠ_bftfinalizes the branch the node holds. - Without Linearity:
Π_bftcan finalize snapshots on the heavier branch. Under raw fork choicecandidate(bc_best)then conflicts withfin, the node records the §3.2 hazard, andfinstays frozen. Under sticky fork choicecandidate(bc_best)is at or below the branch point, sofinfreezes without a hazard record; the event is visible only as the refused switch.
- With Linearity: no final snapshot can move onto the heavier branch past the branch point.
Under raw fork choice the node's
- Partition while
finis frozen on every node. Every chain extending the commonfinis 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'sfinmoved past the fork point. - Partition in which one side advances
fin. Side A holds enough stake forΠ_bftto decide and advancesfinpast 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 theirfin. While B's branch has more work, the nodes stay split along the partition. B's blocks cannot advance B-sidefinpast 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
finnever 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 thanMAX_BLOCK_REORG_HEIGHTblocks 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
Π_bftlater finalizes a snapshot on B's branch, B-side nodes advancefinpast 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 ownfin. Under raw fork choice every node follows the heavier branch, and nodes whosefinlies on the other branch record the hazard once the candidate passes the branch point.
- With Linearity: later final snapshots stay on A's branch, so B-side
- 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
fininto it and then refuses the heavier branch. A node that processes the heavier branch first keepsfinat 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
fininto it, and the two nodes stay on different branches whatever the work.
- Conflicting finality. Each
finlies onprune_σof its own node's earlier best chain, so two conflictingfinvalues 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 whosefinit excludes records the §3.2 hazard once the candidate passes the branch point. Under sticky fork choice each node keeps the branch containing its ownfin, whatever the work on the other, and neither records a hazard.- With Linearity: conflicting
finvalues also require a Final Agreement failure, because everyfinis at or below the snapshot of a final bft-block (§3.4) and those snapshots are bc-linear. - Without Linearity: conflicting
finvalues need no Final Agreement failure; the partition case above is an example.
- With Linearity: conflicting
Implementation in Zebra Crosslink
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 computesN := candidate(bc_best). Iffin ⪯ NandN ≠ fin, it finalizes up toNand storesNasfin. A candidate at or belowfinchanges 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 excludefintherefore 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. -
finis stored in the finalized database as its own block hash, so the floor survives a restart. The database's finalized tip is the higher offinand the reorg-depth commit (next bullet), so finality readers takefin, 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 notbc_best. Only a laterbc_bestchange movesfin, and only by the rule above. -
The node syncs the chain leading to
bft_final_snapshotwhether or not it isbc_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 pastMAX_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-blockbond_rewardsandfinalizer_commissionsit 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 containsfin, 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 inzebra-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(fromzcash_protocol::consensus, applied inzebra-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 offinand 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. Ifbc_bestthen runs more than that depth past the point where the chain tobft_final_snapshotforks from it, the depth commit writes a block that conflicts withbft_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 asfinadvances, and during a stall it stays valid, only staler. Every chain containingfinforks 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 isbft_final_snapshotand 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 oncefinpasses 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_DEPTHblocks past the fork, then commits and reports on stdout that it can no longer followbft_final_snapshot; that path carries an@Todonaming the second state. The hold is an interim and never the design, because under a permanent conflict it is the same wallCONFLICT_HOLD_DEPTHblocks later. A node never requires a resync to resume bft-block validation. - 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
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 fromcandidate(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_networkaccepts a hash found in any non-finalized chain or in the finalized database.NonFinalizedState::crosslink_finalizeretains the chain containing a known side-chain hash, butcrosslink_update_finpasses 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:
candidateanswersNonewhile the snapshot is a block this node does not hold, and the bft-block naming it is refused byvalidatefor the same reason. - the restore path makes a
KnownBlocklookup 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 theCrosslinkFinalizedTip,CrosslinkBlockFinalityandCrosslinkTxFinalityread requests; - the GUI's finalized row and its visualization paging lower bound; and
- the notification subscribers of
fin::fin_change_rx, reached throughCrosslinkFinalizedTipChange.
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_rewardsandfinalizer_commissionsentries 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_ZATSfor that PoW block, increasesstaking_bonded_amountby 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.
5.5 The Crosslink service crate
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.
6. Current tree: divergences from Zebra Crosslink
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
FindBlockHeaderswith 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, andparent(headers[0])is the snapshot.σheaders suffice, becauseheaders[0]carries the snapshot's hash in its parent field, and a validator must hold the snapshot block to validate the certificate anyway.BftRunner::validateenforces 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_fromchecks only the header count and logs that its documented validations are unimplemented, and the deserialization path used for network and stored blocks does not calltry_fromat 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 computestip − σand then takesmin(tip − σ, bft_final_snapshot + 40). Only when that clamp does not bind is the stored markerprune_σ(tip), i.e.σconfirmations. Whenevertip − σ > marker + 40, which is the normal regime during catch-up after a restart or a BFT stall, the candidate ismarker + 40and the block is finalized far deeper thanσ. Any statement of the form "the proposal path finalizes attip − σ" is true only in the unclamped regime. - The improvement test runs before the clamp.
is_improved_finalcompares 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 tomarker + 40, so it never turns an admitted proposal into a non-improving one. - Missing hazard record.
crosslink_update_finholdsfinwhere 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 withReadStateService::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 returnsIndeterminatewith the hash it needs, as a missing snapshot already did. - The confirmation depth is enforced on inclusion. A PoW block at height
Pmay carry a fat pointer to a BFT block whose snapshot is at heightFonly whenP ≥ F + σ + 1: theσcarried headersF+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_pointerresolves the pointer in the decided chain it holds and asks theReadStateServicebeside 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; whetherFis an ancestor ofPis the Last Final Snapshot rule, which the same read answers. - The proposal path departs from honest proposal (§3.4) in two ways. When the
+40candidate clamp in §6.1 binds,headers_bcis a window ending atmarker + 40 + σ, not the tail of the proposer'sbc_best; the window still satisfies Tail Confirmation. The clamp is a Zebra Crosslink design heuristic (§3.4). Where honest proposal repeats the parent'sheaders_bc, the path makes no proposal instead: whenis_improved_finalfails, 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
FatPointerToBFTChainTiprequest cites the newest decided bft-block whosedo_not_include_until_bc_heightadmits 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 owncontext_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
σ + 1below 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 floorcanonical_finalized_tip(§2, §4.3); and - the finalized tip of the PoW state's database, the higher of
finand 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
finwatch channel's subscribers are notified, only after the commit it names has succeeded (§5.2). bft_final_snapshotadvances without any commit, and nothing outsidezebra-statereads it, so a decision is not visible as finality untilfinreaches 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
finand 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 quantity | value identifier | optional newtype |
|---|---|---|
bc_best | bc_best_tip | BcBestTip |
candidate(H) | finalization_candidate | FinalizationCandidate |
fin | local_finalized_tip | LocalFinalizedTip |
bft_final_snapshot | bft_final_snapshot | BftFinalSnapshot |
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:
finis 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_snapshotand 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:
| argument | what the host answers with |
|---|---|
propose_closure | a bft-proposal built from the host's bc_best, or nothing (§3.4) |
validate_closure | a verdict on a proposed bft-block, or Indeterminate with the block it still needs |
push_block_closure | acceptance of a decision, answered with the roster and vote namespace for the next height |
peer_cmd_closure | the peer addresses of the current roster |
bft_access_closure | a 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 tree | fate |
|---|---|
tenderlink and its entry_point interface | unchanged; zebra-state constructs the five closures |
bft_blocks, bft_block_hash_to_height, fat_pointer_to_tip, finalizers_at_current_height | moved: BftChain in new_network::bft, still in memory rather than in the database |
latest_final_block | gone: 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_pointers | moved: BftRunner::{propose, validate, decide} and admit_fat_pointer, each reading one consistent view on the writer thread |
| the PoS store file | gone; 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 imposes | the 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 arms | survive as calls rather than as a service: they relay to the wallet crate and read no finality quantity |
viz2.rs | survives 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_pos | survive 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 endpoint | value | contract or unresolved work |
|---|---|---|
| raw best-tip display | bc_best_tip | current fork-choice result |
| confirmed display | bc_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 display | local_finalized_tip | node-local monotone CL2 view |
get_tfl_final_block_hash and get_tfl_final_block_height_and_hash | local_finalized_tip | no value before the first fin; the exposure condition of §3.5 is an @Todo |
| block status | local_finalized_tip and bc_best_tip | Finalized 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 status | status of the block containing it | the block status of its mined block under the same three states; a mempool transaction has no block status |
| finality-change notifications | local_finalized_tip transitions | sent after fin is persisted; the exposure condition of §3.5 is an @Todo |
| visualization paging | operational paging cursor | do not overload a finality value merely to bound a window |
| canonical state activation | fin | sticky fork choice floor (§4.3) |
| physical database status | database finalized tip | higher 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 rewards | objective per-block source | never use node-local fin; see §9.1 |
| validator roster and hardfork membership | bonds at snapshot(B_{H−1}) | objective; see below |
| block-template BFT context | newest qualifying decided bft-block | the σ-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 diagnostics | tenderlink round state | display only; a published snapshot, never an input to consensus |
| visualization feed | bc_best_tip, local_finalized_tip, the decided bft-chain, round diagnostics | a view: it holds no state and decides nothing |
| wallet, faucet and staking commands | none of the above | not 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 atsnapshot(B_{H−1}), whereB_{H−1}is the decided bft-block at heightH − 1.Π_bftagreement fixesB_{H−1}, its snapshot is a function of itsheaders_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_attakes 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_hashis the only placeparent(headers[0])is computed.BftRunner::decide, the BFT validation path, the restore path and its replay watermarkprev_finalized_bc_height,test_format.rs, andviz2.rs(both the live viz response andVizScene) read it, and the finality-diagram tests inzebrad/tests/crosslink.rsandviz2::scene_testsassert 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::rosteris filled fromFinalizedState::db::aggregated_stakesatsnapshot(B_{H−1}), on the decide path and on the restore path alike, andterminated_finalizers_attakes that same block's height. Nothing reads stakes from the reply tohandle_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:Chaincarries the aggregate per block, appended byChain::pushbesidebond_rewardsandfinalizer_commissionsand popped with them, from the same functionprepare_aggregated_stakes_batchuses, 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 watermarkprev_finalized_bc_heightfrom 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. finmoves only forward.candidate(bc_best)falls belowfinafter a benign reorg (§3.2), andWriteBlockWorkerTask::handle_crosslink_finalizereturns success for a hash the database already holds, so a caller that stored whatever it committed would movefinbackwards. Thefin ⪯ Ncheck is at the caller, incrosslink_update_fin;fin::advanceaborts on a regression rather than recording one.finis written no earlier than its commit. A persistedfinabove the finalized tip can name a block that was only in non-finalized state, which does not survive a restart. Writingfinin the commit's batch, or after it, keepsfinat or below the finalized tip.- The database finalized tip is not
fin. PastMAX_BLOCK_REORG_HEIGHTof lag it is the reorg-depth commit. RPC, GUI, and notification readers take the persistedfin. - 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 incrosslink_test_basic_finalitythat 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 withNeedsBlockinstead 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::pushwithupdate_bonds_with_pos_issuance,fixup_aggregated_stakesinstake_fixup.rs, and the wallet projection inlib.rsmust change together (§5.4). - Header order is a property of the honest producer.
BftBlock::try_fromchecks only the header count, and the network and stored-block deserialization path does not call it. Code that readsheaders[0]as the deepest header relies on the producer, not on validation. NonFinalizedStateholds less than the finalized chain needs. It lives in memory, it drops the lowest-work chain pastMAX_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 tobft_final_snapshotmust survive all three. Its BFT decisions are database rows beside the bc-chain (§7.1); the chain holdingbft_final_snapshotis 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'sREWIND_DISTANCEandCHECKPOINTS_Nderive from that constant, so a client of a node that can switch to a second state needs checkpoints back tofin. - 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_HEIGHTis asserted against the bootstrap gap.ZcashCrosslinkParameters::bootstrap_is_validrequiresactivation_height − roster_height > MAX_BLOCK_REORG_HEIGHT, and aconst _: () = assert!onPROTOTYPE_PARAMETERSchecks 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 unwrapsblock_height_from_hashon the decided header, so a decided block whose header is unknown to state terminates the process, as does everyassert!on that path. finis a time series, not a function of the tip (§3.2). Recomputing it fromcandidate(bc_best)after a restart reproduces only the current candidate, which is whyfinis persisted.- Zebra's depth commit is a second floor, per state. Blocks deeper than
MAX_BLOCK_REORG_HEIGHTon the best chain are written to the PoW state's finalized database regardless offin(§4.3, Implementation in Zebra), and a fork-choice rule abovefinoperates 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
+40candidate 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, andterminated_finalizers_athandle steps of any size regardless. - Block status never reports
bft_final_snapshotas finalized. A block at or belowbft_final_snapshotbut abovelocal_finalized_tipisInBestChainorNotInBestChain, becausefinis the view Assured Finality covers (§2). σcomes fromZcashCrosslinkParameters. The GUI'sapply_viz_ophardcodes it asTMP_SIGMA, which matches only whilePROTOTYPE_PARAMETERSis 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, incrosslink_reject_pos_block_that_regresses_the_snapshot,crosslink_reject_pos_block_with_lt_sigma_headersandcrosslink_reject_pos_block_with_unlinked_headers. - Crosslink node tests and
viz_gui. Tests run throughphest.bat zebra-crosslink, andphargo.batenablesviz_guifor that project, which puts winit on the main thread. The node tests inzebrad/tests/crosslink.rsrun headless, so they run withPH_NO_VIZ_GUIset, 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_ZATSper 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_rewardsentries 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
- Original TFL Book:
candidate,fin, and syncing; the same range defines the omittedba_μ - Original TFL Book: parameters
σ,L,μ, and Stalled Mode, of which onlyσis used here - Original TFL Book: liveness argument, including the note that the Finality Depth rule can be omitted
- Original TFL Book: the arguments for bounded availability, the case for the design this tree does not adopt
- Original TFL Book: BFT validity rules
- Original TFL Book: bc validity and honest production
- Original TFL Book: fork-choice question
- Original TFL Book: Linearity and Last Final Snapshot rules combined, including the note that the Questions argument predates Linearity
- Original TFL Book: what the Linearity rule does, the informal safety sketch for both rules
- Original TFL Book: liveness contrast with Casper FFG and the status of the safety argument
- Shielded Labs warning about the adapted construction. Protocol definitions above are cited separately from the original pinned source.
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