Trusted validators, the UNL and negative UNL, and how peers participate in consensus.
What you'll learn
≈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.
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 |
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.
| 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 |
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.
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:
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.
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.
| 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.
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 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.
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 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 = 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.
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.
src/xrpld/app/consensus/RCLConsensus.cpp / .h - XRPL-specific consensus, proposal sharingsrc/xrpld/app/consensus/RCLCxPeerPos.h - peer position representationsrc/xrpld/consensus/Consensus.h - generic consensus template (peerProposalInternal, disputes, phases)src/xrpld/consensus/DisputedTx.h - dispute votingsrc/xrpld/consensus/ConsensusTypes.h / ConsensusParms.h - states, modes, parameterssrc/xrpld/app/misc/NegativeUNLVote.cpp / .h - N-UNL scoring and votingsrc/libxrpl/tx/transactors/system/Change.cpp - applyUNLModifysrc/xrpld/app/misc/detail/ValidatorList.cpp - UNL and quorum managementsrc/xrpld/overlay - OverlayImpl, PeerSet, PeerImpThis 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:
currPeerPositions_ / recentPeerPositions_ track validator positions during a roundDisputedTx) with rising thresholdssrc/xrpld/app/misc (ValidatorList, NegativeUNLVote)Next up. One question remains inside a ledger: who goes first? Next: transaction ordering, and the salt that keeps the answer fair.
Resources
Assignments
0 of 3 complete