Skip to content

Quorum

Overview

The QuorumManager determines when a conflict has been resolved. After each vote is stored, it retallies the conflict to check if any competing block has reached the required 67% of total delegated weight. When quorum is reached, the winning block is confirmed, losers are rejected, and all conflict state is cleaned up.

Quorum Threshold

A block reaches quorum when its accumulated 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
}

The totalWeight used as the denominator comes from the weight snapshot taken at conflict detection time. If no snapshot is available, the current total delegated weight is used as a fallback.

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. It triggers a retally of the referenced conflict.

Process

  1. Look up the conflict record from the ConflictStore
  2. Verify the voted block is part of this conflict's BlockHashes
  3. Acquire the quorum mutex (serializes tallying)
  4. Call tallyConflict() to check for quorum
  5. If quorum reached, call confirmConflict() to finalize

tallyConflict()

Groups all votes for the conflict by BlockHash, summing weights with big.Int:

weightByBlock := make(map[string]*big.Int)
for _, v := range votes {
    weightByBlock[v.BlockHash].Add(weightByBlock[v.BlockHash],
        new(big.Int).SetUint64(v.Weight))
}

Then checks each block against the 67% threshold. Returns a quorumResult if any block qualifies, or nil if quorum has not been reached.

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 or non-participating representatives, the remaining 60% can never reach 67%.

The fallback mechanism resolves this:

const fallbackDelay = 10 * time.Second

Fallback conditions

All three conditions must be met:

  1. The conflict is older than 10 seconds (measured from DetectedAt)
  2. All votes are on a single block (unanimous among actual voters)
  3. At least one vote exists
if len(weightByBlock) == 1 &&
   !conflict.DetectedAt.IsZero() &&
   Now().Sub(conflict.DetectedAt) >= fallbackDelay {
    // resolve with unanimous agreement
}

This ensures that conflicts are eventually resolved even when total participation is low, while still giving sufficient time for all active representatives to vote.

Conflict Confirmation

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

Steps

Step Action Details
1 Guard: already confirmed? If the winner already has StatusConfirmed, skip (idempotent)
2 Acquire account lock Prevents AddBlock from processing children of the loser during the swap
3 Check staging Is the winner in staging? (It was the second block seen)
4 Validate staged winner Re-verify signature, PoW, and balance rules before promoting
5 Swap blocks Demote loser from main chain to staging, promote winner to main chain
6 Confirm winner Set winner status to StatusConfirmed
7 Update confirmation height Record the height of the confirmed block
8 Reject losers Set all loser blocks to StatusRejected
9 Clean up staged blocks Delete all loser blocks from staging
10 Clean up votes Delete all votes for this conflict
11 Remove conflict record Delete the conflict from the ConflictStore

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 conflict position in the account chain. The demoted block is saved to staging before the swap to prevent data loss if a later step fails.

Staged block validation

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

Block Status

Every block has a status that tracks its confirmation state:

type BlockStatus uint8

const (
    StatusPending   BlockStatus = 0  // default — not yet involved in a conflict
    StatusConfirmed BlockStatus = 1  // won a conflict vote
    StatusRejected  BlockStatus = 2  // lost a conflict vote
)
Status Value Meaning
StatusPending 0 Default state. Block has not been involved in a resolved conflict.
StatusConfirmed 1 Block won the conflict vote and is part of the canonical chain.
StatusRejected 2 Block lost the conflict vote and is invalid.

Most blocks stay Pending

The vast majority of blocks never participate in a conflict and remain StatusPending forever. This is fine — Pending does not mean "unconfirmed" in the traditional blockchain sense. It means the block was never contested.

Stale Conflict Sweep

The StartStaleConflictSweep method launches a background goroutine that periodically retallies unresolved conflicts:

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

Behavior

  • Runs every 15 seconds
  • Iterates all conflicts via GetAllConflicts()
  • Skips conflicts younger than fallbackDelay (10 seconds)
  • Retallies each stale conflict via tallyConflict()
  • If quorum or fallback conditions are met, calls confirmConflict()
  • Stops when the stop channel is closed
┌─────────────────────────────────────────────────┐
│                 Sweep Loop                       │
│                                                  │
│  Every 15s:                                      │
│    for each conflict:                            │
│      if age < 10s: skip                          │
│      tallyConflict() → result?                   │
│        yes → confirmConflict(result)             │
│        no  → continue                            │
└─────────────────────────────────────────────────┘

Why sweep?

The sweep handles the case where fallback conditions are met but no new votes arrive to trigger OnVote(). Without the sweeper, a conflict with unanimous votes but below the 67% threshold would never resolve. The sweeper ensures the fallback path eventually fires.

QuorumManager Construction

func NewQuorumManager(
    store QuorumStore,
    conflictStore ConflictStore,
    voteStore VoteStore,
    ledger *Ledger,
) *QuorumManager
Parameter Description
store QuorumStore for block status and confirmation heights
conflictStore ConflictStore for conflict records and staged blocks
voteStore VoteStore for retrieving votes during tallying
ledger Ledger reference for weight lookups, block access, and chain operations

QuorumStore Interface

type QuorumStore interface {
    SetBlockStatus(hash string, status BlockStatus) error
    GetBlockStatus(hash string) (BlockStatus, error)
    SetConfirmationHeight(account string, height uint64) error
    GetConfirmationHeight(account string) (uint64, error)
    DeleteVotesForConflict(account, previous string) error
}
Method Description
SetBlockStatus Update a block's confirmation status
GetBlockStatus Query a block's status (returns StatusPending if not found)
SetConfirmationHeight Record the height of the last confirmed block for an account
GetConfirmationHeight Query the confirmation height (returns 0 if not found)
DeleteVotesForConflict Remove all vote records for a resolved conflict

Concurrency

The QuorumManager uses a single sync.Mutex to serialize all tallying and confirmation operations. This prevents race conditions when multiple votes for the same conflict arrive simultaneously, which could otherwise cause double-confirmation 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.