How validators vote on amendments and how the network coordinates activation.
What you'll learn
≈90 min · Intermediate · builds on The amendment lifecycle
Amendments live or die by how validators vote and how the network coordinates the switch. In this module you'll learn to configure voting with VoteBehavior, how votes are tallied across the network, and the real-world concerns (backward compatibility, non-updated nodes, contingency plans) of flipping a protocol change on safely. It's the operational side of governance.
In brief: votes ride along with validations over the overlay.
The primary coordination mechanism for amendments is the propagation of validations across the overlay network.
Network structure:
Propagation flow:
Validations are broadcast via XRPL's Peer Protocol:
TMValidation Message:
message TMValidation {
required bytes validation = 1; // Serialized and signed validation
optional bytes cookie = 2; // Tracking cookie
}
Processing by receiving node:
void PeerImp::onValidation(
std::shared_ptr<protocol::TMValidation> const& m)
{
// Deserialize the validation
auto validation = std::make_shared<STValidation>(
SerialIter{m->validation().data(), m->validation().size()},
[](PublicKey const& pk) { return calcNodeID(pk); });
// Verify signature
if (!validation->isValid())
return;
// Extract amendments
auto const amendments = validation->getAmendments();
// Pass to validation handler
app_.getValidations().addValidation(validation);
// Relay to other peers (except sender)
overlay_.relay(m, validation->getNodeID());
}
To avoid message flooding, the overlay network suppresses duplicates (and, separately, squelches redundant validator-message senders via TMSquelch):
Principle: A node only propagates a message if it hasn't seen it yet
bool Overlay::relay(
std::shared_ptr<Message> const& m,
NodeID const& source)
{
// Calculate message hash
auto const hash = sha512Half(m->SerializeAsString());
std::lock_guard lock(dedupMutex_);
// Check if already seen
if (seenMessages_.count(hash))
return false; // Don't propagate
// Mark as seen
seenMessages_.insert(hash);
// Propagate to all peers except source
for (auto const& peer : peers_) {
if (peer->getNodeID() != source)
peer->send(m);
}
return true;
}
Entry expiration: suppression entries expire after a few minutes to free memory.
In brief: how a protocol change is rolled out safely across the network.
6-8 weeks before release: Public announcement of the proposed amendment
Communication channels:
Objective: Distribute code before voting begins
Typical timeline:
Progressive deployment strategy:
Communication to validators:
Validators receive notifications to activate their vote:
Note. The "Subscriptions" amendment used in the examples below is a hypothetical running example (not a real XRPL amendment); the notification and command output are illustrative of the process, not real records.
# Email/notification to validator operators
Subject: Ready to vote for Subscriptions amendment
The Subscriptions amendment (XLS-0078) is ready for voting.
Release: rippled 1.12.0
Amendment hash: 7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8
To activate your vote:
xrpld feature 7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8 accept
Or add to xrpld.cfg:
[amendments]
7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8
Informal coordination: Validators communicate via:
2 weeks of final preparation:
Once the 80% threshold is exceeded, node operators receive alerts:
[WARNING] Amendment 7B73B9E8... (Subscriptions) has reached majority.
It will activate in approximately 14 days (on 2025-05-04 14:32:15 UTC).
If your node does not support this amendment, please upgrade to rippled 1.12.0 or later immediately.
Current version: 1.11.0
Amendment support: NO
Upgrade urgency: CRITICAL
Required actions:
Moment of truth: The activation day
In brief: what happens to nodes that have not upgraded in time.
Nodes can detect that they will soon be blocked:
void NetworkOPsImp::checkAmendmentStatus() {
auto const expected = app_.getAmendmentTable().firstUnsupportedExpected();
if (expected) {
auto const now = app_.timeKeeper().closeTime();
auto const timeUntil = *expected - now;
if (timeUntil < 24h) {
// CRITICAL: Less than 24h
JLOG(j_.fatal())
<< "CRITICAL: Unsupported amendment will activate in "
<< timeUntil.count() << " seconds. UPGRADE NOW!";
// Send alert to operator
sendOperatorAlert("CRITICAL_AMENDMENT_WARNING");
}
else if (timeUntil < 7days) {
// ERROR: Less than a week
JLOG(j_.error())
<< "ERROR: Unsupported amendment will activate in "
<< std::chrono::duration_cast<std::chrono::days>(timeUntil).count()
<< " days. Please upgrade soon.";
}
else if (timeUntil < 14days) {
// WARNING: In majority window
JLOG(j_.warn())
<< "WARNING: Unsupported amendment will activate in "
<< std::chrono::duration_cast<std::chrono::days>(timeUntil).count()
<< " days.";
}
}
}
For validators:
If a validator doesn't have time to update before activation:
// Option 1: Withdraw vote to delay activation
xrpld feature 7B73B9E8... reject
// Option 2: Temporarily withdraw from UNL
// (requires coordination with other validators)
For API nodes/Exchanges:
In case of major problem detected during majority period:
Channel #xrpl-validators (private):
[URGENT] Issue detected with Subscriptions amendment.
Potential impact: [description]
Recommendation: All validators please REJECT the amendment immediately.
Command:
xrpld feature 7B73B9E8... reject
Quick coordination: Validators can withdraw their votes to drop support below 80% and avoid automatic activation.
Watch out. When an amendment activates, a node that does not understand it becomes amendment-blocked and stops validating. Operators must upgrade before activation, not after.
Cannot disable an amendment: There is no rollback mechanism.
Response strategies:
// In doApply(), add temporary guard
TER RecurringPaymentClaim::doApply() {
// EMERGENCY FIX: Disable temporarily until proper fix
if (view().info().seq < EMERGENCY_DISABLE_LEDGER) {
return temDISABLED;
}
// Normal logic...
}
XRPL_FIX(fixSubscriptionsBug, Supported::yes, VoteBehavior::DefaultYes)
Symptom: Two parts of the network activate the amendment at different times or with different results.
Detection:
// Monitoring for forks
void LedgerMaster::checkForFork() {
auto const ourLedger = getValidatedLedger();
auto const peerLedgers = getPeerLedgerHashes();
int matchingCount = 0;
for (auto const& peerHash : peerLedgers) {
if (peerHash == ourLedger->getHash())
matchingCount++;
}
if (matchingCount < peerLedgers.size() * 0.5) {
JLOG(j_.fatal())
<< "Potential network fork detected! "
<< "Our ledger does not match majority of peers.";
}
}
Resolution:
Symptom: Support oscillates around 80%, amendment gains and loses majority multiple times.
Impact:
tfGotMajority and tfLostMajority pseudo-transactionsPrevention:
Operators monitor several metrics during the activation process:
1. Vote Count: Number of validators voting for the amendment
# Regular query
watch -n 60 'xrpld feature 3B95AC15... | jq ".result.count"'
2. Network Consensus: Consensus percentage achieved
xrpld server_info | jq ".info.server_state"
# States: proposing, validating, full, connected
3. Peer Count: Number of connected peers
xrpld peers | jq ".result.peers | length"
4. Ledger Progress: Verify ledgers are progressing normally
watch -n 10 'xrpld server_info | jq ".info.validated_ledger.seq"'
XRPL Metrics Dashboard (conceptual example):
Automatic alert configuration:
# alerts.yml
alerts:
- name: amendment_majority_reached
condition: amendment.status == "MAJORITY"
action: notify_operators
channels: [email, slack, pagerduty]
- name: amendment_activation_imminent
condition: amendment.activation_eta < "24h"
action: critical_alert
channels: [pagerduty, sms]
- name: unsupported_amendment_warning
condition: node.unsupported_amendment_active == true
action: block_node
channels: [email, slack, pagerduty]
Exchanges receive notifications and must coordinate:
Typical timeline for an exchange:
Client libraries must be updated before activation:
xrpl.js:
// Version 2.12.0 (with Subscriptions)
export const TRANSACTION_TYPES = {
// ... existing types
SubscriptionSet: 'SubscriptionSet',
SubscriptionClaim: 'SubscriptionClaim',
SubscriptionCancel: 'SubscriptionCancel',
};
xrpl-py:
# Version 2.8.0
class TransactionType(str, Enum):
# ... existing types
SUBSCRIPTION_SET = "SubscriptionSet"
SUBSCRIPTION_CLAIM = "SubscriptionClaim"
SUBSCRIPTION_CANCEL = "SubscriptionCancel"
Documentation updates:
Public announcements:
# Subscriptions Amendment Activated
Date: 2025-05-04 14:32:15 UTC
Ledger: 86652345
The Subscriptions amendment (XLS-0078) has been successfully activated on the XRPL Mainnet.
## What's New:
- New transaction types: SubscriptionSet, SubscriptionClaim, SubscriptionCancel
- New ledger object: ltSUBSCRIPTION (0x0055)
- Programmable recurring payment subscriptions with XRP, IOUs, and MPTs
## For Developers:
- Update to rippled 1.12.0+
- Update xrpl.js to 2.12.0+
- Update xrpl-py to 2.8.0+
## Documentation:
- [Subscriptions Standard (XLS-0078)](https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0078-subscriptions)
- [API Reference](https://xrpl.org/api-reference.html)
Problem: Asymmetry in threshold calculation (rounding up instead of down).
Impact: In certain cases, the effective threshold was slightly higher than 80%.
Resolution: New amendment to correct the calculation.
Lesson: Even simple mathematical calculations must be rigorously tested.
Success: First major amendment adding significant functionality.
Timeline:
Lesson: Extensive preparation and rigorous testing are essential for complex amendments.
The voting and activation mechanism for amendments is at the heart of XRPL's decentralized governance. This system allows the network to make collective decisions on protocol changes without a central authority, while ensuring a high level of consensus before any modification.
In this section, we will dive into the technical details of the voting process: how validators express their preferences, how these votes are collected and aggregated, how the threshold (over 80%) is calculated and verified, and finally how the network automatically activates amendments once consensus is reached.
We will continue to follow the example of Subscriptions (XLS-0078) to concretely illustrate each mechanism.
In brief: how a validator configures and casts its vote.
A validator expresses their vote for an amendment in several ways:
1. Static configuration: Via the xrpld.cfg configuration file:
[amendments]
# Vote for Subscriptions
7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8
# Vote against (or remove vote) by commenting out or removing the line
# 7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8
2. Dynamic RPC command: Via the admin interface:
# Enable voting
xrpld feature 7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8 accept
# Disable voting
xrpld feature 7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8 reject
3. Default vote: If no explicit configuration is provided, the default behavior defined in features.macro applies:
// Automatically vote for
XRPL_FEATURE(Tickets, Supported::yes, VoteBehavior::DefaultYes)
// Don't vote by default (requires explicit activation)
XRPL_FEATURE(Subscriptions, Supported::yes, VoteBehavior::DefaultNo)
// Never vote (obsolete)
XRPL_FEATURE(OldFeature, Supported::yes, VoteBehavior::Obsolete)
A validator's vote is communicated to the network via the validation messages they publish after validating each ledger.
Structure of a validation message:
class STValidation {
// Identification of validated ledger
uint256 ledgerHash_;
uint32 ledgerSeq_;
NetClock::time_point signTime_;
// List of supported/desired amendments
std::optional<std::vector<uint256>> amendments_;
// Validator's public key
PublicKey publicKey_;
// Cryptographic signature
Buffer signature_;
};
Generation of amendments list: This list is generated by the doValidation() function of AmendmentTable:
std::vector<uint256>
AmendmentTableImpl::doValidation(std::set<uint256> const& enabled) const
{
// Get the list of amendments we support and do not
// veto, but that are not already enabled
std::vector<uint256> amendments;
{
std::lock_guard lock(mutex_);
amendments.reserve(amendmentMap_.size());
for (auto const& e : amendmentMap_)
{
// Include only if:
// 1. Amendment is supported by this node
// 2. Node votes "up" for the amendment
// 3. Amendment is not already enabled
if (e.second.supported && e.second.vote == AmendmentVote::up &&
(enabled.count(e.first) == 0))
{
amendments.push_back(e.first);
JLOG(j_.info()) << "Voting for amendment " << e.second.name;
}
}
}
if (!amendments.empty())
// Sort to ensure deterministic order
std::sort(amendments.begin(), amendments.end());
return amendments;
}
Network broadcast: The signed validation message is broadcast via the overlay network to all connected peers. Each node that receives this validation extracts and stores the list of voted amendments.
The TrustedVotes class maintains a real-time count of amendment votes from trusted validators.
Internal structure:
class TrustedVotes {
// per-validator cached amendment votes, refreshed by each validation
// and expired after 24h of silence (expiresAfter = 24h)
hash_map<PublicKey, UpvotesAndTimeout> recordedVotes_;
};
The cache-and-expire rationale (no vote flapping, offline validators stop counting) is covered in the Amendments, overview & architecture module; this module focuses on how those votes travel the wire.
Expiration mechanism: Votes are considered "fresh" for 5 minutes. If a validator doesn't publish a new validation within this time, their vote is removed from the count. This ensures counts reflect the current state of active validators.
The AmendmentSet class calculates which amendments have exceeded the required threshold for activation.
Construction:
class AmendmentSet {
hash_map<uint256, int> votes_; // tally per amendment
int trustedValidations_; // how many validators we heard from
int threshold_; // max(1, floor(80% of trusted))
// passes(): votes > threshold_ (>= when only 1 trusted validator)
};
Construction, rounding, and the exact passes() semantics are detailed in the Amendments, overview & architecture module.
Threshold calculation (over 80%):
The displayed threshold is calculated as floor(validations * 0.8), but the comparison uses votes > threshold (strictly greater), which ensures over 80% of validators vote for the amendment.
Examples with Subscriptions:
25 trusted validators:
threshold = floor(25 * 0.8) = floor(20.0) = 20
To pass: votes > 20, so minimum 21 votes required
Actual percentage: 21/25 = 84% > 80% ✓
26 validators:
threshold = floor(26 * 0.8) = floor(20.8) = 20
To pass: votes > 20, so minimum 21 votes required
Actual percentage: 21/26 = 80.77% > 80% ✓
35 validators:
threshold = floor(35 * 0.8) = floor(28.0) = 28
To pass: votes > 28, so minimum 29 votes required
Actual percentage: 29/35 = 82.86% > 80% ✓
Special case - 1 validator: With a single validator (test networks), the comparison becomes votes >= threshold instead of votes > threshold, allowing the single validator to activate the amendment.
The doVoting() function is called at each consensus round (before each flag ledger) to determine what actions to take for amendments.
std::map<uint256, std::uint32_t>
AmendmentTableImpl::doVoting(Rules const& rules, /* validations, majorities */)
{
// 1. update TrustedVotes from the latest validations (24h expiry)
// 2. build an AmendmentSet and compute the 80% threshold
// 3. for each amendment not yet enabled, compare tally vs sfMajorities:
// newly passing -> action = tfGotMajority
// newly failing -> action = tfLostMajority
// 2 weeks held -> action = 0 (enable)
return actions; // consumed by the flag ledger's pseudo-transactions
}
(The step-by-step version of this algorithm is in the Amendments, overview & architecture module.)
The returned map associates each amendment with an action code:
tfGotMajority (0x00010000): Amendment exceeded the 80% thresholdtfLostMajority (0x00020000): Amendment fell below the threshold0: Amendment must be activated (stability period elapsed)These codes are used to generate appropriate pseudo-transactions.
Pseudo-transactions are generated by the consensus engine in RCLConsensus::onAccept() after a ledger has been accepted.
Process:
RCLConsensus::Adaptor::onAccept(
Result const& result,
RCLCxLedger const& prevLedger,
NetClock::duration const& closeResolution,
ConsensusCloseTimes const& rawCloseTimes,
ConsensusMode const& mode,
Json::Value&& consensusJson,
bool const validating)
{
app_.getJobQueue().addJob(
jtACCEPT,
"acceptLedger",
[=, this, cj = std::move(consensusJson)]() mutable {
// Note that no lock is held or acquired during this job.
// This is because generic Consensus guarantees that once a ledger
// is accepted, the consensus results and capture by reference state
// will not change until startRound is called (which happens via
// endConsensus).
RclConsensusLogger clog("onAccept", validating, j_);
this->doAccept(
result,
prevLedger,
closeResolution,
rawCloseTimes,
mode,
std::move(cj));
this->app_.getOPs().endConsensus(clog.ss());
});
}
Pseudo-transaction properties:
ttAMENDMENTrrrrrrrrrrrrrrrrrrrrrhoLvTp (special account = AccountID(0))EnableAmendment pseudo-transactions are processed by a specialized function in Change.cpp:
TER Change::applyAmendment()
{
// 1. tfGotMajority -> record the amendment + close time in sfMajorities
// 2. tfLostMajority -> remove it from sfMajorities
// 3. no flag -> two weeks have passed: move the hash into
// sfAmendments, run any activation handler, and
// notify the AmendmentTable (unsupported -> blocked)
// Duplicate activation returns tefALREADY.
}
The full walkthrough of this function lives in the Amendments, overview & architecture module; here what matters is the operational effect: the flag ledger's pseudo-transaction is what actually flips the switch.
Ledger Amendments Object:
{
"LedgerEntryType": "Amendments",
"Amendments": [
"42426C4D4F1009EE67080A9B7965B44656D7714D104A72F9B4369F97ABF044EE",
// ... other enabled amendments ...
"7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8" // Subscriptions
],
"Majorities": [
{
"Majority": {
"Amendment": "AAAA...", // Another amendment pending
"CloseTime": 805500000
}
}
],
"Flags": 0,
"index": "7DB0788C020F02780A673DC74757F23823FA3014C1866E72CC4CD8B226CD6EF4"
}
Modification during GotMajority: Amendment is added to sfMajorities with its CloseTime.
Modification during Activation: Amendment is moved from sfMajorities to sfAmendments.
Let's review the complete timeline with voting details:
State before:
{
"count": 27, // 27/35 validators = 77%
"threshold": 28, // threshold = floor(35 * 0.8) = 28
"enabled": false,
"majority": null // Not yet in majority
}
A 29th validator activates their vote. The next flag ledger detects the threshold crossing.
Votes collected by TrustedVotes:
trustedValidations = 35
votes[Subscriptions] = 29 // Exceeds threshold of 28
threshold = max(1, (35 * 4) / 5) = 28
Verification: votes > threshold → 29 > 28 (82.9% > 80%)
doVoting() returns:
actions[Subscriptions] = tfGotMajority
Injected pseudo-transaction:
{
"TransactionType": "EnableAmendment",
"Account": "rrrrrrrrrrrrrrrrrrrrrhoLvTp",
"Amendment": "7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8",
"Flags": 65536,
"LedgerSequence": 86245789,
"hash": "ABC123..."
}
State after:
{
"count": 29, // 29/35 = 82.9% > 80% ✓
"threshold": 28, // floor(35 * 0.8) = 28
"enabled": false,
"majority": 806021535, // ← CloseTime of ledger 86245789
"name": "Subscriptions"
}
Stability period. At each flag ledger, doVoting() checks:
if (closeTime >= (majorityTime + 2weeks)) {
// Ready for activation
} else {
// Wait longer
}
Exactly 2 weeks after majorityTime. The next flag ledger activates the amendment.
doVoting() returns:
actions[Subscriptions] = 0 // Code 0 = activate
Injected pseudo-transaction:
{
"TransactionType": "EnableAmendment",
"Account": "rrrrrrrrrrrrrrrrrrrrrhoLvTp",
"Amendment": "7B73B9E8D8E6E8E8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8A8B8C8D8E8F8",
"Flags": 0, // ← No flag = activation
"LedgerSequence": 86652345,
"hash": "DEF456..."
}
Change::applyAmendment() adds Subscriptions to sfAmendments.
Final state:
{
"enabled": true,
"name": "Subscriptions",
"supported": true
}
After each validated ledger, doValidatedLedger() synchronizes the internal state:
void
AmendmentTableImpl::doValidatedLedger(
LedgerIndex ledgerSeq,
std::set<uint256> const& enabled,
majorityAmendments_t const& majority)
{
for (auto& e : enabled)
enable(e);
std::lock_guard lock(mutex_);
// Remember the ledger sequence of this update.
lastUpdateSeq_ = ledgerSeq;
// Since we have the whole list in `majority`, reset the time flag, even
// if it's currently set. If it's not set when the loop is done, then any
// prior unknown amendments have lost majority.
firstUnsupportedExpected_.reset();
for (auto const& [hash, time] : majority)
{
AmendmentState& s = add(hash, lock);
if (s.enabled)
continue;
if (!s.supported)
{
JLOG(j_.info()) << "Unsupported amendment " << hash
<< " reached majority at " << to_string(time);
if (!firstUnsupportedExpected_ || firstUnsupportedExpected_ > time)
firstUnsupportedExpected_ = time;
}
}
if (firstUnsupportedExpected_)
firstUnsupportedExpected_ = *firstUnsupportedExpected_ + majorityTime_;
}
This function ensures that even if a node temporarily misses receiving a ledger, it will resynchronize correctly when it receives the validated ledger.
If an amendment is enabled but the node does not support it, the system enters "amendment blocked" mode to protect network integrity.
Detection:
if (app_.getAmendmentTable().hasUnsupportedEnabled())
{
JLOG(m_journal.error()) << "One or more unsupported amendments "
"activated: server blocked.";
app_.getOPs().setAmendmentBlocked();
}
Consequences:
Notification: A warning is displayed in logs and via the server_info API:
{
"info": {
"amendment_blocked": true,
"build_version": "1.11.0"
}
}
This module covered the operational side of governance: how validators cast their vote (configured with VoteBehavior), how votes are tallied per ledger from validations, and how a protocol change is rolled out safely across the network. The recurring risk is the non-updated node, which becomes amendment-blocked when a change it does not understand activates, so operators must upgrade before activation, not after.
To remember:
feature <hash> accept|reject (admin) or the config, on top of the code's VoteBehavior defaultTrustedVotes)floor(validations * 0.8), compared strictly greater, so support must EXCEED 80%doVoting orchestrates the pseudo-transaction injectionamendment_blocked in server_infoNext up. The rollout succeeded; now keep it observable. Next: amendment impact and monitoring, the operator's view of an activation.
Resources
Assignments
0 of 2 complete