How protocol upgrades work on XRPL — the amendment system, feature flags and the AmendmentTable.
What you'll learn
≈60 min · Intermediate · builds on AMM transactions & pathfinding
Watch this short video by XRPL Commons first, then dive into the details below.
A live, decentralized protocol can't just ship a breaking change, it has to vote one in. In this module you'll learn how amendments gate protocol changes behind feature flags, how the AmendmentTable tracks them, and how amendments are classified as supported, obsolete or retired. It's how XRPL evolves without forking.
In brief: the path from proposal, through voting, to activation, at a glance.
An amendment moves through six stages:
The 80% supermajority balances progress against stability: high enough that a large minority cannot be steamrolled, low enough that a few offline validators cannot freeze evolution, and the two-week stability window gives every operator time to upgrade.
Note. This is only the overview. The amendment lifecycle module walks every stage in detail (states,
tfGotMajority/tfLostMajority, the pseudo-transactions, and amendment-blocked mode), so this module stays focused on the architecture.
In brief: four principles, one central table.
The design rests on four principles: distributed consensus (nothing activates without the sustained 80% supermajority), a stability period (two weeks between majority and activation, so operators can upgrade), automatic activation (an EnableAmendment pseudo-transaction injected by the network itself, no human trigger), and network protection (a node that does not support an activated amendment blocks itself rather than apply rules it does not understand).
Key idea. An amendment is a feature flag with a vote attached. The protocol change ships in the code disabled, and only activates once the network agrees to switch it on.
The public interface lives in include/xrpl/ledger/AmendmentTable.h:
| Group | Methods | Purpose |
|---|---|---|
| Management | find, enable, veto, unVeto |
look up and steer individual amendments |
| Status | isEnabled, isSupported, hasUnsupportedEnabled, firstUnsupportedExpected |
what is active, what do we support, when does trouble arrive |
| Voting | doVoting, doValidation |
the flag ledger machinery (below) |
| Genesis | getDesired |
the set of amendments to enable in a fresh genesis ledger |
| Sync and reporting | doValidatedLedger, getJson, trustChanged |
follow the ledger, feed the RPC |
The implementation (AmendmentTableImpl, in src/xrpld/app/misc/detail/AmendmentTable.cpp) keeps all of its state behind one mutex, because network, consensus, and RPC threads all query it:
hash_map<uint256, AmendmentState> amendmentMap_; // every known amendment
TrustedVotes previousTrustedVotes_; // validator votes, cached
std::unique_ptr<AmendmentSet> lastVote_; // last tally
bool unsupportedEnabled_; // danger flag
std::optional<NetClock::time_point> firstUnsupportedExpected_;
DatabaseCon& db_; // vote persistence
std::mutex mutable mutex_;
Each amendment is one small record:
struct AmendmentState {
AmendmentVote vote; // up, down, or obsolete
bool enabled; // active on the network?
bool supported; // does this node have the code?
std::string name;
};
The enabled / supported distinction is the load-bearing one: enabled is a network fact (the hash is in the ledger's sfAmendments), supported is a local fact (this binary has the code). Enabled-but-not-supported is exactly the combination that blocks a node.
Every known amendment is declared at compile time in include/xrpl/protocol/detail/features.macro:
XRPL_FEATURE(ConfidentialTransfer, Supported::Yes, VoteBehavior::DefaultNo) // new feature
XRPL_FIX (Cleanup3_1_3, Supported::Yes, VoteBehavior::DefaultYes) // bug fix
XRPL_RETIRE_FEATURE(MultiSign) // permanent
Note that XRPL_FIX takes the bare name: the macro prepends fix itself, so XRPL_FIX(Cleanup3_1_3, ...) registers the amendment fixCleanup3_1_3 (see the "fix" #name expansion in Feature.cpp).
| Category | Meaning | Marker |
|---|---|---|
| Supported | current features, can be voted and activated | Supported::Yes |
| Obsolete | once votable but never passed; kept supported in case they ever get enabled | VoteBehavior::Obsolete — no current entry uses it; the marker exists for this case (see the comment block in features.macro) |
| Retired | active 2+ years, pre-amendment code deleted, always considered enabled | XRPL_RETIRE_FEATURE |
Registering a new amendment is one line in features.macro (the kNumFeatures count in Feature.h derives automatically), plus the actual feature logic gated behind rules.enabled(featureName), plus tests for both the enabled and disabled paths.
An amendment's identity is the SHA-512Half of its name: that hash appears in validations, in pseudo-transactions, and in the ledger's sfAmendments field.
Vote collection. Each validation carries the validator's amendment wish-list (built by doValidation(): supported, upvoted, not yet enabled). TrustedVotes caches these per validator with a 24-hour expiry (kExpiresAfter = 24h in AmendmentTable.cpp): a validator that goes silent for a day stops counting, which prevents vote flapping during short outages while ensuring stale votes never linger. The practical consequence: the default is "no", and validators must keep actively signalling "yes".
Tallying. Votes are only counted every flag ledger (every 256 ledgers, about 15 minutes), which keeps the cost low and the schedule predictable. AmendmentSet aggregates the cached votes and computes the threshold:
// kAmendmentMajorityCalcThreshold = ratio<80,100> (SystemParameters.h),
// applied in AmendmentTable.cpp
threshold = max(1, (trustedValidations * 80) / 100); // floor of 80%
// passes(): STRICTLY more votes than the threshold...
return votes > threshold;
// ...except with a single trusted validator, where votes >= threshold
Concretely: 25 trusted validators give a threshold of 20, and majority needs 21 votes; 35 give 28, majority needs 29. "More than 80%" is meant literally.
Decisions. For each amendment that is not already enabled, doVoting() compares the vote tally with what the ledger currently records in sfMajorities:
| Situation | Action |
|---|---|
| passes now, no recorded majority, and this validator's own vote is Up | tfGotMajority pseudo-transaction |
| recorded majority, no longer passes | tfLostMajority pseudo-transaction |
| majority held for 2+ weeks, and this validator's own vote is Up | enable (flag-less EnableAmendment) |
| anything else | log, wait for the next flag ledger |
The vote == AmendmentVote::Up condition on the first and third rows is the code-level side of the veto: a validator that vetoes an amendment not only withholds it from its validations, it also declines to propose the tfGotMajority and enable pseudo-transactions.
Two functions close the loop. Change::applyAmendment() (src/libxrpl/tx/transactors/system/Change.cpp) applies the pseudo-transactions: tfGotMajority adds the amendment and its close time to sfMajorities, tfLostMajority removes it, and the flag-less form moves the hash into sfAmendments, notifies the AmendmentTable (enable()), and, if this node does not support the amendment, calls setAmendmentBlocked(). Duplicate activation returns tefALREADY.
doValidatedLedger() is called for validated ledgers but does its work once per 256-ledger window (it short-circuits unless the ledger sequence crosses into a new window) and syncs the table the other way: it enables what the ledger says is enabled, tracks current majorities and their expected activation times, and, if an unsupported amendment is heading for activation, sets firstUnsupportedExpected_, your early warning.
If an unsupported amendment does activate, the node calls setAmendmentBlocked(): it logs a critical error and stops processing ledgers. It can still answer queries, but it no longer validates or proposes; the fix is to upgrade to a release that supports the amendment.
Admin vote preferences survive restarts in the FeatureVotes SQL table (persistVote() writes on every change; readAmendments() replays the most recent vote per amendment at startup, via a window function).
The feature RPC command is the operator's window:
{ "command": "feature", "feature": "AmendmentNameOrHash" }
On a public connection it returns per amendment: name, supported, enabled (plus majority when a majority is recorded). The remaining fields are admin-only, and only appear while the amendment is not yet enabled: vetoed, plus the live count / threshold / validations from the last tally. With admin rights, the same command also sets or clears a veto ("vetoed": true|false), which is persisted and takes effect at the next flag ledger.
There is no explicit "no" vote: withholding support is the veto. Just over 20% of trusted validators not voting for an amendment blocks it, indefinitely, and the veto works even after a majority is reached, right up until the two-week clock completes. The system is deliberately conservative: proponents must build a broad coalition, and controversial changes stall rather than squeak through.
| Scenario | Behaviour |
|---|---|
| very few validators | threshold formula keeps a minimum of 1 vote; small networks stay explicit |
| network partition | neither side sustains 80% for 2 weeks; healing converges on the majority chain |
| emergency bug fix | same mechanism, just faster social coordination; there is no bypass |
| standalone mode | no consensus, so nothing activates by voting; a fresh genesis ledger automatically enables every supported, up-voted, non-vetoed amendment (getDesired()), and [features] in the config presets additional amendments by name |
[features]
# standalone/testing: preset amendments into the ledger rules, by name
ConfidentialTransfer
fixCleanup3_1_3
The [features] section takes amendment names (unknown names are a fatal config error) and injects them as presets into the Rules, so they count as enabled from the genesis ledger onward. Do not confuse it with the [amendments] section: that one seeds an up-vote for the listed amendments into the FeatureVotes table, does not force-enable anything, and is ignored entirely once wallet.db already has a FeatureVotes table.
Amendment voting shares its infrastructure with fee voting and Negative UNL voting: all three run on flag ledgers, all three act through pseudo-transactions, and none of them interferes with the others.
include/xrpl/ledger/AmendmentTable.h - interfacesrc/xrpld/app/misc/detail/AmendmentTable.cpp - implementation, TrustedVotes, AmendmentSet, 24h expiryinclude/xrpl/protocol/detail/features.macro - the registryinclude/xrpl/protocol/Feature.h / src/libxrpl/protocol/Feature.cpp - feature definitionsinclude/xrpl/protocol/SystemParameters.h - kAmendmentMajorityCalcThreshold, kDefaultAmendmentMajorityTimesrc/libxrpl/tx/transactors/system/Change.cpp - applyAmendmentsrc/libxrpl/server/Wallet.cpp - voteAmendment / readAmendments persistencesrc/xrpld/rpc/handlers/server_info/Feature.cpp - the feature RPCThis module explained how XRPL upgrades itself without forking. An amendment is a protocol change that ships in the code disabled behind a feature flag and only activates once the network votes it in; the AmendmentTable tracks every known amendment and its state, and amendments are classified as supported, obsolete, or retired. Each is identified by a hash of its feature name.
To remember:
features.macro via XRPL_FEATURE(name, Supported::..., VoteBehavior::...) (and XRPL_FIX, which prepends fix to the name itself)AmendmentTable tracks every known amendment's state; sfMajorities records current majorities on-ledgerctx.rules.enabled(featureX)feature RPC lists everything; vote counts, threshold, and the vetoed flag are admin-onlyinclude/xrpl/protocol/Feature.h + src/xrpld/app/misc (AmendmentTable)Next up. You know the machinery; now follow one amendment through it. Next: the amendment lifecycle, from proposal to activation, step by step.
Resources
Assignments
0 of 2 complete