24 KiB
Protocol and Serialization
The protocol layer defines XRPL's wire format, type system, and validation rules. It owns the canonical binary encoding required for signatures and consensus, the macro-driven registries for features/transactions/ledger entries/sfields/permissions, the typed object model (STBase hierarchy) that every transaction and ledger object inhabits, the cryptographic primitives, and the JSON/RPC boundary.
Layered Type System
Asset = std::variant<Issue, MPTIssue> ← unified asset identity (XRP/IOU/MPT)
Issue = (Currency, AccountID) ← XRP iff currency==zero
MPTIssue = wraps MPTID (192-bit: seq32 || account160)
Amount types (lean, runtime polymorphic via Asset):
XRPAmount = int64 drops ← integral, no asset
IOUAmount = (mantissa, exponent) ← 15-digit decimal floating point
MPTAmount = int64 ← integral, no asset
STAmount = unified wire/serialized form holding Asset + value
- holds<Issue>(), holds<MPTIssue>(), native(), integral()
- canonicalize() normalizes (mantissa, exponent) per asset rules
PathAsset = std::variant<Currency, MPTID> — pathfinding-only asset reference (no issuer); used inside STPathElement.
Conversion utilities in AmountConversions.h: toSTAmount, toAmount<T>, getAsset<T>. Lean→STAmount is implicit-friendly; STAmount→lean is explicit (get<> throws on type mismatch).
Units.h provides phantom-typed ValueUnit<TAG, T> (Drops, FeeLevel, FeeLevelDouble) with unit_cast<> for explicit conversion; prevents drop/fee-level mixups at compile time.
Key Invariants
- Canonical field ordering: sort by
(SerializedTypeID << 16) | fieldValue, NOT by raw Field ID bytes — wrong sort breaks signatures - Field ID encoding: 1–3 bytes; both type and field codes <16 → single byte
(type<<4)|name - Hash domain separation: every signable payload prepends a 4-byte
HashPrefix(STX\0,SMT\0,VAL\0,BCH\0,CLM\0,LWR\0,MAN,TXN, etc.) — never share hashes across domains. Helpermake_hash_prefix(a,b,c)is constexpruint32_tbuilder. - STObject access semantics:
obj[sfFoo]throwsFieldErrif absent;obj[~sfFoo]returnsstd::optional.getOrThrow<T>(name)family injson_get_or_throw.henforces presence + type for raw JSON inputs. - Amendment IDs are deterministic:
featureFoo == sha512Half(Slice("Foo"))— never change a feature name. Feature names must satisfyisFeatureName()at compile time (UpperCamelregex). numFeaturesis a ceiling, NOT an exact count. Counting includesXRPL_RETIRE_*and any inactive macros; never use it as a length.- Feature registry frozen at startup:
registerFeaturechecksnumFeaturesand aborts via static-initLogicErrorif exceeded. - Singletons everywhere:
SField,LedgerFormats,TxFormats,InnerObjectFormats,Permission,Featureregistry all use Meyer's singletons; registration completes beforemain()via static init. - Multi-sign signers MUST be sorted ascending by AccountID (no duplicates, count in [1,32], cannot include tx account).
vfFullyCanonicalSigalways set by signer; verifiers normalize ECDSA S to low form via libsecp256k1.- Amendment-gated arithmetic:
getSTNumberSwitchover()is aLocalValue<bool>(per-coroutine) selecting legacy vsNumber-based normalization inIOUAmount/STAmount. - TxMeta
AffectedNodesmust be sorted by index for canonical serialization (consensus-critical). - STObject debug-only field-uniqueness checks (
isFieldAllowed): silent duplicate fields in production are possible bugs but no runtime check.
Macro-Driven Registries (X-Macros)
Single source of truth for each registry; .macro files included multiple times with redefined macros to generate enum, declarations, and definitions.
| Macro file | Used for | Add requires |
|---|---|---|
features.macro |
XRPL_FEATURE, XRPL_FIX, XRPL_RETIRE_* |
Bump numFeatures in Feature.h |
transactions.macro |
TRANSACTION(tag, value, name, delegable, amendment, privileges, fields) |
nothing — count derived |
ledger_entries.macro |
LEDGER_ENTRY(tag, value, name, rpcName, fields) + LEDGER_ENTRY_DUPLICATE for name collisions |
nothing |
sfields.macro |
TYPED_SFIELD(name, TYPE, code), UNTYPED_SFIELD |
nothing |
permissions.macro |
PERMISSION(name, txType, value) (granular permissions ≥65537) |
nothing |
Pattern uses #pragma push_macro/pop_macro to protect macro names. UNWRAP(...) strips outer parens around field-list initializers so commas don't confuse the preprocessor. LEDGER_ENTRY_DUPLICATE exists because DepositPreauth is both a transaction type and ledger entry type — JSS() can't emit the same string twice.
Feature name validation is constexpr (compile-time static_assert on the literal); typos like lower-case first letter fail to build.
Field Identity (SField)
- Field code =
(SerializedTypeID << 16) | fieldValue— packs type family and per-type index; canonical sort key SFieldinstances are immutable singletons created at static init viaprivate_access_tag_t(only definable insideSField.cpp)TypedField<T>adds compile-time payload type;OptionaledField<T>viaoperator~(sfField)- Metadata flags (
fieldMeta):sMD_ChangeOrig,sMD_ChangeNew,sMD_DeleteFinal,sMD_Create,sMD_Always,sMD_BaseTen(decimal display),sMD_PseudoAccount,sMD_NeedsAsset(drivesSTTakesAssetassociation) IsSigning::noexcludes fields from signing hash (sfTxnSignature,sfSigners,sfSignature, etc.)isBinary()⇔fieldValue<256(wire-representable);isDiscardable()⇔fieldValue>256(JSON-only, e.g.,sfHash,sfIndex)- Debug-only uniqueness check during static init; release builds will silently mis-register on collision
Wire Format Reference
| Item | Encoding |
|---|---|
| XRP STAmount | 8 bytes; bit63=0, bit62=sign(1=pos), 62-bit value |
| MPT STAmount | 8 bytes header (bit63=0, bit61=1) + 192-bit MPTID |
| IOU STAmount | bit63=1, bit62=sign, 8-bit (offset+97), 54-bit mantissa, +20B currency, +20B issuer |
| AccountID | 20 bytes, VL-prefixed when standalone (STAccount mimics STBlob wire format) |
| MPTID | 192 bits = 32-bit big-endian sequence ‖ 160-bit issuer |
| STArray | elements between markers; ends with STI_ARRAY,1 (0xf1) |
| STObject | fields in canonical order; ends with STI_OBJECT,1 (0xe1) |
| VL prefix | 1 byte (0–192), 2 bytes (193–12480), 3 bytes (12481–918744); else std::overflow_error |
| STIssue | 160-bit currency; if zero → XRP; if next 160 = noAccount() → MPT (then 32-bit seq); else IOU issuer |
| LP token currency | byte0 = 0x03; bytes 1-19 = sha512Half(min(asset1,asset2), max(asset1,asset2)) low bits |
| Order book quality | (exponent+100) << 56 | mantissa; embedded in last 8 bytes of directory key (big-endian) so SHAMap order = price order |
| NFTokenID (256-bit) | flags(2) + transferFee(2) + issuer(20) + cipheredTaxon(4) + serial(4); low 96 bits = page sort key |
| LedgerHeader | 118 bytes fixed layout (seq, drops, parentHash, txHash, accountHash, parentClose/closeTime/closeFlags, closeTimeResolution) |
| Payment channel claim | HashPrefix::paymentChannelClaim ‖ channelID(32) ‖ amount(8) |
| Batch signing payload | HashPrefix::batch ‖ inner-tx hash list |
Canonical Hashes
TXN → transactionID SND → txNode (with metadata)
MLN → leafNode MIN → innerNode
LWR → ledgerMaster STX → txSign (single-sig)
SMT → txMultiSign VAL → validation
PRP → proposal MAN → manifest
CLM → paymentChannelClaim BCH → batch
All hashes use sha512Half (first 256 bits of SHA-512). HashPrefix constants are protocol-immutable; the make_hash_prefix(a,b,c) constexpr packer in HashPrefix.h is the canonical way to declare new prefixes.
STObject and STVar
STObjectstoresstd::vector<detail::STVar>; iterators exposeSTBase const&via transform iteratorSTVaris type-erased with 72-byte inline buffer (small-object optimization);on_heap()reports whether a value spilled; larger ST types heap-allocateSTVaris movable; moving an on-heap STVar steals the pointer, while inline ones must invoke each ST type's move ctor through the v-tablecopy()/move()virtuals on every ST type delegate toSTBase::emplace()for placement-new intoSTVar's buffer- Two modes:
- Free (
mType==nullptr): linear field scan, accepts any field - Templated (
mTypeset): O(1) field lookup viaSOTemplate::indices_, template enforced
- Free (
applyTemplate()validates after deserialization;set(SOTemplate)initializes empty object with template- Deserialization depth capped at 10 to prevent stack exhaustion
Proxy Access Pattern
auto amt = tx[sfAmount]; // ValueProxy: throws FieldErr if absent
auto dst = tx[~sfDestination]; // OptionalProxy: std::optional
tx[sfFlags] = 0; // proxy.assign() — soeDEFAULT zero is silently removed
tx[~sfDestTag] = std::nullopt; // remove field (only valid for soeOPTIONAL)
Proxies forbid removing soeREQUIRED or soeDEFAULT fields.
SOEStyle (Field Presence)
| Style | Meaning |
|---|---|
soeREQUIRED |
must be present |
soeOPTIONAL |
may be absent; if present, may carry default value |
soeDEFAULT |
may be absent; if present, must NOT equal default — auto-removed when assigned default |
SOETxMPTIssue flag on amount/issue fields: soeMPTSupported, soeMPTNotSupported, soeMPTNone. Omitting soeMPTSupported silently rejects MPT amounts in that field.
Common Bug Patterns
- Adding to
transactions.macrowithout adding tosfields.macro→ silent serialization failures - Forgetting to bump
numFeaturesafterXRPL_FEATURE→ static-initLogicError(registry overflow) caught at startup - Hand-built binary blobs in non-canonical field order → signature verification failures
- Omitting
soeMPTSupportedon amount field → MPT payments silently rejected - Mutating
sfTransactionTypeinsideSTTxassembler callback →LogicError(caught at startup) - Storing
STBasesubclasses directly instd::vector→ field names lost on copy-assignment slide; useSTArray/STObjectinstead - Storing
Currencyas"XRP"ISO code (badCurrency()) instead of zero → silently rejected;to_currency()legacy returnsbadCurrency()rather than failing - Forgetting to call
associateAsset(sle, asset)near end ofdoApply()for vault/loan transactors → unroundedSTNumbervalues - Returning
tec*frompreflight()→NotTECtype prevents this at compile time (would allow fee theft on unsigned tx) TxMeta::AffectedNodesleft unsorted before serialization → consensus-fork risk- Comparing
Issueinstances when one side is MPT-wrapped →Issue::operator==only compares currency+account; useAssetequality - Relying on debug-only
assertinsideSTObject::isFieldAllowedto catch duplicate fields in release - Treating
numFeaturesas a length / iteration bound (it includes retired slots)
Key Patterns
Amendment Registration
// In features.macro:
XRPL_FEATURE(MyFeature, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (MyBugFix, Supported::yes, VoteBehavior::DefaultNo)
XRPL_RETIRE_FEATURE(OldFeature) // code removed; remains registered for ledger compat
Lifecycle: Supported::no/DefaultNo → Supported::yes/DefaultNo → (rare) DefaultYes for critical fixes. Never revert Supported::yes to no (would amendment-block existing nodes).
NotTEC vs TER
NotTEC preflight(...); // can only return tel/tem/tef/ter/tes (no tec)
TER doApply(...); // can return any code including tec*
TERSubset<Trait> enforces this at compile time via enable_if. TERtoInt(v) is the authorized free-function conversion (member explicit operator would be too permissive in initializer contexts).
Signing / Verifying
sign(st, HashPrefix::txSign, KeyType::secp256k1, sk); // writes sfSignature
verify(st, HashPrefix::txSign, pubKey); // returns bool
// Multi-sign optimization (shared body, per-signer suffix):
auto s = startMultiSigningData(obj);
finishMultiSigningData(signerAccountID, s); // append signer ID to shared payload
addWithoutSigningFields() excludes signature fields from the signed payload — this is what breaks the circularity.
Batch Signing
HashPrefix::batch + inner-tx hash list is signed by all inner accounts; outer tx carries the assembled sfRawTransactions array. passesLocalChecks() rejects nested batches.
STNumber + STTakesAsset
// Vault/Loan/LoanBroker fields use STNumber (no asset embedded).
// In doApply(), after all mutations:
associateAsset(*sle, vaultAsset); // rounds all sMD_NeedsAsset fields, removes zero-defaults
STNumber serializes a Number (signed mantissa+exponent) directly; rounding is asset-dependent and resolved by associateAsset walking fields flagged sMD_NeedsAsset.
LP Token Currency Derivation
Currency lpc = ammLPTCurrency(asset1, asset2); // canonical std::minmax
// byte 0 = 0x03 (LP marker), bytes 1..19 = low 152 bits of sha512Half(min, max)
Pseudo-Account Synthesis
Pseudo-accounts (AMM, Vault, LoanBroker) carry a 256-bit synthesized ID in fields flagged sMD_PseudoAccount (sfAMMID, sfVaultID, sfLoanBrokerID). These identify a stateless account address derived from the owner ledger entry.
Critical Files
Foundations
include/xrpl/protocol/SField.h,src/libxrpl/protocol/SField.cpp— field registry, X-macro expansion, code packinginclude/xrpl/protocol/Feature.h,src/libxrpl/protocol/Feature.cpp—numFeatures(ceiling!),FeatureBitset,registerFeaturewith compile-time name validationinclude/xrpl/protocol/Rules.h,src/libxrpl/protocol/Rules.cpp—Rulessnapshot of enabled amendments;CurrentTransactionRulesis aLocalValue<Rules const*>(per-coroutine);isFeatureEnabled()queries thread-localinclude/xrpl/protocol/HashPrefix.h— protocol-immutable domain separators;make_hash_prefixconstexpr packer
Macro Tables (single sources of truth)
include/xrpl/protocol/detail/features.macroinclude/xrpl/protocol/detail/transactions.macroinclude/xrpl/protocol/detail/ledger_entries.macroinclude/xrpl/protocol/detail/sfields.macroinclude/xrpl/protocol/detail/permissions.macro
Type System Roots
STBase.h/cpp— polymorphic root;emplace()SOO helper;JsonOptions;STExchangetraits glueSTObject.h/cpp— heterogeneous container, proxy system, template enforcement, debug uniqueness assertsSTVar(detail/STVar.h) — 72-byte inline variant;on_heap(); move steals pointer when heap-allocated; depth guard at 10SOTemplate.h/cpp— schema with O(1) field index; move-only; carriesSOEStyle+SOETxMPTIssue
Format Registries
TxFormats.h/cpp,LedgerFormats.h/cpp,InnerObjectFormats.h/cpp— all inheritKnownFormats<Key, Derived>withforward_list<Item>(pointer-stable) + dual flat_mapsLedgerEntryrpcName vs name distinction enablesDepositPreauthcollision handling
Amount / Asset Stack
Asset.h/cpp— variant of Issue/MPTIssue;visit(),equalTokens(),BadAssetsentinelIssue.h/cpp,MPTIssue.h/cpp— XRP/IOU and MPT identity; noteIssue::operator==ignores MPT-ness — always go throughAssetSTAmount.h/cpp— unified serialized amount;canMul/canAdd/canSubtractsafety checks;mulRound/mulRoundStrict(legacy vs precise rounding);roundToScaleSTNumber.h/cpp—Number-typed field; pairs withSTTakesAssetinfrastructureSTIssue.h/cpp,STCurrency.h/cpp— asset-only fieldsSTTakesAsset.h/cpp—associateAssetwalkssMD_NeedsAssetfields, rounds + strips zero-defaultsIOUAmount.h/cpp,XRPAmount.h,MPTAmount.h/cpp— lean representationsRate2.h—Ratenewtype withparityRate = 1_000_000_000; transfer-rate mathUnits.h— phantom-typedDrops/FeeLevelNumber(inxrpl/basics/) — high-precision arithmetic;MantissaRange::largeenabled by SingleAssetVault/LendingProtocol amendmentsAmountConversions.h— typed coercions
Cryptography
PublicKey.h/cpp— 33-byte unified format (0xED prefix for Ed25519);ECDSACanonicalityenum (canonical vs fullyCanonical); libsecp256k1 normalizationSecretKey.h/cpp—secure_erasein dtor; deleted==/<<; XRPL-specific secp256k1 derivation viaGeneratorSeed.h/cpp— 128-bit;parseGenericSeed()cascades hex→base58→RFC1751→passphrase, rejecting other key types firstsecp256k1.h— libsecp256k1 context singletondigest.h—sha512Half,sha512_half_hasher_s(secure erase variant)tokens.h/cpp(+b58_utils.h,token_errors.h) — Base58Check; fast path uses base 58^10 intermediate (10–15× speedup, gated on non-MSVC for__int128);TokenTypeenum is protocol-stable
Wire I/O
Serializer.h/cpp— accumulator;addVL,addFieldID, big-endian integers,getSHA512Half()SerialIter— non-owning forward cursor over a byte buffer; throws on underrunSign.h/cpp—sign/verifywith HashPrefix prepended toaddWithoutSigningFields()output;signingForIDhelper for arbitrary payload bytesserialize.h— top-level convenience helpersmessages.h— protobuf message tag constants
Higher-Level Objects
STTx.h/cpp— cachestid_andtx_type_;passesLocalChecks(memos, pseudo-tx, MPT support, batch nesting);sterilize()round-tripSTLedgerEntry.h/cpp(aliasSLE) — typed ledger object;thread()updatessfPreviousTxnID;isThreadedType()gated byfixPreviousTxnIDSTValidation.h/cpp— lazyvalid_cache;mTrustedseparate from validity;lookupNodeIDcallback decouples manifest systemSTArray.h/cpp,STVector256.h/cpp,STBitString.h/cpp,STInteger.h/cpp,STBlob.h/cpp,STAccount.h/cpp,STPathSet.h/cpp,STXChainBridge.h/cpp
Transaction Meta
TxMeta.h/cpp—AffectedNodes(sorted by index!),DeliveredAmount; serialization order is consensus-criticalLedgerHeader.h/cpp— 118-byte fixed serialization; close-time-resolution fudging
Indexes and Keys
Indexes.h/cpp—keylet::*factories withLedgerNameSpacetagged hashing;keylet::quality()embeds 64-bit quality in last 8 bytes (big-endian)Keylet.h/cpp— type-tagged(uint256, LedgerEntryType);ltANYwildcard,ltCHILDrejects directoriesProtocol.h— protocol-wide constants (FLAG_LEDGER_INTERVAL, etc.)nftPageMask.h,nft.h— NFT page boundary (low 96 bits); composite keys (high 160 = owner AccountID)NFTokenID.h/cpp— flags(2)+fee(2)+issuer(20)+cipheredTaxon(4)+serial(4); LCG384160001 * seq + 2459ciphers taxonNFTokenOfferID.h/cpp,NFTSyntheticSerializer.h/cpp— derived/synthetic NFT entriesBook.h—(in_asset, out_asset)order-book identitySeqProxy.h/cpp— sequence vs ticket abstraction
Validation Helpers (return NotTEC, preflight-time)
AMMCore.h/cpp—invalidAMMAsset,invalidAMMAssetPair,invalidAMMAmount;ammLPTCurrency()uses canonicalstd::minmaxPermissions.h/cpp— singleton;isDelegable()checks granular vs transaction-level (<UINT16_MAXboundary), amendment, delegable flag
RPC / JSON Boundary
STParsedJSON.h/cpp— depth cap 64; field-path-qualified errors viamake_name; recognizes"Payment","tesSUCCESS", etc.ErrorCodes.h/cpp— append-only enum;sortedErrorInfosvalidated at compile time;warning_code_idistinct fromerror_code_iRPCErr.h/cpp—RPC::Status/make_errorhelpersMultiApiJson.h— per-API-versionJson::Valuearray indexed[version - RPC::apiMinimumVersion]; composes withforAllApiVersionsfromApiVersion.h; preserves wire compatibility across versionsjss.h— every JSON key asJson::StaticStringviaJSS(name)macro; PascalCase = protocol fields, snake_case = RPCjson_get_or_throw.h—getOrThrow<T>(jv, name)specializations enforce presence + type; standard idiom for parsing untrusted JSONst.h— convenience aggregate header for all ST types
Specialized Types
STIssue,STAccount(160-bit, VL-encoded),STBitString<Bits>,STInteger<T>,STBlob,STArray,STVector256,STCurrency,STPathSet,STXChainBridge,STNumber(asset-contextual)Quality.h/cpp— inverted encoding (lower uint64 = higher quality);ceil_in/ceil_outproportional scaling;_strictvariants honor Number rounding modeQualityFunction.h/cpp— linearq(out)=m*out+b; AMMTag (slope from pool) vs CLOBLikeTag (m=0);combine()for multi-step strandsXChainAttestations.h/cpp—Attestations::namespace (full, with signature) vsxrpl::(stored);match()returns three-stateAttestationMatch
Pseudo-Account Fields (sMD_PseudoAccount)
sfAMMID,sfVaultID,sfLoanBrokerID— 256-bit hash representing a synthesized account address
Misc / System
BuildInfo.h/cpp— version string,getVersionString()consumed by manifest/handshakeSystemParameters.h— drops-per-XRP,INITIAL_XRP, ledger-related constants; validated bystatic_assertUintTypes.h—uint256/uint160/uint128aliases and tagged variants (Currency,NodeID, etc.)TER.h/cpp— error code enum families +TERSubsetTxFlags.h— X-macro driven flag tables (tf*); see TxFlags Architecture belowTxFormats.h/cpp— transaction-type → field schema
Numeric Encoding Reference
IOU canonical: mantissa ∈ [10^15, 10^16), exponent ∈ [-96, +80]
zero = (mantissa=0, exponent=-100) — sorts below smallest positive
XRP max: cMaxNativeN = 10^17 drops (100 billion XRP)
MPT max: maxMPTokenAmount = INT64_MAX = 0x7FFFFFFFFFFFFFFF
Transfer rate: Rate{value} where value/1_000_000_000 = 1.0 (parityRate = 1:1)
NFT transfer fee: uint16 basis points (0–50000), convert via nft::transferFeeAsRate (×10000)
AMM auction fee: basis points; trading fee in tenths of basis points (10000 = 1%)
Protocol-Stable Constants (NEVER CHANGE)
LedgerEntryTypenumeric values (in ledger objects)TxTypenumeric values (in signed transactions)SerializedTypeIDandSFieldcodes (in serialized fields)LedgerNameSpacediscriminator characters (in keylet derivation)HashPrefixenum values (in signature/hash domain separation)error_code_iandwarning_code_inumeric values (clients depend on them; append-only)TECcodes(and otherTERfamily numeric values) — recorded in transaction metadataTokenType(Base58Check prefix bytes for accounts/seeds/nodes)- LP token currency prefix byte (
0x03) - Universal transaction flags (
tfFullyCanonicalSig,tfInnerBatchTxn) FLAG_LEDGER_INTERVAL = 256(drives consensus timing)INITIAL_XRP = 100B × 10^6 drops(validated bystatic_assertagainstNumber::maxRep)- NFT taxon LCG constants (
384160001 * seq + 2459) - All flag bit values (
tf*,lsf*,asf*)
Changing any requires an amendment with explicit detection logic for old/new behavior.
TxFlags Architecture
TxFlags.h is itself X-macro driven. Per-transaction flag groups are declared so that:
- Each group has
tf*named bit constants tfUniversalMaskis the union of universal flags (tfFullyCanonicalSig,tfInnerBatchTxn)- Per-transaction
tf*Maskconstants are auto-computed viaMASK_ADJso that mask matches the declared flags exactly — adding a flag automatically updates the mask TF_FLAG2marks flags whose meaning was changed by an amendment; old/new bits coexist with disjoint enable conditions- Inner-batch flag
tfInnerBatchTxnis special: marks a tx as a member of a batch (skipped by ordinary preflight signature checks)
Pattern: when adding a new flag, define it in TxFlags.h in the appropriate group; do NOT manually adjust the mask — the macro derives it.