Quorum

Quorum thresholds and finalization

Overview

The QuorumManager determines when a block at a chain position has been finalized. After each vote is stored, it re-tallies the position's final votes to check whether a candidate has reached the required 67% of total delegated weight. When the threshold is met, the winner is finalized, any losers are rejected, and all conflict state is cleaned up.

It works for contested positions (a conflict record plus a staged sibling) and uncontested ones (no conflict record at all) alike.

[!IMPORTANT] Only final votes finalize Converge votes establish which candidate is leading; they never finalize anything. The tally counts only votes with Final = true.

Quorum Threshold

A block reaches quorum when its accumulated final-vote weight meets or exceeds 67% of the total delegated XE weight:

blockWeight * 100 >= totalWeight * 67

Both sides of the comparison use big.Int arithmetic to prevent overflow:

const (
    quorumNumerator   = 67
    quorumDenominator = 100
)

bigThreshold := new(big.Int).Mul(bigTotal, big.NewInt(quorumNumerator))
bigBlock := new(big.Int).Mul(blockWeight, big.NewInt(quorumDenominator))
if bigBlock.Cmp(bigThreshold) >= 0 {
    // quorum reached
}

On a contested position the totalWeight denominator comes from the weight snapshot taken at fork-detection time. Uncontested positions have no snapshot, so the current total delegated weight is used.

[!IMPORTANT] Snapshotted denominator Using the snapshotted total weight prevents an attacker from inflating the denominator after conflict detection (e.g. by creating new delegations) to make quorum harder to reach.

Tallying: OnVote()

OnVote is called by the VoteManager after each successfully stored vote — locally cast or received. It acquires the quorum mutex, re-tallies the vote's position via finalTally(), and calls confirmConflict() if a winner emerged.

finalTally()

finalTally sums final-vote weight per candidate hash, but only for candidates that survive two filters:

  1. Body held — the node must hold the block (on chain or in staging). A validly-signed final vote for a fabricated hash cannot enter the tally; an unheld hash could never be finalized anyway, since finalization needs the body.
  2. Real sibling at this root — the loaded body's Account and Previous must match the position. Without this, a final vote for a held block at the wrong position (say an already-finalized ancestor of the same account) could hijack the election.

Because both filters are applied, finalTally also builds a candidate set from the votes plus the conflict record, so every non-winner gets rejected when a winner emerges.

Deterministic winner selection

Only the lowest-hash final-voted candidate may finalize on the 67% path. The lowest hash is identical on every node, whereas per-node weight snapshots can drift — so iterating the map and returning the first candidate over threshold could let two honest nodes finalize different siblings. A node that sees the lowest-hash candidate below threshold withholds rather than finalizing a higher-hash sibling.

One refinement: if the deterministic choice cannot actually be promoted (it fails full validation against the now-finalized parent), the tally substitutes the lowest-hash promotable candidate instead. That substitution is only trusted once the parent is finalized, because only then is the validation context frozen and the substitution a pure function of state every node shares.

Fallback Resolution

In some cases the 67% threshold is unreachable because non-voting representatives inflate the total weight. For example, if 40% of weight belongs to offline representatives, the remaining 60% can never reach 67%.

Once the election has been open longer than fallbackDelay — measured from DetectedAt on a fork, or from the oldest final vote on an uncontested position — a lower bar applies:

const fallbackDelay = 10 * time.Second

[!INFO] Fallback conditions The lowest-hash candidate carrying final votes is finalized if, and only if, it holds a strict majority (>50%) of the total delegated weight in final votes.

Two independent guards make this safe, each sufficient on its own:

  • Determinism — the lowest hash is identical on every node, so two honest nodes can never finalize different siblings. Each either finalizes this same candidate or withholds.
  • Strict majority — because each representative is commit-locked to at most one hash per position, two candidates can never each gather >50% of final weight. At most one block clears the bar; a position backed by ≤50% stays a recoverable liveness stall rather than finalizing behind the unhealable rollback wall.

Note that this deliberately does not require a single candidate. A commit-lock split — where a minority locks an earlier-propagating sibling before the lower-hash winner arrives and the majority locks that — leaves two candidates with final votes and neither at 67%. A "unanimous voters" rule would stall that forever.

The wedge-breaker

There is one provably-wedged shape the fallback cannot clear: a majority irrevocably commit-locked a higher-hash sibling first, so the lowest-hash candidate can never reach >50%, and the higher-hash one is never a fallback target. When a strict majority of total weight is already final-voted onto non-lowest candidates, the lowest provably cannot win, so the position is resolved by finalizing the lowest-hash final-voted candidate anyway.

This is safe under weight drift for the same reason as the fallback — the winner is the lowest hash, identical everywhere — and the trigger is monotonic in the irrevocable final-vote set, so every node eventually fires it. It trades "weight-majority wins" for liveness plus determinism in a case that would otherwise stall permanently; the sibling the majority locked is finalized nowhere.

If neither bar is met the tally logs a withholding message and returns nothing. Withholding is always the correct answer: an unresolved position is recoverable, a divergent finalization is not.

Conflict Confirmation

confirmConflict() finalizes a resolved conflict. This is a multi-step process that modifies the main chain.

Steps

StepActionDetails
1Guard: already finalized?If the winner is already at or below the account's final height, run the idempotent cleanup and return
2Acquire account lockPrevents AddBlock from processing children of the loser during the swap
3Check stagingIs the winner in staging? (It was the second block seen)
4Validate staged winnerRe-verify signature, PoW, and semantic balance rules before promoting
5Swap or appendSwap the on-chain loser out for the winner — or, if no candidate is on chain, append the winner if it extends the frontier
6Advance final heightRecord the winner's height as the account's final-height watermark (monotonic)
7Finalize winnerSet winner status to StatusFinalized
8Reject losersSet all loser blocks to StatusRejected
9Clean up staged blocksDelete winner and loser blocks from staging
10Clean up votesDelete all votes for this position
11Remove conflict recordDelete the conflict from the ConflictStore
12Re-drive dependentsFire OnFinalized asynchronously so positions that depended on this block vote immediately

[!IMPORTANT] The watermark advances before the cleanup Step 6 runs immediately after the swap, before the fallible cleanup steps. If a later step failed with the watermark unadvanced, the position would wedge on retry. Once the cleanup begins (steps 8–11) no individual failure aborts it either: leaving a conflict record behind is a permanent account wedge, which is far worse than a loser block carrying a stale status byte — so every step logs on error and continues, and the conflict record is always removed.

Block Swap

If the winning block is in staging (it was the second block seen, not the one on the main chain), a swap is required:

Before:
  Main chain: ... ← Block 2 ← Block 3a (loser, on main)
  Staging:    Block 3b (winner, staged)

After:
  Main chain: ... ← Block 2 ← Block 3b (winner, promoted)
  Staging:    Block 3a (loser, demoted)

The swap is performed by swapBlockLocked() on the ledger, which replaces the block at the position in the account chain. The demoted block is saved to staging before the swap so it is preserved even if a later step fails.

If no candidate is on the main chain — the losing sibling was already rolled back off-chain by a cascade — there is nothing to swap. In that case the winner is appended through the normal validate-and-add path if it extends the current frontier; if a dependency is not ready yet, it stays staged and the next sweep retries.

[!WARNING] Staged block validation Before promoting a staged block, confirmConflict re-validates it: signature verification, PoW validation (if enabled), and semantic balance checks against the parent balance. A staged block that fails validation is refused promotion — the position remains unresolved rather than accepting an invalid block.

A winner that passed the pre-check but then fails full validation deterministically (for example a provider certificate that expired between staging and promotion) is quarantined for 30 seconds, so the next sweep substitutes a promotable candidate instead of re-selecting it forever. Only deterministic rejects are quarantined: quarantining a transient, node-local failure could make two nodes substitute different winners and fork finality.

Cascading Rollback

Resolution is not always confined to one account. If the losing block was a send that another account already received — and possibly spent onward — removing it must also unwind that receive and everything built on it, or the recipient keeps a credit for funds that were never sent.

The rollback is planned before anything is mutated. The planner walks the loser's whole forward cone across accounts and refuses the cascade outright if it would touch a finalized block (the finality wall) or a block type whose side-effects cannot be reversed. Only if the whole plan is safe is any of it applied — so an unsafe cascade is refused cleanly, with no partial state.

Cross-account cycles are handled rather than refused: the walk dedups by block hash, so it terminates on a cycle and emits each block exactly once, in reverse causal order. The whole plan is then committed together with the winner in a single store transaction, so a crash mid-promotion cannot strand an account half-promoted.

[!DANGER] Finality is a hard wall swapBlockLocked refuses to replace any block at or below its account's final-height watermark, and the cascade planner refuses to roll one back. In correct operation this is unreachable — a position is only finalized once its fork, if any, is resolved. It exists as the structural backstop that makes "finalized ⇒ never reorged" a guarantee rather than an expectation.

Block Status

Every block has a status that tracks its finalization state:

type BlockStatus uint8

const (
    StatusPending   BlockStatus = 0
    StatusFinalized BlockStatus = 1
    StatusRejected  BlockStatus = 2
)
StatusValueMeaning
StatusPending0Default state — no finalization outcome has been recorded for this block yet.
StatusFinalized1The block won its election and is part of the canonical chain.
StatusRejected2The block lost its election and is invalid.

[!NOTE] The watermark is the authority The per-account final height is what IsFinalized and the finality wall consult; the status byte is a per-block record of the outcome. confirmConflict's idempotency guard is height-based for exactly this reason: a status byte alone could be set for a block that is not yet on this node's chain, without the watermark having advanced.

Stale Conflict Sweep

StartStaleConflictSweep launches a background goroutine that periodically works through unresolved conflicts:

func (qm *QuorumManager) StartStaleConflictSweep(stop <-chan struct{})

It runs every 15 seconds until the stop channel is closed, and prunes orphaned conflict records once immediately at startup (before the first tick) so a record left by a pre-restart cascade is cleared even on a cold sync.

For each conflict, in order:

CheckAction
Resolved but lingeringThe position is already finalized locally but the record persisted. Run the idempotent cleanup directly and move on.
Orphaned by rollbackEvery candidate has left the account's canonical chain because a cross-account cascade unwound the chain segment it sat on. Drop the record — it can never resolve.
Phantom eviction (age ≥ 30s)Some sibling body was never received. Request the missing bodies from peers, trim what is unrecoverable, and only fall through to voting once every body is present.
Too young (age < 10s)Skip.
Re-drive + re-tallyCall RevoteFn (outside the quorum lock) to re-drive two-phase voting, then finalTally and, on a result, confirmConflict.
Still unresolvedCall VotePullFn to pull final votes for the position from peers.

[!INFO] Why sweep? The sweep is the liveness backstop. It fires the fallback path when no new votes arrive to trigger OnVote(), retries a vote that was withheld because a dependency was not yet finalized, and drives the phantom and vote pulls that unstick a node holding an incomplete view.

Why the two drop paths exist

Both drop paths clear a conflict record that can never resolve on its own. A conflict record is not cosmetic: while one exists for an account, that account rejects new blocks (children of a loser would be orphaned by the swap), so a stuck record freezes the account on that node.

  • Resolved but lingering. Normal cleanup runs from confirmConflict, which needs the tally to still see a quorum of votes. Once the network finalizes the winner and the post-finalization cleanup deletes everyone's votes, a node that finalized the winner but failed to clean its own record can never re-confirm — and it drops inbound votes because the position is already resolved. A finalized child is irrevocable, so running the cleanup directly is always correct.
  • Orphaned by rollback. The predicate is deliberately narrow so it never touches a live phantom conflict: the parent position must be off the canonical chain, no candidate may be on chain, and at least one candidate must be StatusRejected — a block this node definitively rolled back. A phantom's bodies were never applied here, so they are never rejected.

Phantom handling

A phantom is a hash that entered a conflict record (via gossip or a vote) whose body this node does not hold. The sweep asks peers for those bodies by hash, then splits phantoms by whether they still carry converge-vote weight:

  • A live phantom (some weighted representative prefers it) is retained and kept under the pull. Trimming one would strand it: the sweep only re-requests hashes still in the record, so a single missed pull would become permanent while the persisted votes keep that candidate the preferred winner.
  • A dead phantom (no vote weight backs it) is trimmed from the record.

If every candidate is a dead phantom, the whole conflict is dropped so the account is not permanently wedged. If a live phantom stays unretrievable for 10 minutes — far longer than the eviction delay, so a genuinely in-flight body has many sweeps to arrive — and at least one real candidate remains, it is reclassified as droppable and the survivor is routed through the weight-gated tally.

[!DANGER] Eviction never finalizes on its own authority Being the only body a node can load is not evidence the network agrees on it — under an equivocation race the "phantom" is another node's real sibling, which that node may have finalized. Phantom eviction therefore only trims the record and defers to finalTally, which finalizes only on a real ≥67% quorum or the strict-majority fallback.

QuorumManager Construction

func NewQuorumManager(
    store QuorumStore,
    conflictStore ConflictStore,
    voteStore VoteStore,
    ledger *Ledger,
) *QuorumManager
ParameterDescription
storeQuorumStore for block status and per-account final heights
conflictStoreConflictStore for conflict records and staged blocks
voteStoreVoteStore for retrieving votes during tallying
ledgerLedger reference for weight lookups, block access, and chain operations

Optional callbacks

FieldPurpose
RevoteFnRe-drives two-phase voting for a position during the sweep (typically VoteManager.CastVote). Called outside the quorum lock to preserve lock order.
OnFinalizedFired asynchronously after a block finalizes, to immediately re-drive positions that depend on it (typically VoteManager.SweepFrontiers).
PhantomPullFnRequests missing sibling bodies by hash from peers. Called under the quorum lock, so the pull itself runs asynchronously.
VotePullFnRequests final votes for a stalled position from peers. Called outside the quorum lock.

QuorumStore Interface

type QuorumStore interface {
    SetBlockStatus(hash string, status BlockStatus) error
    GetBlockStatus(hash string) (BlockStatus, error)
    SetFinalHeight(account string, height uint64) error
    GetFinalHeight(account string) (uint64, error)
    DeleteVotesForConflict(account, previous string) error
}
MethodDescription
SetBlockStatusUpdate a block's finalization status
GetBlockStatusQuery a block's status (returns StatusPending if not found)
SetFinalHeightRecord the account's final-height watermark
GetFinalHeightQuery the final-height watermark (returns 0 if not found)
DeleteVotesForConflictRemove all vote records at a resolved position

Concurrency

The QuorumManager uses a single sync.Mutex to serialize all tallying and finalization. This prevents race conditions when multiple votes for the same position arrive simultaneously, which could otherwise cause double-finalization or inconsistent state.

During confirmConflict(), the per-account lock is also acquired to prevent AddBlock from processing new blocks on the affected account while the chain is being modified.

Lock order is per-position lock → quorum lock. The sweep therefore calls RevoteFn and VotePullFn outside the quorum lock, since those take the per-position lock themselves.

  • Consensus Overview — the full finalization lifecycle
  • Delegation — weight calculation and snapshots
  • Conflict Detection — how conflicts are discovered and staged
  • Voting — how converge and final votes are cast and validated
  • StorageQuorumStore and ConflictStore implementations
  • Sync Protocol — how blocks (but not finalization state) reach a lagging node