advanced 60 min

Consensus peers & the UNL

Trusted validators, the UNL and negative UNL, and how peers participate in consensus.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Explain the UNL and the negative UNL.
  • Understand how trust is configured and updated.
  • See how peer positions feed consensus.
Complete this module by mentor review and a quiz. Jump to assessment

Introduction

≈60 min · Advanced · builds on Consensus validations

Watch this short video by XRPL Commons first, then dive into the details below.

Consensus is only as trustworthy as the validators you choose to listen to. In this module you'll learn how trust is configured through the UNL, how the negative UNL keeps the network moving when validators go offline, and how peer positions feed into each round. It's the trust layer beneath the algorithm.


Peer Proposal Handling

In brief: how a peer's proposal is received and, if trusted, counted.

A proposal is a validator's signed suggestion for the next ledger: a transaction set hash, close time information, and the parent ledger it builds on. Three functions hand it along:

Function File Role
RCLConsensus::peerProposal src/xrpld/app/consensus/RCLConsensus.cpp entry point from the network layer; takes the lock, delegates
Consensus::peerProposal src/xrpld/consensus/Consensus.h records the proposal in recentPeerPositions_ (rolling 10 per peer)
Consensus::peerProposalInternal src/xrpld/consensus/Consensus.h the real validation and counting

A peer proposal runs the gauntlet: it is dropped if the phase is already accepted, if it references a different parent ledger, if the peer has bowed out, or if its sequence is not newer; otherwise the peer's position is counted and any missing transaction set is acquired.

Two behaviours matter beyond the checks in the diagram. A bow out (isBowOut()) removes the peer from currPeerPositions_, adds it to deadNodes_, and strips its votes from every open dispute: a clean, explicit exit. And when a proposal references a transaction set the node has not seen, adaptor_.acquireTxSet fetches it from the network so the position can be evaluated.

Trust decides the weight of it all:

Source Counted for consensus? Relayed?
UNL validator yes yes
non-UNL validator never usually (network awareness)

Stale proposals age out after proposeFRESHNESS (20s); duplicates are suppressed network-wide by suppression IDs.

The three tracking structures

Structure Type What it holds
currPeerPositions_ hash_map<NodeID, PeerPosition> each peer's current position: the active set used by every consensus calculation
recentPeerPositions_ hash_map<NodeID, deque<PeerPosition>> the last 10 proposals per peer, for behaviour analysis
deadNodes_ hash_set<NodeID> peers that bowed out; their proposals are ignored for the round

Dispute Management

In brief: when validators disagree on a transaction, it becomes a DisputedTx and gets voted on.

When the local transaction set differs from a peer's, createDisputes() diffs the two sets and creates one DisputedTx per difference; updateDisputes() refreshes each peer's vote whenever their position changes. The class (src/xrpld/consensus/DisputedTx.h) is small:

Method Purpose
setVote(peer, votesYes) record or update a peer's vote (yays_ / nays_)
unVote(peer) remove a bowed-out peer's vote
updateVote(percentTime, proposing, parms) adjust our vote using the avalanche thresholds (they rise with round time, see the Consensus fundamentals module)
stalled(parms, proposing, peersUnchanged) detect that a dispute can no longer flip

Resolution follows the trusted majority under rising avalanche thresholds; a transaction that cannot gather the climbing percentage falls out of the set and retries next round.


Consensus State Transitions

In brief: the establish phase in terms of who has agreed; the Consensus lifecycle & phases module covers the phase mechanics.

Each timer tick in the establish phase runs phaseEstablish(): update timing and proposer counts, enforce the minimum consensus time (1.95s), call updateOurPositions(), then ask haveConsensus().

updateOurPositions() is the heart: it drops stale peer proposals, tallies close-time votes, adjusts our vote on every dispute against the avalanche thresholds, and, if our position changed, shares the new one with the network.

haveConsensus() counts agreeing and disagreeing peers in currPeerPositions_ and maps the situation onto four outcomes:

The four consensus outcomes: No means keep voting, Yes means quorum met and the ledger is accepted, MovedOn means the network advanced without this node, and Expired means the round timed out; the Negative UNL shifts these outcomes by removing reliably-offline validators from the counts.

A node that cannot keep up calls leaveConsensus(): it bows out publicly (if proposing) and continues as an observer, rather than polluting the round with a lagging position.


Network Communication

Proposals travel as TMProposeSet protocol messages. Sharing one is a single relay call with duplicate suppression:

// RCLConsensus::Adaptor::share
app_.overlay().relay(prop, peerPos.suppressionID(), peerPos.publicKey());
Message Carries
TMProposeSet a validator's position (sequence, close time, tx set hash, prev ledger, signature)
TMValidation a signed validation of a closed ledger
TMHaveTransactionSet / TMGetObjectByHash transaction set announcement and acquisition

The overlay (src/xrpld/overlay) provides the plumbing: OverlayImpl broadcasts and relays, PeerSet groups peers for an acquisition, PeerImp speaks the wire protocol. Suppression IDs keep one proposal from echoing around the mesh.

Supporting data structures

Structure File Holds
ConsensusParms consensus/ConsensusParms.h timing (ledgerMinConsensus...), 80% threshold, avalanche cutoffs
ConsensusCloseTimes consensus/ConsensusTypes.h proposed close times from peers and self
ConsensusResult consensus/ConsensusTypes.h tx set, position, disputes, timing, proposer count
ConsensusMode / ConsensusPhase consensus/ConsensusTypes.h proposing/observing/wrongLedger/switchedLedger; open/establish/accepted
RCLCxPeerPos app/consensus/RCLCxPeerPos.h a peer's signed position with suppression ID

The design keeps the generic algorithm (Consensus<Adaptor>) separate from XRPL specifics (RCLConsensus as the Adaptor), which is why the consensus core is readable as a standalone template.


UNL Management

In brief: configuring and updating the list of validators you trust.

The Unique Node List is each node's independently chosen set of trusted validators: the only voices that count in its consensus. The Negative UNL extends it with a safety valve: temporarily discount trusted validators that are reliably offline, without ever removing them from the list.

UNL Negative UNL
What validators you trust subset currently discounted
Chosen by each operator (usually via published lists) the network itself, on-ledger
Duration until you change your config until the validator recovers
Purpose safety: who can convince you liveness: keep closing ledgers

Key idea. The negative UNL lowers the effective quorum when trusted validators are reliably offline, so the network keeps closing ledgers instead of stalling.

The on-ledger object

The Negative UNL lives in the ledger state with three fields: sfDisabledValidators (the current list), sfValidatorToDisable and sfValidatorToReEnable (pending actions). Ledger::negativeUNL(), validatorToDisable(), and validatorToReEnable() expose them.

The voting cycle

Everything is orchestrated by NegativeUNLVote (src/xrpld/app/misc/NegativeUNLVote.cpp), which runs on every flag ledger (every 256 ledgers), alongside fee and amendment voting:

The Negative UNL cycle: buildScoreTable counts each validator's validations, findAllCandidates applies the 50 and 80 percent watermarks, choose picks one candidate deterministically from the previous ledger hash, addTx proposes a ttUNL_MODIFY pseudo-transaction, and applyUNLModify updates the on-ledger object once validated.

The thresholds are compile-time constants:

static constexpr size_t negativeUNLLowWaterMark  = FLAG_LEDGER_INTERVAL * 50 / 100;
static constexpr size_t negativeUNLHighWaterMark = FLAG_LEDGER_INTERVAL * 80 / 100;
static constexpr size_t negativeUNLMinLocalValsToVote = FLAG_LEDGER_INTERVAL * 90 / 100;
static constexpr size_t newValidatorDisableSkip  = FLAG_LEDGER_INTERVAL * 2;
static constexpr float  negativeUNLMaxListed     = 0.25;
Rule Value Meaning
low water mark 50% of the interval validate less than this and you are a disable candidate
high water mark 80% disabled validators above this become re-enable candidates
local vote gate 90% a node only votes if it is itself well synchronized
new validator grace 2 intervals fresh validators cannot be disabled immediately
hard cap 25% of the UNL the N-UNL can never disable more

Details that make the system fair: buildScoreTable() refuses to vote at all when ledger history is insufficient or the local node's own validation count looks wrong; re-enables are prioritized over disables; choose() seeds its selection with the previous ledger hash so every node picks the same candidate with no room for manipulation; and applyUNLModify (in src/libxrpl/tx/transactors/system/Change.cpp) re-verifies everything (flag ledger, fields, sequence, current state) before touching the object.

Quorum dynamics

quorum = max(80% of effective UNL, 60% of total UNL)
effective UNL = total UNL - negative UNL entries

The quorum falls as validators are disabled, which is exactly the point, but the 60%-of-total floor and the 25% cap mean it can never fall far enough to compromise safety.

A worked example

Validator A submits 45 of 100 expected validations. On the next flag ledger: the score table shows 45 < 50 (low water mark); findAllCandidates() lists A; choose(prevLedgerHash) selects A on every node independently; addTx(seq, A, ToDisable, initialSet) injects the ttUNL_MODIFY; the ledger validates; applyUNLModify adds A to the N-UNL. A is now excluded from quorum counts. Weeks later A recovers, validates above 80%, and the same cycle re-enables it. Nobody filed a ticket.


References to Source Code


Summary

This module covered the trust layer beneath consensus. You learned how a node's UNL defines the validators it trusts, how peer proposals from those validators are received and counted, and how the negative UNL temporarily discounts reliably-offline validators, lowering the effective quorum so the network keeps closing ledgers instead of stalling.

To remember:

  • Trust is your UNL (validator list), not your connectivity
  • Proposals from non-UNL validators are relayed but never counted
  • currPeerPositions_ / recentPeerPositions_ track validator positions during a round
  • The negative UNL is an on-ledger object that temporarily discounts reliably-offline trusted validators
  • Effective quorum = 80% of (UNL minus negative-UNL entries)
  • Disputes are tracked per transaction (DisputedTx) with rising thresholds
  • Code: src/xrpld/app/misc (ValidatorList, NegativeUNLVote)
  • Watch out: the negative UNL protects liveness, not safety; it never lowers the quorum below its safety floor

Next up. One question remains inside a ledger: who goes first? Next: transaction ordering, and the salt that keeps the answer fair.

Assignments

0 of 3 complete

Unlocks

Finishing this module opens up:

XRPL Academy © 2026