From d8febb71bd05d8ee401875ab14d63a4b60a18e8e Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Wed, 13 May 2026 23:01:44 +0200 Subject: [PATCH] part 1 --- include/xrpl/basics/FileUtilities.h | 36 + include/xrpl/crypto/RFC1751.h | 173 ++++- include/xrpl/crypto/csprng.h | 136 +++- include/xrpl/crypto/secure_erase.h | 43 +- include/xrpl/ledger/AcceptedLedgerTx.h | 91 ++- include/xrpl/ledger/AmendmentTable.h | 276 ++++++- include/xrpl/ledger/ApplyView.h | 569 +++++++++----- include/xrpl/ledger/ApplyViewImpl.h | 113 ++- include/xrpl/ledger/BookDirs.h | 109 +++ include/xrpl/ledger/BookListeners.h | 64 +- include/xrpl/ledger/CachedSLEs.h | 36 + include/xrpl/ledger/CachedView.h | 115 ++- include/xrpl/ledger/CanonicalTXSet.h | 140 +++- include/xrpl/ledger/Dir.h | 139 +++- include/xrpl/ledger/Ledger.h | 534 ++++++++++--- include/xrpl/ledger/LedgerTiming.h | 184 +++-- include/xrpl/ledger/OpenView.h | 259 +++++-- include/xrpl/ledger/OrderBookDB.h | 173 +++-- include/xrpl/ledger/PaymentSandbox.h | 354 +++++++-- include/xrpl/ledger/PendingSaves.h | 146 +++- include/xrpl/ledger/RawView.h | 124 +-- include/xrpl/ledger/ReadView.h | 323 +++++--- include/xrpl/ledger/Sandbox.h | 73 +- include/xrpl/ledger/View.h | 298 +++++-- include/xrpl/ledger/detail/ApplyStateTable.h | 269 ++++++- include/xrpl/ledger/detail/ApplyViewBase.h | 169 ++++ include/xrpl/nodestore/Backend.h | 221 ++++-- include/xrpl/nodestore/Database.h | 416 +++++++--- include/xrpl/nodestore/DatabaseRotating.h | 76 +- include/xrpl/nodestore/DummyScheduler.h | 50 +- include/xrpl/nodestore/Factory.h | 78 +- include/xrpl/nodestore/Manager.h | 141 +++- include/xrpl/nodestore/NodeObject.h | 124 ++- include/xrpl/nodestore/Scheduler.h | 115 ++- include/xrpl/nodestore/Task.h | 46 +- include/xrpl/nodestore/Types.h | 66 +- include/xrpl/nodestore/detail/BatchWriter.h | 130 +++- .../xrpl/nodestore/detail/DatabaseNodeImp.h | 150 +++- .../nodestore/detail/DatabaseRotatingImp.h | 152 +++- include/xrpl/nodestore/detail/DecodedBlob.h | 80 +- include/xrpl/nodestore/detail/EncodedBlob.h | 118 ++- include/xrpl/nodestore/detail/ManagerImp.h | 59 ++ include/xrpl/nodestore/detail/codec.h | 160 +++- include/xrpl/nodestore/detail/varint.h | 118 ++- include/xrpl/protocol/AMMCore.h | 185 ++++- include/xrpl/protocol/AccountID.h | 174 ++++- include/xrpl/protocol/AmountConversions.h | 221 ++++++ include/xrpl/protocol/ApiVersion.h | 182 ++++- include/xrpl/protocol/Asset.h | 325 +++++++- include/xrpl/protocol/Batch.h | 41 + include/xrpl/protocol/Book.h | 185 ++++- include/xrpl/protocol/BuildInfo.h | 159 ++-- include/xrpl/protocol/Concepts.h | 122 ++- include/xrpl/protocol/ErrorCodes.h | 427 ++++++++--- include/xrpl/protocol/Feature.h | 293 ++++++- include/xrpl/protocol/Fees.h | 77 +- include/xrpl/protocol/HashPrefix.h | 137 +++- include/xrpl/protocol/IOUAmount.h | 214 +++++- include/xrpl/protocol/Indexes.h | 652 ++++++++++++++-- include/xrpl/protocol/InnerObjectFormats.h | 54 +- include/xrpl/protocol/Issue.h | 209 ++++- include/xrpl/protocol/KeyType.h | 63 +- include/xrpl/protocol/Keylet.h | 66 +- include/xrpl/protocol/KnownFormats.h | 194 ++++- include/xrpl/protocol/LedgerFormats.h | 202 +++-- include/xrpl/protocol/LedgerHeader.h | 164 +++- include/xrpl/shamap/Family.h | 88 ++- include/xrpl/shamap/FullBelowCache.h | 171 ++++- include/xrpl/shamap/SHAMap.h | 621 ++++++++++++--- .../xrpl/shamap/SHAMapAccountStateLeafNode.h | 87 ++- include/xrpl/shamap/SHAMapAddNode.h | 101 ++- include/xrpl/shamap/SHAMapInnerNode.h | 323 +++++++- include/xrpl/shamap/SHAMapItem.h | 125 ++- include/xrpl/shamap/SHAMapLeafNode.h | 96 ++- include/xrpl/shamap/SHAMapMissingNode.h | 70 +- include/xrpl/shamap/SHAMapNodeID.h | 127 ++- include/xrpl/shamap/SHAMapSyncFilter.h | 67 +- include/xrpl/shamap/SHAMapTreeNode.h | 303 ++++++-- include/xrpl/shamap/SHAMapTxLeafNode.h | 91 ++- .../xrpl/shamap/SHAMapTxPlusMetaLeafNode.h | 106 ++- include/xrpl/shamap/TreeNodeCache.h | 56 +- include/xrpl/shamap/detail/TaggedPointer.h | 330 +++++--- include/xrpl/tx/ApplyContext.h | 180 ++++- include/xrpl/tx/SignerEntries.h | 80 +- include/xrpl/tx/Transactor.h | 697 +++++++++++++++-- include/xrpl/tx/apply.h | 211 +++-- include/xrpl/tx/applySteps.h | 487 ++++++++---- include/xrpl/tx/invariants/AMMInvariant.h | 196 +++++ include/xrpl/tx/invariants/FreezeInvariant.h | 180 ++++- include/xrpl/tx/invariants/InvariantCheck.h | 420 +++++++--- .../tx/invariants/InvariantCheckPrivilege.h | 178 ++++- .../xrpl/tx/invariants/LoanBrokerInvariant.h | 125 ++- include/xrpl/tx/invariants/LoanInvariant.h | 75 +- include/xrpl/tx/invariants/MPTInvariant.h | 168 +++- include/xrpl/tx/invariants/NFTInvariant.h | 143 +++- .../tx/invariants/PermissionedDEXInvariant.h | 64 ++ .../invariants/PermissionedDomainInvariant.h | 116 ++- include/xrpl/tx/invariants/VaultInvariant.h | 164 +++- include/xrpl/tx/paths/AMMLiquidity.h | 163 +++- src/libxrpl/basics/Archive.cpp | 38 +- src/libxrpl/basics/BasicConfig.cpp | 176 ++++- src/libxrpl/basics/CountedObject.cpp | 33 + src/libxrpl/basics/FileUtilities.cpp | 8 + src/libxrpl/basics/Log.cpp | 191 ++++- src/libxrpl/basics/MallocTrim.cpp | 60 +- src/libxrpl/basics/Number.cpp | 398 ++++++++-- src/libxrpl/basics/ResolverAsio.cpp | 298 ++++++- src/libxrpl/basics/StringUtilities.cpp | 122 ++- src/libxrpl/basics/UptimeClock.cpp | 44 +- src/libxrpl/basics/base64.cpp | 120 ++- src/libxrpl/basics/contract.cpp | 39 + src/libxrpl/basics/make_SSLContext.cpp | 152 +++- src/libxrpl/basics/mulDiv.cpp | 34 + .../beast/clock/basic_seconds_clock.cpp | 71 +- src/libxrpl/beast/core/CurrentThreadName.cpp | 80 +- src/libxrpl/beast/core/SemanticVersion.cpp | 184 +++-- src/libxrpl/beast/insight/Collector.cpp | 15 + src/libxrpl/beast/insight/Groups.cpp | 80 ++ src/libxrpl/beast/insight/Hook.cpp | 12 + src/libxrpl/beast/insight/Metric.cpp | 18 + src/libxrpl/beast/insight/NullCollector.cpp | 72 ++ src/libxrpl/beast/insight/StatsDCollector.cpp | 297 ++++++- src/libxrpl/beast/net/IPAddressConversion.cpp | 15 + src/libxrpl/beast/net/IPAddressV4.cpp | 43 ++ src/libxrpl/beast/net/IPAddressV6.cpp | 42 + src/libxrpl/beast/net/IPEndpoint.cpp | 94 ++- src/libxrpl/beast/utility/beast_Journal.cpp | 103 ++- .../beast/utility/beast_PropertyStream.cpp | 378 ++++++++- src/libxrpl/crypto/RFC1751.cpp | 190 ++++- src/libxrpl/crypto/csprng.cpp | 27 +- src/libxrpl/crypto/secure_erase.cpp | 19 + src/libxrpl/json/JsonPropertyStream.cpp | 76 +- src/libxrpl/json/Output.cpp | 48 ++ src/libxrpl/json/Writer.cpp | 202 +++++ src/libxrpl/json/json_reader.cpp | 337 +++++++- src/libxrpl/json/json_value.cpp | 478 +++++++++++- src/libxrpl/json/json_valueiterator.cpp | 147 +++- src/libxrpl/json/json_writer.cpp | 295 ++++++- src/libxrpl/json/to_string.cpp | 36 + src/libxrpl/ledger/AcceptedLedgerTx.cpp | 15 +- src/libxrpl/ledger/ApplyStateTable.cpp | 50 +- src/libxrpl/ledger/ApplyView.cpp | 169 ++++ src/libxrpl/ledger/ApplyViewBase.cpp | 22 + src/libxrpl/ledger/ApplyViewImpl.cpp | 16 + src/libxrpl/ledger/BookDirs.cpp | 104 +++ src/libxrpl/ledger/BookListeners.cpp | 19 +- src/libxrpl/ledger/CachedView.cpp | 19 +- src/libxrpl/ledger/CanonicalTXSet.cpp | 31 +- src/libxrpl/ledger/Dir.cpp | 78 ++ src/libxrpl/ledger/Ledger.cpp | 218 +++++- src/libxrpl/ledger/OpenView.cpp | 29 + src/libxrpl/ledger/PaymentSandbox.cpp | 260 ++++++- src/libxrpl/ledger/RawStateTable.cpp | 215 +++++- src/libxrpl/ledger/ReadView.cpp | 28 + src/libxrpl/ledger/View.cpp | 55 +- src/libxrpl/nodestore/BatchWriter.cpp | 72 +- src/libxrpl/nodestore/Database.cpp | 131 +++- src/libxrpl/nodestore/DatabaseNodeImp.cpp | 90 ++- src/libxrpl/nodestore/DatabaseRotatingImp.cpp | 31 +- src/libxrpl/nodestore/DecodedBlob.cpp | 67 +- src/libxrpl/nodestore/DummyScheduler.cpp | 9 +- src/libxrpl/nodestore/ManagerImp.cpp | 97 ++- src/libxrpl/nodestore/NodeObject.cpp | 23 +- .../nodestore/backend/MemoryFactory.cpp | 156 ++++ src/libxrpl/nodestore/backend/NuDBFactory.cpp | 311 +++++++- src/libxrpl/nodestore/backend/NullFactory.cpp | 79 +- .../nodestore/backend/RocksDBFactory.cpp | 252 +++++- src/libxrpl/protocol/AMMCore.cpp | 113 +++ src/libxrpl/protocol/AccountID.cpp | 137 ++-- src/libxrpl/protocol/Asset.cpp | 97 +++ src/libxrpl/protocol/Book.cpp | 49 ++ src/libxrpl/protocol/BuildInfo.cpp | 134 +++- src/libxrpl/protocol/ErrorCodes.cpp | 147 +++- src/libxrpl/protocol/Feature.cpp | 287 +++++-- src/libxrpl/protocol/IOUAmount.cpp | 208 +++-- src/libxrpl/protocol/Indexes.cpp | 579 ++++++++++++-- src/libxrpl/protocol/InnerObjectFormats.cpp | 46 +- src/libxrpl/protocol/Issue.cpp | 116 +++ src/libxrpl/protocol/Keylet.cpp | 25 + src/libxrpl/protocol/LedgerFormats.cpp | 68 ++ src/libxrpl/protocol/LedgerHeader.cpp | 87 ++- src/libxrpl/protocol/MPTAmount.cpp | 71 ++ src/libxrpl/protocol/MPTIssue.cpp | 90 ++- .../protocol/NFTSyntheticSerializer.cpp | 41 + src/libxrpl/protocol/NFTokenID.cpp | 108 ++- src/libxrpl/protocol/NFTokenOfferID.cpp | 72 ++ src/libxrpl/protocol/PathAsset.cpp | 27 + src/libxrpl/protocol/Permissions.cpp | 142 +++- src/libxrpl/protocol/Protocol.cpp | 56 ++ src/libxrpl/protocol/PublicKey.cpp | 145 +++- src/libxrpl/rdb/DatabaseCon.cpp | 115 ++- src/libxrpl/rdb/SociDB.cpp | 231 +++++- src/libxrpl/shamap/SHAMap.cpp | 606 +++++++++++++-- src/libxrpl/shamap/SHAMapDelta.cpp | 73 +- src/libxrpl/shamap/SHAMapInnerNode.cpp | 221 ++++++ src/libxrpl/shamap/SHAMapLeafNode.cpp | 50 ++ src/libxrpl/shamap/SHAMapNodeID.cpp | 30 +- src/libxrpl/shamap/SHAMapSync.cpp | 160 ++-- src/libxrpl/shamap/SHAMapTreeNode.cpp | 34 +- src/libxrpl/tx/ApplyContext.cpp | 125 ++- src/libxrpl/tx/SignerEntries.cpp | 9 +- src/libxrpl/tx/Transactor.cpp | 661 +++++++++++----- src/libxrpl/tx/apply.cpp | 100 ++- src/libxrpl/tx/applySteps.cpp | 217 ++++-- src/libxrpl/tx/invariants/AMMInvariant.cpp | 36 +- src/libxrpl/tx/invariants/FreezeInvariant.cpp | 219 +++++- src/libxrpl/tx/invariants/InvariantCheck.cpp | 31 +- .../tx/invariants/LoanBrokerInvariant.cpp | 73 +- src/libxrpl/tx/invariants/LoanInvariant.cpp | 15 +- src/libxrpl/tx/invariants/MPTInvariant.cpp | 117 ++- src/libxrpl/tx/invariants/NFTInvariant.cpp | 125 ++- .../invariants/PermissionedDEXInvariant.cpp | 17 +- .../PermissionedDomainInvariant.cpp | 65 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 127 +++ src/libxrpl/tx/paths/AMMLiquidity.cpp | 52 +- src/libxrpl/tx/paths/AMMOffer.cpp | 141 +++- src/libxrpl/tx/paths/BookStep.cpp | 147 +++- src/xrpld/consensus/Consensus.cpp | 78 +- src/xrpld/consensus/Consensus.h | 332 ++++++-- src/xrpld/consensus/ConsensusParms.h | 305 ++++++-- src/xrpld/consensus/ConsensusProposal.h | 242 ++++-- src/xrpld/consensus/ConsensusTypes.h | 291 +++++-- src/xrpld/consensus/DisputedTx.h | 250 +++--- src/xrpld/consensus/LedgerTrie.h | 276 +++++-- src/xrpld/consensus/Validations.h | 725 +++++++++++------- src/xrpld/overlay/Cluster.h | 112 ++- src/xrpld/overlay/ClusterNode.h | 67 ++ src/xrpld/overlay/Compression.h | 103 ++- src/xrpld/overlay/Message.h | 175 ++++- src/xrpld/overlay/Overlay.h | 295 +++++-- src/xrpld/overlay/Peer.h | 242 +++++- src/xrpld/overlay/PeerSet.h | 124 ++- src/xrpld/overlay/ReduceRelayCommon.h | 133 +++- src/xrpld/overlay/Slot.h | 505 ++++++++---- src/xrpld/overlay/Squelch.h | 93 ++- src/xrpld/overlay/detail/Cluster.cpp | 24 + src/xrpld/overlay/detail/ConnectAttempt.cpp | 266 ++++++- src/xrpld/overlay/detail/ConnectAttempt.h | 399 +++++++--- src/xrpld/overlay/detail/Handshake.cpp | 233 +++++- src/xrpld/overlay/detail/Handshake.h | 320 +++++--- src/xrpld/overlay/detail/Message.cpp | 58 +- src/xrpld/overlay/detail/OverlayImpl.cpp | 514 +++++++++++-- src/xrpld/peerfinder/PeerfinderManager.h | 457 ++++++++--- src/xrpld/peerfinder/Slot.h | 106 ++- src/xrpld/peerfinder/detail/Bootcache.cpp | 112 ++- src/xrpld/peerfinder/detail/Bootcache.h | 175 ++++- src/xrpld/peerfinder/detail/Checker.h | 119 ++- src/xrpld/peerfinder/detail/Counts.h | 259 +++++-- src/xrpld/peerfinder/detail/Endpoint.cpp | 20 + src/xrpld/peerfinder/detail/Fixed.h | 62 +- src/xrpld/peerfinder/detail/Handouts.h | 171 ++++- src/xrpld/peerfinder/detail/Livecache.h | 219 ++++-- src/xrpld/peerfinder/detail/Logic.h | 530 +++++++++++-- .../peerfinder/detail/PeerfinderConfig.cpp | 44 +- .../peerfinder/detail/PeerfinderManager.cpp | 138 ++++ src/xrpld/peerfinder/detail/SlotImp.cpp | 142 +++- src/xrpld/peerfinder/detail/SlotImp.h | 230 +++++- src/xrpld/peerfinder/detail/Source.h | 56 +- src/xrpld/peerfinder/detail/SourceStrings.cpp | 64 ++ src/xrpld/peerfinder/detail/SourceStrings.h | 32 +- src/xrpld/peerfinder/detail/Store.h | 58 +- src/xrpld/peerfinder/detail/StoreSqdb.h | 86 ++- src/xrpld/peerfinder/detail/Tuning.h | 188 +++-- src/xrpld/peerfinder/detail/iosformat.h | 199 ++++- src/xrpld/peerfinder/make_Manager.h | 36 +- src/xrpld/rpc/BookChanges.h | 102 ++- src/xrpld/rpc/CTID.h | 100 ++- src/xrpld/rpc/Context.h | 98 ++- src/xrpld/rpc/DeliveredAmount.h | 128 +++- src/xrpld/rpc/GRPCHandlers.h | 87 ++- src/xrpld/rpc/MPTokenIssuanceID.h | 71 +- src/xrpld/rpc/Output.h | 36 + src/xrpld/rpc/RPCCall.h | 114 ++- src/xrpld/rpc/RPCHandler.h | 74 +- src/xrpld/rpc/RPCSub.h | 64 +- src/xrpld/rpc/Role.h | 129 +++- src/xrpld/rpc/ServerHandler.h | 257 ++++++- src/xrpld/rpc/Status.h | 148 +++- src/xrpld/rpc/detail/AccountAssets.cpp | 34 +- src/xrpld/rpc/detail/AccountAssets.h | 81 ++ src/xrpld/rpc/detail/AssetCache.cpp | 96 ++- src/xrpld/rpc/detail/AssetCache.h | 118 ++- src/xrpld/rpc/detail/DeliveredAmount.cpp | 91 ++- src/xrpld/rpc/detail/Handler.cpp | 161 +++- src/xrpld/rpc/detail/Handler.h | 128 +++- src/xrpld/rpc/detail/LegacyPathFind.cpp | 20 + src/xrpld/rpc/detail/LegacyPathFind.h | 53 ++ src/xrpld/rpc/detail/MPT.h | 93 ++- src/xrpld/rpc/detail/MPTokenIssuanceID.cpp | 19 +- src/xrpld/rpc/detail/PathRequest.cpp | 319 ++++++-- src/xrpld/rpc/detail/PathRequest.h | 327 +++++++- src/xrpld/rpc/detail/PathRequestManager.cpp | 150 +++- src/xrpld/rpc/detail/PathRequestManager.h | 208 ++++- 293 files changed, 38699 insertions(+), 6633 deletions(-) diff --git a/include/xrpl/basics/FileUtilities.h b/include/xrpl/basics/FileUtilities.h index 8cf7e4893f..25803cae1f 100644 --- a/include/xrpl/basics/FileUtilities.h +++ b/include/xrpl/basics/FileUtilities.h @@ -7,12 +7,48 @@ namespace xrpl { +/** Read the entire contents of a file into a string. + * + * Resolves `sourcePath` to its canonical (absolute, symlink-free) form before + * opening it, which prevents TOCTOU races between path resolution and the open. + * When `maxSize` is supplied and the file exceeds that byte count, `ec` is set + * to `boost::system::errc::file_too_large` and an empty string is returned + * without reading any data. + * + * All errors — non-existent path, permission denial, size exceeded, open + * failure, and mid-read I/O error — are reported through `ec`. The function + * never throws. + * + * @param ec Output error code; set on any failure, left unchanged on success. + * @param sourcePath Path to the file to read; must exist and be resolvable. + * @param maxSize Optional upper bound on file size in bytes. If the file is + * larger, `ec` is set to `errc::file_too_large` and `{}` is returned. + * @return The full file contents on success, or an empty string on any error. + * @note EOF during the single-pass read is not an error; only `bad()` (hardware + * or stream-corruption failure) triggers an error code after the read. + */ std::string getFileContents( boost::system::error_code& ec, boost::filesystem::path const& sourcePath, std::optional maxSize = std::nullopt); +/** Write a string to a file, creating or truncating it as necessary. + * + * Opens `destPath` with `std::ios::out | std::ios::trunc`, so any existing + * content is discarded and the file is created if it does not yet exist. + * This is a full replacement, not an atomic rename-and-swap; callers that + * require crash-safe writes must implement that at a higher level. + * + * All errors — open failure and mid-write I/O error — are reported through + * `ec`. The function never throws. + * + * @param ec Output error code; set on any failure, left unchanged on success. + * @param destPath Path to the destination file; parent directory must exist. + * @param contents Data to write; written in a single `<<` operation. + * @note Unlike `getFileContents`, this function does not call `canonical()` + * because the destination file may not yet exist. + */ void writeFileContents( boost::system::error_code& ec, diff --git a/include/xrpl/crypto/RFC1751.h b/include/xrpl/crypto/RFC1751.h index c99c691ba0..9870321b2d 100644 --- a/include/xrpl/crypto/RFC1751.h +++ b/include/xrpl/crypto/RFC1751.h @@ -1,3 +1,18 @@ +/** @file + * RFC 1751 mnemonic encoding for 128-bit XRPL wallet seeds. + * + * Declares the `RFC1751` utility class, which encodes and decodes 128-bit + * binary keys as sequences of short English words drawn from a 2048-word + * dictionary. Each word represents exactly 11 bits; a 64-bit block maps + * to 6 words (64 data bits + 2 parity bits = 66 bits). A full 128-bit key + * therefore encodes as 12 words in two back-to-back 6-word groups. + * + * The primary consumer is `Seed.cpp`, which uses the codec to produce + * human-readable wallet seed mnemonics. `NetworkOPs.cpp` also uses + * `getWordFromBlob` to derive a stable short label for the local node's + * public key in log output. + */ + #pragma once #include @@ -5,39 +20,183 @@ namespace xrpl { +/** XRPL adaptation of the RFC 1751 128-bit mnemonic key codec. + * + * Converts 128-bit binary keys to and from sequences of 12 English words + * using a fixed 2048-word dictionary. The dictionary is split at index 571: + * words 0–570 have 1–3 characters; words 571–2047 are all exactly 4 + * characters. This property is exploited internally to halve binary-search + * range during decoding. + * + * All methods are static; this class is a pure stateless namespace and is + * never instantiated. + * + * @note `Seed.cpp` reverses the 16 seed bytes before passing them to + * `getEnglishFromKey` and after receiving them from `getKeyFromEnglish` + * to satisfy the RFC's big-endian byte-order convention. + */ class RFC1751 { public: + /** Decode a 12-word mnemonic string into a 128-bit binary key. + * + * Splits @p strHuman on whitespace (multiple spaces are collapsed), + * validates and normalises each word via `standard()`, looks each one + * up in the dictionary, and packs the resulting 11-bit indices into two + * 8-byte binary halves. Each half carries a 2-bit parity check computed + * from the 64 data bits; the decode fails with `-2` if the recomputed + * parity does not match. + * + * @param strKey Output parameter; set to the 16-byte binary key on + * success. Unchanged on any failure return. + * @param strHuman 12 space-separated words to decode. Leading and + * trailing whitespace is trimmed before splitting. + * @return 1 success — @p strKey holds the decoded 16-byte key. + * @return 0 a word was not found in the dictionary. + * @return -1 malformed input: word count ≠ 12, or a word exceeds 4 + * characters. + * @return -2 all words are valid but the 2-bit parity check failed, + * indicating a transcription error. + * + * @note The four distinct return codes must not be collapsed; `-2` + * (parity failure) implies the words themselves were individually + * valid and is a different diagnostic than `0` (unknown word). + */ static int getKeyFromEnglish(std::string& strKey, std::string const& strHuman); + /** Encode a 128-bit binary key as 12 space-separated English words. + * + * Encodes the first 8 bytes of @p strKey as 6 words and the next 8 + * bytes as a further 6 words, then joins the two groups with a single + * space. A 2-bit parity value is appended to each 64-bit block before + * encoding to support transcription-error detection on decode. + * + * Encoding is lossless and cannot fail for valid 16-byte input; no + * return code is needed. + * + * @param strHuman Output parameter; receives the 12-word mnemonic string. + * @param strKey The 16-byte (128-bit) binary key to encode. Behaviour + * is undefined if fewer than 16 bytes are provided. + */ static void getEnglishFromKey(std::string& strHuman, std::string const& strKey); - /** Chooses a single dictionary word from the data. - - This is not particularly secure but it can be useful to provide - a unique name for something given a GUID or fixed data. We use - it to turn the pubkey_node into an easily remembered and identified - 4 character string. - */ + /** Map arbitrary binary data to a single dictionary word. + * + * Applies the Jenkins one-at-a-time hash to the input bytes, then + * indexes into the 2048-word dictionary using the hash modulo 2048. + * The result is a stable, reproducible label for the input data. + * + * @param blob Pointer to the input data. + * @param bytes Number of bytes to hash. + * @return A single uppercase dictionary word of 1–4 characters. + * + * @note This function is **not** cryptographically secure. It is + * intended only for producing human-readable identifiers, such as + * the `shroudedHostId` label derived from a node's public key in + * `NetworkOPs.cpp`. + */ static std::string getWordFromBlob(void const* blob, size_t bytes); private: + /** Read up to 11 bits from a byte array at an arbitrary bit offset. + * + * Assembles up to 3 adjacent bytes into a 24-bit window, shifts right + * to align the target field, and masks to @p length bits. Works across + * byte boundaries. The output buffer for the 66-bit block (64 data + + * 2 parity) must be at least 9 bytes. + * + * @param s Source byte array (at least ⌈(start + length) / 8⌉ + 1 + * bytes long; 9 bytes for the full 66-bit block). + * @param start First bit to read (0-based). Must be ≥ 0. + * @param length Number of bits to read. Must satisfy 0 ≤ length ≤ 11 + * and start + length ≤ 66. + * @return The extracted value, right-justified and zero-extended. + */ static unsigned long extract(char const* s, int start, int length); + + /** Encode an 8-byte binary block as 6 space-separated dictionary words. + * + * Appends a 9th byte carrying a 2-bit parity value (sum of all 32 + * two-bit pairs in the 64-bit payload, placed at bits 64–65), then + * calls `extract()` at six 11-bit offsets to obtain dictionary indices. + * + * @param strHuman Output; receives the 6-word space-separated string. + * @param strData Exactly 8 bytes of binary data to encode. + */ static void btoe(std::string& strHuman, std::string const& strData); + + /** Write up to 11 bits into a byte array at an arbitrary bit offset. + * + * ORs the bit field into the target bytes; the output buffer must be + * zero-initialised before the first call because this function + * accumulates bits with bitwise OR rather than assignment. + * + * @param s Target byte array (must be zero-initialised). + * @param x Value to insert (only the low @p length bits are used). + * @param start First destination bit (0-based). Must be ≥ 0. + * @param length Number of bits to write. Must satisfy 0 ≤ length ≤ 11 + * and start + length ≤ 66. + */ static void insert(char* s, int x, int start, int length); + + /** Normalise a mnemonic word for dictionary lookup. + * + * Applies three in-place transformations to tolerate common + * handwriting and OCR ambiguities: lowercased letters are uppercased, + * `'1'` is replaced by `'L'`, `'0'` by `'O'`, and `'5'` by `'S'`. + * + * @param strWord Word to normalise in place. + */ static void standard(std::string& strWord); + + /** Binary-search the dictionary within a given index range. + * + * The dictionary is sorted, and its first 571 entries (indices 0–570) + * are words of 1–3 characters while the remaining 1477 (indices + * 571–2047) are all exactly 4 characters. Callers restrict the range + * based on word length to halve the search space. + * + * @param strWord Word to search for (must already be normalised via + * `standard()`). + * @param iMin Inclusive lower bound of the search range. + * @param iMax Exclusive upper bound of the search range. + * @return The dictionary index of @p strWord, or -1 if not found. + */ static int wsrch(std::string const& strWord, int iMin, int iMax); + + /** Decode 6 mnemonic words into an 8-byte binary block. + * + * Normalises each word, looks it up via `wsrch()`, packs the resulting + * 11-bit indices into a 9-byte buffer using `insert()`, then validates + * the 2-bit parity stored at bit offset 64. + * + * @param strData Output; receives the 8 decoded data bytes on success. + * Unchanged on any failure return. + * @param vsHuman Exactly 6 words to decode. Returns -1 immediately + * if the vector does not contain exactly 6 elements, or if any + * word is longer than 4 characters. + * @return 1 success. + * @return 0 a word was not found in the dictionary. + * @return -1 wrong word count or word exceeds 4 characters. + * @return -2 parity mismatch. + */ static int etob(std::string& strData, std::vector vsHuman); + /** The 2048-word mnemonic dictionary, sorted ascending. + * + * Indices 0–570 contain words of 1–3 characters; indices 571–2047 + * contain words of exactly 4 characters. This structural split is + * relied upon by `wsrch()` to restrict binary-search ranges. + */ static char const* dictionary[]; }; diff --git a/include/xrpl/crypto/csprng.h b/include/xrpl/crypto/csprng.h index e386d9d11e..8a0b2ff16a 100644 --- a/include/xrpl/crypto/csprng.h +++ b/include/xrpl/crypto/csprng.h @@ -4,14 +4,38 @@ namespace xrpl { -/** A cryptographically secure random number engine +/** @file + * Cryptographically secure pseudo-random number engine and singleton accessor. + * + * Every piece of key material in the XRP Ledger — wallet seeds, secret keys, + * nonces, session identifiers — is generated through `CsprngEngine`. The class + * is a thin, type-safe C++ wrapper around OpenSSL's `RAND_bytes` that provides + * thread safety and satisfies the C++ *UniformRandomNumberEngine* named + * requirement, allowing it to be used directly with standard-library facilities + * such as `std::uniform_int_distribution` and `beast::rngfill`. + */ - The engine is thread-safe (it uses a lock to serialize - access) and will, automatically, mix in some randomness - from std::random_device. - - Meets the requirements of UniformRandomNumberEngine -*/ +/** Cryptographically secure random number engine backed by OpenSSL. + * + * Wraps OpenSSL's `RAND_bytes` to provide randomness to the rest of the + * codebase without any caller needing to touch OpenSSL directly. Satisfies + * the C++ *UniformRandomNumberEngine* named requirement (`result_type`, + * `operator()()`, `min()`, `max()`), so it plugs directly into + * `std::uniform_int_distribution`, `beast::rngfill`, and similar utilities. + * + * Thread safety is version-conditioned at compile time: on OpenSSL ≥ 1.1.0 + * built with thread support, `RAND_bytes` is internally thread-safe and the + * per-call mutex acquisition is elided on the hot path. On older OpenSSL the + * mutex is always held. Entropy mixing (`mixEntropy`) always holds the mutex + * regardless of OpenSSL version because `RAND_add` modifies shared pool state. + * + * Copy and move operations are deleted. The engine holds a `std::mutex`, is + * backed by a global OpenSSL PRNG pool, and must be accessed as a singleton. + * Copying would produce a second object with no coherent relationship to that + * shared state. Use `cryptoPrng()` to obtain the singleton reference. + * + * @see cryptoPrng() + */ class CsprngEngine { private: @@ -28,29 +52,92 @@ public: CsprngEngine& operator=(CsprngEngine&&) = delete; + /** Construct and eagerly seed the engine. + * + * Calls `RAND_poll()` to harvest OS entropy (e.g., `/dev/urandom` on + * Linux, `CryptGenRandom` on Windows) before any bytes are generated. + * Although OpenSSL seeds itself lazily on first use, polling eagerly + * surfaces seeding failures at startup rather than during key generation. + * + * @throw std::runtime_error if `RAND_poll()` fails. + */ CsprngEngine(); + + /** Destroy the engine, releasing OpenSSL PRNG state on older runtimes. + * + * Calls `RAND_cleanup()` only for OpenSSL versions older than 1.1.0. + * Modern OpenSSL manages cleanup internally via `atexit`; calling + * `RAND_cleanup()` on those versions is unnecessary and was removed. + */ ~CsprngEngine(); - /** Mix entropy into the pool */ + /** Stir additional entropy into the OpenSSL random pool. + * + * Reads 128 values from `std::random_device` and passes them to + * `RAND_add` with an entropy estimate of zero. The caller-supplied + * buffer, if provided, is also added with a zero entropy estimate. + * The zero estimate is deliberate: on some platforms `std::random_device` + * may fall back to a software PRNG, so claiming zero entropy ensures + * OpenSSL's internal seeding threshold is never prematurely satisfied by + * potentially weak input. The data is still mixed into the pool. + * + * Called periodically from `Application.cpp` to stir in fresh OS entropy + * during the node's lifetime. May also be called with caller-supplied + * high-quality entropy from a hardware RNG or other trusted source. + * + * @param buffer Optional pointer to additional entropy material to mix in. + * Ignored if `nullptr` or if `count` is zero. + * @param count Number of bytes at `buffer` to mix in. + */ void mixEntropy(void* buffer = nullptr, std::size_t count = 0); - /** Generate a random integer */ + /** Generate a single random `result_type` value. + * + * Delegates to the buffer-fill overload with `sizeof(result_type)` bytes, + * sharing the same validation and error-handling path. + * + * @return A uniformly distributed random `std::uint64_t`. + * @throw std::runtime_error if the underlying `RAND_bytes` call fails + * (e.g., entropy pool exhausted). This is an unrecoverable condition; + * the exception is not caught by callers such as `randomSecretKey()`. + */ result_type operator()(); - /** Fill a buffer with the requested amount of random data */ + /** Fill a buffer with cryptographically secure random bytes. + * + * On OpenSSL ≥ 1.1.0 (built with thread support) the call to `RAND_bytes` + * is internally thread-safe and the mutex is elided at compile time. On + * older OpenSSL the mutex is held for the duration of the call. + * + * @param ptr Pointer to the buffer to fill; must not be `nullptr` when + * `count` is non-zero. + * @param count Number of random bytes to write into `ptr`. + * @throw std::runtime_error ("CSPRNG: Insufficient entropy") if + * `RAND_bytes` returns anything other than 1. Generating key material + * from an exhausted pool is a security failure, so the exception + * propagates and halts the operation. + */ void operator()(void* ptr, std::size_t count); - /* The smallest possible value that can be returned */ + /** Return the smallest value that `operator()()` can produce. + * + * Required by the *UniformRandomNumberEngine* named requirement. + * Always returns `std::numeric_limits::min()`. + */ static constexpr result_type min() { return std::numeric_limits::min(); } - /* The largest possible value that can be returned */ + /** Return the largest value that `operator()()` can produce. + * + * Required by the *UniformRandomNumberEngine* named requirement. + * Always returns `std::numeric_limits::max()`. + */ static constexpr result_type max() { @@ -58,14 +145,23 @@ public: } }; -/** The default cryptographically secure PRNG - - Use this when you need to generate random numbers or - data that will be used for encryption or passed into - cryptographic routines. - - This meets the requirements of UniformRandomNumberEngine -*/ +/** Return a reference to the process-wide cryptographically secure PRNG. + * + * Use this whenever random numbers or bytes are needed for cryptographic + * purposes: key generation, seed creation, nonce production, or any value + * passed into a cryptographic routine. The returned engine satisfies the + * C++ *UniformRandomNumberEngine* requirement and can be used directly with + * `std::uniform_int_distribution`, `beast::rngfill`, and similar utilities. + * + * The singleton is a Meyers-static local; C++11 guarantees thread-safe + * one-time construction, so the first call from any thread safely initialises + * the engine exactly once. Every caller shares the same OpenSSL PRNG pool. + * + * @return Reference to the process-wide `CsprngEngine` singleton. + * @note Never copy or store the returned reference by value — the deleted + * copy/move operations on `CsprngEngine` prevent this at compile time. + * @see CsprngEngine + */ CsprngEngine& cryptoPrng(); diff --git a/include/xrpl/crypto/secure_erase.h b/include/xrpl/crypto/secure_erase.h index 74284b03f7..39c500f040 100644 --- a/include/xrpl/crypto/secure_erase.h +++ b/include/xrpl/crypto/secure_erase.h @@ -1,23 +1,38 @@ +/** @file + * Declares `xrpl::secureErase`, the canonical primitive for wiping + * sensitive key material from memory in a way that survives compiler + * dead-store elimination. + */ + #pragma once #include namespace xrpl { -/** Attempts to clear the given blob of memory. - - The underlying implementation of this function takes pains to - attempt to outsmart the compiler from optimizing the clearing - away. Please note that, despite that, remnants of content may - remain floating around in memory as well as registers, caches - and more. - - For a more in-depth discussion of the subject please see the - below posts by Colin Percival: - - http://www.daemonology.net/blog/2014-09-04-how-to-zero-a-buffer.html - http://www.daemonology.net/blog/2014-09-06-zeroing-buffers-is-insufficient.html -*/ +/** Best-effort wipe of a memory region containing sensitive data. + * + * Overwrites `bytes` bytes starting at `dest` using `OPENSSL_cleanse`, + * which employs volatile writes or memory barriers to prevent the compiler + * from eliminating the store as a dead write. The function is defined in a + * separate translation unit (`secure_erase.cpp`) so the call is always + * opaque to the optimizer at the call site, reinforcing the effect. + * + * Use this instead of `memset` whenever clearing key material, seeds, or + * derived intermediates. The canonical pattern is to call it in destructors + * and immediately after copying raw key bytes into their final owner object. + * + * @param dest Pointer to the memory region to wipe. Must not be null and + * must point to at least `bytes` bytes of writable memory. + * @param bytes Number of bytes to overwrite. + * + * @note This is a best-effort mitigation, not a guarantee of complete + * erasure. Register contents, CPU caches, and other micro-architectural + * state are outside its reach. For a thorough discussion of the + * inherent limits see Colin Percival's analysis: + * http://www.daemonology.net/blog/2014-09-04-how-to-zero-a-buffer.html + * http://www.daemonology.net/blog/2014-09-06-zeroing-buffers-is-insufficient.html + */ void secureErase(void* dest, std::size_t bytes); diff --git a/include/xrpl/ledger/AcceptedLedgerTx.h b/include/xrpl/ledger/AcceptedLedgerTx.h index 0a1592f6e1..6dec21ce8e 100644 --- a/include/xrpl/ledger/AcceptedLedgerTx.h +++ b/include/xrpl/ledger/AcceptedLedgerTx.h @@ -10,66 +10,133 @@ namespace xrpl { -/** - A transaction that is in a closed ledger. - - Description - - An accepted ledger transaction contains additional information that the - server needs to tell clients about the transaction. For example, - - The transaction in JSON form - - Which accounts are affected - * This is used by InfoSub to report to clients - - Cached stuff -*/ +/** Immutable snapshot of a transaction accepted into a closed ledger. + * + * Constructed once from the closed ledger view, the serialized transaction, + * and its raw metadata; all downstream representations — JSON payload, binary + * metadata blob, affected-account set — are fully materialized at construction + * time and never recomputed. + * + * The pre-built `json_` payload is consumed directly by + * `NetworkOPsImp::pubValidatedTransaction()` and `pubAccountTransaction()` + * for WebSocket subscription delivery; `rawMeta_` (via `getEscMeta()`) feeds + * SQL `INSERT`/`REPLACE` statements in the relational transaction database. + * + * `CountedObject` inheritance exposes live-instance telemetry useful for + * detecting accumulation under load or slow subscriber drain holding ledger + * snapshots open longer than expected. + * + * @note Immutable after construction; safe to share across threads without + * additional locking. + * @note The ledger passed to the constructor must be closed (not open). + * Constructing from an open ledger aborts in debug builds. + * @see AcceptedLedger + */ class AcceptedLedgerTx : public CountedObject { public: + /** Construct and fully materialize a closed-ledger transaction snapshot. + * + * Parses metadata into a `TxMeta`, serializes raw metadata bytes, builds + * the complete JSON payload (transaction, meta, raw_meta, result, affected + * accounts), and — for non-self-funded `ttOFFER_CREATE` transactions — + * annotates the JSON with `owner_funds` queried from `accountFunds()` with + * freeze and auth checks bypassed. This avoids a later ledger round-trip + * when delivering to order-book subscribers. + * + * @param ledger The closed ledger that accepted this transaction. Must not + * be open; the constructor asserts `!ledger->open()` in debug builds. + * @param txn The serialized transaction object. + * @param met The raw metadata `STObject` produced during transaction apply. + */ AcceptedLedgerTx( std::shared_ptr const& ledger, std::shared_ptr const&, std::shared_ptr const&); + /** Returns the serialized transaction. */ [[nodiscard]] std::shared_ptr const& getTxn() const { return txn_; } + + /** Returns the parsed transaction metadata, including affected nodes and + * result code. + */ [[nodiscard]] TxMeta const& getMeta() const { return meta_; } + /** Returns the set of accounts affected by this transaction. + * + * Stored as a `flat_set` for cache-friendly iteration during subscription + * fan-out in `pubAccountTransaction()`. + */ [[nodiscard]] boost::container::flat_set const& getAffected() const { return affected_; } + /** Returns the transaction's unique identifier (SHA-512 half of the + * canonical serialization). + */ [[nodiscard]] TxID getTransactionID() const { return txn_->getTransactionID(); } + + /** Returns the transaction type (e.g., `ttOFFER_CREATE`, `ttPAYMENT`). */ [[nodiscard]] TxType getTxnType() const { return txn_->getTxnType(); } + + /** Returns the transaction result code as recorded in metadata. */ [[nodiscard]] TER getResult() const { return meta_.getResultTER(); } + + /** Returns the transaction's ordinal position within the closed ledger. + * + * This is `TxMeta::getIndex()` — the transaction's sequence number within + * the ledger's ordered transaction set, not the account sequence number. + */ [[nodiscard]] std::uint32_t getTxnSeq() const { return meta_.getIndex(); } + + /** Returns the raw metadata formatted as an escaped SQL blob literal. + * + * Formats `rawMeta_` via `sqlBlobLiteral()` for direct embedding in SQL + * `INSERT`/`REPLACE` statements (see `STTx::getMetaSQL()` in `Node.cpp`). + * + * @return SQL blob literal string suitable for verbatim inclusion in a + * SQL statement. + * @note Asserts that `rawMeta_` is non-empty. An empty blob indicates + * upstream ledger corruption; every accepted transaction must carry + * metadata. + */ [[nodiscard]] std::string getEscMeta() const; + /** Returns the pre-built JSON envelope for WebSocket subscription delivery. + * + * The object contains `transaction`, `meta`, `raw_meta` (hex), `result` + * (human-readable TER string), and `affected` (base58 account array). + * For non-self-funded `ttOFFER_CREATE` transactions, `transaction` also + * contains `owner_funds` — the account's spendable balance of the offered + * asset at acceptance time, computed with freeze and auth checks bypassed. + */ [[nodiscard]] json::Value const& getJson() const { diff --git a/include/xrpl/ledger/AmendmentTable.h b/include/xrpl/ledger/AmendmentTable.h index 8ed3cb81ff..83f66381c1 100644 --- a/include/xrpl/ledger/AmendmentTable.h +++ b/include/xrpl/ledger/AmendmentTable.h @@ -14,56 +14,172 @@ namespace xrpl { class ServiceRegistry; -/** The amendment table stores the list of enabled and potential amendments. - Individuals amendments are voted on by validators during the consensus - process. -*/ +/** Tracks enabled and pending amendments and coordinates validator voting. + * + * Each protocol change (amendment) must achieve an 80% supermajority of + * trusted validators for `majorityTime` before it activates. This class + * manages the full lifecycle: registration of supported amendments, vote + * aggregation across flag ledgers, pseudo-transaction injection at consensus + * time, and detection of "amendment blocked" conditions where the network has + * enabled a feature this node does not support. + * + * The interface is split into two layers. The pure virtual methods form the + * internal API that the concrete implementation satisfies, operating on + * pre-extracted amendment sets. Two concrete non-virtual adapter methods + * (`doValidatedLedger(shared_ptr)` and + * `doVoting(shared_ptr, ...)`) read amendment state from a + * `ReadView` and delegate to the pure-virtual overloads, keeping the + * implementation independent of the ledger view layer. + * + * @note Amendment voting is only meaningful at flag ledgers (multiples of + * 256). Use `needValidatedLedger` to gate the more expensive + * `doValidatedLedger` call. + * @see Feature.h for `VoteBehavior` and `majorityAmendments_t` + */ class AmendmentTable { public: + /** Metadata for a single registered amendment. + * + * Bundles the human-readable name, canonical 256-bit hash, and compiled-in + * vote preference for one amendment. Non-default-constructible: every + * instance must carry all three fields. + * + * @note Amendments with `VoteBehavior::Obsolete` are still registered so + * the node remains amendment-unblocked if the network enables them, but + * the node will never emit votes for them and their vote behavior cannot + * be overridden by config. + */ struct FeatureInfo { FeatureInfo() = delete; + + /** Construct a FeatureInfo with all required fields. + * + * @param n Human-readable amendment name (e.g., "OwnerPaysFee"). + * @param f Canonical 256-bit amendment hash used in ledger state and + * validations. + * @param v Compiled-in voting preference (`DefaultYes`, `DefaultNo`, + * or `Obsolete`). + */ FeatureInfo(std::string n, uint256 const& f, VoteBehavior v) : name(std::move(n)), feature(f), vote(v) { } + /** Human-readable name of the amendment. */ std::string const name; + + /** Canonical 256-bit amendment identifier used throughout the ledger. */ uint256 const feature; + + /** Compiled-in voting preference for this amendment. */ VoteBehavior const vote; }; virtual ~AmendmentTable() = default; + /** Look up an amendment's 256-bit hash by its human-readable name. + * + * @param name The amendment name to look up (case-sensitive). + * @return The amendment's `uint256` hash, or a zero value if no + * amendment with that name is registered. + */ [[nodiscard]] virtual uint256 find(std::string const& name) const = 0; + /** Suppress this node's vote for an amendment. + * + * Changes the amendment's vote from Up to Down regardless of the + * compiled-in `VoteBehavior`. May be called on amendments not in the + * supported list; an entry is created if one does not exist. The new + * state is persisted to the wallet database. + * + * @param amendment The 256-bit amendment hash to veto. + * @return `true` if the vote state changed (was Up, now Down); + * `false` if the amendment was already Down-voted or Obsolete. + */ virtual bool veto(uint256 const& amendment) = 0; + + /** Remove a previously applied veto for an amendment. + * + * Reverts the amendment's vote from Down back to Up. The change is + * persisted to the wallet database. Has no effect if the amendment + * was never vetoed, does not exist, or has `VoteBehavior::Obsolete` + * (Obsolete amendments cannot be unvetoed). + * + * @param amendment The 256-bit amendment hash to un-veto. + * @return `true` if the vote state changed (was Down, now Up); + * `false` if the amendment was not in the Down state. + */ virtual bool unVeto(uint256 const& amendment) = 0; + /** Mark an amendment as enabled in the local amendment table. + * + * Directly flips the amendment's enabled flag. Called by + * `doValidatedLedger` when ledger state confirms the amendment is active. + * If the amendment is not in the supported list, `hasUnsupportedEnabled()` + * will subsequently return `true`. + * + * @param amendment The 256-bit amendment hash to enable. + * @return `true` if the amendment was not already enabled; + * `false` if it was already in the enabled state. + */ virtual bool enable(uint256 const& amendment) = 0; + /** Return whether an amendment is currently active on the network. + * + * @param amendment The 256-bit amendment hash to query. + * @return `true` if the amendment has been enabled via `enable()` or + * through ledger validation; `false` otherwise. + */ [[nodiscard]] virtual bool isEnabled(uint256 const& amendment) const = 0; + + /** Return whether this node's software knows about and supports an amendment. + * + * @param amendment The 256-bit amendment hash to query. + * @return `true` if the amendment was included in the `supported` list + * passed to `makeAmendmentTable`; `false` for unknown amendments. + */ [[nodiscard]] virtual bool isSupported(uint256 const& amendment) const = 0; - /** - * @brief returns true if one or more amendments on the network - * have been enabled that this server does not support + /** Return whether any network-enabled amendment is unsupported by this node. * - * @return true if an unsupported feature is enabled on the network + * When this returns `true`, the node is "amendment blocked" — it is + * executing ledger rules it does not fully implement. The application + * layer should warn operators and eventually halt participation. + * + * @return `true` if at least one enabled amendment is not in this node's + * supported list. */ [[nodiscard]] virtual bool hasUnsupportedEnabled() const = 0; + /** Return the projected activation time of the earliest unsupported amendment. + * + * Scans amendments currently holding validator supermajority that are not + * supported by this node and returns the time at which the earliest such + * amendment is expected to activate (`majorityTime` after it first + * achieved supermajority). Updated by `doValidatedLedger`. + * + * @return The projected activation time of the first unsupported amendment + * that has achieved majority, or `std::nullopt` if no unsupported + * amendment is approaching activation. + */ [[nodiscard]] virtual std::optional firstUnsupportedExpected() const = 0; + /** Serialize all known amendments to JSON for RPC responses. + * + * @param isAdmin `true` to include sensitive or operator-only fields. + * @return A `json::Value` object containing the full amendment list with + * status, vote, and majority information for each entry. + */ [[nodiscard]] virtual json::Value getJson(bool isAdmin) const = 0; @@ -71,7 +187,17 @@ public: [[nodiscard]] virtual json::Value getJson(uint256 const& amendment, bool isAdmin) const = 0; - /** Called when a new fully-validated ledger is accepted. */ + /** Update amendment state from a newly validated ledger. + * + * Adapter that extracts `enabledAmendments` and `majorityAmendments` from + * `lastValidatedLedger` via `getEnabledAmendments()` and + * `getMajorityAmendments()`, then delegates to the pure-virtual + * `doValidatedLedger(LedgerIndex, set, majorityAmendments_t)` overload. + * The call is skipped entirely when `needValidatedLedger` returns `false`. + * + * @param lastValidatedLedger The most recently validated ledger. Amendment + * state is read from this view; the ledger sequence gates the update. + */ void doValidatedLedger(std::shared_ptr const& lastValidatedLedger) { @@ -84,24 +210,77 @@ public: } } - /** Called to determine whether the amendment logic needs to process - a new validated ledger. (If it could have changed things.) - */ + /** Return whether the amendment table needs to process a given ledger sequence. + * + * Amendment voting state only changes at flag ledgers (every 256 ledgers). + * This gate avoids the cost of extracting and processing amendment state + * for the vast majority of validated ledgers that cannot affect voting + * outcomes. + * + * @param seq The sequence number of the validated ledger being considered. + * @return `true` if `seq` crosses a new 256-ledger flag boundary relative + * to the last processed sequence; `false` if no change is possible. + */ [[nodiscard]] virtual bool needValidatedLedger(LedgerIndex seq) const = 0; + /** Update internal amendment state from pre-extracted ledger data. + * + * Enables all amendments in `enabled`, then scans `majority` for + * unsupported amendments approaching activation and updates the + * `firstUnsupportedExpected` projection accordingly. Errors are logged for + * each unsupported amendment that has reached supermajority. + * + * @param ledgerSeq Sequence number of the validated ledger. + * @param enabled Set of amendment hashes currently active in the ledger. + * @param majority Map of amendment hash → time of first observed + * supermajority for amendments that have crossed the voting threshold + * but are not yet enabled. + */ virtual void doValidatedLedger( LedgerIndex ledgerSeq, std::set const& enabled, majorityAmendments_t const& majority) = 0; - // Called when the set of trusted validators changes. + /** Notify the table that the set of trusted validators has changed. + * + * Updates the internal per-validator vote cache: existing records are + * preserved for validators that remain trusted; new validators are + * initialized with empty votes; validators no longer in the UNL have + * their records discarded. Vote history is NOT reset — this preserves + * the anti-flapping behavior that prevents an amendment from appearing to + * oscillate across the 80% threshold as validators come and go. + * + * @param allTrusted The complete current set of trusted validator public keys. + */ virtual void trustChanged(hash_set const& allTrusted) = 0; - // Called by the consensus code when we need to - // inject pseudo-transactions + /** Compute amendment actions for the current consensus round. + * + * Aggregates amendment votes from `valSet` against the current ledger + * state, applying the anti-flapping policy that retains the last known + * vote from each trusted validator for up to 24 hours. For each amendment + * whose vote state has changed relative to the ledger, produces an action + * entry: + * - `tfGotMajority` — validators have supermajority; ledger does not yet + * record it. + * - `tfLostMajority` — validators have lost supermajority; ledger still + * records it. + * - `0` — supermajority has been held for `majorityTime`; enable now. + * + * @param rules Protocol rules in effect for the ledger being built. + * @param closeTime Parent ledger's close time, used to evaluate whether + * `majorityTime` has elapsed since first supermajority. + * @param enabledAmendments Set of amendment hashes already active. + * @param majorityAmendments Map of amendment hash → time first achieving + * supermajority, for amendments not yet enabled. + * @param valSet Validations from the previous ledger; each carries the + * set of amendments the issuing validator supports. + * @return A map from amendment hash to action flag for each amendment + * requiring a pseudo-transaction in the initial consensus position. + */ virtual std::map doVoting( Rules const& rules, @@ -110,15 +289,27 @@ public: majorityAmendments_t const& majorityAmendments, std::vector> const& valSet) = 0; - // Called by the consensus code when we need to - // add feature entries to a validation + /** Return the amendment hashes this node wishes to vote for. + * + * Called when building a `STValidation` message. Returns all amendments + * that this node supports, has Up-voted, and that are not already active + * in the ledger. The result is sorted. + * + * @param enabled The set of amendment hashes currently enabled in the + * ledger; enabled amendments are excluded from the returned set. + * @return Sorted vector of amendment hashes this node wants to vote for. + */ [[nodiscard]] virtual std::vector doValidation(std::set const& enabled) const = 0; - // The set of amendments to enable in the genesis ledger - // This will return all known, non-vetoed amendments. - // If we ever have two amendments that should not both be - // enabled at the same time, we should ensure one is vetoed. + /** Return all non-vetoed amendments desired for a genesis ledger. + * + * Equivalent to `doValidation({})` — returns every supported, Up-voted + * amendment since none are enabled yet. If two amendments must not both be + * enabled simultaneously, one must be vetoed before calling this. + * + * @return All known, supported, non-vetoed amendment hashes. + */ [[nodiscard]] virtual std::vector getDesired() const = 0; @@ -128,6 +319,25 @@ public: // implementation. These APIs will merge when the view code // supports a full ledger API + /** Run the amendment voting pipeline and inject pseudo-transactions. + * + * Adapter for the consensus engine. Extracts amendment state from + * `lastClosedLedger`, delegates to the pure-virtual `doVoting` overload + * to determine required actions, then builds a signed-less `STTx` of type + * `ttAMENDMENT` for each action and inserts it into `initialPosition` + * as a `TnTransactionNm` node. These pseudo-transactions are not user + * transactions; they are injected directly into the consensus-agreed + * transaction set so validators can process them at flag-ledger close. + * + * @param lastClosedLedger The most recently closed ledger; supplies + * rules, parent close time, enabled amendments, and majority state. + * @param parentValidations Validations received for the parent ledger; + * each carries the voting validator's amendment preferences. + * @param initialPosition The SHAMap being built as the node's initial + * consensus position; amendment pseudo-transactions are added here. + * @param j Journal for debug logging of injected + * pseudo-transactions. + */ void doVoting( std::shared_ptr const& lastClosedLedger, @@ -169,6 +379,30 @@ public: } }; +/** Create the concrete AmendmentTable implementation. + * + * Registers all supported amendments, applies config-forced enables and + * vetoes, and loads any persisted vote overrides from the wallet database. + * Config entries in `enabled` and `vetoed` are ignored if the wallet database + * already contains a `FeatureVotes` table — the database is the authoritative + * source for persisted vote state. + * + * @param registry Service registry used to access the wallet database for + * persisting vote state. + * @param majorityTime Duration a supermajority must be continuously held + * before an amendment is enabled (typically two weeks on mainnet). + * @param supported All amendments compiled into this build, each with its + * `VoteBehavior`. Amendments absent from this list are treated as + * unsupported; enabling them sets `hasUnsupportedEnabled()`. + * @param enabled Config section (`[amendments]`) listing amendment IDs + * to force-enable; applied only when the wallet database has no + * `FeatureVotes` table. + * @param vetoed Config section (`[veto_amendments]`) listing amendment + * IDs to suppress votes for; applied only when the wallet database has no + * `FeatureVotes` table. + * @param journal Journal for logging during initialization. + * @return Owning pointer to the constructed `AmendmentTable`. + */ std::unique_ptr makeAmendmentTable( ServiceRegistry& registry, diff --git a/include/xrpl/ledger/ApplyView.h b/include/xrpl/ledger/ApplyView.h index f825311e1d..4d503a3f38 100644 --- a/include/xrpl/ledger/ApplyView.h +++ b/include/xrpl/ledger/ApplyView.h @@ -1,3 +1,14 @@ +/** @file + * Defines `ApplyView`, the writable ledger view used during transaction + * application, and the `ApplyFlags` bitmask that configures each apply pass. + * + * All state mutations produced by a transaction — trust-line updates, offer + * creation, account creation, fee destruction — flow through `ApplyView`. + * Changes are journaled and may be committed to the parent view or discarded + * atomically, enabling transactional rollback at every layer of the view + * hierarchy. + */ + #pragma once #include @@ -7,30 +18,54 @@ namespace xrpl { +/** Bitmask of flags that configure how a transaction apply pass behaves. + * + * Carried through every transaction-application call site. Multiple flags + * may be combined with `operator|`. All bitwise operators use `safeCast` + * to prevent silent conversion to the underlying integer type. + * + * @note Correctness and commutativity of `operator|` and `operator&` are + * verified by `static_assert` at compile time, guarding against future + * value collisions. + */ // Bitwise flag enum with existing operator overloads // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum ApplyFlags : std::uint32_t { + /** No flags set; default processing. */ TapNone = 0x00, - // This is a local transaction with the - // fail_hard flag set. + /** Transaction originated locally with `fail_hard` set. + * + * The engine must not retry; a hard failure that claims fees is + * produced instead of a soft retry. + */ TapFailHard = 0x10, - // This is not the transaction's last pass - // Transaction can be retried, soft failures allowed + /** This is not the transaction's final pass. + * + * Soft failures (insufficient balance, wrong sequence) are allowed + * because the transaction may succeed in a later pass. + */ TapRetry = 0x20, - // Transaction came from a privileged source + /** Transaction arrived from a trusted, privileged source. + * + * Certain per-transaction limits are relaxed (e.g., path count). + */ TapUnlimited = 0x400, - // Transaction is executing as part of a batch + /** Transaction is being processed as part of a batch transaction. */ TapBatch = 0x800, - // Transaction shouldn't be applied - // Signatures shouldn't be checked + /** Dry-run simulation: apply the transaction without committing state. + * + * Signature checks are skipped. A full `TxMeta` is still produced so + * callers can inspect the outcome. Used by the `simulate` RPC handler. + */ TapDryRun = 0x1000 }; +/** Combine two `ApplyFlags` values. */ constexpr ApplyFlags operator|(ApplyFlags const& lhs, ApplyFlags const& rhs) { @@ -42,6 +77,7 @@ operator|(ApplyFlags const& lhs, ApplyFlags const& rhs) static_assert((TapFailHard | TapRetry) == safeCast(0x30u), "ApplyFlags operator |"); static_assert((TapRetry | TapFailHard) == safeCast(0x30u), "ApplyFlags operator |"); +/** Mask `ApplyFlags` values, retaining only the bits present in both operands. */ constexpr ApplyFlags operator&(ApplyFlags const& lhs, ApplyFlags const& rhs) { @@ -53,6 +89,7 @@ operator&(ApplyFlags const& lhs, ApplyFlags const& rhs) static_assert((TapFailHard & TapRetry) == TapNone, "ApplyFlags operator &"); static_assert((TapRetry & TapFailHard) == TapNone, "ApplyFlags operator &"); +/** Invert all bits of an `ApplyFlags` value. */ constexpr ApplyFlags operator~(ApplyFlags const& flags) { @@ -61,6 +98,7 @@ operator~(ApplyFlags const& flags) static_assert(~TapRetry == safeCast(0xFFFFFFDFu), "ApplyFlags operator ~"); +/** Set-assign `ApplyFlags` bits from `rhs` into `lhs`. */ inline ApplyFlags operator|=(ApplyFlags& lhs, ApplyFlags const& rhs) { @@ -68,6 +106,7 @@ operator|=(ApplyFlags& lhs, ApplyFlags const& rhs) return lhs; } +/** Clear `ApplyFlags` bits in `lhs` that are absent from `rhs`. */ inline ApplyFlags operator&=(ApplyFlags& lhs, ApplyFlags const& rhs) { @@ -77,47 +116,40 @@ operator&=(ApplyFlags& lhs, ApplyFlags const& rhs) //------------------------------------------------------------------------------ -/** Writeable view to a ledger, for applying a transaction. - - This refinement of ReadView provides an interface where - the SLE can be "checked out" for modifications and put - back in an updated or removed state. Also added is an - interface to provide contextual information necessary - to calculate the results of transaction processing, - including the metadata if the view is later applied to - the parent (using an interface in the derived class). - The context info also includes values from the base - ledger such as sequence number and the network time. - - This allows implementations to journal changes made to - the state items in a ledger, with the option to apply - those changes to the base or discard the changes without - affecting the base. - - Typical usage is to call read() for non-mutating - operations. - - For mutating operations the sequence is as follows: - - // Add a new value - v.insert(sle); - - // Check out a value for modification - sle = v.peek(k); - - // Indicate that changes were made - v.update(sle) - - // Or, erase the value - v.erase(sle) - - The invariant is that insert, update, and erase may not - be called with any SLE which belongs to different view. -*/ +/** Writable view of a ledger used during transaction application. + * + * Extends `ReadView` with a checkout-modify-commit protocol: callers + * `peek()` an SLE to obtain a mutable handle, mutate it in place, then + * call `update()` (or `erase()`) to journal the change. `insert()` adds + * entries that were never checked out. All deltas are buffered; calling + * `apply()` on the concrete subclass flushes them to the parent view. + * Discarding the view without calling `apply()` abandons all changes. + * + * Also exposes directory management (`dirAppend`, `dirInsert`, `dirRemove`, + * `dirDelete`) and virtual payment hooks (`creditHookIOU`, `creditHookMPT`, + * `issuerSelfDebitHookMPT`, `adjustOwnerCountHook`) that `PaymentSandbox` + * overrides to prevent double-spend within a multi-hop payment path. + * + * @invariant `update()` and `erase()` must be called with an SLE obtained + * from `peek()` on **the same view instance**. Passing an SLE across + * view boundaries is undefined behavior, because each view journals its + * own deltas independently. + */ class ApplyView : public ReadView { private: - /** Add an entry to a directory using the specified insert strategy */ + /** Insert a key into the directory, routing to append-tail or + * sorted-insert logic based on `preserveOrder`. + * + * @param preserveOrder if `true`, append to tail (offer-book order); + * if `false`, insert in sorted position within each page. + * @param directory keylet of the directory root page. + * @param key the `uint256` key to insert. + * @param describe callback invoked on each newly allocated page SLE to + * brand it with type-specific fields (e.g., `sfOwner`). + * @return the 0-based page index where the key was stored, or + * `std::nullopt` if the page counter overflowed. + */ std::optional dirAdd( bool preserveOrder, @@ -128,92 +160,86 @@ private: public: ApplyView() = default; - /** Returns the tx apply flags. - - Flags can affect the outcome of transaction - processing. For example, transactions applied - to an open ledger generate "local" failures, - while transactions applied to the consensus - ledger produce hard failures (and claim a fee). - */ + /** Return the flags that govern this transaction apply pass. + * + * Flags shape engine behavior: `TapRetry` allows soft failures, + * `TapFailHard` demands a fee-claiming hard failure, `TapDryRun` + * suppresses state commits, and `TapUnlimited` relaxes per-tx limits. + * + * @return the `ApplyFlags` bitmask for this view. + */ [[nodiscard]] virtual ApplyFlags flags() const = 0; - /** Prepare to modify the SLE associated with key. - - Effects: - - Gives the caller ownership of a modifiable - SLE associated with the specified key. - - The returned SLE may be used in a subsequent - call to erase or update. - - The SLE must not be passed to any other ApplyView. - - @return `nullptr` if the key is not present - */ + /** Check out a ledger entry for in-place mutation. + * + * Returns an owning `shared_ptr` whose contents may be freely + * modified. The caller must pass the same pointer back to `update()` + * or `erase()` on **this** view instance when done; passing it to any + * other `ApplyView` is undefined behavior. + * + * @param k keylet identifying the entry. + * @return a mutable handle to the SLE, or `nullptr` if `k` is not + * present in this view. + */ virtual std::shared_ptr peek(Keylet const& k) = 0; - /** Remove a peeked SLE. - - Requirements: - - `sle` was obtained from prior call to peek() - on this instance of the RawView. - - Effects: - - The key is no longer associated with the SLE. - */ + /** Remove an entry previously checked out with `peek()`. + * + * Journals a delete delta so the entry is absent when this view's + * changes are later committed. + * + * @param sle a pointer obtained from `peek()` on this view instance. + * + * @note The key is taken from the SLE's own key field. + */ virtual void erase(std::shared_ptr const& sle) = 0; - /** Insert a new state SLE - - Requirements: - - `sle` was not obtained from any calls to - peek() on any instances of RawView. - - The SLE's key must not already exist. - - Effects: - - The key in the state map is associated - with the SLE. - - The RawView acquires ownership of the shared_ptr. - - @note The key is taken from the SLE - */ + /** Insert a brand-new ledger entry that has no prior existence in this view. + * + * The SLE must not have been obtained from `peek()`. Its key must not + * already exist in this view. The view takes ownership of the + * `shared_ptr`. + * + * @param sle the new entry to insert. + * + * @note The key is taken from the SLE's own key field. + */ virtual void insert(std::shared_ptr const& sle) = 0; - /** Indicate changes to a peeked SLE - - Requirements: - - The SLE's key must exist. - - `sle` was obtained from prior call to peek() - on this instance of the RawView. - - Effects: - - The SLE is updated - - @note The key is taken from the SLE - */ - /** @{ */ + /** Journal modifications to a checked-out ledger entry. + * + * Signals to the underlying delta table that the entry has changed and + * its new state must be written when this view's changes are committed. + * The entry's key must already exist. + * + * @param sle a pointer obtained from `peek()` on this view instance. + * + * @note The key is taken from the SLE's own key field. + */ virtual void update(std::shared_ptr const& sle) = 0; //-------------------------------------------------------------------------- - // Called when a credit is made to an account - // This is required to support PaymentSandbox + /** Notification hook invoked whenever an IOU credit is made to an account. + * + * The default implementation is a no-op; `PaymentSandbox` overrides it to + * record the credit in its `DeferredCredits` table so that subsequent + * `balanceHookIOU` calls subtract in-path credits from reported balances, + * preventing circular paths from manufacturing liquidity. + * + * @param from the debited account (sender side of the trust line). + * @param to the credited account (receiver side of the trust line). + * @param amount the IOU amount being credited; must hold an `Issue`. + * @param preCreditBalance the sender's trust-line balance before the credit. + * + * @note The `XRPL_ASSERT` in the default body verifies that `amount` holds + * an `Issue`; it fires in debug builds if the wrong asset type is passed. + */ virtual void creditHookIOU( AccountID const& from, @@ -224,6 +250,23 @@ public: XRPL_ASSERT(amount.holds(), "creditHookIOU: amount is for Issue"); } + /** Notification hook invoked whenever an MPT credit is made to an account. + * + * The default implementation is a no-op; `PaymentSandbox` overrides it to + * record the credit in its `DeferredCredits` table, enabling the same + * double-spend prevention as `creditHookIOU` but for MPT trust lines. + * + * @param from the debited account. + * @param to the credited account. + * @param amount the MPT amount being credited; must hold an `MPTIssue`. + * @param preCreditBalanceHolder the holder's MPT balance before the credit. + * @param preCreditBalanceIssuer the issuer's `OutstandingAmount` before the + * credit (signed to accommodate transient overflow). + * + * @note The `XRPL_ASSERT` in the default body verifies that `amount` holds + * an `MPTIssue`; it fires in debug builds if the wrong asset type is + * passed. + */ virtual void creditHookMPT( AccountID const& from, @@ -235,67 +278,66 @@ public: XRPL_ASSERT(amount.holds(), "creditHookMPT: amount is for MPTIssue"); } - /** Facilitate tracking of MPT sold by an issuer owning MPT sell offer. - * Unlike IOU, MPT doesn't have bi-directional relationship with an issuer, - * where a trustline limits an amount that can be issued to a holder. - * Consequently, the credit step (last MPTEndpointStep or - * BookStep buying MPT) might temporarily overflow OutstandingAmount. - * Limiting of a step's output amount in this case is delegated to - * the next step (in rev order). The next step always redeems when a holder - * account sells MPT (first MPTEndpointStep or BookStep selling MPT). - * In this case the holder account is only limited by the step's output - * and it's available funds since it's transferring the funds from one - * account to another account and doesn't change OutstandingAmount. - * This doesn't apply to an offer owned by an issuer. - * In this case the issuer sells or self debits and is increasing - * OutstandingAmount. Ability to issue is limited by the issuer - * originally available funds less already self sold MPT amounts (MPT sell - * offer). - * Consider an example: - * - GW creates MPT(USD) with 1,000USD MaximumAmount. - * - GW pays 950USD to A1. - * - A1 creates an offer 100XRP(buy)/100USD(sell). - * - GW creates an offer 100XRP(buy)/100USD(sell). - * - A2 pays 200USD to A3 with sendMax of 200XRP. - * Since the payment engine executes payments in reverse, - * OutstandingAmount overflows in MPTEndpointStep: 950 + 200 = 1,150USD. - * BookStep first consumes A1 offer. This reduces OutstandingAmount - * by 100USD: 1,150 - 100 = 1,050USD. GW offer can only be partially - * consumed because the initial available amount is 50USD = 1,000 - 950. - * BookStep limits it's output to 150USD. This in turn limits A3's send - * amount to 150XRP: A1 buys 100XRP and sells 100USD to A3. This doesn't - * change OutstandingAmount. GW buys 50XRP and sells 50USD to A3. This - * changes OutstandingAmount to 1,000USD. + /** Notification hook for MPT issuer self-debit via an owned sell offer. + * + * Unlike IOU trust lines, MPT has no bi-directional issuer↔holder + * relationship that caps issuance. When the payment engine processes a + * sell offer owned by the MPT issuer (in reverse order), it tentatively + * credits the holder first, which can transiently push `OutstandingAmount` + * beyond `MaximumAmount`. A subsequent step then redeems MPT from the + * issuer, restoring `OutstandingAmount`. The hook lets `PaymentSandbox` + * track the issuer's cumulative self-debit so that `balanceHookSelfIssueMPT` + * can cap available-to-issue at `origBalance − selfDebit` across the entire + * payment rather than trusting the transient ledger state. + * + * The default implementation is a no-op. + * + * @param issue the MPT issuance being self-debited. + * @param amount the quantity the issuer is selling (debiting to self). + * @param origBalance the issuer's `OutstandingAmount` at the start of the + * payment, before any path steps executed. */ virtual void issuerSelfDebitHookMPT(MPTIssue const& issue, std::uint64_t amount, std::int64_t origBalance) { } - // Called when the owner count changes - // This is required to support PaymentSandbox + /** Notification hook invoked when an account's owner count changes. + * + * The default implementation is a no-op; `PaymentSandbox` overrides it to + * record the high-water owner count for each account touched during the + * payment, so that reserve checks reflect the peak count rather than the + * instantaneous count at any single path step. + * + * @param account the account whose owner count is changing. + * @param cur the owner count before the change. + * @param next the owner count after the change. + */ virtual void adjustOwnerCountHook(AccountID const& account, std::uint32_t cur, std::uint32_t next) { } - /** Append an entry to a directory - - Entries in the directory will be stored in order of insertion, i.e. new - entries will always be added at the tail end of the last page. - - @param directory the base of the directory - @param key the entry to insert - @param describe callback to add required entries to a new page - - @return a \c std::optional which, if insertion was successful, - will contain the page number in which the item was stored. - - @note this function may create a page (including a root page), if no - page with space is available. This function will only fail if the - page counter exceeds the protocol-defined maximum number of - allowable pages. - */ + /** Append an entry to a directory, preserving insertion order. + * + * New entries are always placed at the tail of the last page, maintaining + * chronological ordering within an offer-book directory. This ordering + * is relied upon during offer matching: earlier offers at the same quality + * have priority. + * + * @param directory keylet of the directory root (page 0). + * @param key keylet of the entry to append; must be of type `ltOFFER`. + * @param describe callback invoked on each newly allocated page SLE to + * brand it with type-specific fields. + * @return the 0-based page index where the entry was stored, or + * `std::nullopt` if the page counter overflowed the protocol maximum. + * + * @note Only `ltOFFER` entries may be appended; passing any other keylet + * type triggers `UNREACHABLE` and returns `std::nullopt`. Use + * `dirInsert` for non-offer entries. + * @note A root page is created automatically if the directory does not yet + * exist. New pages are linked into the chain as needed. + */ /** @{ */ std::optional dirAppend( @@ -318,23 +360,24 @@ public: } /** @} */ - /** Insert an entry to a directory - - Entries in the directory will be stored in a semi-random order, but - each page will be maintained in sorted order. - - @param directory the base of the directory - @param key the entry to insert - @param describe callback to add required entries to a new page - - @return a \c std::optional which, if insertion was successful, - will contain the page number in which the item was stored. - - @note this function may create a page (including a root page), if no - page with space is available.this function will only fail if the - page counter exceeds the protocol-defined maximum number of - allowable pages. - */ + /** Insert an entry into a directory, maintaining per-page sorted order. + * + * Each individual page is kept in sorted key order, but entries may span + * multiple pages so the overall directory is only loosely ordered. + * Because legacy pages may not be sorted, each touched page is re-sorted + * before the new key is binary-inserted. Used for account-owned object + * directories (offers owned by an account, escrows, etc.). + * + * @param directory keylet of the directory root (page 0). + * @param key the `uint256` key to insert. + * @param describe callback invoked on each newly allocated page SLE to + * brand it with type-specific fields. + * @return the 0-based page index where the entry was stored, or + * `std::nullopt` if the page counter overflowed the protocol maximum. + * + * @note A root page is created automatically if the directory does not yet + * exist. New pages are allocated and linked as needed. + */ /** @{ */ std::optional dirInsert( @@ -345,6 +388,10 @@ public: return dirAdd(false, directory, key, describe); } + /** @copydoc dirInsert(Keylet const&, uint256 const&, std::function const&)> const&) + * + * Convenience overload that extracts the `uint256` key from `key.key`. + */ std::optional dirInsert( Keylet const& directory, @@ -355,25 +402,37 @@ public: } /** @} */ - /** Remove an entry from a directory - - @param directory the base of the directory - @param page the page number for this page - @param key the entry to remove - @param keepRoot if deleting the last entry, don't - delete the root page (i.e. the directory itself). - - @return \c true if the entry was found and deleted and - \c false otherwise. - - @note This function will remove zero or more pages from the directory; - the root page will not be deleted even if it is empty, unless - \p keepRoot is not set and the directory is empty. - */ + /** Remove a single entry from a directory and collapse any resulting + * empty non-root pages. + * + * After the key is removed, if the containing page becomes empty: + * - Non-root pages are unlinked and erased from the ledger. + * - The root page (page 0) is never erased unless `keepRoot` is `false` + * and the entire directory is now empty. + * - Legacy empty trailing pages left by older code are cleaned up + * opportunistically when the root page is touched. + * + * @param directory keylet of the directory root (page 0). + * @param page the 0-based page index that contains `key`; obtained from + * the page number stored in the owning ledger entry. + * @param key the `uint256` key to remove. + * @param keepRoot if `true`, preserve the root page even if it becomes + * empty after the removal (the directory anchor remains in the ledger). + * @return `true` if the entry was found and removed; `false` if the page + * or the key was not found. + * + * @note Throws `std::logic_error` if the directory linked-list pointers + * are found to be inconsistent (broken chain); this indicates ledger + * corruption and should never occur under normal operation. + */ /** @{ */ bool dirRemove(Keylet const& directory, std::uint64_t page, uint256 const& key, bool keepRoot); + /** @copydoc dirRemove(Keylet const&, std::uint64_t, uint256 const&, bool) + * + * Convenience overload that extracts the `uint256` key from `key.key`. + */ bool dirRemove(Keylet const& directory, std::uint64_t page, Keylet const& key, bool keepRoot) { @@ -381,31 +440,67 @@ public: } /** @} */ - /** Remove the specified directory, invoking the callback for every node. */ + /** Delete every page of a directory, invoking a callback for each key. + * + * Traverses the entire linked-list chain starting from page 0, erases + * each page SLE, and calls `callback` once per key stored in the + * directory. Callers are responsible for cleaning up the objects + * referenced by those keys before or after this call. + * + * @param directory keylet of the directory root (page 0). + * @param callback function called with each `uint256` key found in the + * directory before the page is erased. + * @return `true` if the root page was found and the directory was deleted; + * `false` if the root page does not exist. + */ bool dirDelete(Keylet const& directory, std::function const&); - /** Remove the specified directory, if it is empty. - - @param directory the identifier of the directory node to be deleted - @return \c true if the directory was found and was successfully deleted - \c false otherwise. - - @note The function should only be called with the root entry (i.e. with - the first page) of a directory. - */ + /** Delete the root page of a directory if and only if it is empty. + * + * Verifies that both `sfIndexes` is empty and the linked-list pointers + * indicate no other pages remain. Legacy empty trailing pages (a known + * edge case from older code) are cleaned up as a side effect before the + * emptiness check. + * + * @param directory keylet of the directory root page (`ltDIR_NODE`); + * must identify page 0 (the root). + * @return `true` if the directory was empty and was successfully erased; + * `false` if the directory was not found, contained entries, or had + * non-empty sub-pages. + * + * @note Throws `std::logic_error` if the directory linked-list pointers + * are inconsistent; this indicates ledger corruption. + */ bool emptyDirDelete(Keylet const& directory); }; -namespace directory { -/** Helper functions for managing low-level directory operations. - These are not part of the ApplyView interface. - - Don't use them unless you really, really know what you're doing. - Instead use dirAdd, dirInsert, etc. +/** Low-level primitives for building and modifying paged ledger directories. + * + * These helpers implement the individual steps of the directory linked-list + * protocol: root creation, tail-page discovery, key insertion, and page + * allocation. They are exposed so that specialised callers (tests, tooling) + * can exercise individual steps, but **transaction processors must always + * go through `ApplyView::dirAppend` / `dirInsert` / `dirRemove`** instead. + * + * @warning Do not call these directly unless you fully understand the + * directory invariants and page-linking protocol. */ +namespace directory { +/** Allocate and insert the root page (page 0) for a new directory. + * + * Creates a fresh `ltDIR_NODE` SLE at `directory`, sets `sfRootIndex`, + * calls `describe` to brand it, stores `key` as the first `sfIndexes` + * entry, and inserts it into the view. + * + * @param view the writable ledger view. + * @param directory keylet for the root page. + * @param key the first key to store in the new directory. + * @param describe callback to set type-specific fields on the root SLE. + * @return `0` — the root page index. + */ std::uint64_t createRoot( ApplyView& view, @@ -413,9 +508,37 @@ createRoot( uint256 const& key, std::function const&)> const& describe); +/** Locate the last used page in a directory by following `sfIndexPrevious` + * from the root. + * + * The root's `sfIndexPrevious` field always points to the tail page (O(1) + * append guarantee). If it is 0 the root itself is the tail. + * + * @param view the writable ledger view. + * @param directory keylet of the directory root. + * @param start the root SLE (already peeked by the caller). + * @return a tuple of `(pageIndex, pageSLE, sfIndexes)` for the tail page. + * @throws std::logic_error if the back-pointer chain is broken. + */ auto findPreviousPage(ApplyView& view, Keylet const& directory, SLE::ref start); +/** Insert a key into the `sfIndexes` vector of an existing page SLE and + * commit the change via `view.update()`. + * + * If `preserveOrder` is `true`, the key is appended at the end (offer-book + * order). If `false`, the page is sorted first (to handle legacy unsorted + * pages), then the key is binary-inserted. Double-insertion throws. + * + * @param view the writable ledger view. + * @param node the page SLE to modify (must have been obtained via `peek()`). + * @param page the 0-based page index of `node`. + * @param preserveOrder `true` to append; `false` to sort-then-insert. + * @param indexes the current `sfIndexes` vector (mutated in place). + * @param key the key to insert. + * @return the page index (`page`) where the key was stored. + * @throws std::logic_error if `key` is already present in `indexes`. + */ std::uint64_t insertKey( ApplyView& view, @@ -425,6 +548,26 @@ insertKey( STVector256& indexes, uint256 const& key); +/** Allocate a new trailing page, link it into the directory chain, and + * store the first key in it. + * + * The new page number is computed as `page + 1`; unsigned wraparound to 0 + * (verified by `static_assert`) signals overflow and causes `std::nullopt` + * to be returned. The `fixDirectoryLimit` amendment lifts the legacy + * per-directory page cap. + * + * @param view the writable ledger view. + * @param page the current last-page index (new page will be `page + 1`). + * @param node the current last-page SLE; its `sfIndexNext` is updated. + * @param nextPage reserved for future mid-chain insertion; must be `0`. + * @param next the root SLE; its `sfIndexPrevious` is updated to point to + * the new tail. + * @param key the first key to store on the new page. + * @param directory keylet of the directory root. + * @param describe callback to brand the new page SLE. + * @return the new page index, or `std::nullopt` on overflow or page-count + * limit violation. + */ std::optional insertPage( ApplyView& view, diff --git a/include/xrpl/ledger/ApplyViewImpl.h b/include/xrpl/ledger/ApplyViewImpl.h index 7f790f2be5..b3deaafca6 100644 --- a/include/xrpl/ledger/ApplyViewImpl.h +++ b/include/xrpl/ledger/ApplyViewImpl.h @@ -7,12 +7,25 @@ namespace xrpl { -/** Editable, discardable view that can build metadata for one tx. - - Iteration of the tx map is delegated to the base. - - @note Presented as ApplyView to clients. -*/ +/** Per-transaction scratch-pad view that buffers ledger mutations and + * constructs `TxMeta` on commit. + * + * `ApplyViewImpl` is the concrete view handed to every `Transactor` + * during the apply phase. It sits at the top of the view hierarchy: + * `ReadView` → `ApplyView` → `detail::ApplyViewBase` → `ApplyViewImpl`. + * All ledger mutations are buffered in the inherited `items_` + * (`ApplyStateTable`) and are not visible to the parent `OpenView` + * until `apply()` is called. If the transaction fails, the view is + * discarded and `base_` is left unchanged. + * + * The object is move-constructible but neither copyable nor + * move-assignable, ensuring that at most one instance can commit a + * given transaction's buffered state. + * + * @note `base_` is held as a raw `const*` (not a shared pointer) for + * performance. The caller must ensure the underlying view outlives + * this object. + */ class ApplyViewImpl final : public detail::ApplyViewBase { public: @@ -24,14 +37,47 @@ public: operator=(ApplyViewImpl const&) = delete; ApplyViewImpl(ApplyViewImpl&&) = default; + + /** Construct a transaction apply view over an existing read view. + * + * @param base The underlying ledger state to read from. Must remain + * valid for the lifetime of this object. + * @param flags Apply-phase control flags (e.g., `tapRETRY`, + * `tapDRY_RUN`, `tapBATCH`) that influence commit behavior and + * the metadata produced by `apply()`. + */ ApplyViewImpl(ReadView const* base, ApplyFlags flags); - /** Apply the transaction. - - After a call to `apply`, the only valid - operation on this object is to call the - destructor. - */ + /** Flush buffered mutations to `to` and produce transaction metadata. + * + * Delegates to `ApplyStateTable::apply()`, which drains every + * pending insert, modify, and erase into `to` and builds the + * `TxMeta` record — including `sfCreatedNode`, `sfModifiedNode`, + * and `sfDeletedNode` entries with `sfPreviousFields`/`sfFinalFields` + * — for the closed ledger. If `isDryRun` is `true`, metadata is + * computed and returned but state changes are suppressed, supporting + * fee-simulation paths without side effects. + * + * When `parentBatchId` is set (i.e., `tapBATCH` is active), the + * generated metadata records the parent batch transaction ID so + * individual results can be traced back to their enclosing batch. + * + * @param to The target open view that accumulates all + * committed transaction changes for the current ledger round. + * @param tx The transaction being applied. + * @param ter The final result code; recorded in metadata. + * @param parentBatchId The ID of the enclosing `ttBATCH` transaction, + * or `std::nullopt` for standalone transactions. + * @param isDryRun If `true`, produce metadata without mutating `to`. + * @param j Journal for diagnostic logging. + * @return The `TxMeta` for closed-ledger commits and dry-run + * evaluation; `std::nullopt` when `to` is still open and + * `isDryRun` is `false`. + * + * @note After this call returns, the only valid operation on this + * object is destruction. The internal `ApplyStateTable` is + * drained and must not be accessed again. + */ std::optional apply( OpenView& to, @@ -41,25 +87,50 @@ public: bool isDryRun, beast::Journal j); - /** Set the amount of currency delivered. - - This value is used when generating metadata - for payments, to set the DeliveredAmount field. - If the amount is not specified, the field is - excluded from the resulting metadata. - */ + /** Record the amount delivered by a payment transaction. + * + * Stores `amount` so that `ApplyStateTable::apply()` can write the + * `sfDeliveredAmount` field into the resulting `TxMeta`. The + * delivered amount can differ from the send amount in cross-currency + * or partial-payment scenarios. If never called, `sfDeliveredAmount` + * is omitted from the metadata. + * + * Must be called before `apply()` to take effect. + * + * @param amount The currency amount actually received by the destination. + */ void deliver(STAmount const& amount) { deliver_ = amount; } - /** Get the number of modified entries + /** Return the number of pending write-intent entries. + * + * Counts only entries with an `Erase`, `Insert`, or `Modify` action; + * cache-only reads are excluded. Used by `ApplyContext::size()` to + * support batch-processing decisions before committing. + * + * @return Count of SLE mutations buffered since construction or the + * last `discard()`. */ std::size_t size(); - /** Visit modified entries + /** Iterate every pending write-intent entry, invoking a callback per entry. + * + * Delegates to `ApplyStateTable::visit()`. `Cache`-only reads are + * skipped. Used by invariant checkers and batch-processing logic to + * inspect accumulated changes before deciding whether to commit them. + * + * @param target The open view used to fetch pre-change SLE snapshots + * for `Erase` and `Modify` entries. + * @param func Callback invoked once per pending entry with: + * - `key` — ledger index of the entry. + * - `isDelete` — `true` if the entry is being erased. + * - `before` — the SLE state before this transaction (`nullptr` + * for insertions). + * - `after` — the pending SLE state (`nullptr` for deletions). */ void visit( diff --git a/include/xrpl/ledger/BookDirs.h b/include/xrpl/ledger/BookDirs.h index afde6ee7b3..f1bc170bb9 100644 --- a/include/xrpl/ledger/BookDirs.h +++ b/include/xrpl/ledger/BookDirs.h @@ -4,6 +4,25 @@ namespace xrpl { +/** A range-based view over all offers in a single order-book direction. + * + * The XRPL DEX stores offers in a two-level ledger directory structure: a + * *book* groups offers for one currency pair in one direction, and within + * the book each quality level (encoded exchange rate) has its own directory + * page. `BookDirs` presents this multi-level structure as a flat sequence of + * `SLE` objects, letting callers iterate every offer with a standard + * range-for loop without reasoning about quality boundaries or directory + * pagination. + * + * Construction eagerly locates the first quality directory via + * `ReadView::succ` and loads the first page with `cdirFirst`; subsequent + * advancement is handled lazily by `const_iterator::operator++`. + * + * @note The `ReadView` passed at construction must outlive both the + * `BookDirs` object and any iterators derived from it. Iterators hold + * raw pointers to the view. + * @see Dir for single-directory iteration (e.g., NFTokenOffer pages). + */ class BookDirs { private: @@ -19,15 +38,49 @@ public: class const_iterator; // NOLINT(readability-identifier-naming) using value_type = std::shared_ptr; + /** Construct a `BookDirs` range over all offers in `book` as seen by `view`. + * + * Finds the first quality directory in the book's key-space via + * `view.succ` and positions the internal state at the first offer. + * If the book is empty, `begin() == end()` immediately. + * + * @param view The ledger view to read from; must remain valid for the + * lifetime of this object and all derived iterators. + * @param book The currency pair and direction defining the order book. + */ BookDirs(ReadView const&, Book const&); + /** Return an iterator positioned at the first offer in the book. + * + * If the book is empty the returned iterator compares equal to `end()`. + */ [[nodiscard]] const_iterator begin() const; + /** Return the past-the-end sentinel iterator for this book. */ [[nodiscard]] const_iterator end() const; }; +/** Forward iterator over offers in an order book, crossing quality boundaries. + * + * Advances through all offers in a book by walking pages within each quality + * directory via `cdirNext`, then locating the next quality directory via + * `ReadView::succ` when a quality is exhausted. Dereference re-reads the + * current offer SLE from the view and caches it until `operator++` clears + * the cache. + * + * **End-sentinel encoding:** the end iterator and an exhausted begin iterator + * share identical state — `entry_ == 0`, `cur_key_ == key_`, and + * `index_ == beast::zero`. `operator++` explicitly resets to this state when + * no further quality directory exists, which is how loop termination is + * detected. + * + * @note Default-constructed iterators have a null `view_` and compare + * unequal to everything, including each other. They are valid only as + * placeholders; dereferencing them is undefined behaviour. + * @note Only `BookDirs` may construct iterators in valid, non-default states. + */ class BookDirs::const_iterator // NOLINT(readability-identifier-naming) { public: @@ -37,35 +90,91 @@ public: using difference_type = std::ptrdiff_t; using iterator_category = std::forward_iterator_tag; + /** Construct a default (placeholder) iterator with a null view. + * + * Required by the `ForwardIterator` concept. The resulting iterator + * compares unequal to all other iterators and must not be dereferenced + * or incremented. + */ const_iterator() = default; + /** Return true if both iterators refer to the same offer position. + * + * Equality is determined by comparing `entry_`, `cur_key_`, and + * `index_`. If either iterator has a null view, returns false. + * + * @note Comparing iterators from different `BookDirs` instances + * (different views or roots) triggers an assertion in debug builds. + */ bool operator==(const_iterator const& other) const; + /** Return true if the iterators do not refer to the same offer position. */ bool operator!=(const_iterator const& other) const { return !(*this == other); } + /** Return a reference to the current offer SLE. + * + * Reads the offer SLE from the view on first access and caches the + * result; subsequent dereferences of the same position return the cached + * value. The cache is cleared by `operator++`. + * + * @note Asserts that `index_` is non-zero; dereferencing the end + * iterator or a default-constructed iterator is undefined behaviour. + */ reference operator*() const; + /** Return a pointer to the current offer SLE. + * + * Equivalent to `&**this`. Safe to use with `->` because `operator*` + * stores the result in a `mutable` cache member whose lifetime matches + * the iterator. + */ pointer operator->() const { return &**this; } + /** Advance to the next offer in the book and return this iterator. + * + * First attempts to advance within the current quality directory via + * `cdirNext`. If that quality is exhausted, uses `ReadView::succ` to + * find the next quality directory and positions at its first offer via + * `cdirFirst`. If no further quality directory exists, resets to the + * end-sentinel state. Clears the dereference cache. + * + * @note Asserts that the iterator is not already at the end position + * (i.e. `index_` must be non-zero) before advancing. + */ const_iterator& operator++(); + /** Post-increment: advance and return a copy of the pre-increment state. */ const_iterator operator++(int); private: friend class BookDirs; + /** Construct a valid iterator anchored to `view`, `root`, and `dirKey`. + * + * Only `BookDirs` calls this constructor. `dirKey` becomes both `key_` + * (the end-sentinel anchor) and the initial `cur_key_`. Additional + * fields (`next_quality_`, `sle_`, `entry_`, `index_`) are populated by + * `BookDirs::begin()` for the begin iterator; the end iterator leaves + * them at their zero-initialised defaults. + * + * @param view The ledger view; must outlive this iterator. + * @param root The root key of the book's quality key-space; must be + * non-zero. + * @param dirKey The key of the first quality directory, or `beast::zero` + * if the book is empty. + */ const_iterator(ReadView const& view, uint256 const& root, uint256 const& dirKey) : view_(&view), root_(root), key_(dirKey), cur_key_(dirKey) { diff --git a/include/xrpl/ledger/BookListeners.h b/include/xrpl/ledger/BookListeners.h index 3b96aca680..0ef82b4f47 100644 --- a/include/xrpl/ledger/BookListeners.h +++ b/include/xrpl/ledger/BookListeners.h @@ -8,35 +8,71 @@ namespace xrpl { -/** Listen to public/subscribe messages from a book. */ +/** Per-book fan-out layer for WebSocket order-book subscriptions. + * + * One instance exists for each `Book` (currency pair) that has at least one + * active subscriber. `OrderBookDB` owns and looks up instances via + * `getBookListeners()` / `makeBookListeners()`; callers hold references + * through the `pointer` alias. + * + * Subscribers are stored as `InfoSub::wptr` (weak pointers) so that + * `BookListeners` does not extend the lifetime of the connection object. + * Dead entries are pruned lazily inside `publish()` when the weak pointer + * can no longer be locked. + * + * All three public methods take `lock_` for their full duration, including + * across the `p->send()` calls in `publish()`. This favours correctness over + * throughput on high-subscriber-count books. + */ class BookListeners { public: + /** Shared-ownership handle used by `OrderBookDB` and callers. */ using pointer = std::shared_ptr; BookListeners() = default; - /** Add a new subscription for this book + /** Register a subscriber for this book. + * + * Stores a weak pointer to @p sub, keyed by its sequence number, so that + * subsequent `publish()` calls deliver notifications to it. + * + * @param sub The subscriber to add; must not be null. */ void addSubscriber(InfoSub::ref sub); - /** Stop publishing to a subscriber + /** Unregister a subscriber by sequence number. + * + * Removes the entry whose key equals @p sub. If no such entry exists + * (e.g. the subscriber was already pruned by a `publish()` call after + * disconnect), this is a no-op. + * + * @param sub Sequence number returned by `InfoSub::getSeq()` for the + * subscriber to remove. */ void removeSubscriber(std::uint64_t sub); - /** Publish a transaction to subscribers - - Publish a transaction to clients subscribed to changes on this book. - Uses havePublished to prevent sending duplicate transactions to clients - that have subscribed to multiple books. - - @param jvObj JSON transaction data to publish - @param havePublished InfoSub sequence numbers that have already - published this transaction. - - */ + /** Deliver a transaction notification to all live subscribers. + * + * Iterates over the internal listener map and, for each live subscriber, + * attempts to insert its sequence number into @p havePublished. If the + * insertion succeeds (i.e. this subscriber has not yet received this + * transaction from another book), the version-appropriate JSON is + * dispatched via `InfoSub::send()`. + * + * Dead weak pointers (subscribers that have disconnected) are erased + * in-place during the scan, providing lazy GC without a separate sweep. + * + * @param jvObj Version-indexed JSON built once upstream; each subscriber + * receives the slice matching its negotiated API version. + * @param havePublished Per-transaction set of subscriber sequence numbers + * that have already been notified. Shared across every + * `BookListeners::publish()` call for the same transaction so that a + * client subscribed to multiple affected books receives the message + * only once. Passed by reference and mutated in-place. + */ void publish(MultiApiJson const& jvObj, hash_set& havePublished); diff --git a/include/xrpl/ledger/CachedSLEs.h b/include/xrpl/ledger/CachedSLEs.h index c05aab8856..95d44f3fc3 100644 --- a/include/xrpl/ledger/CachedSLEs.h +++ b/include/xrpl/ledger/CachedSLEs.h @@ -1,3 +1,12 @@ +/** @file + * Process-wide cache of deserialized ledger state entries (SLEs). + * + * Declares `CachedSLEs`, a named alias for the `TaggedCache` instantiation + * that backs the two-level SLE read cache used by `CachedView`. Any future + * change to the underlying container's key hasher, pointer policy, or mutex + * type can be made here without touching consumers. + */ + #pragma once #include @@ -5,5 +14,32 @@ #include namespace xrpl { + +/** Process-wide, thread-safe cache of immutable ledger state entries (SLEs). + * + * Maps the cryptographic digest of a serialized SLE (`uint256`) to the + * deserialized `SLE const` object, allowing multiple read paths to share a + * single in-memory representation without re-deserializing from disk. + * + * The `SLE const` mapped type enforces at compile time that stored objects + * are never mutated through the cache, satisfying `TaggedCache`'s requirement + * that callers must not modify stored objects unless they hold a lock over all + * cache operations. This makes cached entries safe to share across threads + * without additional per-object locking. + * + * The key is the on-disk hash (digest) of the serialized entry — not an + * account ID or keylet — which integrates directly with `DigestAwareReadView`. + * `CachedView` delegates `read()` calls to `CachedSLEs::fetch(digest, ...)`, + * falling through to the underlying store only on a miss. + * + * The application-wide instance is constructed with a target size of `0` + * (no fixed count limit) and a one-minute expiration window. + * `TaggedCache::sweep()` is called periodically to demote strong references + * to weak references and eventually reclaim memory. + * + * @see CachedView + * @see TaggedCache + */ using CachedSLEs = TaggedCache; + } // namespace xrpl diff --git a/include/xrpl/ledger/CachedView.h b/include/xrpl/ledger/CachedView.h index 34a75e4c07..30c0436a60 100644 --- a/include/xrpl/ledger/CachedView.h +++ b/include/xrpl/ledger/CachedView.h @@ -1,3 +1,16 @@ +/** @file + * Transparent two-level caching layer over a `DigestAwareReadView`. + * + * Declares `detail::CachedViewImpl` (non-template caching logic) and the + * public template `CachedView`, which adds `shared_ptr` ownership of + * the wrapped view. The canonical instantiation `CachedLedger` (defined in + * `Ledger.h`) wraps the immutable closed ledger that serves as the base for + * transaction application. + * + * @see CachedSLEs + * @see CachedLedger + */ + #pragma once #include @@ -11,12 +24,43 @@ namespace xrpl { namespace detail { +/** Non-template base class that implements SLE caching over a `DigestAwareReadView`. + * + * All caching logic is compiled once here, avoiding template-instantiation bloat + * in `CachedView`. The class maintains two complementary caches: + * + * - **`map_`** — a per-instance `unordered_map` from ledger key (`uint256`) to + * SLE digest. Once a key has been resolved to its content hash, subsequent + * reads skip the SHAMap traversal. Uses `HardenedHash<>` to resist + * hash-flood attacks from adversarially crafted ledger keys. + * - **`cache_`** — a reference to an externally owned, process-wide `CachedSLEs` + * (`TaggedCache`) keyed by digest. Multiple views over + * different ledgers share this cache; if two ledgers carry an unchanged SLE, + * only one deserialized copy lives in memory. + * + * `mutex_` guards `map_` only; it is deliberately *not* held across + * `base_.digest()` or `base_.read()` calls so that concurrent readers are not + * serialized through SHAMap traversal or deserialization. Two threads may both + * call `base_.digest()` for the same key on a cold miss — this is safe because + * `base_` is an immutable ledger snapshot. + * + * Copy and assignment are deleted; a cached view always represents a unique, + * coherent window onto a specific ledger snapshot. + * + * @note All `ReadView` and `DigestAwareReadView` pass-through methods delegate + * directly to `base_`; only `exists()` and `read()` go through the cache. + */ class CachedViewImpl : public DigestAwareReadView { private: DigestAwareReadView const& base_; CachedSLEs& cache_; std::mutex mutable mutex_; + /** Per-instance map from ledger key to SLE digest. + * + * Uses `HardenedHash<>` to prevent adversarial hash-bucket flooding from + * network-visible ledger keys (account IDs, object types). + */ std::unordered_map> mutable map_; public: @@ -25,6 +69,13 @@ public: CachedViewImpl& operator=(CachedViewImpl const&) = delete; + /** Construct over an existing `DigestAwareReadView` and a shared SLE cache. + * + * @param base The underlying immutable view to cache reads against. + * The caller is responsible for ensuring `base` outlives this object; + * `CachedView` satisfies this by holding the owning `shared_ptr`. + * @param cache The process-wide SLE cache shared across all views. + */ CachedViewImpl(DigestAwareReadView const* base, CachedSLEs& cache) : base_(*base), cache_(cache) { } @@ -33,9 +84,30 @@ public: // ReadView // + /** Returns `true` if an SLE exists for the given keylet. + * + * Delegates to `read(k) != nullptr`; benefits from caching on repeated + * calls for the same key. + */ bool exists(Keylet const& k) const override; + /** Return the SLE associated with the keylet, going through both cache levels. + * + * The lookup sequence is: + * 1. Check `map_` for a known digest (under `mutex_`). + * 2. If absent, call `base_.digest(k.key)` outside the lock. + * 3. Pass the digest to `cache_.fetch()`, which deserializes from `base_` + * only on a shared-cache miss. + * 4. Populate `map_` on a cold miss (re-acquires `mutex_`). + * 5. Validate the SLE type with `k.check(*sle)`. + * + * Hit/miss statistics are tracked via `CountedObjects` counters + * `CachedView::hit`, `CachedView::hitExpired`, and `CachedView::miss`. + * + * @return The matching `SLE const`, or `nullptr` if the key is absent or + * the stored type does not match the keylet's expected type. + */ std::shared_ptr read(Keylet const& k) const override; @@ -124,10 +196,25 @@ public: } // namespace detail -/** Wraps a DigestAwareReadView to provide caching. - - @tparam Base A subclass of DigestAwareReadView -*/ +/** Transparent caching layer over a `DigestAwareReadView`. + * + * Wraps a `shared_ptr` to ensure the underlying view remains alive + * for the lifetime of this object, then delegates all caching logic to + * `detail::CachedViewImpl`. The `static_assert` enforces that `Base` satisfies + * the `DigestAwareReadView` contract required for two-level caching. + * + * The production instantiation is `CachedLedger = CachedView`, used + * by `OpenLedger::create()` to wrap the closed ledger that forms the base for + * each round of transaction application. + * + * Copy and assignment are deleted; each `CachedView` instance is the sole + * owner of its per-instance key→digest `map_`. + * + * @tparam Base A type derived from `DigestAwareReadView`. + * + * @see detail::CachedViewImpl + * @see CachedSLEs + */ template class CachedView : public detail::CachedViewImpl { @@ -144,15 +231,27 @@ public: CachedView& operator=(CachedView const&) = delete; + /** Construct a caching view over a shared immutable ledger snapshot. + * + * @param base Shared ownership of the underlying view; must not be null. + * @param cache Process-wide SLE cache shared across all `CachedView` + * instances. Must outlive this object. + */ CachedView(std::shared_ptr const& base, CachedSLEs& cache) : CachedViewImpl(base.get(), cache), sp_(base) { } - /** Returns the base type. - - @note This breaks encapsulation and bypasses the cache. - */ + /** Return the underlying view, bypassing both cache levels. + * + * @note This breaks encapsulation: callers interact with the + * `DigestAwareReadView` directly, skipping both the per-instance + * key→digest `map_` and the shared `CachedSLEs`. Use only when the + * full `Base` type (e.g. `Ledger`) is needed and cannot be expressed + * through the `ReadView` interface alone. + * + * @return A const shared pointer to the wrapped `Base` instance. + */ std::shared_ptr const& base() const { diff --git a/include/xrpl/ledger/CanonicalTXSet.h b/include/xrpl/ledger/CanonicalTXSet.h index 8653816eee..0ca3b2bb8c 100644 --- a/include/xrpl/ledger/CanonicalTXSet.h +++ b/include/xrpl/ledger/CanonicalTXSet.h @@ -7,17 +7,51 @@ namespace xrpl { -/** Holds transactions which were deferred to the next pass of consensus. - - "Canonical" refers to the order in which transactions are applied. - - - Puts transactions from the same account in SeqProxy order - -*/ +/** Ordered transaction queue for deterministic consensus application. + * + * Holds transactions deferred from a previous ledger-building pass and + * re-applies them in the next pass. The "canonical" in the name is the + * ordering guarantee: given the same input transaction set and the same + * salt, every validator iterates and applies transactions in identical + * sequence, which is required for Byzantine fault-tolerant ledger + * agreement. + * + * Ordering is three-level (implemented in `Key::operator<`): + * 1. Salted account ID — groups all transactions from the same account. + * 2. `SeqProxy` — within an account, sequence-based transactions sort + * before ticket-based ones, preserving the dependency that a ticket + * creator must apply before ticket consumers. + * 3. Transaction ID — tiebreaker within the same account and sequence. + * + * @note Account keys are XORed with a `LedgerHash` salt at construction + * (and via `reset()`) so that no actor can mine account addresses to + * achieve a persistent early-sort advantage across ledger rounds. + * + * @note Inherits from `CountedObject` for diagnostic + * memory-pressure accounting; the instance count is queryable via + * `CountedObjects::getInstance().getCounts()` and has no effect on + * behavior. + * + * Usage in `BuildLedger.cpp`: `applyTransactions()` iterates this set in + * map order across multiple passes, erasing each transaction on success or + * definitive failure and leaving retryable ones in place for the next pass. + */ // VFALCO TODO rename to SortedTxSet class CanonicalTXSet : public CountedObject { private: + /** Sort key for the internal transaction map. + * + * Holds a salted account identifier, a `SeqProxy`, and the transaction + * hash. The three-level `operator<` groups transactions by account, then + * orders within an account by `SeqProxy` (sequences before tickets), then + * breaks ties by transaction ID. + * + * `operator==` compares only `txId_` — identity is the transaction hash + * alone, independent of account or sequence context. This asymmetry is + * intentional: iterator-based `erase` must not conflate distinct + * transactions that happen to share account/sequence metadata. + */ class Key { public: @@ -47,6 +81,14 @@ private: return !(lhs < rhs); } + /** Tests equality by transaction ID only. + * + * Deliberately asymmetric with `operator<`: two keys with different + * account/sequence values but the same `txId_` compare equal. This + * keeps iterator-based removal (`erase`) safe — the map's ordering + * key is account+seq+id, but uniqueness is solely the transaction + * hash. + */ friend bool operator==(Key const& lhs, Key const& rhs) { @@ -59,12 +101,14 @@ private: return !(lhs == rhs); } + /** Returns the salted account identifier used as the primary sort key. */ [[nodiscard]] uint256 const& getAccount() const { return account_; } + /** Returns the transaction hash. */ [[nodiscard]] uint256 const& getTXID() const { @@ -80,7 +124,14 @@ private: friend bool operator<(Key const& lhs, Key const& rhs); - // Calculate the salted key for the given account + /** Computes the salted sort key for an account. + * + * Copies the 20-byte `AccountID` into a zeroed `uint256`, then XORs the + * result with `salt_`. The XOR prevents an attacker from mining account + * addresses with low byte values to gain a persistent ordering advantage: + * because `salt_` changes each ledger round, the effective sort position + * of any account is randomized per round. + */ uint256 accountKey(AccountID const& account); @@ -88,23 +139,59 @@ public: using const_iterator = std::map>::const_iterator; public: + /** Constructs the set with the given ledger hash as the account-key salt. + * + * @param saltHash Hash of the current ledger (or consensus map); used to + * randomize per-round account sort positions. Pass `uint256{}` when a + * stable, unsalted ordering is acceptable (e.g., `LocalTxs`). + */ explicit CanonicalTXSet(LedgerHash const& saltHash) : salt_(saltHash) { } + /** Inserts a transaction into the set. + * + * Constructs a `Key` from the transaction's salted account ID, `SeqProxy`, + * and transaction hash, then inserts the `(Key, tx)` pair into the map. + * Duplicate inserts (same transaction hash) are silently ignored by the + * underlying `std::map`. + * + * @param txn The signed transaction to enqueue. + */ void insert(std::shared_ptr const& txn); - // Pops the next transaction on account that follows seqProx in the - // sort order. Normally called when a transaction is successfully - // applied to the open ledger so the next transaction can be resubmitted - // without waiting for ledger close. - // - // The return value is often null, when an account has no more - // transactions. + /** Pops and returns the next eligible transaction for the same account. + * + * After `tx` has been successfully applied to the open ledger, call this + * method to retrieve and remove the immediately-following transaction for + * the same account, if one exists and is eligible. A transaction is + * eligible if it either: + * - uses a ticket (tickets may be applied regardless of sequence gaps), or + * - has a sequence number exactly one greater than `tx`'s sequence. + * + * The search uses `lower_bound` on a synthetic key whose `txId_` is + * `beast::zero` (which sorts before any real transaction ID) to locate the + * first map entry past `tx`'s position. If that entry belongs to a + * different account, or its sequence constraint is not satisfied, the + * method returns `nullptr`. + * + * @param tx The just-applied transaction whose account and sequence + * establish the search anchor. + * @return The next eligible transaction (removed from the set), or + * `nullptr` if no suitable successor exists. + */ std::shared_ptr popAcctTransaction(std::shared_ptr const& tx); + /** Resets the set for a new ledger round. + * + * Installs a fresh salt and clears all transactions, allowing the same + * `CanonicalTXSet` instance to be reused across rounds without + * reallocating the underlying container. + * + * @param salt New ledger hash to use as the account-key salt. + */ void reset(LedgerHash const& salt) { @@ -112,35 +199,54 @@ public: map_.clear(); } + /** Erases the transaction at `it` and returns an iterator to the next element. + * + * Supports in-place removal during iteration, as used by `applyTransactions()` + * in `BuildLedger.cpp` when a transaction succeeds or definitively fails. + * + * @param it A valid iterator into this set. + * @return Iterator to the element following the removed one. + */ const_iterator erase(const_iterator const& it) { return map_.erase(it); } + /** Returns an iterator to the first transaction in canonical order. */ [[nodiscard]] const_iterator begin() const { return map_.begin(); } + /** Returns a past-the-end iterator. */ [[nodiscard]] const_iterator end() const { return map_.end(); } + /** Returns the number of transactions currently in the set. */ [[nodiscard]] size_t size() const { return map_.size(); } + + /** Returns `true` if the set contains no transactions. */ [[nodiscard]] bool empty() const { return map_.empty(); } + /** Returns the salt hash that identifies this set's ordering context. + * + * Callers use this for logging the transaction set identity alongside + * the ledger close time (e.g., `RCLConsensus` logs `retriableTxs.key()` + * when building the canonical set from the consensus map). + */ [[nodiscard]] uint256 const& key() const { @@ -150,7 +256,9 @@ public: private: std::map> map_; - // Used to salt the accounts so people can't mine for low account numbers + // XORed into each account's sort key to prevent mining for low account + // numbers that would gain a persistent ordering advantage. Refreshed each + // ledger round via reset(). uint256 salt_; }; diff --git a/include/xrpl/ledger/Dir.h b/include/xrpl/ledger/Dir.h index cfbef357b1..483c6f0d80 100644 --- a/include/xrpl/ledger/Dir.h +++ b/include/xrpl/ledger/Dir.h @@ -5,18 +5,22 @@ namespace xrpl { -/** A class that simplifies iterating ledger directory pages - - The Dir class provides a forward iterator for walking through - the uint256 values contained in ledger directories. - - The Dir class also allows accelerated directory walking by - stepping directly from one page to the next using the next_page() - member function. - - As of July 2024, the Dir class is only being used with NFTokenOffer - directories and for unit tests. -*/ +/** Read-only range adaptor for a paged ledger directory (`ltDIR_NODE`). + * + * A ledger directory is a linked list of `DirectoryNode` SLEs, each holding + * a `STVector256` (`sfIndexes`) of 256-bit keys pointing to other ledger + * objects. `Dir` wraps that structure in a C++ forward-iterable range, + * hiding page-chasing and SLE loading behind `begin()`/`end()`. + * + * Construction reads the root page eagerly but loads no entry SLEs; + * per-entry loading is deferred to `operator*()`. The class is used + * with NFTokenOffer directories (`keylet::nft_buys()`, `keylet::nft_sells()`) + * and in unit tests with owner directories (`keylet::ownerDir()`). + * + * @note Callers that only need per-page counts (not per-entry SLEs) should + * use `nextPage()` as the loop increment and `pageSize()` for counting, + * which avoids the per-entry `ReadView::read()` calls entirely. + */ class Dir { private: @@ -27,17 +31,57 @@ private: public: class ConstIterator; + + /** `shared_ptr`, matching `ReadView::read()`'s return type. */ using value_type = std::shared_ptr; + /** Construct a range over the directory rooted at `key` in `view`. + * + * Reads the root `DirectoryNode` SLE immediately and caches its + * `sfIndexes`. If the root page is absent the range is empty. + * + * @param view The ledger view to read from; must outlive this object. + * @param key Keylet of the directory root page. + */ Dir(ReadView const&, Keylet const&); + /** Return an iterator to the first entry of the directory. + * + * If the root page is missing or its `sfIndexes` is empty, the returned + * iterator compares equal to `end()`. + * + * @return A `ConstIterator` positioned at the first directory entry, + * or `end()` if the directory is empty. + */ [[nodiscard]] ConstIterator begin() const; + /** Return a past-the-end sentinel iterator. + * + * The sentinel has `page_.key == root_.key` and `index_ == beast::zero`. + * An iterator reaches this state when `nextPage()` finds `sfIndexNext == 0` + * on the last `DirectoryNode` page. + * + * @return A past-the-end `ConstIterator`. + */ [[nodiscard]] ConstIterator end() const; }; +/** Forward iterator over entries in a paged ledger directory. + * + * Each dereference lazily loads the ledger object pointed to by the current + * directory entry key via `ReadView::read(keylet::child(index_))`. The result + * is cached in `cache_` and cleared on every advance, including page + * transitions. + * + * Equality compares `page_.key` and `index_`. Two iterators are equal when + * both fields match; comparing iterators from different views or roots is + * undefined (asserted in debug builds). + * + * @note Advancing an iterator that is already at `end()` is undefined. + * Always guard with `it != dir.end()` before incrementing. + */ class Dir::ConstIterator { public: @@ -47,42 +91,113 @@ public: using difference_type = std::ptrdiff_t; using iterator_category = std::forward_iterator_tag; + /** Return true if both iterators point to the same directory entry. + * + * Returns `false` if either view pointer is null. Asserts in debug builds + * that both iterators share the same view and root keylet. + * + * @param other The iterator to compare against. + * @return `true` if `page_.key` and `index_` match in both iterators. + */ bool operator==(ConstIterator const& other) const; + /** Return true if the iterators do not point to the same directory entry. + * + * @param other The iterator to compare against. + * @return `!(*this == other)`. + */ bool operator!=(ConstIterator const& other) const { return !(*this == other); } + /** Load and return the ledger object for the current directory entry. + * + * The result is cached after the first call and reused on subsequent + * dereferences of the same position. The cache is cleared on every + * advance (including page transitions). + * + * @return `shared_ptr` to the referenced ledger object, + * or `nullptr` if the object is not present in the view. + */ reference operator*() const; + /** Return a pointer to the current entry's `shared_ptr`. + * + * @return Pointer to the cached SLE shared pointer. + */ pointer operator->() const { return &**this; } + /** Advance to the next directory entry, crossing page boundaries as needed. + * + * When the end of the current page's `sfIndexes` is reached, calls + * `nextPage()` to load the subsequent `DirectoryNode`. If no next page + * exists the iterator converges to the `end()` sentinel. + * + * @return Reference to this iterator after advancement. + */ ConstIterator& operator++(); + /** Post-increment: return a copy of this iterator, then advance. + * + * @return Copy of the iterator before advancement. + */ ConstIterator operator++(int); + /** Jump directly to the first entry of the next `DirectoryNode` page. + * + * Reads `sfIndexNext` from the current page SLE. If the value is zero + * (last page), the iterator is set to the `end()` sentinel. Otherwise, + * loads `keylet::page(root_, sfIndexNext)` and positions the iterator + * at the beginning of that page's `sfIndexes`. + * + * This method is public so callers can skip an entire page without + * loading individual entries — useful when only the per-page count is + * needed (see `pageSize()`). + * + * @return Reference to this iterator, now positioned at the start of the + * next page, or at `end()` if the directory is exhausted. + */ ConstIterator& nextPage(); + /** Return the number of entries on the current page. + * + * Reports `sfIndexes.size()` for the currently loaded `DirectoryNode` + * without reading any entry SLEs. Combined with `nextPage()` as a loop + * increment, this enables O(pages) offer-count checks instead of + * O(entries). + * + * @return Number of `uint256` entries in the current page's `sfIndexes`. + */ std::size_t pageSize(); + /** Return the keylet of the currently loaded `DirectoryNode` page. + * + * @return `Keylet` identifying the current page SLE. + */ Keylet const& page() const { return page_; } + /** Return the `uint256` key of the current directory entry. + * + * Equal to `beast::zero` when the iterator is at `end()`. + * + * @return The current entry's 256-bit ledger object key. + */ uint256 index() const { diff --git a/include/xrpl/ledger/Ledger.h b/include/xrpl/ledger/Ledger.h index 2d6f48db2d..8b9ae6b4d9 100644 --- a/include/xrpl/ledger/Ledger.h +++ b/include/xrpl/ledger/Ledger.h @@ -1,3 +1,9 @@ +/** @file + * Declares the Ledger class — the central data structure of the XRP Ledger + * daemon — together with supporting types for genesis ledger construction + * and the CachedLedger alias. + */ + #pragma once #include @@ -20,44 +26,58 @@ class TransactionMaster; class SqliteStatement; +/** Tag type used to select the genesis-ledger constructor of Ledger. + * + * Pass the singleton `kCREATE_GENESIS` constant to construct ledger + * sequence 1. The explicit constructor prevents accidental conversions. + */ struct CreateGenesisT { explicit CreateGenesisT() = default; }; +/** Singleton tag constant passed to the genesis-ledger constructor. */ extern CreateGenesisT const kCREATE_GENESIS; -/** Holds a ledger. - - The ledger is composed of two SHAMaps. The state map holds all of the - ledger entries such as account roots and order books. The tx map holds - all of the transactions and associated metadata that made it into that - particular ledger. Most of the operations on a ledger are concerned - with the state map. - - This can hold just the header, a partial set of data, or the entire set - of data. It all depends on what is in the corresponding SHAMap entry. - Various functions are provided to populate or depopulate the caches that - the object holds references to. - - Ledgers are constructed as either mutable or immutable. - - 1) If you are the sole owner of a mutable ledger, you can do whatever you - want with no need for locks. - - 2) If you have an immutable ledger, you cannot ever change it, so no need - for locks. - - 3) Mutable ledgers cannot be shared. - - @note Presented to clients as ReadView - @note Calls virtuals in the constructor, so marked as final -*/ +/** Immutable or mutable snapshot of the XRP Ledger at a single sequence number. + * + * A Ledger owns two SHAMap Merkle–radix trees: `stateMap_` (all account + * state — account roots, trust lines, offers, escrows, amendments, fee + * settings, etc.) and `txMap_` (every transaction together with its + * execution metadata that produced this ledger's state). + * + * **Mutable/immutable lifecycle:** + * - A freshly constructed ledger begins mutable; it must not be shared + * across threads while mutable. + * - After `setImmutable()` is called the ledger hashes are finalised, + * both SHAMaps are locked, and the object may be shared freely without + * any locking. Any attempt to mutate the SHAMaps after this point will + * assert. + * - `setAccepted()` is the standard close-time + `setImmutable()` sequence + * used after consensus. + * + * The class inherits `DigestAwareReadView` (read + per-entry digest), + * `TxsRawView` (raw state and transaction mutation), and + * `CountedObject` (intrusive diagnostics). It is marked `final` + * because constructors call virtual functions through `setup()`. + * + * @note Presented to most callers through the `ReadView` interface. + * @note `txMap_` and `stateMap_` are declared `mutable` to allow + * `setFull()` and iterator operations in `const` contexts without + * compromising the logical-constness contract. + * @see CachedLedger — the standard shareable form used at rest. + */ class Ledger final : public std::enable_shared_from_this, public DigestAwareReadView, public TxsRawView, public CountedObject { public: + /** Copying and moving are prohibited. + * + * Ledger objects are always owned through `std::shared_ptr`. Shared + * ownership combined with the mutable-→-immutable transition makes + * value-semantic copies unsafe and unnecessary. + */ Ledger(Ledger const&) = delete; Ledger& operator=(Ledger const&) = delete; @@ -66,20 +86,22 @@ public: Ledger& operator=(Ledger&&) = delete; - /** Create the Genesis ledger. - - The Genesis ledger contains a single account whose - AccountID is generated with a Generator using the seed - computed from the string "masterpassphrase" and ordinal - zero. - - The account has an XRP balance equal to the total amount - of XRP in the system. No more XRP than the amount which - starts in this account can ever exist, with amounts - used to pay fees being destroyed. - - Amendments specified are enabled in the genesis ledger - */ + /** Construct ledger sequence 1 (the genesis ledger). + * + * Seeds a single master account whose `AccountID` is derived + * deterministically from the seed of `"masterpassphrase"`, credits it + * with `kINITIAL_XRP` drops, inserts the `sfAmendments` SLE for any + * pre-enabled amendments, and inserts the fee schedule SLE using either + * drop-native fields (`sfBaseFeeDrops`, etc.) when `featureXRPFees` is + * among `amendments`, or legacy integer fields otherwise. Ends with + * `setImmutable()`. + * + * @param rules Protocol rules in effect at genesis. + * @param fees Initial fee schedule (base fee, reserve, increment). + * @param amendments Amendments that are enabled from ledger 1 onward. + * Determines which fee-field format is used for the genesis fee SLE. + * @param family Node-store family that owns the SHAMap backing storage. + */ Ledger( CreateGenesisT, Rules rules, @@ -87,15 +109,37 @@ public: std::vector const& amendments, Family& family); + /** Construct an immutable header-only placeholder ledger. + * + * Creates SHAMaps initialised with the root hashes from `info` but does + * not attempt to fetch SHAMap nodes from the node store. The canonical + * ledger hash is computed immediately from the header fields. Used for + * skeleton or partial ledgers reconstructed from database metadata. + * + * @param info Fully populated ledger header (must include root hashes). + * @param rules Protocol rules in effect for this ledger. + * @param family Node-store family for the underlying SHAMaps. + */ Ledger(LedgerHeader const& info, Rules rules, Family& family); - /** Used for ledgers loaded from JSON files - - @param acquire If true, acquires the ledger if not found locally - - @note The fees parameter provides default values, but setup() may - override them from the ledger state if fee-related SLEs exist. - */ + /** Restore a ledger from its header, fetching SHAMap roots from the node store. + * + * Constructs both SHAMaps with the root hashes from `info` and calls + * `fetchRoot()` on each. If either root is absent from the node store, + * `loaded` is set to `false`; when `acquire` is also `true`, async + * acquisition is triggered via `family.missingNodeAcquireByHash()`. + * The resulting ledger is always immutable. + * + * @param info Ledger header, including `txHash` and `accountHash` roots. + * @param loaded Set to `false` on return if either SHAMap root was missing. + * @param acquire If `true`, trigger async node acquisition when `loaded` + * would be set to `false`. + * @param rules Protocol rules in effect for this ledger. + * @param fees Default fee values; `setup()` will override these from the + * on-ledger fee SLE if one exists. + * @param family Node-store family for the underlying SHAMaps. + * @param j Journal for missing-root warnings. + */ Ledger( LedgerHeader const& info, bool& loaded, @@ -105,15 +149,33 @@ public: Family& family, beast::Journal j); - /** Create a new ledger following a previous ledger - - The ledger will have the sequence number that - follows previous, and have - parentCloseTime == previous.closeTime. - */ + /** Create the next mutable ledger in the chain following `previous`. + * + * The new ledger has sequence `previous.seq() + 1`. Its `stateMap_` + * is a copy-on-write snapshot of `previous.stateMap_` so state changes + * do not affect the closed parent. Its `txMap_` starts empty (a fresh + * SHAMap for the new round's transactions). `parentCloseTime` is set + * to `previous.closeTime`; the close-time resolution is advanced via + * `getNextLedgerTimeResolution`. + * + * @param previous The preceding closed ledger; must be immutable. + * @param closeTime Proposed close time for the new ledger. + */ Ledger(Ledger const& previous, NetClock::time_point closeTime); - // used for database ledgers + /** Construct a mutable empty ledger for database reconstruction. + * + * Creates an empty, mutable ledger at `ledgerSeq` and calls `setup()` + * to initialise `fees_` and `rules_` from any state entries that may + * already exist. Used when the node store needs to rebuild a ledger + * from raw DB data outside the normal consensus flow. + * + * @param ledgerSeq Target ledger sequence number. + * @param closeTime Close time to record in the ledger header. + * @param rules Protocol rules for this ledger. + * @param fees Initial fee schedule (may be overridden by `setup()`). + * @param family Node-store family for the underlying SHAMaps. + */ Ledger( std::uint32_t ledgerSeq, NetClock::time_point closeTime, @@ -127,66 +189,118 @@ public: // ReadView // + /** Always returns `false`; Ledger objects are never open. */ bool open() const override { return false; } + /** Returns the ledger header (sequence, hashes, close time, drops, etc.). */ LedgerHeader const& header() const override { return header_; } + /** Overwrite the in-memory ledger header wholesale. + * + * Used during ledger reconstruction from external data before the + * ledger is made immutable. Do not call on an immutable ledger. + * + * @param info New header to install. + */ void setLedgerInfo(LedgerHeader const& info) { header_ = info; } + /** Returns the fee schedule parsed from the on-ledger fee SLE. */ Fees const& fees() const override { return fees_; } + /** Returns the protocol rules in effect for this ledger. */ Rules const& rules() const override { return rules_; } + /** Returns `true` if the state map contains an entry matching `k`. + * + * @param k Keylet identifying the ledger entry (type + key). + */ bool exists(Keylet const& k) const override; + /** Returns `true` if the state map contains an entry at the raw key. + * + * @param key 256-bit SHAMap key to look up (no type check). + */ bool exists(uint256 const& key) const; + /** Find the smallest state-map key strictly greater than `key`. + * + * @param key Lower bound (exclusive) for the search. + * @param last If set, keys >= `last` are not returned. + * @return The next key, or `std::nullopt` if none exists in range. + */ std::optional succ(uint256 const& key, std::optional const& last = std::nullopt) const override; + /** Deserialize and return the state entry identified by `k`. + * + * Checks the keylet type against the deserialized SLE; returns + * `nullptr` if the key is missing or the type check fails. + * + * @param k Keylet specifying the key and expected ledger-entry type. + * @return Shared pointer to the immutable SLE, or `nullptr`. + */ std::shared_ptr read(Keylet const& k) const override; + /** Return a begin iterator over all state-map entries. */ std::unique_ptr slesBegin() const override; + /** Return a past-the-end iterator over all state-map entries. */ std::unique_ptr slesEnd() const override; + /** Return an iterator to the first state-map entry with key > `key`. */ std::unique_ptr slesUpperBound(uint256 const& key) const override; + /** Return a begin iterator over all transaction-map entries. + * + * @note Transactions are yielded with metadata for closed ledgers and + * without metadata for open ledgers (always closed for `Ledger`). + */ std::unique_ptr txsBegin() const override; + /** Return a past-the-end iterator over all transaction-map entries. */ std::unique_ptr txsEnd() const override; + /** Returns `true` if the transaction map contains an entry for `key`. */ bool txExists(uint256 const& key) const override; + /** Deserialize and return the transaction (plus metadata) for `key`. + * + * For a closed ledger both the `STTx` and the `STObject` metadata are + * returned. Returns an empty pair if the key is not present. + * + * @param key Transaction ID to look up. + * @return Pair of `(STTx const*, STObject const*)` shared pointers; + * either or both may be null on miss. + */ tx_type txRead(key_type const& key) const override; @@ -194,6 +308,17 @@ public: // DigestAwareReadView // + /** Return the Merkle hash of the state-map leaf at `key`. + * + * Used by `CachedView` to detect whether a cached SLE is stale. + * Returns `std::nullopt` if no entry exists at `key`. + * + * @note The current implementation loads the SHAMap item from the node + * store as a side-effect; see the inline comment in `Ledger.cpp`. + * + * @param key 256-bit state-map key to hash. + * @return The leaf node hash, or `std::nullopt` if absent. + */ std::optional digest(key_type const& key) const override; @@ -201,18 +326,53 @@ public: // RawView // + /** Remove the state entry whose key matches `sle->key()`. + * + * Calls `logicError` if the key does not exist in the state map. + * + * @param sle Entry to remove; only the key is used. + */ void rawErase(std::shared_ptr const& sle) override; + /** Insert a new state entry for `sle`. + * + * Serializes the SLE and adds it to the state SHAMap. Calls + * `logicError` if an entry with the same key already exists. + * + * @param sle Entry to insert; must not already be present. + */ void rawInsert(std::shared_ptr const& sle) override; + /** Remove the state entry at the raw key `key`. + * + * Overload for callers that hold only the key rather than an SLE. + * Calls `logicError` if the key does not exist. + * + * @param key 256-bit state-map key of the entry to remove. + */ void rawErase(uint256 const& key); + /** Replace (overwrite) an existing state entry with `sle`. + * + * Serializes the SLE and updates the state SHAMap in place. Calls + * `logicError` if no entry exists at `sle->key()`. + * + * @param sle Replacement entry; key must already be present. + */ void rawReplace(std::shared_ptr const& sle) override; + /** Burn `fee` drops from the ledger's total XRP supply. + * + * Implements XRPL's deflationary model: transaction fees are + * permanently destroyed rather than redistributed. Decrements + * `header_.drops` directly. + * + * @param fee Amount to deduct from the total coin supply. + */ void rawDestroyXRP(XRPAmount const& fee) override { @@ -223,6 +383,17 @@ public: // TxsRawView // + /** Append a transaction + metadata blob to the transaction map. + * + * Encodes `txn` and `metaData` as two back-to-back variable-length + * fields and inserts the result at `key`. Asserts that `metaData` + * is non-null (open ledgers must not call this). Calls `logicError` + * if `key` is already present (duplicate transaction). + * + * @param key Transaction ID (SHAMap key). + * @param txn Serialized transaction blob. + * @param metaData Serialized transaction metadata blob; must be non-null. + */ void rawTxInsert( uint256 const& key, @@ -231,37 +402,66 @@ public: //-------------------------------------------------------------------------- + /** Mark this ledger as validated by the network. + * + * Sets `header_.validated = true`. This is a local-node annotation + * only; it does not affect the consensus hash or any on-ledger state. + */ void setValidated() const { header_.validated = true; } + /** Finalise timing fields and transition this ledger to immutable. + * + * Records `closeTime`, `closeResolution`, and the close-flag + * (`kS_LCF_NO_CONSENSUS_TIME` when `correctCloseTime` is `false`), + * then delegates to `setImmutable()`. + * + * @pre `!open()` — the ledger must already be closed. + * + * @param closeTime Agreed consensus close time. + * @param closeResolution Resolution used to bin the close time. + * @param correctCloseTime `true` if consensus agreed on the close time; + * `false` sets the no-consensus-time flag in the header. + */ void setAccepted( NetClock::time_point closeTime, NetClock::duration closeResolution, bool correctCloseTime); + /** Compute hashes and lock the ledger against further mutation. + * + * When `rehash` is `true` (the default): computes `header_.txHash` + * and `header_.accountHash` from the respective SHAMap roots, then + * computes the canonical ledger hash via `calculateLedgerHash()`. + * Regardless of `rehash`, sets `immutable_ = true`, calls + * `setImmutable()` on both SHAMaps, and calls `setup()` to populate + * `fees_` and `rules_` from the state map. + * + * @param rehash If `false`, skip hash computation (used when the + * hashes are already known, e.g. on load from the database). + */ void setImmutable(bool rehash = true); + /** Returns `true` if `setImmutable()` has been called on this ledger. */ bool isImmutable() const { return immutable_; } - /* Mark this ledger as "should be full". - - "Full" is metadata property of the ledger, it indicates - that the local server wants all the corresponding nodes - in durable storage. - - This is marked `const` because it reflects metadata - and not data that is in common with other nodes on the - network. - */ + /** Tell the node store to retain all SHAMap nodes for this ledger. + * + * "Full" is a local storage policy: when set, the node store will keep + * all state-map and transaction-map nodes for this ledger in durable + * storage rather than evicting them. Declared `const` because fullness + * is node-local metadata — two nodes holding the same ledger may differ + * on this property without affecting consensus. + */ void setFull() const { @@ -271,145 +471,283 @@ public: stateMap_.setLedgerSeq(header_.seq); } + /** Overwrite the total XRP supply recorded in the ledger header. + * + * Used when building ledgers from external data sources (e.g. JSON + * import) before the ledger is made immutable. + * + * @param totDrops New total supply in drops. + */ void setTotalDrops(std::uint64_t totDrops) { header_.drops = totDrops; } + /** Returns a read-only reference to the state SHAMap. */ SHAMap const& stateMap() const { return stateMap_; } + /** Returns a mutable reference to the state SHAMap. + * + * @note Only valid while the ledger is mutable. + */ SHAMap& stateMap() { return stateMap_; } + /** Returns a read-only reference to the transaction SHAMap. */ SHAMap const& txMap() const { return txMap_; } + /** Returns a mutable reference to the transaction SHAMap. + * + * @note Only valid while the ledger is mutable. + */ SHAMap& txMap() { return txMap_; } - // returns false on error + /** Serialize `sle` and add it directly to the state SHAMap. + * + * Convenience wrapper used during ledger construction from external + * data sources. Unlike `rawInsert`, this does not assert on failure. + * + * @param sle State entry to serialize and insert. + * @return `true` on success; `false` if the key already exists or the + * underlying `SHAMap::addItem` call fails. + */ bool addSLE(SLE const& sle); //-------------------------------------------------------------------------- + /** Update the two-tier skip list stored in the state map. + * + * The skip list enables O(1) historical hash lookup. This method + * maintains two SLEs: + * - `keylet::skip(prevIndex)` — a permanent record written for every + * 256-aligned predecessor sequence; stores up to 256 ancestor hashes. + * - `keylet::skip()` — a rolling window of the 256 most recent parent + * hashes; oldest entry is evicted when the list is full. + * + * Must be called on a mutable ledger before `setImmutable()`. + */ void updateSkipList(); + /** Verify that every SHAMap node for this ledger is reachable. + * + * Walks both the state map and the transaction map and collects missing + * node reports. Logs the first missing node of each type to `j`. + * + * @param j Journal to receive missing-node diagnostics. + * @param parallel If `true`, walks the state map using parallel + * traversal (faster on multi-core hardware). + * @return `true` if both maps are fully present; `false` if any nodes + * are missing. + */ bool walkLedger(beast::Journal j, bool parallel = false) const; + /** Perform basic sanity checks on the ledger header vs. SHAMap hashes. + * + * Verifies that `header_.hash`, `header_.accountHash`, and + * `header_.txHash` are all non-zero and that the account and + * transaction hashes match the actual SHAMap roots. + * + * @return `true` if all checks pass. + */ bool isSensible() const; + /** Assert internal SHAMap invariants for both the state and tx maps. + * + * Delegates to `SHAMap::invariants()` on each map. Intended for + * debug-build integrity checks. + */ void invariants() const; + + /** Release copy-on-write sharing of SHAMap nodes. + * + * After a copy-on-write snapshot is made (e.g. in the successor + * constructor), internal SHAMap nodes may be shared between the parent + * and child ledgers. Calling `unshare()` on the mutable child forces + * a deep copy so the two trees are fully independent. + */ void unshare() const; - /** - * get Negative UNL validators' master public keys + /** Read the current set of Negative UNL validators from the state map. * - * @return the public keys + * The Negative UNL is a consensus mechanism that temporarily removes + * chronically offline validators without breaking liveness. This + * method reads the `sfDisabledValidators` array from the + * `keylet::negativeUNL()` SLE. + * + * @return Master public keys of all currently disabled validators; + * empty if no Negative UNL entry exists or it has no members. */ hash_set negativeUNL() const; - /** - * get the to be disabled validator's master public key if any + /** Return the validator scheduled for disabling at the next flag ledger. * - * @return the public key if any + * Reads `sfValidatorToDisable` from the Negative UNL SLE, if present. + * + * @return The validator's master public key, or `std::nullopt` if none + * is pending. */ std::optional validatorToDisable() const; - /** - * get the to be re-enabled validator's master public key if any + /** Return the validator scheduled for re-enabling at the next flag ledger. * - * @return the public key if any + * Reads `sfValidatorToReEnable` from the Negative UNL SLE, if present. + * + * @return The validator's master public key, or `std::nullopt` if none + * is pending. */ std::optional validatorToReEnable() const; - /** - * update the Negative UNL ledger component. - * @note must be called at and only at flag ledgers - * must be called before applying UNLModify Tx + /** Apply the pending Negative UNL changes recorded in the state map. + * + * Promotes `sfValidatorToDisable` into `sfDisabledValidators` and + * removes `sfValidatorToReEnable` from that array. If the resulting + * disabled set is empty, the entire Negative UNL SLE is deleted. + * + * @note Must be called exactly once per flag ledger (sequence divisible + * by 256) and *before* any `UNLModify` transaction is applied. */ void updateNegativeUNL(); - /** Returns true if the ledger is a flag ledger */ + /** Returns `true` if this is a flag ledger (sequence divisible by 256). + * + * Flag ledgers carry out amendment votes, fee votes, and Negative UNL + * updates. These actions must not occur on non-flag ledgers. + */ bool isFlagLedger() const; - /** Returns true if the ledger directly precedes a flag ledger */ + /** Returns `true` if this ledger directly precedes a flag ledger. + * + * Voting ledgers (flagSeq − 1) are where validators cast their + * amendment and fee preferences before the flag-ledger processing pass. + */ bool isVotingLedger() const; + /** Deserialize and return a mutable SLE at keylet `k`. + * + * Unlike `read()`, the returned SLE is not `const` and may be passed + * to `rawReplace()` or `rawErase()`. Returns `nullptr` if the key + * is absent or the keylet type check fails. + * + * @note The caller must use the returned pointer only with the same + * `Ledger` instance; crossing to another view is a `LogicError`. + * + * @param k Keylet identifying the entry. + * @return Mutable SLE, or `nullptr` if not found. + */ std::shared_ptr peek(Keylet const& k) const; private: + /** SHAMap-backed iterator implementation for `ReadView::sles`. */ class SlesIterImpl; + + /** SHAMap-backed iterator implementation for `ReadView::txs`. + * + * Deserializes with metadata for closed ledgers, without for open ones. + */ class TxsIterImpl; + /** Populate `fees_` and `rules_` from the current state map. + * + * Reads `keylet::fees()` and applies the fee fields to `fees_`, then + * rebuilds `rules_` via `makeRulesGivenLedger`. Returns `false` if a + * `SHAMapMissingNode` is caught or if the fee SLE contains an illegal + * combination of old and new fee fields; otherwise returns `true`. + * + * @note Called by every constructor and by `setImmutable()`. + */ bool setup(); - /** @brief Deserialize a SHAMapItem containing a single STTx. + /** Deserialize a SHAMapItem containing a single `STTx`. * - * @param item The SHAMapItem to deserialize. - * @return A shared pointer to the deserialized transaction. - * @throw May throw on deserialization error. + * Used by `TxsIterImpl` for open ledgers (no metadata). + * + * @param item The SHAMap leaf to deserialize. + * @return Shared pointer to the deserialized transaction. + * @throw May throw on deserialization error. */ static std::shared_ptr deserializeTx(SHAMapItem const& item); - /** @brief Deserialize a SHAMapItem containing STTx + STObject metadata. + /** Deserialize a SHAMapItem containing an `STTx` followed by `STObject` metadata. * - * The SHAMapItem must contain two variable length serialization objects. + * The item must encode two back-to-back variable-length fields: the + * serialized transaction blob first, then the metadata blob. * - * @param item The SHAMapItem to deserialize. - * @return A pair containing shared pointers to the deserialized transaction - * and metadata. - * @throw May throw on deserialization error. + * @param item The SHAMap leaf to deserialize. + * @return Pair of shared pointers to the transaction and its metadata. + * @throw May throw on deserialization error. */ static std::pair, std::shared_ptr> deserializeTxPlusMeta(SHAMapItem const& item); + /** `true` after `setImmutable()` has been called; mutations are forbidden. */ bool immutable_; - // A SHAMap containing the transactions associated with this ledger. + /** Merkle–radix tree of transactions + metadata keyed by transaction ID. + * + * Declared `mutable` so `setFull()` and iterator accessors can be + * called in `const` contexts without violating logical immutability. + */ SHAMap mutable txMap_; - // A SHAMap containing the state objects for this ledger. + /** Merkle–radix tree of all ledger state entries (SLEs) keyed by their + * 256-bit key. + * + * Declared `mutable` for the same reason as `txMap_`. + */ SHAMap mutable stateMap_; - // Protects fee variables + /** Guards `fees_` during the narrow mutable window before `setImmutable()` + * completes; not held on the read path once the ledger is immutable. + */ std::mutex mutable mutex_; - Fees fees_; - Rules rules_; - LedgerHeader header_; - beast::Journal j_; + Fees fees_; /**< Fee schedule parsed from the on-ledger fee SLE. */ + Rules rules_; /**< Protocol rules derived from enabled amendments. */ + LedgerHeader header_; /**< Sequence, hashes, close time, coin supply, etc. */ + beast::Journal j_; /**< Journal for constructor and `setup()` diagnostics. */ }; -/** A ledger wrapped in a CachedView. */ +/** Standard shareable ledger type used at rest in most of the server. + * + * `CachedView` layers an `unordered_map` in front of the raw + * `Ledger`, caching deserialized SLEs by key so that frequently accessed + * state entries are not repeatedly deserialized from the SHAMap. This is + * the type that callers such as the transaction engine and RPC handlers + * typically hold, not a raw `Ledger`. + * + * @see CachedView + */ using CachedLedger = CachedView; } // namespace xrpl diff --git a/include/xrpl/ledger/LedgerTiming.h b/include/xrpl/ledger/LedgerTiming.h index dce28bd44b..ae176ed5bb 100644 --- a/include/xrpl/ledger/LedgerTiming.h +++ b/include/xrpl/ledger/LedgerTiming.h @@ -1,3 +1,15 @@ +/** @file + * Ledger close-time resolution binning and monotonicity enforcement. + * + * Provides compile-time constants and three header-only template functions + * that translate raw wall-clock observations into canonical, network-agreed + * close timestamps written into every immutable ledger record. The binning + * approach lets validators with imperfectly synchronized clocks converge on + * a single close time without requiring a global time source. + * + * @see getNextLedgerTimeResolution, roundCloseTime, effCloseTime + */ + #pragma once #include @@ -7,11 +19,18 @@ namespace xrpl { -/** Possible ledger close time resolutions. - - Values should not be duplicated. - @see getNextLedgerTimeResolution -*/ +/** Ordered ladder of candidate close-time bin sizes, in seconds. + * + * The six values — 10, 20, 30, 60, 90, 120 seconds — form a strictly + * increasing sequence. `getNextLedgerTimeResolution` traverses this array + * to coarsen (move toward index 5) on disagreement and to refine (move + * toward index 0) on agreement. The array order directly encodes the + * coarser/finer direction; no separate mapping is needed. + * + * Values must be unique and sorted in ascending order. + * + * @see getNextLedgerTimeResolution + */ std::chrono::seconds constexpr kLEDGER_POSSIBLE_TIME_RESOLUTIONS[] = { std::chrono::seconds{10}, std::chrono::seconds{20}, @@ -20,41 +39,77 @@ std::chrono::seconds constexpr kLEDGER_POSSIBLE_TIME_RESOLUTIONS[] = { std::chrono::seconds{90}, std::chrono::seconds{120}}; -//! Initial resolution of ledger close time. +/** Default close-time resolution used for all ordinary (non-genesis) ledgers. + * + * Equal to `kLEDGER_POSSIBLE_TIME_RESOLUTIONS[2]` (30 seconds). Every + * consensus round starts from this resolution and adjusts based on prior + * agreement history via `getNextLedgerTimeResolution`. + */ auto constexpr kLEDGER_DEFAULT_TIME_RESOLUTION = kLEDGER_POSSIBLE_TIME_RESOLUTIONS[2]; -//! Close time resolution in genesis ledger +/** Close-time resolution used exclusively for the genesis ledger. + * + * Equal to `kLEDGER_POSSIBLE_TIME_RESOLUTIONS[0]` (10 seconds), the finest + * available bin. There is no prior-ledger disagreement history at genesis, + * so the finest resolution is chosen as the starting point. + */ auto constexpr kLEDGER_GENESIS_TIME_RESOLUTION = kLEDGER_POSSIBLE_TIME_RESOLUTIONS[0]; -//! How often we increase the close time resolution (in numbers of ledgers) +/** Number of ledgers between successive close-time resolution refinements. + * + * When the prior ledger reached close-time consensus, the resolution moves + * one step finer only every 8th ledger. This conservative cadence avoids + * prematurely tightening the bin size after a brief period of agreement, + * which could immediately reintroduce disagreements on slightly skewed clocks. + * + * @see getNextLedgerTimeResolution, kDECREASE_LEDGER_TIME_RESOLUTION_EVERY + */ auto constexpr kINCREASE_LEDGER_TIME_RESOLUTION_EVERY = 8; -//! How often we decrease the close time resolution (in numbers of ledgers) +/** Number of ledgers between successive close-time resolution coarsenings. + * + * When the prior ledger failed to reach close-time consensus, the resolution + * moves one step coarser on every ledger (value = 1). This aggressive + * back-off quickly finds a bin size that absorbs the validators' clock skew, + * deliberately asymmetric with the slower refinement cadence. + * + * @see getNextLedgerTimeResolution, kINCREASE_LEDGER_TIME_RESOLUTION_EVERY + */ auto constexpr kDECREASE_LEDGER_TIME_RESOLUTION_EVERY = 1; -/** Calculates the close time resolution for the specified ledger. - - The XRPL protocol uses binning to represent time intervals using only one - timestamp. This allows servers to derive a common time for the next ledger, - without the need for perfectly synchronized clocks. - The time resolution (i.e. the size of the intervals) is adjusted dynamically - based on what happened in the last ledger, to try to avoid disagreements. - - @param previousResolution the resolution used for the prior ledger - @param previousAgree whether consensus agreed on the close time of the prior - ledger - @param ledgerSeq the sequence number of the new ledger - - @pre previousResolution must be a valid bin - from @ref kLEDGER_POSSIBLE_TIME_RESOLUTIONS - - @tparam Rep Type representing number of ticks in std::chrono::duration - @tparam Period An std::ratio representing tick period in - std::chrono::duration - @tparam Seq Unsigned integer-like type corresponding to the ledger sequence - number. It should be comparable to 0 and support modular - division. Built-in and tagged_integers are supported. -*/ +/** Compute the close-time resolution to use for the next ledger. + * + * Implements the adaptive binning policy: if the prior ledger failed to + * reach close-time consensus the bin size is coarsened (every ledger, + * per `kDECREASE_LEDGER_TIME_RESOLUTION_EVERY`); if it succeeded the bin + * size is refined (every 8th ledger, per + * `kINCREASE_LEDGER_TIME_RESOLUTION_EVERY`). Both adjustments saturate at + * the boundaries of `kLEDGER_POSSIBLE_TIME_RESOLUTIONS` rather than + * wrapping. The two rules are mutually exclusive — only one fires per call. + * + * Called by the consensus engine at the start of every round to set + * `closeResolution_`, which is then used for the full round's close-time + * voting and embedded in the accepted ledger. + * + * @param previousResolution The close-time resolution used for the prior + * ledger; must be one of the values in + * `kLEDGER_POSSIBLE_TIME_RESOLUTIONS`. + * @param previousAgree Whether the network agreed on the prior ledger's + * close time (true = finer bins are safe to try). + * @param ledgerSeq Sequence number of the ledger being built; must be + * non-zero. Used for the modulo-based rate-limiting of each direction. + * @return The resolution to apply for the new ledger, chosen from + * `kLEDGER_POSSIBLE_TIME_RESOLUTIONS`. + * + * @pre `previousResolution` is an element of `kLEDGER_POSSIBLE_TIME_RESOLUTIONS`. + * @pre `ledgerSeq != Seq{0}`. + * + * @tparam Rep Tick-count type of the `std::chrono::duration`. + * @tparam Period `std::ratio` tick period of the `std::chrono::duration`. + * @tparam Seq Unsigned integer-like type for the ledger sequence number; + * supports `operator%` and comparison with `Seq{0}`. Both built-in + * integers and XRPL `tagged_integer` wrappers are accepted. + */ template std::chrono::duration getNextLedgerTimeResolution( @@ -65,7 +120,6 @@ getNextLedgerTimeResolution( XRPL_ASSERT(ledgerSeq != Seq{0}, "xrpl::getNextLedgerTimeResolution : valid ledger sequence"); using namespace std::chrono; - // Find the current resolution: auto iter = std::find( std::begin(kLEDGER_POSSIBLE_TIME_RESOLUTIONS), std::end(kLEDGER_POSSIBLE_TIME_RESOLUTIONS), @@ -78,16 +132,12 @@ getNextLedgerTimeResolution( if (iter == std::end(kLEDGER_POSSIBLE_TIME_RESOLUTIONS)) return previousResolution; - // If we did not previously agree, we try to decrease the resolution to - // improve the chance that we will agree now. if (!previousAgree && (ledgerSeq % Seq{kDECREASE_LEDGER_TIME_RESOLUTION_EVERY} == Seq{0})) { if (++iter != std::end(kLEDGER_POSSIBLE_TIME_RESOLUTIONS)) return *iter; } - // If we previously agreed, we try to increase the resolution to determine - // if we can continue to agree. if (previousAgree && (ledgerSeq % Seq{kINCREASE_LEDGER_TIME_RESOLUTION_EVERY} == Seq{0})) { if (iter-- != std::begin(kLEDGER_POSSIBLE_TIME_RESOLUTIONS)) @@ -97,13 +147,26 @@ getNextLedgerTimeResolution( return previousResolution; } -/** Calculates the close time for a ledger, given a close time resolution. - - @param closeTime The time to be rounded - @param closeResolution The resolution - @return @b closeTime rounded to the nearest multiple of @b closeResolution. - Rounds up if @b closeTime is midway between multiples of @b closeResolution. -*/ +/** Round a ledger close time to the nearest bin boundary. + * + * Bins are aligned to multiples of `closeResolution` measured from the + * clock epoch (`time_since_epoch()`), so any two validators computing this + * on the same raw time will produce the same result regardless of local + * state — a correctness prerequisite for network agreement. Ties (a time + * exactly at the midpoint between two boundaries) round up to the later bin. + * + * A default-constructed `time_point{}` (the epoch sentinel signalling no + * agreed close time) is returned unchanged without any rounding. + * + * @param closeTime The raw close-time observation to round. + * @param closeResolution The bin size; must be positive and non-zero. + * @return `closeTime` rounded to the nearest epoch-anchored multiple of + * `closeResolution`, or `closeTime` unmodified if it equals + * `time_point{}`. + * + * @note Called by `effCloseTime` and also directly by the consensus engine + * via `asCloseTime()` to canonicalize individual peer proposals. + */ template std::chrono::time_point roundCloseTime( @@ -118,15 +181,30 @@ roundCloseTime( return closeTime - (closeTime.time_since_epoch() % closeResolution); } -/** Calculate the effective ledger close time - - After adjusting the ledger close time based on the current resolution, also - ensure it is sufficiently separated from the prior close time. - - @param closeTime The raw ledger close time - @param resolution The current close time resolution - @param priorCloseTime The close time of the prior ledger -*/ +/** Compute the effective close time for a ledger, enforcing monotonicity. + * + * Rounds `closeTime` via `roundCloseTime`, then clamps the result to be + * strictly greater than `priorCloseTime`. The clamp (`priorCloseTime + 1s`) + * handles the edge case where a very fast close would otherwise produce a + * rounded time equal to or earlier than the prior ledger's close time, + * violating the invariant that ledger timestamps increase strictly along the + * chain. When the rounded value is already later than `priorCloseTime`, it + * passes through unchanged. + * + * A default-constructed `closeTime` (the epoch sentinel for "no agreed close + * time") is returned unchanged without rounding or clamping. + * + * @param closeTime The raw close-time observation for this ledger. + * @param resolution The bin size for this round's close-time voting. + * @param priorCloseTime The effective close time of the preceding ledger; + * used as the strict lower bound. + * @return `max(roundCloseTime(closeTime, resolution), priorCloseTime + 1s)`, + * or `closeTime` unmodified if it equals `time_point{}`. + * + * @note Example edge cases (30 s bins, priorCloseTime = 0 s): + * - `effCloseTime(10s, 30s, 0s)` → `1s` (rounded = 0s, clamped to 1s) + * - `effCloseTime(16s, 30s, 0s)` → `30s` (rounded = 30s, passes through) + */ template std::chrono::time_point effCloseTime( diff --git a/include/xrpl/ledger/OpenView.h b/include/xrpl/ledger/OpenView.h index 59ab733211..7168de9721 100644 --- a/include/xrpl/ledger/OpenView.h +++ b/include/xrpl/ledger/OpenView.h @@ -14,21 +14,29 @@ namespace xrpl { -/** Open ledger construction tag. - - Views constructed with this tag will have the - rules of open ledgers applied during transaction - processing. +/** Tag type for constructing an open-ledger view. + * + * Pass `kOPEN_LEDGER` to the `OpenView` constructor to build a fresh open + * ledger on top of a base. The header sequence is incremented, `parentHash` + * and `parentCloseTime` are derived from the base, and `validated`/`accepted` + * flags are cleared. Rules are supplied explicitly by the caller. + * + * @see kOPEN_LEDGER */ inline constexpr struct OpenLedgerT { explicit constexpr OpenLedgerT() = default; } kOPEN_LEDGER{}; -/** Batch view construction tag. - - Views constructed with this tag are part of a stack of views - used during batch transaction applied. +/** Tag type for constructing a batch-mode view. + * + * Pass `kBATCH_VIEW` to the `OpenView` constructor when building a child view + * during batch transaction processing. The child wraps an existing `OpenView` + * and captures its current transaction count as `baseTxCount_`, so that + * `txCount()` ordinals remain globally unique and monotonically increasing + * within the enclosing ledger regardless of how many sub-views are stacked. + * + * @see kBATCH_VIEW */ inline constexpr struct BatchViewT { @@ -37,10 +45,31 @@ inline constexpr struct BatchViewT //------------------------------------------------------------------------------ -/** Writable ledger view that accumulates state and tx changes. - - @note Presented as ReadView to clients. -*/ +/** Mutable ledger view used during transaction processing. + * + * Implements the delta-accumulation pattern: holds an immutable base + * `ReadView` (typically the most recent closed ledger) and records all SLE + * mutations and inserted transactions as a pending diff on top of it. + * Nothing is written through to the base until `apply()` is called, making + * it safe to discard changes on failure. + * + * State-object mutations are buffered in `items_` (`RawStateTable`). All + * `ReadView` queries merge the base and the pending diff transparently, so + * the apparent ledger state is always consistent. Transaction records are + * kept in `txs_` (a PMR `std::map`); open ledgers omit metadata while + * closed representations include it. + * + * Both maps are backed by a 256 KB `monotonic_buffer_resource` for O(1) + * amortised allocation with no per-element heap overhead. The resource is + * a `unique_ptr` so move-construction maintains stable addressing for the + * maps' `polymorphic_allocator` raw pointers. + * + * @note Move assignment and copy assignment are deleted; only move + * construction and copy construction are available. + * @note Callers holding `ReadView const*` see a coherent read-only snapshot + * that merges base state and pending modifications without needing to + * know whether the ledger is settled. + */ class OpenView final : public ReadView, public TxsRawView { private: @@ -98,145 +127,249 @@ public: OpenView(OpenView&&) = default; - /** Construct a shallow copy. - - Effects: - - Creates a new object with a copy of - the modification state table. - - The objects managed by shared pointers are - not duplicated but shared between instances. - Since the SLEs are immutable, calls on the - RawView interface cannot break invariants. - */ + /** Construct a copy of this view with a fresh PMR arena. + * + * The modification state table (`items_`) and transaction map (`txs_`) + * are copied into a newly allocated 256 KB monotonic buffer. `shared_ptr` + * members (SLEs, `hold_`) are shared with the source — they are not + * deep-copied — which is safe because SLEs are immutable once published. + */ OpenView(OpenView const&); - /** Construct an open ledger view. - - Effects: - - The sequence number is set to the - sequence number of parent plus one. - - The parentCloseTime is set to the - closeTime of parent. - - If `hold` is not nullptr, retains - ownership of a copy of `hold` until - the MetaView is destroyed. - - Calls to rules() will return the - rules provided on construction. - - The tx list starts empty and will contain - all newly inserted tx. - */ + /** Construct a fresh open ledger view on top of a closed base. + * + * The header is derived from `base`: sequence is incremented by one, + * `parentCloseTime` is set to the base close time, `parentHash` is set + * to the base hash, and `validated`/`accepted` flags are cleared. + * The transaction list starts empty. + * + * @param base The most recent closed ledger; must outlive this view + * unless `hold` is provided. + * @param rules Rules governing this open ledger; may differ from what + * the base recorded. + * @param hold Optional shared pointer keeping `base`'s backing object + * alive for the lifetime of this view. + */ OpenView( OpenLedgerT, ReadView const* base, Rules rules, std::shared_ptr hold = nullptr); + /** Convenience overload that keeps the base alive via shared ownership. + * + * Equivalent to the three-argument `OpenLedgerT` constructor, but takes + * a `shared_ptr` so the caller need not manage lifetime separately. + * + * @param rules Rules governing this open ledger. + * @param base Shared pointer to the closed base ledger. + */ OpenView(OpenLedgerT, Rules const& rules, std::shared_ptr const& base) : OpenView(kOPEN_LEDGER, &*base, rules, base) { } + /** Construct a batch child view on top of an existing open ledger. + * + * Wraps `base` as a read-through fallback and snapshots its current + * `txCount()` into `baseTxCount_`. This ensures that `txCount()` on this + * child continues from where the parent left off, preserving monotonically + * increasing apply-ordinals in transaction metadata. + * + * @param base The parent `OpenView` to wrap; must outlive this child. + */ OpenView(BatchViewT, OpenView& base) : OpenView(std::addressof(base)) { baseTxCount_ = base.txCount(); } - /** Construct a new last closed ledger. - - Effects: - - The LedgerHeader is copied from the base. - - The rules are inherited from the base. - - The tx list starts empty and will contain - all newly inserted tx. - */ + /** Construct a view representing a last-closed ledger. + * + * Copies the `LedgerHeader` and `Rules` directly from `base`, and + * inherits its `open_` flag — so if the base was a closed ledger, this + * view will also report itself as closed. The transaction list starts + * empty. + * + * @param base The source ledger; must outlive this view unless `hold` + * is provided. + * @param hold Optional shared pointer keeping `base`'s backing object + * alive for the lifetime of this view. + */ OpenView(ReadView const* base, std::shared_ptr hold = nullptr); - /** Returns true if this reflects an open ledger. */ + /** Returns true if this view represents an open (not yet closed) ledger. */ bool open() const override { return open_; } - /** Return the number of tx inserted since creation. - - This is used to set the "apply ordinal" - when calculating transaction metadata. - */ + /** Return the total number of transactions applied since ledger construction. + * + * Computed as `baseTxCount_ + txs_.size()`. In batch mode `baseTxCount_` + * captures the parent view's count at the time this child was constructed, + * so ordinals are globally unique and monotonically increasing even when + * child views are committed incrementally. + * + * @return Number of transactions, used as the apply ordinal in metadata. + */ std::size_t txCount() const; - /** Apply changes. */ + /** Commit all accumulated changes to the target view. + * + * Replays every buffered SLE mutation (`items_`) into `to` via + * `RawStateTable::apply`, then iterates `txs_` and calls + * `to.rawTxInsert()` for each transaction. The typical call site is + * `ApplyViewImpl::apply()`, which applies a per-transaction sandbox into + * the enclosing `OpenView`; later the `OpenView` itself is applied into + * the final ledger object. + * + * @param to The target view that receives all mutations and transactions. + */ void apply(TxsRawView& to) const; // ReadView + /** @return The current ledger header (sequence, hashes, close times). */ LedgerHeader const& header() const override; + /** @return The fee schedule inherited from the base ledger. */ Fees const& fees() const override; + /** @return The amendment rules supplied at construction or inherited from base. */ Rules const& rules() const override; + /** Check whether a ledger entry exists, merging base state and pending diff. + * + * @param k Keylet identifying the entry. + * @return `true` if the entry exists in the merged view. + */ bool exists(Keylet const& k) const override; + /** Return the smallest key strictly greater than `key` in the merged view. + * + * @param key The lower bound (exclusive) to search from. + * @param last Optional upper bound (inclusive); search is bounded to + * `[key+1, last]` when provided. + * @return The next key, or `std::nullopt` if none exists in range. + */ std::optional succ(key_type const& key, std::optional const& last = std::nullopt) const override; + /** Read a ledger entry from the merged view (base + pending diff). + * + * @param k Keylet identifying the entry. + * @return Shared pointer to the immutable SLE, or `nullptr` if absent. + */ std::shared_ptr read(Keylet const& k) const override; + /** @return Iterator to the first SLE in the merged state map. */ std::unique_ptr slesBegin() const override; + /** @return Past-the-end iterator for the merged state map. */ std::unique_ptr slesEnd() const override; + /** @return Iterator to the first SLE whose key is > `key` in the merged map. + * + * @param key The exclusive lower bound. + */ std::unique_ptr slesUpperBound(uint256 const& key) const override; + /** @return Iterator to the first transaction in this view's tx map. + * + * @note For open ledgers the iterator will not deserialize metadata; + * for closed-ledger views it will. + */ std::unique_ptr txsBegin() const override; + /** @return Past-the-end iterator for this view's tx map. */ std::unique_ptr txsEnd() const override; + /** Check whether a transaction is present in this view's tx map. + * + * @param key The transaction ID. + * @return `true` if the transaction was inserted into this view. + */ bool txExists(key_type const& key) const override; + /** Read a transaction from this view, falling back to the base. + * + * @param key The transaction ID. + * @return Pair of `(STTx, optional metadata STObject)`; both pointers are + * null if the transaction is not found in this view or the base. + */ tx_type txRead(key_type const& key) const override; // RawView + /** Buffer a deletion of an existing state item. + * + * Delegates to `RawStateTable::erase`. The entry will be removed from + * the merged view immediately and will not appear in subsequent reads. + * + * @param sle The SLE to erase; its key is extracted from the object. + */ void rawErase(std::shared_ptr const& sle) override; + /** Buffer an insertion of a new state item. + * + * Delegates to `RawStateTable::insert`. The key must not already exist + * in the merged view. + * + * @param sle The new SLE to insert; its key is extracted from the object. + */ void rawInsert(std::shared_ptr const& sle) override; + /** Buffer a replacement of an existing state item. + * + * Delegates to `RawStateTable::replace`. The key must already exist in + * the merged view. + * + * @param sle The replacement SLE; its key is extracted from the object. + */ void rawReplace(std::shared_ptr const& sle) override; + /** Record destruction of XRP (burned as transaction fees). + * + * Delegates to `RawStateTable::destroyXRP`. The destroyed amount + * accumulates in the state table and is flushed to the target on `apply()`. + * + * @param fee The amount of XRP to destroy. + */ void rawDestroyXRP(XRPAmount const& fee) override; // TxsRawView + /** Record a transaction in this view's transaction map. + * + * For open ledgers `metaData` is typically `nullptr`; for closed-ledger + * representations it carries the serialized `TxMeta`. + * + * @param key The transaction ID (must be unique within this view). + * @param txn Serialized transaction blob. + * @param metaData Serialized transaction metadata, or `nullptr` for open + * ledger entries. + * @throws std::logic_error if `key` is already present in this view's + * tx map. Duplicate transaction IDs are a hard invariant violation. + */ void rawTxInsert( key_type const& key, diff --git a/include/xrpl/ledger/OrderBookDB.h b/include/xrpl/ledger/OrderBookDB.h index a0aee58e2a..0742a08bba 100644 --- a/include/xrpl/ledger/OrderBookDB.h +++ b/include/xrpl/ledger/OrderBookDB.h @@ -14,75 +14,122 @@ namespace xrpl { -/** Tracks order books in the ledger. - - This interface provides access to order book information, including: - - Which order books exist in the ledger - - Querying order books by issue - - Managing order book subscriptions - - The order book database is updated as ledgers are accepted and provides - efficient lookup of order book information for pathfinding and client - subscriptions. -*/ +/** Pure abstract index of all active order books across the ledger. + * + * An order book is a directed trading pair — a set of open `ltOFFER` entries + * sharing the same "taker pays" (`in`) and "taker gets" (`out`) assets. + * Because pathfinding and client subscriptions both need fast lookups of + * which markets exist, this index is maintained separately from ledger state. + * + * The interface lives in the public ledger layer; the concrete implementation + * (`OrderBookDBImpl`) is instantiated via `makeOrderBookDb()` and injected + * through the service registry, keeping heavy implementation details out of + * consumer headers. + * + * @note All internal maps are guarded by a `std::recursive_mutex`. The + * expensive full-ledger scan in `setup()` builds new maps outside the + * lock and swaps them in a brief critical section, so reader calls are + * only briefly blocked rather than held for the duration of a full ledger + * traversal. + */ class OrderBookDB { public: virtual ~OrderBookDB() = default; - /** Initialize or update the order book database with a new ledger. - - This method should be called when a new ledger is accepted to update - the order book database with the current state of all order books. - - @param ledger The ledger to scan for order books - */ + /** Notify the database that a new ledger has been accepted. + * + * Triggers a throttled full-ledger scan when needed. The scan is skipped + * if the new ledger is within 25,600 sequences ahead of the last scanned + * ledger (incremental updates from `processTxn` keep the index current) + * or within 16 sequences behind it (small reorg). Outside these windows + * a full scan is scheduled — synchronously in standalone mode, or as a + * background job queue task otherwise. The scan rebuilds the book maps in + * local variables then swaps them under a lock to minimise reader + * contention. + * + * @param ledger The accepted ledger to evaluate; the scan reads every + * `ltDIR_NODE` with an `sfExchangeRate` field and every `ltAMM` + * object to rebuild the in-memory book maps. + */ virtual void setup(std::shared_ptr const& ledger) = 0; - /** Add an order book to track. - - @param book The order book to add - */ + /** Register a single order book without triggering a full ledger scan. + * + * Used to record a newly discovered book incrementally — for example, + * when a new offer type is seen in `processTxn` before the next scheduled + * full `setup()` scan. + * + * @param book The directed trading pair to register. + */ virtual void addOrderBook(Book const& book) = 0; - /** Get all order books that want a specific issue. - - Returns a list of all order books where the taker pays the specified - issue. This is useful for pathfinding to find all possible next hops - from a given currency. - - @param asset The asset to search for - @param domain Optional domain restriction for the order book - @return Vector of books that want this issue - */ + /** Return all order books whose "taker pays" side is @p asset. + * + * The primary pathfinding query: given an asset a sender currently holds, + * enumerate every market where that asset can be spent. The pathfinding + * engine calls this at each hop to discover possible next steps toward + * the destination currency. + * + * @param asset The asset the taker pays (the "in" side of the book). + * @param domain If provided, restricts results to books scoped to that + * permissioned domain; if absent, returns only global books. + * @return All `Book` objects with @p asset as their `in` side. + */ virtual std::vector getBooksByTakerPays(Asset const& asset, std::optional const& domain = std::nullopt) = 0; - /** Get the count of order books that want a specific issue. - - @param asset The asset to search for - @param domain Optional domain restriction for the order book - @return Number of books that want this issue - */ + /** Return the number of distinct "taker gets" assets available for @p asset. + * + * Used as a breadth-limiting heuristic by the pathfinding engine: a large + * count signals a liquid hub currency; a small count may not warrant + * deeper exploration. + * + * @param asset The asset the taker pays. + * @param domain If provided, counts only books in that permissioned domain; + * if absent, counts only global books. + * @return The number of order books whose "in" side matches @p asset. + */ virtual int getBookSize(Asset const& asset, std::optional const& domain = std::nullopt) = 0; - /** Check if an order book to XRP exists for the given issue. - - @param asset The asset to check - @param domain Optional domain restriction for the order book - @return true if a book from this issue to XRP exists - */ + /** Return whether any order book exists that sells @p asset for XRP. + * + * The implementation maintains a dedicated O(1) set (`xrpBooks_` / + * `xrpDomainBooks_`) so this check does not scan `allBooks_`. Pathfinding + * uses it to identify assets that can be liquidated directly to XRP + * without an intermediate hop. + * + * @param asset The asset the taker pays. + * @param domain If provided, checks the permissioned-domain book set; + * if absent, checks the global book set. + * @return `true` if a book with @p asset as "in" and XRP as "out" exists. + */ virtual bool isBookToXRP(Asset const& asset, std::optional const& domain = std::nullopt) = 0; - /** - * Process a transaction for order book tracking. - * @param ledger The ledger the transaction was applied to - * @param alTx The transaction to process - * @param jvObj The JSON object of the transaction + /** Fan out a closed-ledger transaction to all relevant book subscribers. + * + * Walks the transaction's metadata nodes looking for `ltOFFER` entries + * that were created, modified, or deleted and extracts their `TakerGets` + * and `TakerPays` fields. For each affected offer, the reversed book + * (`TakerGets` → `TakerPays`) is looked up in the listeners map and, if + * subscribers exist, `BookListeners::publish()` is called. + * + * Deduplication is handled via a `hash_set havePublished` local + * to each call: a subscriber whose sequence number is already in the set + * will not receive a second copy of the same transaction, even if multiple + * of its subscribed books were touched. + * + * @note Only called for transactions with result `tesSUCCESS`. + * + * @param ledger The closed ledger the transaction was applied to. + * @param alTx The fully materialised transaction-in-ledger projection, + * including metadata. + * @param jvObj Version-indexed JSON representation of the transaction, + * built once upstream and dispatched to subscribers by API version. */ virtual void processTxn( @@ -90,18 +137,30 @@ public: AcceptedLedgerTx const& alTx, MultiApiJson const& jvObj) = 0; - /** - * Get the book listeners for a book. - * @param book The book to get the listeners for - * @return The book listeners for the book + /** Return the listener set for @p book, or `nullptr` if none exists. + * + * Used when unsubscribing: a `nullptr` result means no entry needs to be + * updated. Avoids creating empty `BookListeners` objects for every book + * that passes through the system. + * + * @param book The directed trading pair to look up. + * @return Shared pointer to the existing `BookListeners` for @p book, or + * `nullptr` if no subscribers are registered. */ virtual BookListeners::pointer getBookListeners(Book const&) = 0; - /** - * Create a new book listeners for a book. - * @param book The book to create the listeners for - * @return The new book listeners for the book + /** Return the listener set for @p book, creating it on demand. + * + * Used when subscribing: if no `BookListeners` entry exists for the book, + * one is created and inserted into the map before returning. + * + * @note Internally calls `getBookListeners()` under the same lock, + * which is why the implementation uses a `std::recursive_mutex`. + * + * @param book The directed trading pair to look up or create. + * @return Shared pointer to the (possibly newly created) `BookListeners` + * for @p book; never `nullptr`. */ virtual BookListeners::pointer makeBookListeners(Book const&) = 0; diff --git a/include/xrpl/ledger/PaymentSandbox.h b/include/xrpl/ledger/PaymentSandbox.h index 1cd89d9388..0fd07c6896 100644 --- a/include/xrpl/ledger/PaymentSandbox.h +++ b/include/xrpl/ledger/PaymentSandbox.h @@ -14,10 +14,35 @@ namespace detail { // VFALCO TODO Inline this implementation // into the PaymentSandbox class itself +/** Bookkeeping ledger for credits deferred during payment execution. + * + * Tracks every credit applied through a `PaymentSandbox` so that + * balance queries can subtract those credits before reporting available + * funds. This prevents circular-path liquidity: a credit arriving at an + * intermediate account mid-payment cannot be re-spent by an earlier step + * in the same path. + * + * Two separate tables are maintained: `creditsIOU_` for IOU trust-line + * transfers (keyed by canonical `(lowAccount, highAccount, currency)`) and + * `creditsMPT_` for MPT issuances (keyed by `MPTID`). Owner-count + * maximums are stored in `ownerCounts_`. + * + * @note This class is an implementation detail of `PaymentSandbox` and is + * not intended for direct use by other components. + */ class DeferredCredits { private: using KeyIOU = std::tuple; + + /** Per-trust-line record of accumulated debits and the pre-credit balance. + * + * Debits are split by canonical endpoint: `lowAcctDebits` accumulates + * amounts sent by the account whose `AccountID` is lower; `highAcctDebits` + * accumulates amounts sent by the other endpoint. `lowAcctOrigBalance` + * holds the low-account's balance at the moment the first credit was + * recorded; it is never overwritten by subsequent credits. + */ struct ValueIOU { explicit ValueIOU() = default; @@ -26,41 +51,52 @@ private: STAmount lowAcctOrigBalance; }; + /** Per-holder MPT debit record. + * + * `debit` accumulates the total MPT amount sent by this holder during + * the payment. `origBalance` is the holder's balance at the time the + * first debit was recorded; it is never overwritten by subsequent debits. + */ struct HolderValueMPT { HolderValueMPT() = default; - // Debit to issuer std::uint64_t debit = 0; std::uint64_t origBalance = 0; }; + /** Per-issuance MPT record aggregating credits and self-debits. + * + * `holders` tracks per-holder debit entries. `credit` accumulates the + * total amount issued (i.e. credited to holders) during the payment. + * `origBalance` holds the issuer's `OutstandingAmount` at the time the + * first entry was recorded; it is never overwritten. + * + * `selfDebit` handles the case where the MPT issuer owns a sell offer. + * Because the payment engine runs in reverse, crediting a holder first + * can transiently push `OutstandingAmount` above `MaximumAmount`. When + * the issuer's own sell offer is consumed in a later (reversed) step, + * the available issuance capacity must be reduced by the offer amount. + * `selfDebit` accumulates those offer amounts so that + * `balanceHookSelfIssueMPT` can correctly cap available issuance. + */ struct IssuerValueMPT { IssuerValueMPT() = default; std::map holders; - // Credit to holder std::uint64_t credit = 0; - // OutstandingAmount might overflow when MPTs are credited to a holder. - // Consider A1 paying 100MPT to A2 and A1 already having maximum MPTs. - // Since the payment engine executes a payment in revers, A2 is - // credited first and OutstandingAmount is going to be equal - // to MaximumAmount + 100MPT. In the next step A1 redeems 100MPT - // to the issuer and OutstandingAmount balances out. std::int64_t origBalance = 0; - // Self debit on offer selling MPT. Since the payment engine executes - // a payment in reverse, a crediting/buying step may overflow - // OutstandingAmount. A sell MPT offer owned by a holder can redeem any - // amount up to the offer's amount and holder's available funds, - // balancing out OutstandingAmount. But if the offer's owner is issuer - // then it issues more MPT. In this case the available amount to issue - // is the initial issuer's available amount less all offer sell amounts - // by the issuer. This is self-debit, where the offer's owner, - // issuer in this case, debits to self. std::uint64_t selfDebit = 0; }; using AdjustmentMPT = IssuerValueMPT; public: + /** Query result for a single IOU trust-line adjustment. + * + * Oriented from the perspective of the `main` account passed to + * `adjustmentsIOU()`: `debits` is what `main` has sent, `credits` is + * what `main` has received, and `origBalance` is `main`'s balance + * before the first credit in this sandbox was recorded. + */ struct AdjustmentIOU { AdjustmentIOU(STAmount d, STAmount c, STAmount b) @@ -72,14 +108,44 @@ public: STAmount origBalance; }; - // Get the adjustments for the balance between main and other. - // Returns the debits, credits and the original balance + /** Return the accumulated debit/credit adjustments for an IOU trust line. + * + * The result is oriented from `main`'s perspective: `debits` contains + * what `main` has sent to `other`, `credits` contains what `other` has + * sent to `main`, and `origBalance` is `main`'s balance at the time the + * first credit for this pair was recorded. + * + * @param main The account whose perspective determines orientation. + * @param other The counterparty account. + * @param currency The currency of the trust line. + * @return Adjustment record, or `std::nullopt` if no credits have been + * recorded for this pair in this sandbox. + */ [[nodiscard]] std::optional adjustmentsIOU(AccountID const& main, AccountID const& other, Currency const& currency) const; + /** Return the accumulated MPT adjustments for a given issuance. + * + * @param mptID The unique identifier of the MPT issuance. + * @return Adjustment record, or `std::nullopt` if no credits have been + * recorded for this issuance in this sandbox. + */ [[nodiscard]] std::optional adjustmentsMPT(MPTID const& mptID) const; + /** Record an IOU credit from `sender` to `receiver`. + * + * On the first call for a given `(sender, receiver, currency)` triple the + * pre-credit sender balance is saved as the original balance. Subsequent + * calls for the same triple accumulate debits without overwriting the + * original balance. + * + * @param sender Account sending the credit. + * @param receiver Account receiving the credit. + * @param amount Non-negative IOU amount being transferred. + * @param preCreditSenderBalance Sender's balance immediately before + * this credit is applied; only stored on the first call. + */ void creditIOU( AccountID const& sender, @@ -87,6 +153,20 @@ public: STAmount const& amount, STAmount const& preCreditSenderBalance); + /** Record an MPT credit from `sender` to `receiver`. + * + * Distinguishes between issuer-to-holder transfers (which increment the + * aggregate `credit` counter) and holder-to-issuer redemptions (which + * increment the per-holder `debit` counter). The original balances are + * stored only on the first call for each holder/issuance combination. + * + * @param sender Account sending the MPT. + * @param receiver Account receiving the MPT. + * @param amount Non-negative MPT amount being transferred. + * @param preCreditBalanceHolder Holder's MPT balance before this credit. + * @param preCreditBalanceIssuer Issuer's `OutstandingAmount` before this + * credit; only stored on the first call for this issuance. + */ void creditMPT( AccountID const& sender, @@ -95,22 +175,61 @@ public: std::uint64_t preCreditBalanceHolder, std::int64_t preCreditBalanceIssuer); + /** Record an MPT self-debit incurred by the issuer via a sell offer. + * + * When the issuer owns a sell offer and it is consumed, the payment + * engine (running in reverse) may have already credited a holder, + * pushing `OutstandingAmount` transiently above `MaximumAmount`. This + * call registers the offer amount as a self-debit so that + * `balanceHookSelfIssueMPT` can cap available issuance correctly. + * + * @param issue The MPT issuance involved. + * @param amount Amount of the issuer's sell offer that was consumed. + * @param origBalance Issuer's `OutstandingAmount` before this entry; only + * stored on the first call for this issuance. + */ void issuerSelfDebitMPT(MPTIssue const& issue, std::uint64_t amount, std::int64_t origBalance); + /** Record an owner-count transition for `account`. + * + * Stores the maximum of `cur` and `next`, and takes the maximum with any + * previously recorded value. Because payments only ever decrease owner + * counts, the highest observed count is the conservative bound that + * prevents a transient low count from bypassing reserve checks mid-payment. + * + * @param id Account whose owner count is changing. + * @param cur Current owner count before the transition. + * @param next Owner count after the transition. + */ void ownerCount(AccountID const& id, std::uint32_t cur, std::uint32_t next); - // Get the adjusted owner count. Since DeferredCredits is meant to be used - // in payments, and payments only decrease owner counts, return the max - // remembered owner count. + /** Return the maximum owner count observed for `account` in this sandbox. + * + * Since payments only decrease owner counts, the maximum is the correct + * conservative bound for reserve checks. + * + * @param id Account to query. + * @return The peak owner count, or `std::nullopt` if no transition has + * been recorded for this account. + */ [[nodiscard]] std::optional ownerCount(AccountID const& id) const; + /** Merge this sandbox's deferred credits into a parent sandbox. + * + * Debit accumulators and self-debit fields are summed; original balances + * are never overwritten (the parent's earlier record takes precedence). + * Owner-count maximums are taken across both sandboxes. + * + * @param to The parent `DeferredCredits` table to merge into. + */ void apply(DeferredCredits& to); private: + /** Produce a canonical `KeyIOU` by ordering the two accounts. */ static KeyIOU makeKeyIOU(AccountID const& a1, AccountID const& a2, Currency const& currency); @@ -123,18 +242,29 @@ private: //------------------------------------------------------------------------------ -/** A wrapper which makes credits unavailable to balances. - - This is used for payments and pathfinding, so that consuming - liquidity from a path never causes portions of that path or - other paths to gain liquidity. - - The behavior of certain free functions in the ApplyView API - will change via the balanceHook and creditHook overrides - of PaymentSandbox. - - @note Presented as ApplyView to clients -*/ +/** Speculative ledger view that hides in-flight credits from balance queries. + * + * The XRPL payment engine processes multi-hop paths where value flows through + * chains of trust lines, order books, and AMM pools. Without a guard, a + * credit arriving at an intermediate account mid-path could immediately + * appear as spendable liquidity for a later step in the same path — allowing + * phantom value to be created. `PaymentSandbox` prevents this by intercepting + * every credit via the hook protocol defined in `ApplyView` and recording it + * in a `DeferredCredits` table. Balance queries then subtract those deferred + * credits so freshly-received funds are invisible to outgoing transfer checks + * until the entire transaction commits. + * + * `PaymentSandbox` can be stacked: constructing one on top of another via the + * pointer constructors creates a child sandbox whose deferred credits chain to + * the parent. The pathfinding engine uses this to evaluate each candidate + * strand in a disposable child, committing to the parent only on success. + * + * @note When constructing on top of an existing `PaymentSandbox`, you **must** + * use the explicit pointer constructors. Using the plain `ApplyView*` + * constructor would bypass deferred-credit propagation and break invariants. + * + * @note Presented as `ApplyView` to clients. + */ class PaymentSandbox final : public detail::ApplyViewBase { public: @@ -147,27 +277,40 @@ public: PaymentSandbox(PaymentSandbox&&) = default; + /** Construct a root payment sandbox over a read-only base view. + * + * @param base The underlying ledger state to layer mutations on top of. + * @param flags Transaction-processing flags forwarded to `ApplyViewBase`. + */ PaymentSandbox(ReadView const* base, ApplyFlags flags) : ApplyViewBase(base, flags) { } + /** Construct a payment sandbox over an existing `ApplyView`. + * + * Inherits the flags of the base view. Use the explicit pointer + * constructors instead if `base` is itself a `PaymentSandbox`. + * + * @param base The mutable view to build on top of. + */ PaymentSandbox(ApplyView const* base) : ApplyViewBase(base, base->flags()) { } - /** Construct on top of existing PaymentSandbox. - - The changes are pushed to the parent when - apply() is called. - - @param parent A non-null pointer to the parent. - - @note A pointer is used to prevent confusion - with copy construction. - */ - // VFALCO If we are constructing on top of a PaymentSandbox, - // or a PaymentSandbox-derived class, we MUST go through - // one of these constructors or invariants will be broken. + /** Construct a child payment sandbox on top of an existing `PaymentSandbox`. + * + * The child's deferred-credit table chains to the parent so that balance + * adjustments aggregate correctly across the sandbox stack. Changes are + * not visible in the parent until `apply(PaymentSandbox&)` is called. + * + * @param parent Non-null pointer to the parent sandbox. A pointer is + * used rather than a reference to prevent confusion with copy + * construction. + * + * @note This overload set **must** be used whenever building on top of + * a `PaymentSandbox` or derived class. The plain `ApplyView*` + * constructor does not propagate deferred credits. + */ /** @{ */ explicit PaymentSandbox(PaymentSandbox const* base) : ApplyViewBase(base, base->flags()), ps_(base) @@ -179,17 +322,67 @@ public: } /** @} */ + /** Return the IOU balance adjusted for deferred credits. + * + * Walks the sandbox chain (this → parent → … ) and accumulates total + * debits from all ancestor tables. Returns + * `min(amount, origBalance - totalDebits, minObservedBalance)` to + * handle edge cases where rounding in the deferred table could otherwise + * overestimate usable funds. A computed negative XRP result is clamped + * to zero (it is not an error — it arises when a large credit is + * followed by the same debit within the path). + * + * @param account The account whose perspective determines orientation. + * @param issuer The IOU issuer (doubles as the currency issuer). + * @param amount The raw balance as reported by the underlying ledger. + * @return Adjusted balance with deferred credits hidden. + */ [[nodiscard]] STAmount balanceHookIOU(AccountID const& account, AccountID const& issuer, STAmount const& amount) const override; + /** Return the MPT holder or issuer balance adjusted for deferred credits. + * + * Walks the sandbox chain accumulating per-holder debits (if `account` + * is a holder) or the aggregate issuer credit (if `account` is the + * issuer). Returns `min(amount, origBalance - totalAdjustment, + * minObservedBalance)`, clamped to zero. + * + * @param account The account being queried (holder or issuer). + * @param issue The MPT issuance. + * @param amount The raw balance as reported by the underlying ledger. + * @return Adjusted balance with deferred credits hidden. + */ [[nodiscard]] STAmount balanceHookMPT(AccountID const& account, MPTIssue const& issue, std::int64_t amount) const override; + /** Return the issuer's available MPT issuance capacity, net of self-debits. + * + * When the issuer owns sell offers and the payment engine (running in + * reverse) has already consumed some of them, those amounts are recorded + * as self-debits. This hook caps available issuance at + * `origOutstandingAmount - totalSelfDebits`, returning zero if the result + * is non-positive. + * + * @param issue The MPT issuance. + * @param amount The raw `OutstandingAmount` from the underlying ledger. + * @return Available issuance capacity after subtracting self-debits. + */ [[nodiscard]] STAmount balanceHookSelfIssueMPT(MPTIssue const& issue, std::int64_t amount) const override; + /** Record an IOU credit in the deferred-credits table. + * + * Called by ledger mutation helpers at every IOU transfer. The recorded + * debit is used by `balanceHookIOU` to hide this credit from future + * balance queries within the same payment path. + * + * @param from Account sending the credit. + * @param to Account receiving the credit. + * @param amount Non-negative IOU amount being transferred. + * @param preCreditBalance Sender's balance immediately before this credit. + */ void creditHookIOU( AccountID const& from, @@ -197,6 +390,19 @@ public: STAmount const& amount, STAmount const& preCreditBalance) override; + /** Record an MPT credit in the deferred-credits table. + * + * Called by ledger mutation helpers at every MPT transfer. The recorded + * debit is used by `balanceHookMPT` to hide this credit from future + * balance queries within the same payment path. + * + * @param from Account sending the MPT. + * @param to Account receiving the MPT. + * @param amount Non-negative MPT amount being transferred. + * @param preCreditBalanceHolder Holder's MPT balance before this credit. + * @param preCreditBalanceIssuer Issuer's `OutstandingAmount` before this + * credit. + */ void creditHookMPT( AccountID const& from, @@ -205,22 +411,60 @@ public: std::uint64_t preCreditBalanceHolder, std::int64_t preCreditBalanceIssuer) override; + /** Record an MPT issuer self-debit arising from a consumed sell offer. + * + * Called when the MPT issuer's own sell offer is consumed during + * payment processing. Accumulates the offer amount in the + * `DeferredCredits` self-debit field so that `balanceHookSelfIssueMPT` + * can correctly limit further issuance capacity. + * + * @param issue The MPT issuance. + * @param amount Amount consumed from the issuer's sell offer. + * @param origBalance Issuer's `OutstandingAmount` before this entry. + */ void issuerSelfDebitHookMPT(MPTIssue const& issue, std::uint64_t amount, std::int64_t origBalance) override; + /** Record an owner-count transition for reserve-check purposes. + * + * Stores the maximum of `cur` and `next` in the deferred-credits table. + * Because payments only decrease owner counts, the peak value is the + * conservative bound that prevents a transient low count from bypassing + * reserve checks mid-payment. + * + * @param account Account whose owner count is changing. + * @param cur Owner count before the transition. + * @param next Owner count after the transition. + */ void adjustOwnerCountHook(AccountID const& account, std::uint32_t cur, std::uint32_t next) override; + /** Return the peak owner count observed for `account` in this sandbox chain. + * + * Walks the sandbox chain and returns the maximum recorded count across + * all ancestors, or `count` if no transition has been recorded. + * + * @param account Account to query. + * @param count Baseline count from the underlying ledger. + * @return The peak owner count seen across the sandbox chain. + */ [[nodiscard]] std::uint32_t ownerCountHook(AccountID const& account, std::uint32_t count) const override; - /** Apply changes to base view. - - `to` must contain contents identical to the parent - view passed upon construction, else undefined - behavior will result. - */ + /** Commit changes to a base view. + * + * The two overloads serve different commit targets: + * - `apply(RawView&)` is the terminal commit: asserts this sandbox has + * no parent (`ps_ == nullptr`) and flushes the state journal to the + * raw ledger. The `RawView` must contain state identical to the view + * passed at construction, otherwise behavior is undefined. + * - `apply(PaymentSandbox&)` asserts that `&to == ps_` (you can only + * apply to your direct parent) and propagates both the state journal + * and the deferred-credits table into the parent sandbox. + * + * @param to The target view to flush changes into. + */ /** @{ */ void apply(RawView& to); @@ -229,6 +473,10 @@ public: apply(PaymentSandbox& to); /** @} */ + /** Return the amount of XRP destroyed (as fees) during this payment. + * + * Delegates to `items_.dropsDestroyed()`. Distinct from transferred XRP. + */ [[nodiscard]] XRPAmount xrpDestroyed() const; diff --git a/include/xrpl/ledger/PendingSaves.h b/include/xrpl/ledger/PendingSaves.h index a18292df68..07e40b31bf 100644 --- a/include/xrpl/ledger/PendingSaves.h +++ b/include/xrpl/ledger/PendingSaves.h @@ -8,12 +8,49 @@ namespace xrpl { -/** Keeps track of which ledgers haven't been fully saved. - - During the ledger building process this collection will keep - track of those ledgers that are being built but have not yet - been completely written. -*/ +/** Coordination primitive tracking validated ledgers not yet fully written to + * the SQLite relational database. + * + * When a validated ledger is being persisted, there is a window in which it + * exists in memory but its index entries are incomplete on disk. Any code that + * reports the "validated range" of ledgers to peers or clients must exclude + * these in-progress sequences; otherwise it could direct a requester to query + * a partially-written row. + * + * ## Internal state machine + * + * The internal map encodes three observable states per ledger sequence: + * + * | Map state | Meaning | + * |----------------------------|--------------------------------------------| + * | key absent | Not pending; safe for DB queries | + * | key present, value `false` | Registered/dispatched, write not started | + * | key present, value `true` | A thread is actively writing to SQLite | + * + * The canonical "finished" state is key-absent; `finishWork()` erases the + * entry (rather than resetting the flag) so that `pending()` and the blocking + * loop in `shouldWork()` use absence as the termination condition. + * + * ## Typical call sequence + * + * 1. `pendSaveValidated()` calls `shouldWork(seq, isSynchronous)` to either + * claim a fresh entry or "steal" a registered-but-unstarted one. + * 2. `saveValidatedLedger()` calls `startWork(seq)` to atomically flip the + * flag from `false` → `true`. A `false` return means another thread won + * the race; the caller logs "Save aborted" and exits early. + * 3. `saveValidatedLedger()` calls `finishWork(seq)` after the DB write + * completes, waking any synchronous waiters. + * 4. `LedgerMaster::getValidatedRange()` calls `getSnapshot()` to trim the + * reported min/max validated range, excluding any in-progress sequences. + * + * This class is a pure coordination primitive. It does not own a thread pool + * or `JobQueue`; all scheduling policy lives in `pendSaveValidated()`. + * + * @note Thread-safe. All methods acquire `mutex_` internally. The synchronous + * blocking path in `shouldWork()` re-acquires the lock after each + * `await_.wait()` and re-checks in a loop because `notify_all()` can + * wake multiple waiters simultaneously. + */ class PendingSaves { private: @@ -22,12 +59,18 @@ private: std::condition_variable await_; public: - /** Start working on a ledger - - This is called prior to updating the SQLite indexes. - - @return 'true' if work should be done - */ + /** Atomically claim the right to begin writing a ledger to the database. + * + * Flips the map entry for @p seq from `false` to `true`, signalling that + * a thread is actively writing to SQLite. This must be called after + * `shouldWork()` returns `true` and before the DB write begins. + * + * @param seq Ledger sequence number to claim. + * @return `true` if this caller successfully claimed the write; `false` if + * the entry is absent (write already completed) or already `true` + * (another thread started it first). A `false` return is the caller's + * signal to abort with a "Save aborted" log and return early. + */ bool startWork(LedgerIndex seq) { @@ -45,12 +88,14 @@ public: return true; } - /** Finish working on a ledger - - This is called after updating the SQLite indexes. - The tracking of the work in progress is removed and - threads awaiting completion are notified. - */ + /** Mark a ledger's database write as complete and wake any waiters. + * + * Erases the entry for @p seq from the map — key-absent is the canonical + * "done" state — then calls `notify_all()` so any synchronous caller + * blocked in `shouldWork()` can re-evaluate. + * + * @param seq Ledger sequence number whose write has completed. + */ void finishWork(LedgerIndex seq) { @@ -60,7 +105,14 @@ public: await_.notify_all(); } - /** Return `true` if a ledger is in the progress of being saved. */ + /** Return `true` if @p seq has a pending or in-progress database write. + * + * A `true` result means the sequence appears in the map (either + * dispatched-but-not-started or actively writing). Callers use this to + * avoid re-dispatching a save that is already in flight. + * + * @param seq Ledger sequence number to test. + */ bool pending(LedgerIndex seq) { @@ -68,14 +120,34 @@ public: return map_.contains(seq); } - /** Check if a ledger should be dispatched - - Called to determine whether work should be done or - dispatched. If work is already in progress and the - call is synchronous, wait for work to be completed. - - @return 'true' if work should be done or dispatched - */ + /** Determine whether the caller should proceed with (or wait for) a save. + * + * This is the entry point for `pendSaveValidated()`. It implements the + * full dispatch/steal/wait decision: + * + * - **Not present**: Inserts `(seq, false)` and returns `true` — the + * caller owns the work. + * - **Present as `false`** (registered, unstarted): + * - Asynchronous caller: returns `false` (already dispatched; skip). + * - Synchronous caller: returns `true`, stealing the work before any + * thread can claim it via `startWork()`. + * - **Present as `true`** (write in progress): + * - Asynchronous caller: unreachable in practice; the `!isSynchronous` + * branch returns `false` before reaching the wait. + * - Synchronous caller: blocks on `await_` in a `do/while` loop, + * re-checking after each `notify_all()` from `finishWork()`, until + * the entry disappears (write complete). + * + * @param seq Ledger sequence number to check or register. + * @param isSynchronous `true` if the caller requires the write to be + * complete before returning; `false` if dispatch-once is sufficient. + * @return `true` if the caller should perform (or has stolen) the write; + * `false` if the work is already dispatched or complete. + * + * @note The blocking synchronous path re-acquires `mutex_` after each + * wake-up and loops because `notify_all()` may unblock multiple + * waiters; only one will find the entry absent. + */ bool shouldWork(LedgerIndex seq, bool isSynchronous) { @@ -108,12 +180,20 @@ public: } while (true); } - /** Get a snapshot of the pending saves - - Each entry in the returned map corresponds to a ledger - that is in progress or dispatched. The boolean indicates - whether work is currently in progress. - */ + /** Return a point-in-time copy of the pending-saves map. + * + * Used by `LedgerMaster::getValidatedRange()` to trim the reported + * min/max validated-ledger range: any sequence present in the snapshot — + * regardless of whether its flag is `false` (dispatched) or `true` + * (writing) — is excluded from the range to avoid directing peers to + * query a partially-written DB row. + * + * The returned map is a value copy taken under `mutex_`; the caller may + * iterate it freely without holding any lock. + * + * @return A snapshot of `map_`, where each key is an in-flight ledger + * sequence and each value is `false` (unstarted) or `true` (active). + */ std::map getSnapshot() const { diff --git a/include/xrpl/ledger/RawView.h b/include/xrpl/ledger/RawView.h index cfcf807e13..a73ed18859 100644 --- a/include/xrpl/ledger/RawView.h +++ b/include/xrpl/ledger/RawView.h @@ -6,10 +6,29 @@ namespace xrpl { -/** Interface for ledger entry changes. - - Subclasses allow raw modification of ledger entries. -*/ +/** Low-level write surface for committing ledger state mutations. + * + * Defines the three-operation contract (`rawErase`, `rawInsert`, + * `rawReplace`) plus an XRP-burn hook (`rawDestroyXRP`) that together + * represent the minimal interface a backing store must provide to absorb + * flushed changes from a sandbox. + * + * `detail::RawStateTable::apply(RawView&)` is the canonical driver: + * it iterates its buffered erase/insert/replace actions and dispatches + * each through the corresponding method here, so flushing logic is written + * once and any concrete target — a finalising `Ledger`, an `OpenView`, or + * another sandbox — implements the contract without exposing checkout + * semantics. + * + * The "raw" prefix is a semantic contract: these methods perform no + * precondition checking, no journaling, and no ownership tracking. + * They are the trusted commit surface, not the API that transaction + * logic should call directly. + * + * @note The copy constructor is defaulted (subclasses may need to snapshot + * state), but copy assignment is deleted to prevent silent cross-type + * assignment through the base interface. + */ class RawView { public: @@ -19,66 +38,79 @@ public: RawView& operator=(RawView const&) = delete; - /** Delete an existing state item. - - The SLE is provided so the implementation - can calculate metadata. - */ + /** Unconditionally remove an existing state entry. + * + * The full SLE (not just its key) is passed so that implementations + * can compute metadata such as changes to owner count or the type of + * the deleted object. + * + * @param sle The ledger entry to remove. The key is derived from + * the SLE itself; the entry must exist in the backing store. + */ virtual void rawErase(std::shared_ptr const& sle) = 0; - /** Unconditionally insert a state item. - - Requirements: - The key must not already exist. - - Effects: - - The key is associated with the SLE. - - @note The key is taken from the SLE - */ + /** Unconditionally insert a new state entry. + * + * The key is read from the SLE rather than passed separately, + * which prevents key/value mismatches at the call site. + * + * @param sle The ledger entry to insert. The key must not already + * exist in the backing store. + */ virtual void rawInsert(std::shared_ptr const& sle) = 0; - /** Unconditionally replace a state item. - - Requirements: - - The key must exist. - - Effects: - - The key is associated with the SLE. - - @note The key is taken from the SLE - */ + /** Unconditionally overwrite an existing state entry. + * + * The key is read from the SLE rather than passed separately, + * which prevents key/value mismatches at the call site. + * + * @param sle The replacement ledger entry. The key must already + * exist in the backing store. + */ virtual void rawReplace(std::shared_ptr const& sle) = 0; - /** Destroy XRP. - - This is used to pay for transaction fees. - */ + /** Permanently remove XRP drops from the ledger supply. + * + * XRPL burns transaction fees rather than redistributing them. + * This method is the accounting hook for that burn: separating it + * from `rawErase` keeps fee accounting explicit and auditable. + * + * @param fee The quantity of XRP drops to destroy. + */ virtual void rawDestroyXRP(XRPAmount const& fee) = 0; }; //------------------------------------------------------------------------------ -/** Interface for changing ledger entries with transactions. - - Allows raw modification of ledger entries and insertion - of transactions into the transaction map. -*/ +/** Extends `RawView` with the ability to insert transactions into the + * ledger's transaction map. + * + * The split between `RawView` (state-only writes) and `TxsRawView` + * (state plus transaction map) is architecturally significant. + * `detail::ApplyViewBase` — the sandbox used during transaction + * processing — only needs `RawView`: sandboxes accumulate state + * mutations but do not independently maintain a transaction map. + * `OpenView`, by contrast, inherits both `ReadView` and `TxsRawView` + * because it is the accumulation point for an open ledger round and + * must track both the growing state diff and the applied-transaction + * set. + */ class TxsRawView : public RawView { public: - /** Add a transaction to the tx map. - - Closed ledgers must have metadata, - while open ledgers omit metadata. - */ + /** Insert a serialized transaction into the ledger's transaction map. + * + * @param key The transaction's map key (typically its hash). + * @param txn Serialized transaction blob; must not be null. + * @param metaData Serialized transaction metadata, or null for open + * ledgers. Closed ledgers must supply metadata; open ledgers must + * pass null because consensus has not yet produced execution + * results. + */ virtual void rawTxInsert( ReadView::key_type const& key, diff --git a/include/xrpl/ledger/ReadView.h b/include/xrpl/ledger/ReadView.h index 4f9bf9c31d..77307ae74a 100644 --- a/include/xrpl/ledger/ReadView.h +++ b/include/xrpl/ledger/ReadView.h @@ -1,3 +1,17 @@ +/** @file + * Defines the foundational read-only ledger view interface. + * + * `ReadView` is the base of the entire ledger view hierarchy. Every concrete + * ledger representation — finalized `Ledger`, in-progress `OpenView`, apply-time + * `Sandbox`, or payment-path `PaymentSandbox` — exposes its state through this + * interface. Code that only reads ledger data can operate on any view type without + * knowing the concrete implementation. + * + * `DigestAwareReadView` extends `ReadView` with per-entry cryptographic digests, + * used by `CachedView` for efficient cache invalidation and by `makeRulesGivenLedger` + * to detect amendment changes between ledger closes. + */ + #pragma once #include @@ -21,21 +35,43 @@ namespace xrpl { //------------------------------------------------------------------------------ -/** A view into a ledger. - - This interface provides read access to state - and transaction items. There is no checkpointing - or calculation of metadata. -*/ +/** Pure abstract read-only interface to a ledger. + * + * Exposes two conceptually distinct maps: the **state map** (SLEs keyed by + * `uint256`) and the **transaction map** (committed transactions with metadata). + * Concrete implementations include `Ledger` (finalized), `OpenView` (in-progress), + * `Sandbox` (discardable apply-time copy), and `PaymentSandbox` (payment engine). + * + * @note Copy and move constructors explicitly re-initialize `sles` and `txs` + * with `*this`. Both members store a raw pointer to their owning view; a + * default memberwise copy would leave them pointing at the source object. + * Assignment operators are deleted for the same reason. + */ class ReadView { public: + /** Pair of transaction and its associated metadata object. + * + * The metadata `STObject` is empty for open ledgers, since metadata is + * only finalized at ledger close time. + */ using tx_type = std::pair, std::shared_ptr>; + /** Raw key type for state-map and transaction-map lookups. */ using key_type = uint256; + /** Shared ownership handle to a non-modifiable state entry. */ using mapped_type = std::shared_ptr; + /** STL-compatible forward range over the ledger state map. + * + * Iterates all SLEs present in this view. Backed by type-erased + * `ReadViewFwdIter` so the same interface works across SHAMap-backed, + * delta-list, and sandbox views. `upperBound` enables sub-range scans + * without a full traversal. + * + * @note Visiting every state entry can be expensive as the ledger grows. + */ struct SlesType : detail::ReadViewFwdRange> { explicit SlesType(ReadView const& view); @@ -43,13 +79,20 @@ public: begin() const; [[nodiscard]] Iterator end() const; + /** Returns an iterator to the first SLE whose key is strictly greater than @p key. */ [[nodiscard]] Iterator upperBound(key_type const& key) const; }; + /** STL-compatible forward range over the ledger transaction map. + * + * Iterates all `tx_type` pairs (transaction + metadata) present in + * this view. For open ledgers the metadata member of each pair is empty. + */ struct TxsType : detail::ReadViewFwdRange { explicit TxsType(ReadView const& view); + /** Returns `true` when the transaction map contains no entries. */ [[nodiscard]] bool empty() const; [[nodiscard]] Iterator @@ -65,92 +108,118 @@ public: ReadView& operator=(ReadView const& other) = delete; + /** Constructs the view and binds `sles` and `txs` to `*this`. */ ReadView() : sles(*this), txs(*this) { } + /** Copy-constructs the view, re-binding `sles` and `txs` to `*this`. + * + * @note The `sles` and `txs` members store a pointer to their owning + * view. They are explicitly re-initialized here to point at the new + * object, not at `other`. + */ ReadView(ReadView const& other) : sles(*this), txs(*this) { } + /** Move-constructs the view, re-binding `sles` and `txs` to `*this`. + * + * @note Same aliasing concern as the copy constructor; `sles` and `txs` + * are explicitly re-initialized to point at the new object. + */ ReadView(ReadView&& other) : sles(*this), txs(*this) { } - /** Returns information about the ledger. */ + /** Returns the immutable header fields for this ledger. + * + * All non-virtual convenience accessors (`seq()`, `parentCloseTime()`) + * delegate here, keeping the virtual dispatch surface minimal. + */ [[nodiscard]] virtual LedgerHeader const& header() const = 0; - /** Returns true if this reflects an open ledger. */ + /** Returns `true` if this view reflects an open (not yet closed) ledger. */ [[nodiscard]] virtual bool open() const = 0; - /** Returns the close time of the previous ledger. */ + /** Returns the close time of the previous (parent) ledger. */ [[nodiscard]] NetClock::time_point parentCloseTime() const { return header().parentCloseTime; } - /** Returns the sequence number of the base ledger. */ + /** Returns the sequence number of this ledger. */ [[nodiscard]] LedgerIndex seq() const { return header().seq; } - /** Returns the fees for the base ledger. */ + /** Returns the fee schedule in effect for this ledger. */ [[nodiscard]] virtual Fees const& fees() const = 0; - /** Returns the tx processing rules. */ + /** Returns the amendment rules active for this ledger. */ [[nodiscard]] virtual Rules const& rules() const = 0; - /** Determine if a state item exists. - - @note This can be more efficient than calling read. - - @return `true` if a SLE is associated with the - specified key. - */ + /** Returns `true` if a state entry matching the keylet is present. + * + * The `Keylet` bundles a raw `uint256` key with its `LedgerEntryType`, + * allowing implementations to reject type mismatches without deserializing + * the entry. This makes `exists` more efficient than calling `read` when + * only presence is needed. + * + * @param k The keylet (key + expected entry type) to probe. + * @return `true` if an SLE with the given key and type exists. + */ [[nodiscard]] virtual bool exists(Keylet const& k) const = 0; - /** Return the key of the next state item. - - This returns the key of the first state item - whose key is greater than the specified key. If - no such key is present, std::nullopt is returned. - - If `last` is engaged, returns std::nullopt when - the key returned would be outside the open - interval (key, last). - */ + /** Returns the smallest state-map key strictly greater than @p key. + * + * Enables ordered range scans of the SHAMap without deserializing entries. + * If @p last is set, the search is bounded to the open interval + * `(key, last)` — any candidate key outside that range causes + * `std::nullopt` to be returned instead. + * + * @param key The key to search above. + * @param last Optional exclusive upper bound for the result. + * @return The next key, or `std::nullopt` if none exists within bounds. + */ [[nodiscard]] virtual std::optional succ(key_type const& key, std::optional const& last = std::nullopt) const = 0; - /** Return the state item associated with a key. - - Effects: - If the key exists, gives the caller ownership - of the non-modifiable corresponding SLE. - - @note While the returned SLE is `const` from the - perspective of the caller, it can be changed - by other callers through raw operations. - - @return `nullptr` if the key is not present or - if the type does not match. - */ + /** Returns a read-only handle to the state entry identified by @p k. + * + * Gives the caller shared ownership of a non-modifiable SLE. The `const` + * qualifier reflects this caller's view; the underlying object may be + * mutated through `ApplyView` in another code path. + * + * @param k The keylet (key + expected entry type) to look up. + * @return Shared pointer to the SLE, or `nullptr` if the key is absent + * or the ledger entry type does not match the keylet. + */ [[nodiscard]] virtual std::shared_ptr read(Keylet const& k) const = 0; - // Accounts in a payment are not allowed to use assets acquired during that - // payment. The PaymentSandbox tracks the debits, credits, and owner count - // changes that accounts make during a payment. `balanceHookIOU` adjusts - // balances so newly acquired assets are not counted toward the balance. - // This is required to support PaymentSandbox. + /** Adjusts an IOU balance to exclude assets acquired during the current payment. + * + * The payment engine executes paths in reverse (destination-first), which + * means an account may be credited before it has redeemed the corresponding + * asset. Accounts must not spend assets acquired within the same payment. + * `PaymentSandbox` overrides this hook to subtract deferred credits recorded + * in its `DeferredCredits` table. The default implementation returns + * @p amount unchanged, making the hook zero-cost for non-payment views. + * + * @param account The account whose balance is being queried. + * @param issuer The IOU issuer. + * @param amount The raw IOU balance (must hold `Issue`). + * @return The effective spendable balance after deducting deferred credits. + */ [[nodiscard]] virtual STAmount balanceHookIOU(AccountID const& account, AccountID const& issuer, STAmount const& amount) const { @@ -159,71 +228,113 @@ public: return amount; } - // balanceHookMPT adjusts balances so newly acquired assets are not counted - // toward the balance. + /** Adjusts an MPT balance to exclude assets acquired during the current payment. + * + * Mirrors `balanceHookIOU` for MPT-denominated amounts. `PaymentSandbox` + * overrides this hook; the default implementation wraps @p amount in an + * `STAmount` and returns it unchanged. + * + * @param account The account whose balance is being queried. + * @param issue The MPT issuance. + * @param amount The raw MPT balance as a signed 64-bit integer. + * @return The effective spendable balance after deducting deferred credits. + */ [[nodiscard]] virtual STAmount balanceHookMPT(AccountID const& account, MPTIssue const& issue, std::int64_t amount) const { return STAmount{issue, amount}; } - // An offer owned by an issuer and selling MPT is limited by the issuer's - // funds available to issue, which are originally available funds less - // already self sold MPT amounts (MPT sell offer). This hook is used - // by issuerFundsToSelfIssue() function. + /** Adjusts the available issuance capacity for an issuer selling their own MPT. + * + * An issuer's sell-offer for their own MPT is limited by their remaining + * issuance capacity (i.e., `MaximumAmount - OutstandingAmount`), reduced + * by any MPT already committed to self-issued sell offers during this payment. + * `PaymentSandbox` overrides this hook to track that self-debit; the default + * returns @p amount unchanged. Used by `issuerFundsToSelfIssue()`. + * + * @param issue The MPT issuance. + * @param amount The raw available-issuance amount. + * @return The effective capacity after accounting for in-flight self-sold amounts. + */ [[nodiscard]] virtual STAmount balanceHookSelfIssueMPT(MPTIssue const& issue, std::int64_t amount) const { return STAmount{issue, amount}; } - // Accounts in a payment are not allowed to use assets acquired during that - // payment. The PaymentSandbox tracks the debits, credits, and owner count - // changes that accounts make during a payment. `ownerCountHook` adjusts the - // ownerCount so it returns the max value of the ownerCount so far. - // This is required to support PaymentSandbox. + /** Returns the effective owner count, adjusted for in-payment reserve changes. + * + * A payment could temporarily free reserves by consuming offers in intermediate + * steps, making it appear that an account has fewer owner-count obligations. + * `PaymentSandbox` overrides this hook to return the maximum owner count seen + * so far during the payment, preventing reserve-bypass exploits. The default + * implementation returns @p count unchanged. + * + * @param account The account being queried. + * @param count The current owner count from ledger state. + * @return The high-water-mark owner count for reserve purposes. + */ [[nodiscard]] virtual std::uint32_t ownerCountHook(AccountID const& account, std::uint32_t count) const { return count; } - // used by the implementation + /** Returns a heap-allocated iterator positioned at the start of the state map. + * + * Called by `SlesType::begin()`; not intended for direct use by callers. + */ [[nodiscard]] virtual std::unique_ptr slesBegin() const = 0; - // used by the implementation + /** Returns a heap-allocated sentinel iterator for the state map. + * + * Called by `SlesType::end()`; not intended for direct use by callers. + */ [[nodiscard]] virtual std::unique_ptr slesEnd() const = 0; - // used by the implementation + /** Returns a heap-allocated iterator to the first SLE whose key is strictly greater than @p key. + * + * Called by `SlesType::upperBound()`; not intended for direct use by callers. + */ [[nodiscard]] virtual std::unique_ptr slesUpperBound(key_type const& key) const = 0; - // used by the implementation + /** Returns a heap-allocated iterator positioned at the start of the transaction map. + * + * Called by `TxsType::begin()`; not intended for direct use by callers. + */ [[nodiscard]] virtual std::unique_ptr txsBegin() const = 0; - // used by the implementation + /** Returns a heap-allocated sentinel iterator for the transaction map. + * + * Called by `TxsType::end()`; not intended for direct use by callers. + */ [[nodiscard]] virtual std::unique_ptr txsEnd() const = 0; - /** Returns `true` if a tx exists in the tx map. - - A tx exists in the map if it is part of the - base ledger, or if it is a newly inserted tx. - */ + /** Returns `true` if a transaction with the given key exists in the tx map. + * + * A transaction is present if it is part of the base ledger or was + * inserted into this view's delta since the base. + * + * @param key The transaction hash to probe. + */ [[nodiscard]] virtual bool txExists(key_type const& key) const = 0; - /** Read a transaction from the tx map. - - If the view represents an open ledger, - the metadata object will be empty. - - @return A pair of nullptr if the - key is not found in the tx map. - */ + /** Returns the transaction and its metadata for the given key. + * + * For open ledgers the metadata `STObject` in the returned pair will be + * empty, since metadata is only finalized at close time. + * + * @param key The transaction hash to look up. + * @return A `tx_type` pair where both pointers are `nullptr` if the key + * is not found in the transaction map. + */ [[nodiscard]] virtual tx_type txRead(key_type const& key) const = 0; @@ -231,20 +342,29 @@ public: // Memberspaces // - /** Iterable range of ledger state items. - - @note Visiting each state entry in the ledger can - become quite expensive as the ledger grows. - */ + /** Iterable range over all state entries (SLEs) in this view. + * + * @note Full traversal can be expensive on a large ledger. Use + * `upperBound` or `succ` for targeted sub-range scans. + */ SlesType sles; - // The range of transactions + /** Iterable range over all transactions in this view. */ TxsType txs; }; //------------------------------------------------------------------------------ -/** ReadView that associates keys with digests. */ +/** Extension of `ReadView` that provides per-entry cryptographic digests. + * + * `Ledger` implements this interface cheaply by reading the hash directly + * from the SHAMap trie node without deserializing the leaf entry. Sandboxes + * and delta-views do not expose digests, which is why this capability is a + * separate subclass rather than part of `ReadView`. + * + * Used by `CachedView` for two-level cache invalidation and by + * `makeRulesGivenLedger` to detect amendments changes across ledger closes. + */ class DigestAwareReadView : public ReadView { public: @@ -253,19 +373,48 @@ public: DigestAwareReadView() = default; DigestAwareReadView(DigestAwareReadView const&) = default; - /** Return the digest associated with the key. - - @return std::nullopt if the item does not exist. - */ + /** Returns the cryptographic hash of the serialized state entry at @p key. + * + * Implementations may return this without fully deserializing the entry. + * + * @param key The raw state-map key to query. + * @return The entry's digest, or `std::nullopt` if no entry exists at that key. + */ [[nodiscard]] virtual std::optional digest(key_type const& key) const = 0; }; //------------------------------------------------------------------------------ +/** Constructs the active amendment `Rules` from a closed ledger, updating from existing rules. + * + * Reads the `sfAmendments` field from the ledger's amendments object and passes + * its digest to the `Rules` constructor so that `Rules` can detect unchanged + * amendments between successive ledger closes without re-parsing. Requires a + * `DigestAwareReadView` because the optimization depends on querying the entry + * hash directly. Falls back to a default `Rules` object if the amendments object + * is absent. + * + * @param ledger The closed ledger to read amendments from. + * @param current The current rules object; its internal preset set is forwarded + * to the new `Rules` instance. + * @return A `Rules` object reflecting the amendments active in @p ledger. + * @see makeRulesGivenLedger(DigestAwareReadView const&, std::unordered_set> const&) + */ Rules makeRulesGivenLedger(DigestAwareReadView const& ledger, Rules const& current); +/** Constructs the active amendment `Rules` from a closed ledger using an explicit preset set. + * + * Identical behavior to the `Rules const& current` overload but accepts + * the preset set directly. Used during initialization before a prior `Rules` + * object is available. + * + * @param ledger The closed ledger to read amendments from. + * @param presets The set of always-enabled amendment flags to seed the rules object. + * @return A `Rules` object reflecting the amendments active in @p ledger. + * @see makeRulesGivenLedger(DigestAwareReadView const&, Rules const&) + */ Rules makeRulesGivenLedger( DigestAwareReadView const& ledger, diff --git a/include/xrpl/ledger/Sandbox.h b/include/xrpl/ledger/Sandbox.h index dc80df5ba2..783c1011f1 100644 --- a/include/xrpl/ledger/Sandbox.h +++ b/include/xrpl/ledger/Sandbox.h @@ -5,12 +5,41 @@ namespace xrpl { -/** Discardable, editable view to a ledger. - - The sandbox inherits the flags of the base. - - @note Presented as ApplyView to clients. -*/ +/** Discardable staging layer for ledger mutations within a single transaction. + * + * `Sandbox` accumulates ledger changes in a private write buffer inherited + * from `detail::ApplyViewBase` without touching the underlying ledger. The + * caller decides at the end of the operation whether to commit — by calling + * `apply()` — or to discard — by letting the sandbox go out of scope. This + * eliminates the need for explicit rollback: on failure, destruction of the + * sandbox is sufficient. + * + * The typical pattern used by transactors: + * @code + * Sandbox sb(&ctx_.view()); + * auto const result = doWork(sb, ...); + * if (result == tesSUCCESS) + * sb.apply(ctx_.rawView()); + * @endcode + * + * `Sandbox` is the minimal concrete subclass of `ApplyViewBase`: it adds + * only constructors and `apply()`. It does not produce `TxMeta` (that is + * `ApplyViewImpl`'s responsibility) and does not track deferred credits (that + * is `PaymentSandbox`'s responsibility). Use `Sandbox` whenever a transactor + * or helper needs a safe, atomic scratchpad without those heavier features. + * + * The sandbox always inherits the `ApplyFlags` of its base view, so + * dry-run, no-check-sign, and similar execution-context properties propagate + * correctly through nested sandboxes without re-specification. + * + * Not copyable or move-assignable; move-constructible only. This enforces + * single ownership of the change buffer. + * + * @see detail::ApplyViewBase for the full `ApplyView`/`RawView` interface. + * @see ApplyViewImpl for the outermost commit path that also builds `TxMeta`. + * @see PaymentSandbox for the variant that prevents within-payment + * double-counting of credits. + */ class Sandbox : public detail::ApplyViewBase { public: @@ -23,14 +52,46 @@ public: Sandbox(Sandbox&&) = default; + /** Construct over any read-only ledger snapshot with explicit flags. + * + * @param base Non-owning pointer to the underlying ledger state; must + * outlive this sandbox. All reads that bypass the change buffer + * are forwarded here. + * @param flags Per-transaction policy flags (e.g. `tapDRY_RUN`, + * `tapNO_CHECK_SIGN`) governing this apply pass. + */ Sandbox(ReadView const* base, ApplyFlags flags) : ApplyViewBase(base, flags) { } + /** Construct over an existing `ApplyView`, inheriting its flags. + * + * Convenience form used when stacking a `Sandbox` on top of another + * mutable view (including another `Sandbox` or a `PaymentSandbox`). + * Flags are copied from the parent so that execution-context properties + * such as `tapDRY_RUN` propagate without the caller re-specifying them. + * + * @param base Non-owning pointer to the parent mutable view; must + * outlive this sandbox. + */ Sandbox(ApplyView const* base) : Sandbox(base, base->flags()) { } + /** Commit all buffered changes to a target `RawView`. + * + * Replays every insert, modify, and erase action accumulated in the + * internal change buffer against `to`, atomically promoting the tentative + * mutations into the target. After this call the buffer is reset; the + * sandbox must not be used again. + * + * If the caller decides the operation failed, simply do not call `apply()` + * — destroying the sandbox discards all buffered changes without touching + * the target view. + * + * @param to The target `RawView` to receive the committed mutations; + * typically `ctx_.rawView()` at the outermost transactor boundary. + */ void apply(RawView& to) { diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 4958a89d8c..cfea538b91 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -19,6 +19,13 @@ namespace xrpl { +/** Controls whether `cleanupOnAccountDelete()` adjusts the directory iterator + * after a deletion. + * + * When `No`, the iterator position is decremented to compensate for the + * element shift caused by the deletion. When `Yes`, the entry was + * intentionally left in place by the deleter, so no adjustment is made. + */ enum class SkipEntry : bool { No = false, Yes }; //------------------------------------------------------------------------------ @@ -51,7 +58,21 @@ enum class SkipEntry : bool { No = false, Yes }; [[nodiscard]] bool hasExpired(ReadView const& view, std::optional const& exp); -// Note, depth parameter is used to limit the recursion depth +/** Determines whether a vault pseudo-account's MPT share token is indirectly + * frozen because the vault's underlying asset is frozen. + * + * Traverses: MPT issuance → issuer account root → vault object → vault asset, + * then delegates to `isAnyFrozen()`. Returns `false` immediately if the + * `featureSingleAssetVault` amendment is not enabled. + * + * @param view The ledger state to inspect. + * @param account The account whose holdings are being queried. + * @param mptShare The MPT share token issued by the vault pseudo-account. + * @param depth Recursion depth guard; returns `true` (conservatively frozen) + * if `depth >= kMAX_ASSET_CHECK_DEPTH`. + * @return `true` if the underlying asset is frozen for `account`; `false` + * otherwise or if the amendment is not enabled. + */ [[nodiscard]] bool isVaultPseudoAccountFrozen( ReadView const& view, @@ -59,6 +80,17 @@ isVaultPseudoAccountFrozen( MPTIssue const& mptShare, int depth); +/** Determines whether LP tokens for an AMM pool are frozen for an account. + * + * LP tokens are considered frozen if *either* constituent asset of the pool + * is frozen for `account`. + * + * @param view The ledger state to inspect. + * @param account The account whose holdings are being queried. + * @param asset The first asset of the AMM pool. + * @param asset2 The second asset of the AMM pool. + * @return `true` if either `asset` or `asset2` is frozen for `account`. + */ [[nodiscard]] bool isLPTokenFrozen( ReadView const& view, @@ -66,50 +98,94 @@ isLPTokenFrozen( Asset const& asset, Asset const& asset2); -// Return the list of enabled amendments +/** Returns the set of amendment hashes currently enabled on the ledger. + * + * Reads from the singleton `keylet::amendments()` SLE. If no amendments + * SLE exists or none are yet enabled, returns an empty set. + * + * @param view The ledger state to query. + * @return A `std::set` containing every enabled amendment hash. + */ [[nodiscard]] std::set getEnabledAmendments(ReadView const& view); -// Return a map of amendments that have achieved majority +/** Maps amendment hashes to the `NetClock::time_point` at which each first + * achieved validator supermajority. Used by the amendment governance process + * to enforce the two-week waiting period before activation. + */ using majorityAmendments_t = std::map; + +/** Returns amendments that have achieved validator supermajority but are not + * yet enabled. + * + * Reads the `sfMajorities` array from the singleton `keylet::amendments()` + * SLE and converts each entry's `sfCloseTime` to a `NetClock::time_point`. + * Returns an empty map if no SLE exists or no majority amendments are pending. + * + * @param view The ledger state to query. + * @return A `majorityAmendments_t` mapping each amendment hash to the time + * at which it first achieved supermajority. + */ [[nodiscard]] majorityAmendments_t getMajorityAmendments(ReadView const& view); -/** Return the hash of a ledger by sequence. - The hash is retrieved by looking up the "skip list" - in the passed ledger. As the skip list is limited - in size, if the requested ledger sequence number is - out of the range of ledgers represented in the skip - list, then std::nullopt is returned. - @return The hash of the ledger with the - given sequence number or std::nullopt. -*/ +/** Returns the hash of a past ledger by sequence number via the skip list. + * + * Implements a three-tier lookup: + * 1. **Trivial**: `seq == ledger.seq()` → returns the ledger's own hash; + * `seq == ledger.seq() - 1` → returns `parentHash` directly. + * 2. **Within 256**: Reads the rolling `keylet::skip()` object, which stores + * the hashes of the previous ≤ 256 ledgers, and indexes by offset. + * 3. **Aligned deep history**: For sequences that are multiples of 256, reads + * the permanent `LedgerHashes` page at `keylet::skip(seq)` and indexes into + * it. Non-aligned sequences beyond the 256-ledger rolling window are not + * reachable and return `std::nullopt`. + * + * @param ledger The view from whose skip list the search starts. + * @param seq The target ledger sequence number. + * @param journal Used to log warnings when the skip list is incomplete or the + * requested sequence is out of range. + * @return The hash of ledger `seq`, or `std::nullopt` if it cannot be + * determined from the available skip-list data. + */ [[nodiscard]] std::optional hashOfSeq(ReadView const& ledger, LedgerIndex seq, beast::Journal journal); -/** Find a ledger index from which we could easily get the requested ledger - - The index that we return should meet two requirements: - 1) It must be the index of a ledger that has the hash of the ledger - we are looking for. This means that its sequence must be equal to - greater than the sequence that we want but not more than 256 greater - since each ledger contains the hashes of the 256 previous ledgers. - - 2) Its hash must be easy for us to find. This means it must be 0 mod 256 - because every such ledger is permanently enshrined in a LedgerHashes - page which we can easily retrieve via the skip list. -*/ +/** Computes the nearest 256-aligned ledger sequence ≥ `requested`. + * + * Every ledger whose sequence is a multiple of 256 permanently stores a + * `LedgerHashes` page (`keylet::skip(seq)`) containing the hashes of + * the preceding 256 ledgers. That page is retrievable via the skip list, + * making it the ideal starting point for resolving an arbitrary past hash. + * The expression `(requested + 255) & (~255)` rounds up to the next 256 + * boundary in a single instruction. + * + * @param requested The target ledger sequence number. + * @return The smallest value ≥ `requested` that is divisible by 256. + */ inline LedgerIndex getCandidateLedger(LedgerIndex requested) { return (requested + 255) & (~255); } -/** Return false if the test ledger is provably incompatible - with the valid ledger, that is, they could not possibly - both be valid. Use the first form if you have both ledgers, - use the second form if you have not acquired the valid ledger yet -*/ +/** Returns `false` if `testLedger` is provably on a different chain than + * `validLedger`. + * + * Uses `hashOfSeq()` to walk the skip list of whichever ledger is later and + * confirms that the earlier ledger's hash appears in that list. A mismatch + * proves a fork. When the skip list is incomplete or the sequences are too + * far apart to compare, the function conservatively returns `true` (cannot + * prove incompatibility). Diagnostic lines are written to `s` on mismatch. + * + * Use this overload when both ledger objects are available. + * + * @param validLedger The authoritative ledger. + * @param testLedger The candidate ledger being verified. + * @param s Journal stream for diagnostic messages on mismatch. + * @param reason Short label prepended to log messages for context. + * @return `false` if a fork is proven; `true` otherwise. + */ [[nodiscard]] bool areCompatible( ReadView const& validLedger, @@ -117,6 +193,19 @@ areCompatible( beast::Journal::Stream& s, char const* reason); +/** Returns `false` if `testLedger` is provably on a different chain than the + * ledger identified by `(validHash, validIndex)`. + * + * Use this overload when the authoritative ledger object has not been fully + * loaded but its identity is known from consensus. + * + * @param validHash Hash of the authoritative ledger. + * @param validIndex Sequence number of the authoritative ledger. + * @param testLedger The candidate ledger being verified. + * @param s Journal stream for diagnostic messages on mismatch. + * @param reason Short label prepended to log messages for context. + * @return `false` if a fork is proven; `true` otherwise. + */ [[nodiscard]] bool areCompatible( uint256 const& validHash, @@ -131,6 +220,19 @@ areCompatible( // //------------------------------------------------------------------------------ +/** Inserts an SLE into an account's owner directory and records the page. + * + * Calls `view.dirInsert()` to append `object` to `owner`'s owner directory, + * then writes the assigned page number back into `object`'s `node` field. + * + * @param view The mutable ledger view. + * @param owner The account whose owner directory receives the entry. + * @param object The SLE being linked; updated in-place with the page number. + * @param node The field on `object` that receives the directory page number; + * defaults to `sfOwnerNode`. + * @return `tecDIR_FULL` if the owner directory has no room; `tesSUCCESS` + * otherwise. + */ [[nodiscard]] TER dirLink( ApplyView& view, @@ -138,19 +240,30 @@ dirLink( std::shared_ptr& object, SF_UINT64 const& node = sfOwnerNode); -/** Checks that can withdraw funds from an object to itself or a destination. +/** Checks whether funds can be withdrawn from `from` to `to` given a + * pre-fetched destination SLE. * - * The receiver may be either the submitting account (sfAccount) or a different - * destination account (sfDestination). + * This is the innermost overload; use it when the caller already holds `toSle` + * to avoid a redundant ledger read. Rules enforced in order: + * - `toSle` must be non-null (destination account must exist). + * - If `lsfRequireDestTag` is set, `hasDestinationTag` must be `true` even + * for self-sends. + * - If `from == to`, succeed immediately. + * - If `lsfDepositAuth` is set, `from` must have a pre-authorized + * `DepositPreauth` entry under `to`. + * - For IOU amounts, the withdrawal must not push `to` past its trust-line + * credit limit. MPT transfers skip this check because they move existing + * supply rather than creating new tokens. * - * - Checks that the receiver account exists. - * - If the receiver requires a destination tag, check that one exists, even - * if withdrawing to self. - * - If withdrawing to self, succeed. - * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. - * - Checks that the receiver will not exceed the limit (IOU trustline limit - * or MPT MaximumAmount). + * @param view Ledger state to query. + * @param from Source account (e.g., vault or broker pseudo-account). + * @param to Destination account. + * @param toSle Pre-fetched SLE for `to`; may be null. + * @param amount Asset and quantity being transferred. + * @param hasDestinationTag Whether the transaction includes `sfDestinationTag`. + * @return `tesSUCCESS`, or a `tec` code: `tecNO_DST` (account absent), + * `tecDST_TAG_NEEDED` (tag missing), `tecNO_PERMISSION` (deposit auth + * denied), or `tecNO_LINE` (IOU limit exceeded). */ [[nodiscard]] TER canWithdraw( @@ -161,19 +274,17 @@ canWithdraw( STAmount const& amount, bool hasDestinationTag); -/** Checks that can withdraw funds from an object to itself or a destination. +/** Checks whether funds can be withdrawn from `from` to `to`. * - * The receiver may be either the submitting account (sfAccount) or a different - * destination account (sfDestination). + * Looks up the destination account SLE and delegates to the six-argument + * overload. See that overload for the full rule set. * - * - Checks that the receiver account exists. - * - If the receiver requires a destination tag, check that one exists, even - * if withdrawing to self. - * - If withdrawing to self, succeed. - * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. - * - Checks that the receiver will not exceed the limit (IOU trustline limit - * or MPT MaximumAmount). + * @param view Ledger state to query. + * @param from Source account. + * @param to Destination account. + * @param amount Asset and quantity being transferred. + * @param hasDestinationTag Whether the transaction includes `sfDestinationTag`. + * @return `tesSUCCESS` or a `tec` code; see the six-argument overload. */ [[nodiscard]] TER canWithdraw( @@ -183,23 +294,45 @@ canWithdraw( STAmount const& amount, bool hasDestinationTag); -/** Checks that can withdraw funds from an object to itself or a destination. +/** Checks whether the withdrawal described by `tx` is permitted. * - * The receiver may be either the submitting account (sfAccount) or a different - * destination account (sfDestination). + * Extracts `sfAccount`, `sfDestination` (defaults to `sfAccount` when absent), + * `sfAmount`, and the presence of `sfDestinationTag` from the transaction, then + * delegates to the five-argument overload. Intended for use in preclaim. * - * - Checks that the receiver account exists. - * - If the receiver requires a destination tag, check that one exists, even - * if withdrawing to self. - * - If withdrawing to self, succeed. - * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. - * - Checks that the receiver will not exceed the limit (IOU trustline limit - * or MPT MaximumAmount). + * @param view Ledger state to query. + * @param tx The withdrawal transaction (e.g., `VaultWithdraw` or + * `LoanBrokerCoverWithdraw`). + * @return `tesSUCCESS` or a `tec` code; see the six-argument overload. */ [[nodiscard]] TER canWithdraw(ReadView const& view, STTx const& tx); +/** Executes the physical asset transfer from a pseudo-account to a destination. + * + * When `dstAcct == senderAcct` (self-withdrawal), calls `addEmptyHolding()` + * to lazily create a trust line or MPToken record if one does not already + * exist (`tecDUPLICATE` is silently tolerated). For third-party + * destinations, calls `verifyDepositPreauth()` to enforce deposit + * authorisation and prune any expired credential objects as a side-effect. + * + * Before transferring, asserts via `accountHolds()` that `sourceAcct` holds + * at least `amount`; a shortfall surfaces as `tefINTERNAL` rather than an + * overdraft. On success, calls `accountSend()` with `WaiveTransferFee::Yes`. + * + * @param view The mutable ledger view. + * @param tx The originating transaction (used by `verifyDepositPreauth`). + * @param senderAcct The transaction submitter / withdrawal beneficiary. + * @param dstAcct The account that will receive the funds. + * @param sourceAcct The pseudo-account (vault, loan broker) holding the funds. + * @param priorBalance The XRP balance of `senderAcct` before the transaction, + * used for reserve calculation when creating an empty holding. + * @param amount The asset and quantity to transfer. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` on success; `tefINTERNAL` if the source has + * insufficient balance; any TER propagated from `verifyDepositPreauth` or + * `accountSend` otherwise. + */ [[nodiscard]] TER doWithdraw( ApplyView& view, @@ -211,18 +344,41 @@ doWithdraw( STAmount const& amount, beast::Journal j); -/** Deleter function prototype. Returns the status of the entry deletion - * (if should not be skipped) and if the entry should be skipped. The status - * is always tesSUCCESS if the entry should be skipped. +/** Callback invoked by `cleanupOnAccountDelete()` for each owner-directory entry. + * + * Returns a pair: + * - `TER` — `tesSUCCESS` if the entry was handled or intentionally skipped; + * any other code aborts the cleanup loop immediately. + * - `SkipEntry` — `Yes` if the entry was left in place (iterator must not be + * decremented); `No` if the entry was removed (iterator must be decremented + * to compensate for the index shift). + * + * The `TER` value is always `tesSUCCESS` when `SkipEntry` is `Yes`. */ using EntryDeleter = std::function< std::pair(LedgerEntryType, uint256 const&, std::shared_ptr&)>; -/** Cleanup owner directory entries on account delete. - * Used for a regular and AMM accounts deletion. The caller - * has to provide the deleter function, which handles details of - * specific account-owned object deletion. - * @return tecINCOMPLETE indicates maxNodesToDelete - * are deleted and there remains more nodes to delete. + +/** Iterates an account's owner directory and removes entries via `deleter`. + * + * Used by `DeleteAccount` and AMM account deletion. Traversal uses the + * `dirFirst`/`dirNext` exposed-cursor pattern; after each successful removal + * the cursor is decremented by one to compensate for the index shift that + * occurs when an element is erased mid-iteration. When the deleter leaves an + * entry in place (`SkipEntry::Yes`), the cursor is not adjusted. + * + * When `maxNodesToDelete` is supplied and the limit is reached before the + * directory is empty, `tecINCOMPLETE` is returned, signaling the caller that + * the account-delete transaction must be retried in a future ledger. + * + * @param view Mutable ledger view. + * @param ownerDirKeylet Keylet of the account's owner directory root. + * @param deleter Callback invoked once per directory entry. + * @param j Journal for diagnostic logging. + * @param maxNodesToDelete Optional cap on entries processed per call. + * When absent, all entries are consumed in a single invocation. + * @return `tesSUCCESS` when the directory is fully processed; + * `tecINCOMPLETE` if `maxNodesToDelete` is exhausted with entries + * remaining; `tefBAD_LEDGER` if a ledger invariant is violated. */ [[nodiscard]] TER cleanupOnAccountDelete( diff --git a/include/xrpl/ledger/detail/ApplyStateTable.h b/include/xrpl/ledger/detail/ApplyStateTable.h index 7b18f742b4..c49128df7d 100644 --- a/include/xrpl/ledger/detail/ApplyStateTable.h +++ b/include/xrpl/ledger/detail/ApplyStateTable.h @@ -1,3 +1,9 @@ +/** @file + * Declares `ApplyStateTable`, the per-transaction write-staging buffer used + * by all `ApplyView`/`ApplyViewImpl` instances. This is an implementation + * detail of `ApplyViewBase` and is not intended for direct use by transactors. + */ + #pragma once #include @@ -12,18 +18,36 @@ namespace xrpl::detail { -// Helper class that buffers modifications +/** Write-staging buffer for a single transaction's ledger mutations. + * + * Every SLE touched by a transaction is recorded here — keyed by its + * `uint256` ledger key — along with an `Action` tag that tracks whether + * the entry was merely read (`Cache`), newly created (`Insert`), mutated + * (`Modify`), or scheduled for removal (`Erase`). On success the buffer + * is flushed atomically to the underlying view; on failure the table is + * simply discarded. + * + * The class is the core member of `ApplyViewBase` and backs all + * `ApplyView`/`ApplyViewImpl` instances that transactors receive. + * + * @note Not copyable. Move-constructible only to support placement inside + * `ApplyViewBase` during construction. + * @note `erase()` and `update()` enforce pointer-identity: the caller + * must pass the exact `shared_ptr` returned by `peek()` on this same + * table instance. Crossing views is a `LogicError`. + */ class ApplyStateTable { public: using key_type = ReadView::key_type; private: + /** Lifecycle state of a buffered ledger entry. */ enum class Action { - Cache, - Erase, - Insert, - Modify, + Cache, /**< Read from base; no write intent yet. */ + Erase, /**< Scheduled for deletion from the base view. */ + Insert, /**< New object not yet in the base view. */ + Modify, /**< Existing object with pending mutations. */ }; using items_t = std::map>>; @@ -41,9 +65,48 @@ public: ApplyStateTable& operator=(ApplyStateTable const&) = delete; + /** Flush all pending mutations to a raw view without generating metadata. + * + * Maps each buffered action to a raw write on `to`: `Cache` entries + * are skipped; `Erase` → `rawErase`; `Insert` → `rawInsert`; + * `Modify` → `rawReplace`. Also forwards the accumulated + * `dropsDestroyed_` to `to.rawDestroyXRP()`. + * + * Used when committing a sandbox or nested view back to its parent. + * + * @param to The target raw view to receive the mutations. + */ void apply(RawView& to) const; + /** Flush mutations to an open view, generating `TxMeta` for closed ledgers. + * + * For closed ledgers (`!to.open()`) or dry-run mode (`isDryRun`), + * builds full `TxMeta` — classifying every pending item as + * `sfCreatedNode`, `sfModifiedNode`, or `sfDeletedNode` — and + * populates `sfPreviousFields`/`sfFinalFields`/`sfNewFields` using + * `SField` metadata flags. Threads `sfPreviousTxnID`/ + * `sfPreviousTxnLgrSeq` onto affected account roots and trust-line + * endpoints. + * + * In dry-run mode the metadata is produced but state changes and the + * raw tx insert are suppressed — supporting fee simulation without + * side effects. + * + * A `sfModifiedNode` whose buffered content is byte-for-byte equal to + * the original is silently omitted from the metadata. + * + * @param to The open view to commit into. + * @param tx The transaction being applied. + * @param ter The transaction result code; recorded in the metadata. + * @param deliver Optional delivered amount annotation for the metadata. + * @param parentBatchId Optional batch parent ID for the metadata. + * @param isDryRun If true, produce metadata but suppress state mutations. + * @param j Journal for diagnostic logging. + * @return The generated `TxMeta` when `!to.open() || isDryRun`; + * `std::nullopt` when the view is open and `isDryRun` is false + * (live open-ledger apply, no metadata needed). + */ std::optional apply( OpenView& to, @@ -54,21 +117,88 @@ public: bool isDryRun, beast::Journal j); + /** Test whether a ledger object exists, accounting for pending changes. + * + * Returns `false` for objects pending `Erase`; returns `true` for + * objects buffered as `Cache`, `Insert`, or `Modify`; falls back to + * `base.exists(k)` for keys not yet in the buffer. + * + * @param base The underlying read view (base ledger state). + * @param k The keylet identifying the object to test. + * @return `true` if the object will exist after the pending changes. + */ [[nodiscard]] bool exists(ReadView const& base, Keylet const& k) const; + /** Find the smallest key strictly greater than `key` that will exist + * after applying pending changes, up to but not including `last`. + * + * Merges two sorted key spaces: the base ledger (skipping keys + * pending deletion) and the local `items_` map (skipping erased + * entries). Returns whichever candidate is smaller. + * + * @param base The underlying read view supplying the base key space. + * @param key The starting key (exclusive lower bound). + * @param last Optional exclusive upper bound; if the result reaches + * or exceeds `last`, `std::nullopt` is returned. + * @return The next live key, or `std::nullopt` if none exists in + * range. + */ [[nodiscard]] std::optional succ(ReadView const& base, key_type const& key, std::optional const& last) const; + /** Read a ledger object as an immutable snapshot, accounting for + * pending changes. + * + * Returns `nullptr` for objects pending `Erase` or whose keylet + * check fails; returns the buffered SLE for `Cache`, `Insert`, and + * `Modify` entries; falls back to `base.read(k)` for unknown keys. + * + * @param base The underlying read view. + * @param k The keylet identifying the object. + * @return A `const`-qualified `shared_ptr` to the SLE, or `nullptr` + * if the object does not exist or the keylet check fails. + */ [[nodiscard]] std::shared_ptr read(ReadView const& base, Keylet const& k) const; + /** Obtain a mutable handle to a ledger object, loading it on first + * access. + * + * If the key is not yet in the buffer, reads from `base` and stores + * a private copy under `Action::Cache`. Subsequent calls return the + * same `shared_ptr`. Returns `nullptr` for erased objects or when the + * object does not exist in `base`. + * + * The returned pointer is the exact instance that must be passed to + * `update()` or `erase()` — pointer identity is enforced. + * + * @param base The underlying read view. + * @param k The keylet identifying the object. + * @return A mutable `shared_ptr` to the buffered SLE, or `nullptr`. + */ std::shared_ptr peek(ReadView const& base, Keylet const& k); + /** Count pending mutations (Erase, Insert, Modify), excluding cache-only reads. + * + * @return The number of entries with a write-intent action. + */ [[nodiscard]] std::size_t size() const; + /** Invoke a callback for every pending write-intent entry. + * + * Calls `func` once for each `Erase`, `Insert`, or `Modify` entry in + * the buffer. `Cache`-only entries are skipped. The `before` snapshot + * is read from `base` on each call; `after` is the buffered SLE. + * + * @param base The underlying read view used to fetch the pre-change + * snapshots for `Erase` and `Modify` entries. + * @param func Callback invoked as + * `func(key, isDelete, before, after)`. `before` is `nullptr` + * for `Insert`; `after` is the pending SLE in all cases. + */ void visit( ReadView const& base, @@ -78,25 +208,95 @@ public: std::shared_ptr const& before, std::shared_ptr const& after)> const& func) const; + /** Mark a buffered object for deletion. + * + * Transitions the action from `Cache` or `Modify` to `Erase`. If the + * object was previously `Insert`ed within this same transaction, the + * entry is removed entirely (net-zero effect on the base). Calling on + * an unknown key or a different `shared_ptr` than the one returned by + * `peek()` is a `LogicError`. + * + * @param base The underlying read view (used for key lookup context). + * @param sle The exact `shared_ptr` previously obtained from `peek()`. + * @throws std::logic_error If the key is not in the buffer, the + * pointer does not match, or the entry is already erased. + */ void erase(ReadView const& base, std::shared_ptr const& sle); + /** Mark an object for deletion without enforcing pointer identity. + * + * Behaves like `erase()` but accepts any SLE with the matching key — + * the caller-provided pointer replaces whatever is stored. Used by + * `ApplyViewBase` for raw-level operations that bypass the ownership + * protocol enforced by `erase()`. + * + * @param base The underlying read view (used for key lookup context). + * @param sle An SLE whose key identifies the object to erase. + * @throws std::logic_error If the object is already pending erasure. + */ void rawErase(ReadView const& base, std::shared_ptr const& sle); + /** Stage a new ledger object for insertion. + * + * Records the SLE under `Action::Insert`. If the key was previously + * erased within this same transaction, the action is collapsed to + * `Action::Modify` (insert-after-erase = replace). Attempting to + * insert over an existing `Cache`, `Insert`, or `Modify` entry is + * a `LogicError`. + * + * @param base The underlying read view (used for key lookup context). + * @param sle The new SLE to insert. + * @throws std::logic_error If the key already exists with a + * non-erase action. + */ void insert(ReadView const& base, std::shared_ptr const& sle); + /** Promote a cached or new SLE to a definitive write. + * + * Requires the exact `shared_ptr` returned by `peek()`. Transitions + * `Cache` → `Modify`; `Insert` and `Modify` are left unchanged + * (already write-intent). Calling on an erased or unknown entry is a + * `LogicError`. + * + * @param base The underlying read view (used for key lookup context). + * @param sle The exact `shared_ptr` previously obtained from `peek()`. + * @throws std::logic_error If the key is missing, the pointer does not + * match, or the entry is already erased. + */ void update(ReadView const& base, std::shared_ptr const& sle); + /** Unconditionally overwrite the buffered SLE for a given key. + * + * Records the SLE under `Action::Modify`, replacing any existing + * `Cache` or `Insert` entry with the supplied pointer. Calling on an + * erased entry is a `LogicError`. Unlike `update()`, does not enforce + * pointer identity — the caller supplies a fresh SLE. + * + * @param base The underlying read view (used for key lookup context). + * @param sle The SLE to store. + * @throws std::logic_error If the key is currently pending erasure. + */ void replace(ReadView const& base, std::shared_ptr const& sle); + /** Record XRP drops destroyed by fees within this transaction's scope. + * + * Accumulates into `dropsDestroyed_`, which is forwarded to + * `RawView::rawDestroyXRP()` on `apply()`. + * + * @param fee The amount of XRP to permanently remove from circulation. + */ void destroyXRP(XRPAmount const& fee); - // For debugging + /** Return the total XRP drops marked for destruction so far. + * + * @return Reference to the accumulated destroyed-drops counter. + */ [[nodiscard]] XRPAmount const& dropsDestroyed() const { @@ -104,17 +304,74 @@ public: } private: + /** Scratch map used during threading to track SLEs modified solely by + * metadata updates (i.e., objects whose only change is the addition of + * `sfPreviousTxnID`/`sfPreviousTxnLgrSeq` fields). These are kept + * separate from `items_` to avoid promoting cache entries to + * `Action::Modify` for transactional purposes. + */ using Mods = hash_map>; + /** Update an SLE's thread fields and record the previous tx link in metadata. + * + * Calls `sle->thread(txID, lgrSeq, ...)` to update `sfPreviousTxnID` + * and `sfPreviousTxnLgrSeq` in place. If there was a preceding + * transaction, adds those old fields to the affected node in `meta` + * so the chain of transactions is visible in on-ledger metadata. + * + * @param meta The `TxMeta` object being built for the current transaction. + * @param sle The account-root SLE to thread. + */ static void threadItem(TxMeta& meta, std::shared_ptr const& to); + /** Retrieve an SLE for threading modification, using `mods` as a cache. + * + * Checks `mods` first, then `items_` (returning non-cache entries + * directly), then falls back to `base`. Objects found in `items_` as + * `Action::Cache` are copied into `mods` so that threading-only + * mutations do not promote them to `Action::Modify` in the primary + * table. Returns `nullptr` when threading to a deleted or nonexistent + * account (e.g., an expired Escrow destination), which is legal. + * + * @param base The underlying read view. + * @param key The ledger key of the SLE to retrieve. + * @param mods Scratch map of threading-only modifications. + * @param j Journal for warnings about missing or deleted targets. + * @return A mutable SLE, or `nullptr` if the account does not exist. + */ std::shared_ptr getForMod(ReadView const& base, key_type const& key, Mods& mods, beast::Journal j); + /** Thread the current transaction to a specific account's root SLE. + * + * Looks up the account root via `getForMod()` and calls `threadItem()` + * on it. Logs a warning and returns without error if the account does + * not exist (e.g., destination of a deleted Escrow or PayChannel). + * + * @param base The underlying read view. + * @param meta The `TxMeta` object being built. + * @param to The account whose root SLE should be threaded. + * @param mods Scratch map of threading-only modifications. + * @param j Journal for warnings about missing targets. + */ void threadTx(ReadView const& base, TxMeta& meta, AccountID const& to, Mods& mods, beast::Journal j); + /** Thread the current transaction to all owner accounts of a ledger entry. + * + * Dispatches by `LedgerEntryType`: + * - `ltACCOUNT_ROOT`: no-op (threading to self is handled by the caller). + * - `ltRIPPLE_STATE`: threads to both the low-limit and high-limit account. + * - All others: threads to `sfAccount` if present, and to `sfDestination` + * if present. + * + * @param base The underlying read view. + * @param meta The `TxMeta` object being built. + * @param sle The ledger entry whose owner accounts should be threaded. + * @param mods Scratch map of threading-only modifications. + * @param j Journal for warnings about missing targets. + */ void threadOwners( ReadView const& base, diff --git a/include/xrpl/ledger/detail/ApplyViewBase.h b/include/xrpl/ledger/detail/ApplyViewBase.h index 558c9e5d4d..451ec4b5d8 100644 --- a/include/xrpl/ledger/detail/ApplyViewBase.h +++ b/include/xrpl/ledger/detail/ApplyViewBase.h @@ -7,6 +7,24 @@ namespace xrpl::detail { +/** Concrete base for buffered mutable ledger views. + * + * Implements the full `ApplyView` and `RawView` interfaces on top of two + * members: a read-only pointer to the base ledger snapshot (`base_`) and + * an `ApplyStateTable` change buffer (`items_`). Queries that need + * awareness of pending mutations (e.g. `exists`, `read`, `peek`) merge + * `items_` with `base_`; purely structural queries (`header`, `fees`, + * `rules`) and SLE iterators bypass `items_` and forward directly to + * `base_`. + * + * Not copyable; move-constructible only. Subclasses (`ApplyViewImpl`, + * `Sandbox`) supply lifecycle logic such as `apply()`. + * + * @note The `erase()` and `update()` methods enforce pointer identity: the + * caller must pass the exact `shared_ptr` returned by `peek()` on + * **this** view instance. Passing an SLE obtained from a different view + * results in a `LogicError`. + */ class ApplyViewBase : public ApplyView, public RawView { public: @@ -19,30 +37,82 @@ public: ApplyViewBase(ApplyViewBase&&) = default; + /** Construct over an existing read-only ledger snapshot. + * + * @param base Non-owning pointer to the base ledger state; must + * outlive this view. All reads that bypass the change buffer + * are forwarded here. + * @param flags Per-transaction policy flags (retry mode, dry-run, + * unlimited, etc.) that are carried through the apply pass and + * exposed via `flags()`. + */ ApplyViewBase(ReadView const* base, ApplyFlags flags); // ReadView + + /** @return `true` if the underlying view represents an open ledger. */ [[nodiscard]] bool open() const override; + /** @return The ledger header from the base snapshot. */ [[nodiscard]] LedgerHeader const& header() const override; + /** @return The fee schedule from the base snapshot. */ [[nodiscard]] Fees const& fees() const override; + /** @return The amendment rules from the base snapshot. */ [[nodiscard]] Rules const& rules() const override; + /** Test whether a ledger object exists, accounting for pending changes. + * + * Returns `false` for objects pending erasure, `true` for objects + * buffered as inserted or modified, and delegates to `base_` for + * keys not yet in the change buffer. + * + * @param k Keylet identifying the object. + * @return `true` if the object will exist after the pending changes. + */ [[nodiscard]] bool exists(Keylet const& k) const override; + /** Find the next live key after `key`, accounting for pending changes. + * + * Merges the base key space (skipping keys pending deletion) with the + * local change buffer (skipping erased entries) and returns the smaller + * candidate key that is less than `last`. + * + * @param key Exclusive lower bound. + * @param last Optional exclusive upper bound. + * @return The next live key, or `std::nullopt` if none exists in range. + */ [[nodiscard]] std::optional succ(key_type const& key, std::optional const& last = std::nullopt) const override; + /** Read a ledger object as an immutable snapshot, accounting for pending + * changes. + * + * Returns `nullptr` for objects pending erasure or when the keylet check + * fails; returns the buffered SLE for inserted/modified entries; falls + * back to `base_` for unknown keys. + * + * @param k Keylet identifying the object. + * @return A `const`-qualified handle to the SLE, or `nullptr`. + */ [[nodiscard]] std::shared_ptr read(Keylet const& k) const override; + /** @name SLE iterators (base snapshot only) + * + * These iterators forward directly to `base_` and do **not** reflect + * pending insertions or deletions in the change buffer. This is + * intentional: the apply phase never needs to iterate its own buffered + * writes, and bypassing the buffer keeps SLE traversal consistent with + * the base ledger snapshot. + */ + /** @{ */ [[nodiscard]] std::unique_ptr slesBegin() const override; @@ -51,7 +121,10 @@ public: [[nodiscard]] std::unique_ptr slesUpperBound(uint256 const& key) const override; + /** @} */ + /** @name Transaction-map accessors (forwarded to base snapshot) */ + /** @{ */ [[nodiscard]] std::unique_ptr txsBegin() const override; @@ -63,41 +136,137 @@ public: [[nodiscard]] tx_type txRead(key_type const& key) const override; + /** @} */ // ApplyView + /** Return the flags governing this transaction apply pass. + * + * @return The `ApplyFlags` bitmask set at construction. + */ [[nodiscard]] ApplyFlags flags() const override; + /** Check out a ledger entry for in-place mutation. + * + * Loads the entry into the change buffer on first access (tagged + * `Cache`). Returns the same `shared_ptr` on subsequent calls. + * The returned pointer must later be passed to `update()` or `erase()` + * on **this** view instance to record the intended change. + * + * @param k Keylet identifying the entry. + * @return A mutable handle to the buffered SLE, or `nullptr` if the + * entry does not exist (including if it is pending erasure). + */ std::shared_ptr peek(Keylet const& k) override; + /** Stage a deletion for a checked-out entry. + * + * Transitions the buffer entry from `Cache` or `Modify` to `Erase`. + * If the entry was inserted within this same transaction, it is removed + * entirely (net-zero effect on the base). + * + * @param sle The exact `shared_ptr` previously returned by `peek()` + * on this view instance. + * @throws std::logic_error If the pointer does not match the buffered + * entry or the entry is already erased. + */ void erase(std::shared_ptr const& sle) override; + /** Stage a new ledger entry for insertion. + * + * Records the SLE under `Action::Insert`. If the key was previously + * erased within this transaction the action is collapsed to `Modify`. + * + * @param sle The new entry; its key must not already exist in the view. + * @throws std::logic_error If the key already exists with a non-erase + * action in the buffer. + */ void insert(std::shared_ptr const& sle) override; + /** Promote a checked-out entry to a definitive write. + * + * Transitions the buffer action from `Cache` to `Modify`; `Insert` and + * `Modify` entries are left unchanged (already write-intent). + * + * @param sle The exact `shared_ptr` previously returned by `peek()` + * on this view instance. + * @throws std::logic_error If the pointer does not match, the entry is + * erased, or the key is unknown. + */ void update(std::shared_ptr const& sle) override; // RawView + /** Erase a ledger entry without enforcing pointer-identity ownership. + * + * Bypasses the `peek()`-pointer ownership check enforced by `erase()`. + * Used by `RawView` callers (e.g. `Sandbox::apply`) that flush changes + * from another view's table and cannot satisfy the same-instance + * invariant. + * + * @param sle An SLE whose key identifies the object to erase. + * @throws std::logic_error If the object is already pending erasure. + */ void rawErase(std::shared_ptr const& sle) override; + /** Insert a ledger entry via the same validated path as `insert()`. + * + * Despite being a raw-tier operation, this method calls the same + * `items_.insert()` that the high-level `insert()` uses; the + * distinction is that callers from the `RawView` flush path are not + * required to have obtained the SLE from `peek()`. + * + * @param sle The new entry to stage for insertion. + * @throws std::logic_error If the key already exists with a non-erase + * action. + */ void rawInsert(std::shared_ptr const& sle) override; + /** Unconditionally overwrite the buffered SLE for an existing key. + * + * Records the SLE under `Action::Modify`, replacing any `Cache` or + * `Insert` entry. Unlike `update()`, does not enforce pointer identity. + * + * @param sle The SLE to store; its key must exist in this view. + * @throws std::logic_error If the key is currently pending erasure. + */ void rawReplace(std::shared_ptr const& sle) override; + /** Record XRP drops destroyed by fees within this transaction's scope. + * + * Accumulates into the change buffer and is forwarded to the parent + * view's `rawDestroyXRP()` when the buffer is committed. + * + * @param feeDrops The amount of XRP to permanently remove from + * circulation. + */ void rawDestroyXRP(XRPAmount const& feeDrops) override; protected: + /** Per-transaction policy flags set at construction; exposed via `flags()`. */ ApplyFlags flags_; + + /** Non-owning pointer to the base ledger snapshot. + * + * All reads that do not need awareness of pending changes are forwarded + * here. The pointed-to view must outlive this object. + */ ReadView const* base_; + + /** Change buffer accumulating per-transaction ledger mutations. + * + * Maps each touched `uint256` key to an `(Action, SLE)` pair. Flushed + * to the parent view atomically on `apply()`; discarded on destruction. + */ detail::ApplyStateTable items_; }; diff --git a/include/xrpl/nodestore/Backend.h b/include/xrpl/nodestore/Backend.h index 124cd99db5..6cc34356f0 100644 --- a/include/xrpl/nodestore/Backend.h +++ b/include/xrpl/nodestore/Backend.h @@ -6,33 +6,50 @@ namespace xrpl::NodeStore { -/** A backend used for the NodeStore. - - The NodeStore uses a swappable backend so that other database systems - can be tried. Different databases may offer various features such - as improved performance, fault tolerant or distributed storage, or - all in-memory operation. - - A given instance of a backend is fixed to a particular key size. -*/ +/** Pure abstract storage interface for the NodeStore persistence layer. + * + * Every ledger object (account states, transactions, ledger headers) is a + * `NodeObject` keyed by its 256-bit hash. `Backend` defines the narrow + * interface that lets the `Database` layer remain independent of the + * underlying engine — NuDB, RocksDB, or an in-memory store for tests all + * satisfy this contract identically. + * + * A backend instance is fixed to a particular key size (always 32 bytes in + * practice, matching `NodeObject::keyBytes`) at construction. + * + * **Concurrency contract**: `fetch()` and `store()` will be called + * concurrently by multiple threads; implementations must be internally + * thread-safe for these two operations. `storeBatch()` and `forEach()` are + * never called concurrently with each other or with other writes. + * + * **Lifecycle**: Construction is separated from initialization via `open()`. + * Backends are never constructed directly — use `Factory::createInstance()` + * dispatched through `Manager`. + * + * @see Factory, Manager, Database + */ class Backend { public: /** Destroy the backend. - - All open files are closed and flushed. If there are batched writes - or other tasks scheduled, they will be completed before this call - returns. - */ + * + * All open files are closed and flushed. Any batched writes or scheduled + * tasks complete before this returns, so dropping a `unique_ptr` + * cannot silently discard data. + */ virtual ~Backend() = default; - /** Get the human-readable name of this backend. - This is used for diagnostic output. - */ + /** Return the human-readable name of this backend, used in diagnostics. */ virtual std::string getName() = 0; - /** Get the block size for backends that support it + /** Return the storage block size, if the backend has a meaningful one. + * + * NuDB organizes data into fixed-size blocks; callers that care about + * I/O alignment or prefetch granularity can query this without + * downcasting. Backends with no block concept return `std::nullopt`. + * + * @return Block size in bytes, or `std::nullopt` if not applicable. */ [[nodiscard]] virtual std::optional getBlockSize() const @@ -40,25 +57,37 @@ public: return std::nullopt; } - /** Open the backend. - @param createIfMissing Create the database files if necessary. - This allows the caller to catch exceptions. - */ + /** Open the backend, optionally creating the database if absent. + * + * Separating `open()` from the constructor allows I/O errors to be + * caught without wrapping constructors in try/catch. + * + * @param createIfMissing If `true`, create the database files when they + * do not exist. Pass `false` to fail fast on a missing database. + * @throws implementation-defined exception on I/O or database errors. + */ virtual void open(bool createIfMissing = true) = 0; - /** Returns true is the database is open. - */ + /** Return `true` if the backend is currently open. */ virtual bool isOpen() = 0; - /** Open the backend. - @param createIfMissing Create the database files if necessary. - @param appType Deterministic appType used to create a backend. - @param uid Deterministic uid used to create a backend. - @param salt Deterministic salt used to create a backend. - @throws std::runtime_error is function is called not for NuDB backend. - */ + /** Open the backend with deterministic NuDB header parameters. + * + * This overload exists exclusively to support NuDB's header-level + * application identification (appnum, uid, salt). It enables shard + * databases to be created with reproducible identifiers. + * + * @param createIfMissing Create the database files if they do not exist. + * @param appType Application-defined type tag embedded in the NuDB header. + * @param uid Deterministic unique identifier for this database instance. + * @param salt Deterministic salt value used during NuDB database creation. + * @throws std::runtime_error for every backend except NuDB, as this + * capability is not part of the general interface. + * @note Non-NuDB backends inherit a default implementation that always + * throws, clearly advertising that the capability is unavailable. + */ virtual void open(bool createIfMissing, uint64_t appType, uint64_t uid, uint64_t salt) { @@ -66,75 +95,137 @@ public: "Deterministic appType/uid/salt not supported by backend " + getName()); } - /** Close the backend. - This allows the caller to catch exceptions. - */ + /** Close the backend, flushing any pending writes. + * + * Separating `close()` from the destructor allows the caller to catch + * and handle I/O exceptions explicitly. + */ virtual void close() = 0; - /** Fetch a single object. - If the object is not found or an error is encountered, the - result will indicate the condition. - @note This will be called concurrently. - @param hash The hash of the object. - @param pObject [out] The created object if successful. - @return The result of the operation. - */ + /** Fetch a single object by its 256-bit hash. + * + * On success, `*pObject` is set to the retrieved `NodeObject`. On any + * non-`Ok` outcome, `*pObject` is left unchanged (or reset). + * + * @note Called concurrently by multiple threads; implementations must + * be thread-safe for this operation. + * @param hash The 256-bit hash key identifying the object. + * @param pObject Output parameter; receives the fetched object on success. + * @return `Status::Ok` on success, `Status::NotFound` if the key is + * absent, `Status::DataCorrupt` if the stored blob fails validation, + * or another `Status` value on backend or unknown errors. + */ virtual Status fetch(uint256 const& hash, std::shared_ptr* pObject) = 0; - /** Fetch a batch synchronously. */ + /** Fetch a batch of objects by their 256-bit hashes. + * + * Amortizes round-trip or I/O overhead when prefetching sets of related + * objects. The returned vector is parallel to `hashes`: a null + * `shared_ptr` at position `i` indicates the object at `hashes[i]` was + * not found or could not be retrieved. + * + * @param hashes Ordered list of 256-bit hash keys to fetch. + * @return A pair of (results vector, aggregate Status). Each element in + * the results vector is the fetched object, or an empty + * `shared_ptr` if the corresponding hash was not found. + */ virtual std::pair>, Status> fetchBatch(std::vector const& hashes) = 0; /** Store a single object. - Depending on the implementation this may happen immediately - or deferred using a scheduled task. - @note This will be called concurrently. - @param object The object to store. - */ + * + * Depending on the implementation, the write may be synchronous or + * deferred to a scheduled task (e.g., via `BatchWriter`). Either way, + * the object is guaranteed to be durable before the backend is destroyed. + * + * @note Called concurrently by multiple threads; implementations must + * be thread-safe for this operation. + * @param object The `NodeObject` to persist. + */ virtual void store(std::shared_ptr const& object) = 0; - /** Store a group of objects. - @note This function will not be called concurrently with - itself or @ref store. - */ + /** Store a group of objects as a batch. + * + * More efficient than repeated `store()` calls for backends that + * support atomic or coalesced multi-key writes (e.g., RocksDB + * `WriteBatch`). The entire batch is treated as a single unit. + * + * @note Never called concurrently with itself or with `store()`. + * @param batch The collection of `NodeObject`s to persist. + */ virtual void storeBatch(Batch const& batch) = 0; + /** Flush all previously submitted stores to durable storage. + * + * Provides an explicit durability barrier: after `sync()` returns, + * all objects passed to `store()` or `storeBatch()` before the call + * are guaranteed to be on disk. Backends backed by a write-ahead log + * (e.g., RocksDB) may implement this as a no-op. + */ virtual void sync() = 0; - /** Visit every object in the database - This is usually called during import. - @note This routine will not be called concurrently with itself - or other methods. - @see import - */ + /** Invoke a callback for every object stored in the backend. + * + * Typically used during database import or migration. Because it closes + * and reopens the underlying database (NuDB), it must not be called + * while concurrent reads or writes are in flight. + * + * @note Never called concurrently with itself or with any other method. + * @param f Callback invoked once per stored object; receives a + * `shared_ptr` for each entry in the database. + * @see importInternal + */ virtual void forEach(std::function)> f) = 0; - /** Estimate the number of write operations pending. */ + /** Return an estimate of the number of pending write operations. + * + * Used by the `Database` layer for back-pressure and diagnostic + * reporting. The value is advisory; implementations may return 0 if + * writes are always synchronous (e.g., NuDB). + * + * @return Approximate count of writes not yet flushed to storage. + */ virtual int getWriteLoad() = 0; - /** Remove contents on disk upon destruction. */ + /** Schedule the backend's on-disk files for deletion on destruction. + * + * After this call, the next `close()` (including the one in the + * destructor) removes all database files from the filesystem. Used by + * temporary databases — unit tests and ephemeral shard stores — that + * require automatic cleanup without external management. + */ virtual void setDeletePath() = 0; - /** Perform consistency checks on database. + /** Perform an offline consistency check of the stored data. * - * This method is implemented only by NuDBBackend. It is not yet called - * anywhere, but it might be a good idea to one day call it at startup to - * avert a crash. + * Closes and reopens the database around the check, so it must not be + * called while I/O is in progress. Currently implemented only by + * `NuDBBackend`; all other backends inherit a no-op. + * + * @note Not yet called at startup, but could one day be invoked at + * launch to detect on-disk corruption before it causes a crash. */ virtual void verify() { } - /** Returns the number of file descriptors the backend expects to need. */ + /** Return the number of file descriptors this backend expects to consume. + * + * The `Database` base class aggregates these values across all open + * backends and exposes the total so the process can pre-check against + * the OS file descriptor limit before opening any databases. + * + * @return Expected file descriptor count (e.g., 3 for NuDB, 0 for Null). + */ [[nodiscard]] virtual int fdRequired() const = 0; }; diff --git a/include/xrpl/nodestore/Database.h b/include/xrpl/nodestore/Database.h index ca2dde560c..914a8399ab 100644 --- a/include/xrpl/nodestore/Database.h +++ b/include/xrpl/nodestore/Database.h @@ -1,3 +1,14 @@ +/** @file + * Abstract base class for the NodeStore persistence layer. + * + * Defines the full public contract for node object storage: async and + * synchronous fetch, store, import, and diagnostics. Concrete subclasses + * (`DatabaseNodeImp`, `DatabaseRotatingImp`) implement the private virtual + * `fetchNodeObject()` and `forEach()` hooks; all instrumentation (timing, + * counters, scheduler callbacks) is applied in this base class and cannot + * be bypassed. + */ + #pragma once #include @@ -12,100 +23,159 @@ namespace xrpl::NodeStore { -/** Persistency layer for NodeObject - - A Node is a ledger object which is uniquely identified by a key, which is - the 256-bit hash of the body of the node. The payload is a variable length - block of serialized data. - - All ledger data is stored as node objects and as such, needs to be persisted - between launches. Furthermore, since the set of node objects will in - general be larger than the amount of available memory, purged node objects - which are later accessed must be retrieved from the node store. - - @see NodeObject -*/ +/** Persistence layer for NodeObject records. + * + * Every ledger datum — account states, transactions, ledger headers — is + * stored as a `NodeObject` keyed by the 256-bit hash of its payload. Because + * the total object set typically exceeds available memory, any hash absent + * from the in-memory cache must be fetched from disk through this class. + * + * `Database` owns the async read thread pool and all performance counters. + * The public non-virtual `fetchNodeObject()` wraps the private pure-virtual + * one, applying timing, hit/miss accounting, and `Scheduler::onFetch()` + * callbacks — so no subclass can escape the instrumentation. + * + * **Shutdown ordering**: Derived classes **must** call `stop()` in their own + * destructors before the base destructor runs. Worker threads invoke the + * virtual `fetchNodeObject()` through a subclass vtable; if the derived + * object is destroyed before all threads have exited, a waking thread will + * call through a dangling vtable entry (undefined behaviour). The base + * destructor calls `stop()` only as a last-resort safety net. + * + * @see NodeObject, Backend, Scheduler, DatabaseNodeImp, DatabaseRotatingImp + */ class Database { public: Database() = delete; - /** Construct the node store. - - @param scheduler The scheduler to use for performing asynchronous tasks. - @param readThreads The number of asynchronous read threads to create. - @param config The configuration settings - @param journal Destination for logging output. - */ + /** Construct the node store and start the async read thread pool. + * + * Validates configuration parameters, then spawns `readThreads` detached + * worker threads. Threads are controlled by `readStopping_`; `stop()` + * spin-waits (≤ 30 s) until `readThreads_` reaches zero. + * + * @param scheduler Task scheduler for async I/O dispatch and telemetry + * callbacks; must outlive this object. + * @param readThreads Number of prefetch worker threads to create; clamped + * to at least 1. + * @param config `[node_db]` config section; reads `earliest_seq` (default + * `kXRP_LEDGER_EARLIEST_SEQ`, must be ≥ 1) and `rq_bundle` (default 4, + * clamped [1, 64]). + * @param j Logging sink. + * @throws std::runtime_error if `earliest_seq` < 1 or `rq_bundle` is + * outside [1, 64]. + */ Database(Scheduler& scheduler, int readThreads, Section const& config, beast::Journal j); /** Destroy the node store. - All pending operations are completed, pending writes flushed, - and files closed before this returns. - */ + * + * Calls `stop()` as a safety net to drain the read queue and wait for all + * worker threads to exit. Derived classes **must** call `stop()` in their + * own destructors first — worker threads invoke the pure-virtual + * `fetchNodeObject()` through the subclass vtable, which is already gone + * by the time this base destructor runs. + */ virtual ~Database(); - /** Retrieve the name associated with this backend. - This is used for diagnostics and may not reflect the actual path - or paths used by the underlying backend. - */ + /** Return the name of the underlying backend for diagnostics. + * + * The returned string may not reflect the actual on-disk path when + * multiple backends are in use (e.g. `DatabaseRotatingImp`). + * + * @return A human-readable backend identifier. + */ virtual std::string getName() const = 0; - /** Import objects from another database. */ + /** Bulk-import all objects from another database into this one. + * + * Iterates every `NodeObject` in @p source and writes it to this + * database's backend. Implementations typically delegate to + * `importInternal()`. Large databases may take significant time. + * + * @param source The source database to read from; must remain valid + * and quiescent (no concurrent writes) for the duration of the call. + */ virtual void importDatabase(Database& source) = 0; - /** Retrieve the estimated number of pending write operations. - This is used for diagnostics. - */ + /** Return the estimated number of pending write operations. + * + * Used for backpressure diagnostics; the value is approximate and may + * change immediately after it is read. + * + * @return Pending write count, or 0 if the backend does not batch writes. + */ virtual std::int32_t getWriteLoad() const = 0; - /** Store the object. - - The caller's Blob parameter is overwritten. - - @param type The type of object. - @param data The payload of the object. The caller's - variable is overwritten. - @param hash The 256-bit hash of the payload data. - @param ledgerSeq The sequence of the ledger the object belongs to. - - @return `true` if the object was stored? - */ + /** Persist a node object to the backend. + * + * Takes ownership of @p data (the caller's `Blob` is consumed). The object + * is keyed by @p hash; backends are content-addressed, so storing an object + * whose hash already exists is a no-op (same key → same data). + * + * @param type The semantic type of the object (ledger, account node, etc.). + * @param data Serialized payload; moved into the backend — caller's variable + * is left in a valid but unspecified state. + * @param hash 256-bit hash of @p data. The caller is responsible for + * correctness; the hash is not re-verified by the store. + * @param ledgerSeq The ledger sequence this object belongs to; used by + * rotating backends to route writes to the correct physical file. + */ virtual void store(NodeObjectType type, Blob&& data, uint256 const& hash, std::uint32_t ledgerSeq) = 0; - /* Check if two ledgers are in the same database - - If these two sequence numbers map to the same database, - the result of a fetch with either sequence number would - be identical. - - @param s1 The first sequence number - @param s2 The second sequence number - - @return 'true' if both ledgers would be in the same DB - - */ + /** Return whether two ledger sequence numbers resolve to the same backend. + * + * When this returns `true`, a fetch with either sequence number will + * reach the same physical storage and yield identical results. The async + * thread pool uses this to avoid redundant backend reads when multiple + * callbacks for the same hash were registered with different sequence + * numbers. + * + * `DatabaseNodeImp` always returns `true` (single backend). + * `DatabaseRotatingImp` returns `false` when the sequences straddle a + * rotation boundary. + * + * @param s1 First ledger sequence number. + * @param s2 Second ledger sequence number. + * @return `true` if both sequences map to the same physical backend. + */ virtual bool isSameDB(std::uint32_t s1, std::uint32_t s2) = 0; + /** Flush any buffered writes to durable storage. + * + * Called by maintenance paths (e.g. ledger close) to ensure consistency. + * Not latency-sensitive; implementations may hold locks for the full call. + */ virtual void sync() = 0; - /** Fetch a node object. - If the object is known to be not in the database, isn't found in the - database during the fetch, or failed to load correctly during the fetch, - `nullptr` is returned. - - @note This can be called concurrently. - @param hash The key of the object to retrieve. - @param ledgerSeq The sequence of the ledger where the object is stored. - @param fetchType the type of fetch, synchronous or asynchronous. - @return The object, or nullptr if it couldn't be retrieved. - */ + /** Fetch a node object by hash, recording timing and hit/miss metrics. + * + * This is the public entry point for all node lookups. It wraps the + * private pure-virtual `fetchNodeObject(hash, seq, FetchReport&, duplicate)` + * using the Template Method pattern: timing, atomic counters, and + * `Scheduler::onFetch()` are applied here and cannot be bypassed by + * subclasses. + * + * Returns `nullptr` if the object is absent, could not be decoded, or the + * backend encountered an error. + * + * @note Thread-safe; may be called concurrently from any thread. + * @param hash 256-bit content hash of the desired object. + * @param ledgerSeq Ledger sequence that owns this object; used by rotating + * backends to select the correct physical file. Defaults to 0. + * @param fetchType `FetchType::Synchronous` (default) or + * `FetchType::Async` when called from the async worker pool. + * @param duplicate When `true`, the object is also written into the + * writable backend after being found in the archive backend + * (`DatabaseRotatingImp` promotion path). Defaults to `false`. + * @return The requested `NodeObject`, or `nullptr` on miss or error. + */ std::shared_ptr fetchNodeObject( uint256 const& hash, @@ -113,75 +183,124 @@ public: FetchType fetchType = FetchType::Synchronous, bool duplicate = false); - /** Fetch an object without waiting. - If I/O is required to determine whether or not the object is present, - `false` is returned. Otherwise, `true` is returned and `object` is set - to refer to the object, or `nullptr` if the object is not present. - If I/O is required, the I/O is scheduled and `true` is returned - - @note This can be called concurrently. - @param hash The key of the object to retrieve - @param ledgerSeq The sequence of the ledger where the - object is stored. - @param callback Callback function when read completes - */ + /** Schedule a non-blocking background fetch for a node object. + * + * Enqueues a `(hash, ledgerSeq, callback)` entry in the async read map. + * Multiple calls for the same hash are coalesced: a single backend read + * satisfies all registered callbacks. If `isStopping()` is `true` at the + * time of the call, the request is silently discarded and the callback + * will never fire. + * + * @note Thread-safe; may be called concurrently from any thread. + * @param hash 256-bit content hash of the desired object. + * @param ledgerSeq Ledger sequence that owns this object; passed through + * to `isSameDB()` for multi-sequence coalescing. + * @param callback Invoked on a worker thread with the fetched + * `NodeObject`, or `nullptr` on miss or error. + */ virtual void asyncFetch( uint256 const& hash, std::uint32_t ledgerSeq, std::function const&)>&& callback); - /** Gather statistics pertaining to read and write activities. - * - * @param obj Json object reference into which to place counters. - */ + // --- Performance counters (all lock-free atomic reads) --- + + /** Return the total number of objects written since construction. */ std::uint64_t getStoreCount() const { return storeCount_; } + /** Return the total number of fetch attempts (hits + misses). */ std::uint32_t getFetchTotalCount() const { return fetchTotalCount_; } + /** Return the number of fetch attempts that found the requested object. */ std::uint32_t getFetchHitCount() const { return fetchHitCount_; } + /** Return the cumulative byte count of all stored objects. */ std::uint64_t getStoreSize() const { return storeSz_; } + /** Return the cumulative byte count of all successfully fetched objects. */ std::uint32_t getFetchSize() const { return fetchSz_; } + /** Populate a JSON object with read/write diagnostics for `get_counts` RPC. + * + * Snapshots the async read queue depth (under `readLock_`) and then reads + * thread counts, request bundle size, and all atomic counters without + * holding any lock. The resulting fields include: `read_queue`, + * `read_threads_total`, `read_threads_running`, `read_request_bundle`, + * `node_writes`, `node_reads_total`, `node_reads_hit`, + * `node_written_bytes`, `node_read_bytes`, `node_reads_duration_us`. + * + * @param obj A JSON object to populate; must satisfy `obj.isObject()`. + */ void getCountsJson(json::Value& obj); - /** Returns the number of file descriptors the database expects to need */ + /** Return the number of file descriptors this database expects to hold open. + * + * Aggregated from the underlying backend(s). Used by the application to + * check that the process file-descriptor limit is sufficient before + * opening backends. Inaccurate values cause silent failures when the + * limit is exceeded. + * + * @return File descriptor count, or 0 if not set by the subclass. + */ int fdRequired() const { return fdRequired_; } + /** Begin orderly shutdown of the async read thread pool. + * + * Sets `readStopping_`, clears the pending `read_` queue, broadcasts on + * `readCondVar_`, then spin-yields until `readThreads_` reaches zero. + * An assertion fires if shutdown takes longer than 30 seconds. + * + * Idempotent: a second call after shutdown has already completed is a + * no-op. Derived classes must call this in their own destructors before + * their data members are torn down. + */ virtual void stop(); + /** Return whether `stop()` has been called. + * + * Uses a relaxed atomic load — only the flag value is observed; no + * ordering is imposed on surrounding operations. + * + * @return `true` once `stop()` has been invoked. + */ bool isStopping() const; - /** @return The earliest ledger sequence allowed + /** Return the earliest ledger sequence this database will serve. + * + * Configured via `earliest_seq` in `[node_db]`; defaults to + * `kXRP_LEDGER_EARLIEST_SEQ` (32570 on the main network). The value is + * constant after construction. Only unit tests or alternate networks + * should set this below the default. + * + * @return The minimum valid ledger sequence number, always ≥ 1. */ [[nodiscard]] std::uint32_t earliestLedgerSeq() const noexcept @@ -190,26 +309,34 @@ public: } protected: - beast::Journal const j_; - Scheduler& scheduler_; + beast::Journal const j_; ///< Logging sink; set at construction. + Scheduler& scheduler_; ///< Task scheduler for async dispatch and telemetry. + + /** Number of file descriptors consumed by the underlying backend(s). + * Subclasses set this in their constructors; read by `fdRequired()`. + */ int fdRequired_{0}; - std::atomic fetchHitCount_{0}; - std::atomic fetchSz_{0}; + std::atomic fetchHitCount_{0}; ///< Fetches that returned a non-null object. + std::atomic fetchSz_{0}; ///< Cumulative bytes returned by successful fetches. - // The default is XRP_LEDGER_EARLIEST_SEQ (32570) to match the XRP ledger - // network's earliest allowed ledger sequence. Can be set through the - // configuration file using the 'earliest_seq' field under the 'node_db' - // stanza. If specified, the value must be greater than zero. - // Only unit tests or alternate - // networks should change this value. + /** Minimum ledger sequence this store will serve; constant after construction. + * Defaults to `kXRP_LEDGER_EARLIEST_SEQ` (32570). Must be ≥ 1. + */ std::uint32_t const earliestLedgerSeq_; - // The maximum number of requests a thread extracts from the queue in an - // attempt to minimize the overhead of mutex acquisition. This is an - // advanced tunable, via the config file. The default value is 4. + /** Maximum number of read-queue entries extracted per mutex acquisition. + * Amortises lock overhead under load. Configured via `rq_bundle` in + * `[node_db]`; clamped to [1, 64]; defaults to 4. + */ int const requestBundle_; + /** Update store counters after a successful batch write. + * + * @param count Number of objects written. + * @param sz Total byte size of those objects. + * @note Asserts `count <= sz` — byte total must be ≥ item count. + */ void storeStats(std::uint64_t count, std::uint64_t sz) { @@ -218,10 +345,32 @@ protected: storeSz_ += sz; } - // Called by the public import function + /** Bulk-import all objects from @p srcDB into @p dstBackend. + * + * Iterates @p srcDB via `forEach()`, accumulates objects into batches of + * `kBATCH_WRITE_PREALLOCATION_SIZE`, and flushes each batch with + * `dstBackend.storeBatch()`. Byte statistics are recorded via + * `storeStats()` after each flush. On exception, logs the error and + * returns early without aborting the overall import. + * + * Called by subclass `importDatabase()` implementations. + * + * @param dstBackend Destination backend; must be open and writable. + * @param srcDB Source database; iterated sequentially — no concurrent + * writes to @p srcDB should occur during the call. + */ void importInternal(Backend& dstBackend, Database& srcDB); + /** Merge externally-collected fetch metrics into the atomic counters. + * + * Used by subclasses that perform their own batched reads (e.g. import + * paths) and need to credit the counters in bulk rather than per-object. + * + * @param fetches Number of fetch attempts to add to `fetchTotalCount_`. + * @param hits Number of successful fetches to add to `fetchHitCount_`. + * @param duration Elapsed microseconds to add to `fetchDurationUs_`. + */ void updateFetchMetrics(uint64_t fetches, uint64_t hits, uint64_t duration) { @@ -231,26 +380,51 @@ protected: } private: - std::atomic storeCount_{0}; - std::atomic storeSz_{0}; - std::atomic fetchTotalCount_{0}; - std::atomic fetchDurationUs_{0}; - std::atomic storeDurationUs_{0}; + // --- Write-side atomic counters --- + std::atomic storeCount_{0}; ///< Total objects stored. + std::atomic storeSz_{0}; ///< Total bytes stored. + std::atomic storeDurationUs_{0}; ///< Cumulative store duration (µs); reserved. - mutable std::mutex readLock_; - std::condition_variable readCondVar_; + // --- Fetch-side atomic counters (incremented by the public fetchNodeObject wrapper) --- + std::atomic fetchTotalCount_{0}; ///< Total fetch attempts. + std::atomic fetchDurationUs_{0}; ///< Cumulative fetch duration (µs). - // reads to do + // --- Async read-queue state (all guarded by readLock_ except atomic members) --- + mutable std::mutex readLock_; ///< Guards `read_` and `readCondVar_`. + std::condition_variable readCondVar_; ///< Wakes worker threads when `read_` is non-empty or stopping. + + /** Pending async read requests, keyed by hash. + * + * Each map entry holds all `(ledgerSeq, callback)` pairs registered for a + * given hash. Multiple calls to `asyncFetch()` with the same hash are + * coalesced here so that a single backend read services all callbacks. + */ std::map< uint256, std::vector< std::pair const&)>>>> read_; - std::atomic readStopping_ = false; - std::atomic readThreads_ = 0; - std::atomic runningThreads_ = 0; + std::atomic readStopping_ = false; ///< Set by `stop()`; workers exit when observed. + std::atomic readThreads_ = 0; ///< Count of live worker threads; reaches 0 on full stop. + std::atomic runningThreads_ = 0; ///< Threads currently active (not blocked on condvar). + /** Backend fetch hook — the Template Method target. + * + * Called exclusively by the public non-virtual `fetchNodeObject()` wrapper, + * which applies timing and metrics around this call. Subclasses must + * implement this and may not call the public wrapper from within it. + * + * @param hash 256-bit content hash to look up. + * @param ledgerSeq Ledger sequence, used by rotating backends to select + * the correct physical file. + * @param fetchReport Mutable report populated by the implementation; + * the public wrapper reads `fetchReport.wasFound` and `elapsed`. + * @param duplicate When `true`, if the object is found in the archive + * backend it should also be written back to the writable backend + * (promotion path for `DatabaseRotatingImp`). + * @return The fetched `NodeObject`, or `nullptr` on miss or error. + */ virtual std::shared_ptr fetchNodeObject( uint256 const& hash, @@ -258,16 +432,26 @@ private: FetchReport& fetchReport, bool duplicate) = 0; - /** Visit every object in the database - This is usually called during import. - - @note This routine will not be called concurrently with itself - or other methods. - @see import - */ + /** Iterate every object in the database and invoke @p f for each one. + * + * Used exclusively by `importInternal()`. Implementations may close and + * reopen the underlying store (e.g. NuDB) and are not safe for concurrent + * access; the caller must ensure no other reads or writes occur during + * iteration. + * + * @note Never called concurrently with itself or other methods. + * @param f Callback invoked with each `NodeObject`; must not be null. + */ virtual void forEach(std::function)> f) = 0; + /** Worker thread body for the async read pool. + * + * Loops waiting on `readCondVar_`, extracts up to `requestBundle_` entries + * from `read_` per lock acquisition, and dispatches each to the private + * `fetchNodeObject()`. Handles multi-sequence coalescing via `isSameDB()`. + * Exits when `readStopping_` is observed, then decrements `readThreads_`. + */ void threadEntry(); }; diff --git a/include/xrpl/nodestore/DatabaseRotating.h b/include/xrpl/nodestore/DatabaseRotating.h index a7deed294a..cb8d63ffc1 100644 --- a/include/xrpl/nodestore/DatabaseRotating.h +++ b/include/xrpl/nodestore/DatabaseRotating.h @@ -1,17 +1,47 @@ +/** @file + * Abstract interface extending `Database` with a two-backend rotation + * operation for online ledger history deletion. + */ + #pragma once #include namespace xrpl::NodeStore { -/* This class has two key-value store Backend objects for persisting SHAMap - * records. This facilitates online deletion of data. New backends are - * rotated in. Old ones are rotated out and deleted. +/** Abstract seam for the two-backend rotation scheme that enables online + * deletion of ledger history without taking the node offline. + * + * The concrete subclass `DatabaseRotatingImp` maintains two physical + * `Backend` objects: a *writable* backend that receives all current writes + * and an *archive* backend holding older data. When enough new history has + * accumulated, `SHAMapStoreImp` calls `rotate()` to atomically promote the + * writable backend to the archive role, install a fresh writable backend, + * and discard the old archive — all without interrupting read or write + * traffic. + * + * `DatabaseRotating` carries no state; it extends `Database` solely with + * the `rotate()` pure-virtual method. Components that drive rotation + * (currently only `SHAMapStoreImp`) hold a `DatabaseRotating*` pointer, + * keeping the rotation mechanism decoupled from storage format. + * + * @see DatabaseRotatingImp, Database, SHAMapStoreImp */ - class DatabaseRotating : public Database { public: + /** Construct the rotating database and start the async read thread pool. + * + * Delegates entirely to `Database(scheduler, readThreads, config, + * journal)`. The two physical backends are supplied when constructing + * the concrete `DatabaseRotatingImp` subclass. + * + * @param scheduler Task scheduler for async I/O dispatch and telemetry; + * must outlive this object. + * @param readThreads Number of prefetch worker threads to create. + * @param config `[node_db]` config section forwarded to `Database`. + * @param journal Logging sink. + */ DatabaseRotating( Scheduler& scheduler, int readThreads, @@ -21,13 +51,37 @@ public: { } - /** Rotates the backends. - - @param newBackend New writable backend - @param f A function executed after the rotation outside of lock. The - values passed to f will be the new backend database names _after_ - rotation. - */ + /** Atomically replace the current writable backend with @p newBackend. + * + * Performs a three-step pointer swap under the implementation's internal + * mutex: + * 1. Mark the current archive backend for directory deletion and stash it + * in a local `shared_ptr` to keep it alive past the lock release. + * 2. Demote the current writable backend to the archive role. + * 3. Install @p newBackend as the new writable backend. + * + * After releasing the lock, @p f is called with the new backend names. + * Only after @p f returns does the old archive `shared_ptr` go out of + * scope and its on-disk files are removed. This sequencing is + * **crash-safe**: if the process dies between the pointer swap and @p f + * completing, both directory sets still exist on disk and can be + * recovered from the SQL state database on restart. + * + * Concurrent fetches already in flight hold `shared_ptr` references to + * the old backends; reference counting keeps those backends alive until + * all in-flight I/O completes. + * + * @param newBackend Freshly created, opened backend that becomes the new + * writable store; ownership is transferred. + * @param f Callback invoked after the in-memory swap completes but + * before the old archive is deleted, and outside the implementation + * mutex. Receives two names post-rotation: @p writableName is the + * name of @p newBackend, and @p archiveName is the name of the former + * writable backend now serving as the archive. `SHAMapStoreImp` uses + * @p f to durably persist the new backend names and `lastRotated` + * ledger sequence to a SQL state database, creating an atomic + * checkpoint for crash recovery. + */ virtual void rotate( std::unique_ptr&& newBackend, diff --git a/include/xrpl/nodestore/DummyScheduler.h b/include/xrpl/nodestore/DummyScheduler.h index 472684ff13..74748ad439 100644 --- a/include/xrpl/nodestore/DummyScheduler.h +++ b/include/xrpl/nodestore/DummyScheduler.h @@ -4,16 +4,64 @@ namespace xrpl::NodeStore { -/** Simple NodeStore Scheduler that just performs the tasks synchronously. */ +/** Null-object implementation of @ref Scheduler for tests and offline import. + * + * Satisfies the full `Scheduler` interface contract while doing the minimum + * possible work: every task is executed immediately on the calling thread, and + * the two performance-reporting hooks are no-ops. There is no thread pool, no + * queue, and no statistics collection. + * + * The `Scheduler` contract explicitly permits running a task on the calling + * thread, so `DummyScheduler` is always correct — it differs from a + * production scheduler only in latency and throughput characteristics. + * + * **Effect on `BatchWriter`**: Because `scheduleTask` flushes the batch + * inline before returning, batching is effectively disabled. This is + * acceptable for import and test workloads but would degrade performance + * under normal ledger-processing load. + * + * **Typical call sites**: + * - `Application.cpp` — transient scheduler for the source database during + * node-startup `doImport`; sequential offline migration makes async + * scheduling unnecessary. + * - `Backend_test.cpp`, `Database_test.cpp`, `Timing_test.cpp`, + * `NuDBFactory_test.cpp`, `shamap/common.h` — test fixtures use this to + * obtain deterministic, single-threaded execution without the teardown + * complexity of a real async scheduler. + * + * @see Scheduler + * @see BatchWriter + */ class DummyScheduler : public Scheduler { public: DummyScheduler() = default; ~DummyScheduler() override = default; + + /** Execute @p task synchronously on the calling thread. + * + * Calls `task.performScheduledTask()` directly and returns only after + * the task completes. With `BatchWriter` as the consumer, this causes + * the pending write batch to be flushed inline, disabling asynchronous + * batching. + * + * @param task The task to execute; must remain valid for the duration + * of the call. + */ void scheduleTask(Task& task) override; + + /** No-op performance hook — fetch telemetry is not collected. + * + * @param report Ignored. + */ void onFetch(FetchReport const& report) override; + + /** No-op performance hook — batch-write telemetry is not collected. + * + * @param report Ignored. + */ void onBatchWrite(BatchWriteReport const& report) override; }; diff --git a/include/xrpl/nodestore/Factory.h b/include/xrpl/nodestore/Factory.h index c40be62d21..7b661f1479 100644 --- a/include/xrpl/nodestore/Factory.h +++ b/include/xrpl/nodestore/Factory.h @@ -9,24 +9,55 @@ namespace xrpl::NodeStore { -/** Base class for backend factories. */ +/** Abstract factory for constructing pluggable NodeStore `Backend` instances. + * + * Each concrete subclass wraps one storage engine (NuDB, RocksDB, memory, + * null). Subclasses register themselves with the `Manager` singleton at + * program startup by calling `Manager::insert(*this)` from their constructor, + * typically via a module-level `register*Factory()` free function that holds + * the factory as a function-local static. `Manager::find()` then resolves the + * `type=` configuration string to the matching factory by name. + * + * @note Factory objects are stored as raw (non-owning) pointers in + * `ManagerImp`. Concrete factories registered as function-local statics + * have program lifetime and must outlive the `Manager`. + * + * @see Backend, Manager + */ class Factory { public: virtual ~Factory() = default; - /** Retrieve the name of this factory. */ + /** Return the configuration type string that identifies this backend. + * + * The returned name is used as the lookup key by `Manager::find()`, + * which compares case-insensitively against the `type=` value in the + * `[node_db]` config section (e.g., `"NuDB"`, `"RocksDB"`, `"memory"`). + * + * @return The backend type name (e.g., `"NuDB"`). + */ [[nodiscard]] virtual std::string getName() const = 0; - /** Create an instance of this factory's backend. - - @param keyBytes The fixed number of bytes per key. - @param parameters A set of key/value configuration pairs. - @param burstSize Backend burst size in bytes. - @param scheduler The scheduler to use for running tasks. - @return A pointer to the Backend object. - */ + /** Construct a Backend from configuration, without a shared NuDB context. + * + * The returned backend has not yet been opened; the caller must invoke + * `Backend::open()` before performing any I/O. In production, this is + * done by `ManagerImp::makeDatabase()`. + * + * @param keyBytes Fixed width of every storage key in bytes. Always 32 + * (SHA-512 Half) in production; may differ in tests. + * @param parameters Key/value pairs from the `[node_db]` config section, + * supplying backend-specific settings such as `path` and + * `nudb_block_size`. + * @param burstSize Maximum bytes the backend may buffer before flushing. + * Flows directly into NuDB's `db_.set_burst()` after open; other + * backends may use or ignore it. + * @param scheduler Async task dispatcher for background write jobs. + * @param journal Logging sink for backend diagnostics. + * @return An unopened, uniquely-owned Backend instance. + */ virtual std::unique_ptr createInstance( size_t keyBytes, @@ -35,15 +66,24 @@ public: Scheduler& scheduler, beast::Journal journal) = 0; - /** Create an instance of this factory's backend. - - @param keyBytes The fixed number of bytes per key. - @param parameters A set of key/value configuration pairs. - @param burstSize Backend burst size in bytes. - @param scheduler The scheduler to use for running tasks. - @param context The context used by database. - @return A pointer to the Backend object. - */ + /** Construct a Backend sharing an existing NuDB I/O context. + * + * This overload is provided for NuDB backends that share a `nudb::context` + * thread pool across multiple backends (e.g., the rotating database used + * for shard imports). Non-NuDB factories inherit a default implementation + * that returns an empty `unique_ptr`, signaling to `ManagerImp` that this + * backend does not use a NuDB context; the caller falls back to the + * context-free overload in that case. + * + * @param keyBytes Fixed width of every storage key in bytes. + * @param parameters Key/value pairs from the `[node_db]` config section. + * @param burstSize Maximum bytes the backend may buffer before flushing. + * @param scheduler Async task dispatcher for background write jobs. + * @param context Shared NuDB I/O thread pool. Ignored by non-NuDB backends. + * @param journal Logging sink for backend diagnostics. + * @return An unopened Backend, or an empty `unique_ptr` if this factory + * does not support the NuDB context overload. + */ virtual std::unique_ptr createInstance( size_t keyBytes, diff --git a/include/xrpl/nodestore/Manager.h b/include/xrpl/nodestore/Manager.h index 1c4e5b63cf..9e6a784054 100644 --- a/include/xrpl/nodestore/Manager.h +++ b/include/xrpl/nodestore/Manager.h @@ -5,7 +5,28 @@ namespace xrpl::NodeStore { -/** Singleton for managing NodeStore factories and back ends. */ +/** Abstract interface for the NodeStore backend registry and factory. + * + * `Manager` maps the `type=` string from `[node_db]` in `xrpld.cfg` to the + * concrete `Backend` implementation that implements it, and exposes the two + * construction entry points the rest of the application needs: `makeBackend()` + * for a raw storage engine and `makeDatabase()` for a fully-wired `Database`. + * + * The concrete implementation is `ManagerImp`, a Meyers singleton returned by + * `instance()`. Its constructor eagerly registers the four built-in backends + * (NuDB, RocksDB, memory, null). Additional backends may be registered at + * runtime via `insert()`. The abstract base class is exposed here so callers + * depend only on the interface without being coupled to `ManagerImp` or its + * dependencies. + * + * All registry operations (`insert`, `erase`, `find`) are protected by an + * internal mutex and are safe to call concurrently. + * + * @note Copy construction and copy assignment are deleted — there is exactly + * one manager for the lifetime of the process. + * + * @see Factory, Backend, Database + */ class Manager { public: @@ -15,26 +36,81 @@ public: Manager& operator=(Manager const&) = delete; - /** Returns the instance of the manager singleton. */ + /** Return the process-wide Manager singleton. + * + * Delegates to `ManagerImp::instance()`, which uses a Meyers static local + * for thread-safe, once-only initialization under C++11 and later. The + * four built-in backends are registered before the reference is returned + * for the first time. + * + * @return A reference to the singleton `ManagerImp`. + */ static Manager& instance(); - /** Add a factory. */ + /** Register a backend factory with the manager. + * + * After insertion, `find(factory.getName())` will return `&factory`. The + * call is protected by a mutex and safe to make concurrently. The manager + * stores a non-owning pointer; the caller is responsible for ensuring the + * factory outlives the manager (function-local statics are the idiomatic + * approach). + * + * @param factory The factory to register. Must remain alive for the + * lifetime of the manager. + */ virtual void insert(Factory& factory) = 0; - /** Remove a factory. */ + /** Deregister a previously inserted backend factory. + * + * Removes `factory` from the internal list. The call is protected by a + * mutex. Passing a pointer that was never inserted triggers an + * `XRPL_ASSERT`. + * + * @note Built-in backend factories registered by `ManagerImp`'s + * constructor are intentionally never erased: because static-storage + * destruction order across translation units is undefined, calling + * `erase()` from a `Factory` destructor could invoke a destroyed + * `ManagerImp`. The built-in factories use function-local statics + * that outlive the manager. + * + * @param factory The factory to remove. Must have been previously passed + * to `insert()`. + */ virtual void erase(Factory& factory) = 0; - /** Return a pointer to the matching factory if it exists. - @param name The name to match, performed case-insensitive. - @return `nullptr` if a match was not found. - */ + /** Look up a factory by its type name. + * + * Comparison is case-insensitive (via `boost::iequals`), so `"NuDB"`, + * `"nudb"`, and `"NUDB"` all resolve to the same factory. The call is + * protected by a mutex. + * + * @param name The backend type name to search for (e.g., `"NuDB"`). + * @return Pointer to the matching `Factory`, or `nullptr` if none found. + */ virtual Factory* find(std::string const& name) = 0; - /** Create a backend. */ + /** Construct an unopened Backend from configuration parameters. + * + * Reads the `type` key from `parameters`, resolves it to a registered + * `Factory` via `find()`, and delegates to `Factory::createInstance()`. + * The returned backend has not yet been opened; the caller must invoke + * `Backend::open()` before performing any I/O (this is done automatically + * by `makeDatabase()`). + * + * @param parameters Key/value pairs from the `[node_db]` config section. + * Must contain a `type` key naming a registered backend. + * @param burstSize Maximum bytes the backend may buffer before flushing. + * @param scheduler Async task dispatcher for background write jobs. + * @param journal Logging sink for backend diagnostics. + * @return A uniquely-owned, unopened Backend instance. + * @throws std::runtime_error If the `type` key is absent or names an + * unrecognised backend, with a message directing the operator to + * check `xrpld.cfg`. + */ virtual std::unique_ptr makeBackend( Section const& parameters, @@ -42,34 +118,25 @@ public: Scheduler& scheduler, beast::Journal journal) = 0; - /** Construct a NodeStore database. - - The parameters are key value pairs passed to the backend. The - 'type' key must exist, it defines the choice of backend. Most - backends also require a 'path' field. - - Some choices for 'type' are: - HyperLevelDB, LevelDBFactory, SQLite, MDB - - If the fastBackendParameter is omitted or empty, no ephemeral database - is used. If the scheduler parameter is omitted or unspecified, a - synchronous scheduler is used which performs all tasks immediately on - the caller's thread. - - @note If the database cannot be opened or created, an exception is - thrown. - - @param name A diagnostic label for the database. - @param burstSize Backend burst size in bytes. - @param scheduler The scheduler to use for performing asynchronous tasks. - @param readThreads The number of async read threads to create - @param backendParameters The parameter string for the persistent - backend. - @param fastBackendParameters [optional] The parameter string for the - ephemeral backend. - - @return The opened database. - */ + /** Construct and open a fully-wired Database backed by a single backend. + * + * Calls `makeBackend()` to create and open the backend, then wraps it in + * a `DatabaseNodeImp` which adds an async read-thread pool and the full + * `Database` API. The `backendParameters` section must contain a `type` + * key naming a registered backend; most backends also require a `path` + * key. Currently registered built-in types are: `NuDB`, `RocksDB`, + * `memory`, `none`. + * + * @param burstSize Maximum bytes the backend may buffer before flushing. + * @param scheduler Async task dispatcher for read and write jobs. + * @param readThreads Number of threads in the async read pool. + * @param backendParameters Key/value pairs for the persistent backend, + * including at minimum a `type` key. + * @param journal Logging sink for database diagnostics. + * @return A uniquely-owned, open Database ready for I/O. + * @throws std::runtime_error If the backend cannot be created or opened, + * or if the `type` key is missing or unrecognised. + */ virtual std::unique_ptr makeDatabase( std::size_t burstSize, diff --git a/include/xrpl/nodestore/NodeObject.h b/include/xrpl/nodestore/NodeObject.h index 2a216606c4..02359ca2f1 100644 --- a/include/xrpl/nodestore/NodeObject.h +++ b/include/xrpl/nodestore/NodeObject.h @@ -1,72 +1,126 @@ +/** @file + * Defines `NodeObject`, the atomic storage unit of the XRPL node store. + * + * Every piece of ledger state — account tree nodes, transaction tree nodes, + * and ledger headers — is stored and retrieved as a `NodeObject`. The class + * is a pure value type: a type tag, a 256-bit hash key, and a raw binary + * blob. Higher layers (SHAMap, ledger, serialization) are responsible for + * interpreting the blob's contents. + * + * `NodeObject` lives in the `xrpl` namespace rather than `xrpl::NodeStore` + * so that the SHAMap layer, ledger subsystem, and serialization paths can + * consume it without pulling in the full nodestore backend API. + */ #pragma once #include #include #include -// VFALCO NOTE Intentionally not in the NodeStore namespace - namespace xrpl { -/** The types of node objects. */ +/** Identifies the kind of data stored in a `NodeObject`. + * + * The integer values are part of the on-disk format (written by + * `EncodedBlob` and read by `DecodedBlob`), so they must not be changed. + * Value 2 is a historical gap left by a removed type and must remain + * unused. `Dummy` (512) is deliberately outside the contiguous valid range + * so it cannot be confused with a legitimate type by accident or by + * off-by-one arithmetic; it is used as a cache sentinel meaning "confirmed + * missing". + */ enum class NodeObjectType : std::uint32_t { - Unknown = 0, - Ledger = 1, - AccountNode = 3, - TransactionNode = 4, - Dummy = 512 // an invalid or missing object + Unknown = 0, /**< Type not yet determined or not applicable. */ + Ledger = 1, /**< Serialized ledger header. */ + // Value 2 intentionally absent — historical removal; do not reuse. + AccountNode = 3, /**< SHAMap node from an account-state tree. */ + TransactionNode = 4, /**< SHAMap node from a transaction tree. */ + Dummy = 512 /**< Sentinel for a confirmed-missing cache entry; not a real object. */ }; -/** A simple object that the Ledger uses to store entries. - NodeObjects are comprised of a type, a hash, and a blob. - They can be uniquely identified by the hash, which is a half-SHA512 of - the blob. The blob is a variable length block of serialized data. The - type identifies what the blob contains. - - @note No checking is performed to make sure the hash matches the data. - @see SHAMap -*/ +/** Immutable storage unit carrying a type tag, a 256-bit hash key, and a + * raw binary payload. + * + * `NodeObject` is the payload type at every level of the nodestore stack: + * `Backend::fetch()` produces instances; `Backend::store()` and + * `Backend::storeBatch()` consume them; `Database` caches shared pointers + * to them. All three data members are `const` — once constructed the + * object never changes, which is correct for content-addressed storage. + * + * Instances must be created exclusively through `createObject()`. Direct + * construction is blocked via the `PrivateAccess` tag idiom (see below). + * All shared references are `std::shared_ptr`; ownership is + * always shared, never transferred. + * + * Inherits `CountedObject` to maintain a global atomic + * live-instance count that feeds the `get_counts` diagnostic RPC. + * + * @note The hash is accepted on trust — no verification that it matches + * the payload is performed here. Correctness is enforced at higher + * layers (SHAMap traversal, ledger validation). + * @see SHAMap + */ class NodeObject : public CountedObject { public: + /** Size in bytes of the hash key used to identify a `NodeObject`. */ static constexpr std::size_t kKEY_BYTES = 32; private: - // This hack is used to make the constructor effectively private - // except for when we use it in the call to make_shared. - // There's no portable way to make make_shared<> a friend work. + /** Tag type that makes the public constructor effectively private. + * + * `std::make_shared` requires the constructor it calls to be + * accessible, so the constructor cannot be `private`. Instead, it + * takes a `PrivateAccess` argument. Because `PrivateAccess` itself is + * a private nested type, only code inside `NodeObject` (i.e., + * `createObject`) can construct one — achieving the same effect. + */ struct PrivateAccess { explicit PrivateAccess() = default; }; public: - // This constructor is private, use createObject instead. + /** Constructs a `NodeObject`; use `createObject()` instead. + * + * The `PrivateAccess` parameter is intentionally inaccessible to + * external callers; it exists solely to satisfy `std::make_shared`. + */ NodeObject(NodeObjectType type, Blob&& data, uint256 const& hash, PrivateAccess); - /** Create an object from fields. - - The caller's variable is modified during this call. The - underlying storage for the Blob is taken over by the NodeObject. - - @param type The type of object. - @param ledgerIndex The ledger in which this object appears. - @param data A buffer containing the payload. The caller's variable - is overwritten. - @param hash The 256-bit hash of the payload data. - */ + /** Create a `NodeObject`, transferring ownership of the payload buffer. + * + * The caller's `data` buffer is moved into the new object; after this + * call `data` is in a valid but unspecified state. No copy of the + * payload is made. + * + * @param type The kind of ledger data the payload represents. + * @param data Raw serialized payload; ownership is transferred to the + * returned object. + * @param hash 256-bit hash that uniquely identifies this object in the + * node store. Must be the correct hash of `data` — no verification + * is performed. + * @return A `shared_ptr` to the newly created, immutable `NodeObject`. + */ static std::shared_ptr createObject(NodeObjectType type, Blob&& data, uint256 const& hash); - /** Returns the type of this object. */ + /** Returns the type tag indicating what kind of ledger data this object + * holds. + */ [[nodiscard]] NodeObjectType getType() const; - /** Returns the hash of the data. */ + /** Returns the 256-bit hash that identifies this object in the node + * store. + * + * @note The hash is not verified against the payload at construction + * time; callers must ensure consistency at higher layers. + */ [[nodiscard]] uint256 const& getHash() const; - /** Returns the underlying data. */ + /** Returns the raw serialized payload stored in this object. */ [[nodiscard]] Blob const& getData() const; diff --git a/include/xrpl/nodestore/Scheduler.h b/include/xrpl/nodestore/Scheduler.h index 588ff19bdc..a688f1c580 100644 --- a/include/xrpl/nodestore/Scheduler.h +++ b/include/xrpl/nodestore/Scheduler.h @@ -6,59 +6,120 @@ namespace xrpl::NodeStore { -enum class FetchType { Synchronous, Async }; +/** Distinguishes how a node-object fetch was initiated. + * + * Used by `FetchReport` to let the `Scheduler` route telemetry to the + * correct load-tracking bucket (`jtNS_SYNC_READ` vs `jtNS_ASYNC_READ` + * in production). + */ +enum class FetchType { + Synchronous, /**< Fetch was issued on the caller's thread and awaited inline. */ + Async /**< Fetch was queued and completed on a background read thread. */ +}; -/** Contains information about a fetch operation. */ +/** Performance telemetry for a single completed node-object fetch. + * + * Created on the stack immediately before a fetch and passed to + * `Scheduler::onFetch()` once the fetch returns. `fetchType` is fixed at + * construction; `elapsed` and `wasFound` are filled in afterwards. + * + * @see Scheduler::onFetch + */ struct FetchReport { + /** Construct a report for a fetch of the given type. + * + * @param fetchType Whether the fetch was synchronous or asynchronous; + * stored as a `const` member and cannot be changed after construction. + */ explicit FetchReport(FetchType fetchType) : fetchType(fetchType) { } - std::chrono::milliseconds elapsed{}; - FetchType const fetchType; - bool wasFound = false; + std::chrono::milliseconds elapsed{}; /**< Wall-clock duration of the fetch; zero-initialized. */ + FetchType const fetchType; /**< Sync or async; set at construction. */ + bool wasFound = false; /**< True if the object was present in the backend. */ }; -/** Contains information about a batch write operation. */ +/** Performance telemetry for a single completed batch write. + * + * Constructed by `BatchWriter` after each flush and passed to + * `Scheduler::onBatchWrite()`. Both fields must be filled in by the caller + * before the report is forwarded. + * + * @see Scheduler::onBatchWrite + */ struct BatchWriteReport { explicit BatchWriteReport() = default; - std::chrono::milliseconds elapsed; - int writeCount; + std::chrono::milliseconds elapsed; /**< Wall-clock duration of the batch flush. */ + int writeCount; /**< Number of `NodeObject`s written in this batch. */ }; -/** Scheduling for asynchronous backend activity - - For improved performance, a backend has the option of performing writes - in batches. These writes can be scheduled using the provided scheduler - object. - - @see BatchWriter -*/ +/** Scheduling and telemetry interface for NodeStore backend activity. + * + * Decouples backend write batching and I/O instrumentation from any + * particular threading strategy. A `Scheduler` implementation may run a + * submitted task synchronously on the calling thread (as `DummyScheduler` + * does) or post it to a thread pool (as `NodeStoreScheduler` does via the + * application `JobQueue`). The same backend code is correct under either + * policy. + * + * The interface serves two orthogonal purposes that share one injection + * point: *work dispatch* (`scheduleTask`) and *telemetry ingestion* + * (`onFetch`, `onBatchWrite`). Concrete implementations may ignore the + * telemetry hooks entirely or forward them to a load-balancing subsystem. + * + * @note `scheduleTask` takes `task` by non-const reference rather than by + * value or smart pointer. `BatchWriter` implements `Task` privately and + * manages its own lifetime, so no heap allocation is required for the + * common write-batching case. Callers must ensure the task object + * remains valid until `performScheduledTask()` returns. + * + * @see BatchWriter + * @see DummyScheduler + */ class Scheduler { public: virtual ~Scheduler() = default; - /** Schedules a task. - Depending on the implementation, the task may be invoked either on - the current thread of execution, or an unspecified - implementation-defined foreign thread. - */ + /** Dispatch a task for execution. + * + * The scheduler may call `task.performScheduledTask()` on the current + * thread before returning, or post the task to an unspecified foreign + * thread. Both behaviours are valid; callers must not assume which will + * occur. The task object must remain valid until `performScheduledTask()` + * returns. + * + * @param task The deferred work to execute; typically a `BatchWriter` + * flush. Passed by reference — ownership is not transferred. + */ virtual void scheduleTask(Task& task) = 0; - /** Reports completion of a fetch - Allows the scheduler to monitor the node store's performance - */ + /** Telemetry hook called after each node-object fetch completes. + * + * Allows the scheduler to record I/O latency and hit/miss statistics. + * This is a pure reporting path with no effect on control flow; backends + * call it unconditionally after every fetch, whether or not the object + * was found. + * + * @param report Timing, fetch type, and hit/miss outcome for the + * completed fetch. + */ virtual void onFetch(FetchReport const& report) = 0; - /** Reports the completion of a batch write - Allows the scheduler to monitor the node store's performance - */ + /** Telemetry hook called after each batch write completes. + * + * Allows the scheduler to record write throughput. Called by + * `BatchWriter` after each flush, with `report.writeCount` reflecting + * the number of objects flushed in that batch. + * + * @param report Elapsed time and object count for the completed batch. + */ virtual void onBatchWrite(BatchWriteReport const& report) = 0; }; diff --git a/include/xrpl/nodestore/Task.h b/include/xrpl/nodestore/Task.h index 0695970a68..7e7c41fe34 100644 --- a/include/xrpl/nodestore/Task.h +++ b/include/xrpl/nodestore/Task.h @@ -1,15 +1,53 @@ +/** @file + * Defines the `Task` abstract interface for NodeStore scheduled work units. + * + * Any piece of deferred backend work (e.g., a `BatchWriter` flush) inherits + * from `Task` and implements `performScheduledTask()`. The `Scheduler` + * interface accepts a `Task&` and decides *where* and *when* to invoke it, + * decoupling the work unit from any knowledge of threads or job queues. + */ + #pragma once namespace xrpl::NodeStore { -/** Derived classes perform scheduled tasks. */ +/** Pure command-pattern base for NodeStore deferred backend work. + * + * A `Task` is the minimal callable token the scheduling system needs: a single + * `performScheduledTask()` entry point and a virtual destructor. Concrete work + * units inherit from this struct (typically privately, as `BatchWriter` does) + * and are submitted to `Scheduler::scheduleTask()`. + * + * The scheduling contract is intentionally loose: `Scheduler::scheduleTask()` + * may invoke the task synchronously on the calling thread (as `DummyScheduler` + * does for tests) or post it to an unspecified foreign thread (as + * `NodeStoreScheduler` does via the application `JobQueue`). Concrete `Task` + * implementations must be safe under either policy. + * + * The interface is deliberately as small as possible. A richer alternative + * such as `std::function` or `std::unique_ptr` would impose a heap + * allocation on every scheduled operation and couple the interface to a + * specific ownership model. With this design, `BatchWriter` can implement + * `Task` privately and pass `*this` to `scheduleTask()` — no extra allocation + * needed, and lifetime management stays entirely within `BatchWriter`. + * + * @see Scheduler + * @see BatchWriter + * @see DummyScheduler + */ struct Task { virtual ~Task() = default; - /** Performs the task. - The call may take place on a foreign thread. - */ + /** Execute the deferred work represented by this task. + * + * Called by the `Scheduler` either synchronously on the submitting thread + * or asynchronously on a foreign thread, depending on the scheduler + * implementation. Implementations must tolerate either calling context. + * + * The object must remain valid and unmodified from the time it is passed + * to `Scheduler::scheduleTask()` until this method returns. + */ virtual void performScheduledTask() = 0; }; diff --git a/include/xrpl/nodestore/Types.h b/include/xrpl/nodestore/Types.h index cf1a9db42e..1afe8de560 100644 --- a/include/xrpl/nodestore/Types.h +++ b/include/xrpl/nodestore/Types.h @@ -1,3 +1,13 @@ +/** @file + * Shared vocabulary types for the xrpl::NodeStore subsystem. + * + * This header sits at the base of the NodeStore include hierarchy and is + * pulled in by every other NodeStore interface header. It defines only the + * primitives that all participants — backends, the async database layer, and + * callers — must agree on: the operation status codes, the batch container + * alias, and the batch-size policy constants. + */ + #pragma once #include @@ -6,29 +16,57 @@ namespace xrpl::NodeStore { -// This is only used to pre-allocate the array for -// batch objects and does not affect the amount written. -// +/** Initial capacity hint for a `Batch` vector and the backpressure threshold + * in `BatchWriter::store`. + * + * `BatchWriter` reserves this many slots on construction and re-reserves after + * each flush to avoid repeated allocations. `BatchWriter::store` also blocks + * when `writeSet_` reaches this size, providing backpressure against producers + * that outrun the flush thread. This value does not cap how many objects can + * ultimately be written in a single pass. + */ static constexpr auto kBATCH_WRITE_PREALLOCATION_SIZE = 256; -// This sets a limit on the maximum number of writes -// in a batch. Actual usage can be twice this since -// we have a new batch growing as we write the old. -// +/** Maximum number of objects flushed in a single batch write. + * + * Once a batch accumulates this many objects it is handed off to the backend. + * Because a new batch begins accumulating while the previous one is being + * written to disk (double-buffer pattern), peak in-flight memory for pending + * objects can reach approximately twice this limit. + */ static constexpr auto kBATCH_WRITE_LIMIT_SIZE = 65536; -/** Return codes from Backend operations. */ +/** Return codes from `Backend` fetch and store operations. + * + * Values 0–99 are reserved for the standard codes defined here. Backend + * implementations that need additional error distinctions must use values + * starting at `CustomCode` (100) to avoid collisions. + */ enum class Status { - Ok = 0, - NotFound = 1, - DataCorrupt = 2, - Unknown = 3, - BackendError = 4, + Ok = 0, /**< Operation completed successfully. */ + NotFound = 1, /**< Key is not present in the store. */ + DataCorrupt = 2, /**< Stored blob failed integrity validation. */ + Unknown = 3, /**< An unclassified error occurred. */ + BackendError = 4, /**< The underlying storage backend reported an error. */ + /** First value available for backend-defined extended error codes. + * Backend implementations may define their own codes as + * `static_cast(Status::CustomCode) + N` without colliding with the + * standard range (0–99). + */ CustomCode = 100 }; -/** A batch of NodeObjects to write at once. */ +/** A collection of `NodeObject`s to be written together in a single batch. + * + * Using a named alias rather than spelling out the type at every call site + * means that a change to the container type or ownership model propagates + * from this single definition. The `shared_ptr` element type reflects that + * individual `NodeObject` instances may be concurrently referenced by + * in-memory caches and the write pipeline at the same time. + * + * @see Backend::storeBatch + */ using Batch = std::vector>; } // namespace xrpl::NodeStore diff --git a/include/xrpl/nodestore/detail/BatchWriter.h b/include/xrpl/nodestore/detail/BatchWriter.h index b0383838dc..1fa5510f10 100644 --- a/include/xrpl/nodestore/detail/BatchWriter.h +++ b/include/xrpl/nodestore/detail/BatchWriter.h @@ -9,18 +9,46 @@ namespace xrpl::NodeStore { -/** Batch-writing assist logic. - - The batch writes are performed with a scheduled task. Use of the - class it not required. A backend can implement its own write batching, - or skip write batching if doing so yields a performance benefit. - - @see Scheduler -*/ +/** Coalesces individual NodeObject writes into batches for NodeStore backends. + * + * Individual key-value store writes carry per-operation overhead (system + * call, WAL append, compaction pressure). `BatchWriter` amortises that cost + * by accumulating objects in an internal buffer and flushing them as a single + * batch via a `Scheduler`-dispatched task. Use of this class is optional — + * a backend may implement its own batching strategy or skip batching entirely. + * + * The class privately inherits `Task`, turning itself into a schedulable unit + * of work with no additional heap allocation. The actual write is delegated + * to a `Callback` (typically the owning backend), keeping storage-engine + * specifics out of the batching logic. + * + * **Thread safety**: `store()` and `getWriteLoad()` are safe to call + * concurrently from multiple threads. The flush task may run on the calling + * thread (synchronous scheduler) or a background thread (async scheduler); + * the recursive mutex design is safe under both policies. + * + * **Backpressure**: `store()` blocks when the pending buffer reaches + * `kBATCH_WRITE_LIMIT_SIZE` (65,536 objects), preventing unbounded memory + * growth when disk I/O cannot keep pace with producers. Peak in-flight + * memory can reach approximately twice this limit due to the double-buffer + * swap pattern (one batch being written while the next accumulates). + * + * @see Scheduler + * @see Backend + */ class BatchWriter : private Task { public: - /** This callback does the actual writing. */ + /** Pure interface through which `BatchWriter` delivers a completed batch. + * + * The concrete backend (e.g., `RocksDBBackend`) inherits both `Backend` + * and `BatchWriter::Callback`, implementing `writeBatch` to forward the + * batch to the underlying storage engine. This indirection keeps batching + * logic storage-agnostic. + * + * `writeBatch` is invoked outside the internal mutex, so implementations + * may perform blocking I/O without serialising concurrent `store()` calls. + */ struct Callback { virtual ~Callback() = default; @@ -29,49 +57,111 @@ public: Callback& operator=(Callback const&) = delete; + /** Flush a completed batch to the storage engine. + * + * Called by `BatchWriter` once per scheduled flush, with the lock + * already released. The implementation must persist every object in + * `batch` before returning. + * + * @param batch The collection of `NodeObject`s to write. Objects in + * the batch may be concurrently referenced by in-memory caches. + */ virtual void writeBatch(Batch const& batch) = 0; }; - /** Create a batch writer. */ + /** Construct a `BatchWriter` tied to the given sink and scheduler. + * + * Pre-allocates the internal write buffer to avoid repeated small + * reallocations during normal operation. + * + * @param callback The sink that receives each flushed `Batch` via + * `Callback::writeBatch()`. Typically the owning backend. Must + * outlive this `BatchWriter`. + * @param scheduler The scheduler used to dispatch the flush task. May be + * a synchronous `DummyScheduler` (tests and bulk import) or the + * production async scheduler; both are supported. + */ BatchWriter(Callback& callback, Scheduler& scheduler); - /** Destroy a batch writer. - - Anything pending in the batch is written out before this returns. - */ + /** Destroy the `BatchWriter`, draining any pending writes first. + * + * Blocks until all accumulated objects have been flushed to the + * `Callback`. No objects passed to `store()` are silently abandoned. + */ ~BatchWriter() override; - /** Store the object. - - This will add to the batch and initiate a scheduled task to - write the batch out. - */ + /** Enqueue a `NodeObject` for the next scheduled batch flush. + * + * Appends `object` to the internal accumulation buffer and, if no flush + * task is already outstanding, schedules one via the `Scheduler`. + * Subsequent `store()` calls before the flush fires piggyback on the + * single in-flight task. + * + * @param object The `NodeObject` to persist. + * @note Blocks the caller when the buffer reaches `kBATCH_WRITE_LIMIT_SIZE` + * (65,536 objects) until the in-flight batch is fully written. This + * backpressure prevents unbounded memory growth when disk I/O falls + * behind producers. + */ void store(std::shared_ptr const& object); - /** Get an estimate of the amount of writing I/O pending. */ + /** Return a conservative estimate of pending write I/O. + * + * Returns the larger of the item count currently being written to the + * backend and the item count waiting for the next scheduled flush. + * Taking the maximum reflects pressure in both the in-flight and + * accumulating phases, giving callers a meaningful load signal for + * scheduling decisions. + * + * @return Estimated number of `NodeObject`s awaiting or undergoing write. + */ int getWriteLoad(); private: + /** `Task` entry-point; delegates to the internal `writeBatch()`. */ void performScheduledTask() override; + + /** Drain accumulated objects to the backend using the double-buffer swap. + * + * Holds the lock only long enough to swap the internal buffer with a + * local vector (O(1)), then releases the lock before calling + * `Callback::writeBatch()`. Loops until no objects remain after a swap, + * then clears `writePending_` and notifies any blocked `store()` callers. + */ void writeBatch(); + + /** Block until any in-flight flush has completed. + * + * Waits on the condition variable until `writePending_` is false. + * Called by the destructor to guarantee no pending objects are abandoned + * on teardown. + */ void waitForWriting(); private: + /** Recursive to allow synchronous schedulers that invoke `writeBatch()` + * on the same thread as `store()` or `waitForWriting()`. */ using LockType = std::recursive_mutex; + + /** Required by `LockType`; `std::condition_variable` only works with + * `std::mutex`. */ using CondvarType = std::condition_variable_any; Callback& callback_; Scheduler& scheduler_; LockType writeMutex_; CondvarType writeCondition_; + /** Item count of the batch currently being written; used by `getWriteLoad()`. */ int writeLoad_{0}; + /** True when a flush task has been scheduled but not yet completed. */ bool writePending_{false}; + /** Accumulation buffer; swapped out atomically inside `writeBatch()`. */ Batch writeSet_; }; diff --git a/include/xrpl/nodestore/detail/DatabaseNodeImp.h b/include/xrpl/nodestore/detail/DatabaseNodeImp.h index add66c51a7..920c928b15 100644 --- a/include/xrpl/nodestore/detail/DatabaseNodeImp.h +++ b/include/xrpl/nodestore/detail/DatabaseNodeImp.h @@ -1,3 +1,13 @@ +/** @file + * Single-backend concrete implementation of the NodeStore `Database` interface. + * + * `DatabaseNodeImp` is the standard node-store path for deployments that keep + * all ledger objects in one persistent key/value backend (NuDB, RocksDB, etc.). + * It adapts the thin `Backend` interface onto the richer `Database` contract + * — async read pool, telemetry, and scheduler callbacks — all of which live in + * the base class and cannot be bypassed. + */ + #pragma once #include @@ -6,6 +16,25 @@ namespace xrpl::NodeStore { +/** Single-backend implementation of the NodeStore `Database` interface. + * + * Wraps exactly one `Backend` (NuDB, RocksDB, Memory, Null) and serves all + * ledger objects regardless of their ledger sequence number. This is the + * standard deployment path; the two-backend rotation variant is + * `DatabaseRotatingImp`. + * + * Every public method is a thin delegation: to `backend_` for storage + * operations and to base-class helpers for async dispatch, telemetry, and + * bulk import. No business logic lives here. + * + * **Shutdown ordering**: The destructor calls `stop()` to drain all pending + * async reads and wait for worker threads to exit before releasing `backend_`. + * Worker threads invoke the virtual `fetchNodeObject()` hook; if `backend_` + * were released while a thread was active, it would dereference a dangling + * pointer. + * + * @see Database, DatabaseRotatingImp, Backend + */ class DatabaseNodeImp : public Database { public: @@ -14,6 +43,21 @@ public: DatabaseNodeImp& operator=(DatabaseNodeImp const&) = delete; + /** Construct the database and start the async read thread pool. + * + * Asserts that @p backend is non-null, then delegates to the `Database` + * base constructor which spawns `readThreads` detached worker threads. + * + * @param scheduler Task scheduler for async I/O dispatch and telemetry; + * must outlive this object. + * @param readThreads Number of async prefetch threads; clamped to at least 1 + * by the base constructor. + * @param backend Open, non-null backend to use for all storage; shared + * ownership is assumed. + * @param config `[node_db]` config section; forwarded to `Database` + * for `earliest_seq` and `rq_bundle` parsing. + * @param j Logging sink. + */ DatabaseNodeImp( Scheduler& scheduler, int readThreads, @@ -28,48 +72,126 @@ public: "backend"); } + /** Drain pending I/O and release the backend. + * + * Calls `stop()` to wait for all async read worker threads to exit before + * `backend_` is destroyed. This must happen in the derived destructor + * because worker threads call the virtual `fetchNodeObject()` hook, which + * dereferences `backend_`. + */ ~DatabaseNodeImp() override { stop(); } + /** Return the name of the underlying backend for diagnostics. + * + * @return The backend's human-readable identifier (e.g. the on-disk path). + */ std::string getName() const override { return backend_->getName(); } + /** Return the estimated number of pending write operations in the backend. + * + * Approximate; the value may change immediately after it is read. + * + * @return Pending write count, or 0 if the backend does not batch writes. + */ std::int32_t getWriteLoad() const override { return backend_->getWriteLoad(); } + /** Bulk-import all objects from @p source into this database's backend. + * + * Delegates to `importInternal()`, which iterates @p source via `forEach()` + * and stores objects in batches. Large source databases may take significant + * time; no concurrent writes to @p source should occur during the call. + * + * @param source The database to read from; must remain open and quiescent. + */ void importDatabase(Database& source) override { importInternal(*backend_.get(), source); } + /** Persist a node object to the backend. + * + * Updates store telemetry, wraps the payload in a `NodeObject`, and + * forwards to the backend. The ledger sequence parameter is part of the + * `Database` contract but is ignored here — a single backend holds objects + * from all ledger sequences. + * + * @param type Type tag for the object (ledger, account node, etc.). + * @param data Serialized payload; ownership is transferred — the caller's + * variable is left in a valid but unspecified state. + * @param hash 256-bit content-address key. The caller is responsible for + * correctness; the hash is not re-verified. + */ void store(NodeObjectType type, Blob&& data, uint256 const& hash, std::uint32_t) override; + /** Report whether two ledger sequence numbers map to the same backend. + * + * Always returns `true` for `DatabaseNodeImp` because there is exactly one + * backend: every sequence number resolves to the same physical store. This + * allows the async read pool to coalesce duplicate hash requests that carry + * different sequence numbers without issuing a second backend read. + * + * @return `true` unconditionally. + */ bool isSameDB(std::uint32_t, std::uint32_t) override { - // only one database return true; } + /** Flush any buffered writes to durable storage. + * + * Delegates directly to `backend_->sync()`. Not latency-sensitive; + * typically called on ledger close or maintenance paths. + */ void sync() override { backend_->sync(); } + /** Synchronously fetch a batch of node objects by hash. + * + * Calls `backend_->fetchBatch()` directly, bypassing the async read queue. + * Enforces a positional contract: the returned vector is always the same + * length as @p hashes, with null entries for objects not found. Missing + * objects are logged at `error` level. Wall-clock elapsed time is reported + * via `updateFetchMetrics()`; per-slot hit counts are not tracked here and + * remain the caller's responsibility. + * + * @note The batch-level `Status` from the backend is discarded; object + * availability is inferred entirely from null vs. non-null slots. + * @param hashes Ordered list of 256-bit keys to retrieve. + * @return Vector of the same length as @p hashes; null entries indicate + * objects absent from the backend. + */ std::vector> fetchBatch(std::vector const& hashes); + /** Schedule a non-blocking background fetch for a single node object. + * + * Forwards unconditionally to `Database::asyncFetch()`, which coalesces + * duplicate hash requests and dispatches callbacks from the worker thread + * pool. No per-backend routing is needed for the single-backend case. + * + * @param hash 256-bit key of the object to retrieve. + * @param ledgerSeq Ledger sequence the object belongs to; forwarded for + * hash-coalescing decisions via `isSameDB()`. + * @param callback Invoked on a worker thread with the fetched `NodeObject`, + * or `nullptr` on miss or error. + */ void asyncFetch( uint256 const& hash, @@ -77,13 +199,37 @@ public: std::function const&)>&& callback) override; private: - // Persistent key/value storage + /** The single persistent key/value backend that holds all ledger objects. */ std::shared_ptr backend_; + /** Template Method hook called by the base-class public `fetchNodeObject()`. + * + * Delegates to `backend_->fetch()` with structured error logging: + * `Status::Ok` and `Status::NotFound` are silent; `Status::DataCorrupt` + * logs at `fatal`; any other code logs at `warn`. Exceptions from the + * backend are logged at `fatal` then re-raised via `Rethrow()`. Sets + * `fetchReport.wasFound = true` on a hit to feed the base-class metric. + * The ledger sequence parameter is accepted by the signature but unused. + * + * @param hash 256-bit key to look up. + * @param fetchReport Mutable report; `wasFound` is set on a hit. + * @param duplicate Whether this fetch was deduplicated from another + * in-flight request for the same hash; unused in this implementation. + * @return The fetched `NodeObject`, or `nullptr` on miss or error. + * @throws Any exception propagated from `backend_->fetch()` after logging. + */ std::shared_ptr fetchNodeObject(uint256 const& hash, std::uint32_t, FetchReport& fetchReport, bool duplicate) override; + /** Iterate every object in the backend and invoke @p f for each one. + * + * Used exclusively by `importInternal()` for bulk export. Delegates + * directly to `backend_->forEach()`. Not safe for concurrent access with + * reads or writes; see `Backend::forEach()` for details. + * + * @param f Callback invoked with each `NodeObject`; must not be null. + */ void forEach(std::function)> f) override { diff --git a/include/xrpl/nodestore/detail/DatabaseRotatingImp.h b/include/xrpl/nodestore/detail/DatabaseRotatingImp.h index 39441ef4d8..dedd3d144c 100644 --- a/include/xrpl/nodestore/detail/DatabaseRotatingImp.h +++ b/include/xrpl/nodestore/detail/DatabaseRotatingImp.h @@ -6,6 +6,24 @@ namespace xrpl::NodeStore { +/** Concrete two-backend node store that enables online deletion of old ledger data. + * + * Maintains a _writable_ backend (receives all new stores) and an _archive_ + * backend (holds older data). The `SHAMapStore` sweep thread drives rotations: + * when the configured deletion horizon is reached it calls `rotate()`, which + * atomically promotes the current writable to archive, installs a fresh backend + * as the new writable, and schedules the old archive for deletion. + * + * All public methods follow a capture-under-lock / use-outside-lock pattern: + * the mutex protects only the `shared_ptr` swap, not the backend I/O. This + * keeps unrelated readers and writers concurrent during disk operations. + * + * **Thread safety**: all public methods are safe to call from any thread + * concurrently. `stop()` must be called in the derived destructor before + * the base `Database` destructor tears down the async read pool. + * + * @see DatabaseRotating, Database, SHAMapStoreImp + */ class DatabaseRotatingImp : public DatabaseRotating { public: @@ -14,6 +32,20 @@ public: DatabaseRotatingImp& operator=(DatabaseRotatingImp const&) = delete; + /** Construct the rotating database and initialise the async read pool. + * + * Both backends must already be open. Their `fdRequired()` values are + * accumulated into `fdRequired_` so the application can pre-validate the + * process file-descriptor limit before any I/O begins. + * + * @param scheduler Task scheduler for async dispatch and telemetry; + * must outlive this object. + * @param readThreads Number of async read worker threads to spawn. + * @param writableBackend The backend that receives all new stores. + * @param archiveBackend The backend holding older (pre-rotation) data. + * @param config `[node_db]` config section forwarded to `Database`. + * @param j Logging sink. + */ DatabaseRotatingImp( Scheduler& scheduler, int readThreads, @@ -22,48 +54,166 @@ public: Section const& config, beast::Journal j); + /** Destroy the rotating database. + * + * Calls `stop()` before the base destructor so that async worker threads + * stop invoking the virtual `fetchNodeObject()` while derived data members + * are still valid. + */ ~DatabaseRotatingImp() override { stop(); } + /** Atomically swap in a new writable backend, demoting the current one. + * + * The rotation sequence under the mutex is: + * 1. Mark the existing archive backend for on-disk deletion, move it into + * a local to extend its lifetime past the callback. + * 2. Promote the current writable backend to become the new archive. + * 3. Install @p newBackend as the writable backend. + * + * The lock is released before @p f is called. This ordering is critical: + * the callback (in production, `SHAMapStoreImp`) persists the new backend + * names to a SQLite state database. The old archive `shared_ptr` remains + * alive on the stack until after @p f returns, so the archive directory is + * deleted only after the persistent state has been updated — making the + * rotation crash-safe. + * + * @param newBackend Freshly prepared backend to install as the new writable. + * Ownership is transferred; the caller's pointer is null on return. + * @param f Callback invoked after the swap, outside the mutex. + * Receives the new writable name and the new archive name (the former + * writable). Must persist these names to durable storage before + * returning so the node can recover the correct layout after a crash. + * @note The callback is invoked outside the mutex, so other methods + * (including `getName()` and even `rotate()`) may be called from within + * @p f without deadlocking. Re-entering `rotate()` from @p f is + * technically safe but should never occur in production code. + */ void rotate( std::unique_ptr&& newBackend, std::function const& f) override; + /** Return the name of the current writable backend. + * + * Acquires the mutex to take a consistent snapshot of `writableBackend_`. + * + * @return A human-readable identifier for the writable backend. + */ std::string getName() const override; + /** Return the estimated pending write count from the writable backend. + * + * Acquires the mutex to snapshot `writableBackend_`, then queries it + * outside the lock. + * + * @return Pending write count; 0 if the backend does not batch writes. + */ std::int32_t getWriteLoad() const override; + /** Bulk-import all objects from @p source into the current writable backend. + * + * Snapshots `writableBackend_` under the mutex, then delegates to + * `importInternal()`. A rotation that occurs concurrently will not affect + * the import — it continues writing to the backend that was writable when + * it started. + * + * @param source Source database to read from; must remain valid and + * quiescent (no concurrent writes) for the duration of the call. + */ void importDatabase(Database& source) override; + /** Return `true`, since both backends form a single logical namespace. + * + * The async read pool calls this to decide whether two in-flight fetches + * for the same hash (with different ledger sequence numbers) can share a + * single backend read. Because the rotating store presents one logical + * keyspace across both tiers, this always returns `true`. + * + * @return Always `true`. + */ bool isSameDB(std::uint32_t, std::uint32_t) override { - // rotating store acts as one logical database return true; } + /** Store a node object in the current writable backend. + * + * Snapshots `writableBackend_` under the mutex, constructs a `NodeObject` + * from the supplied data, then writes it outside the lock. The ledger + * sequence parameter is accepted for interface compatibility but ignored — + * all writes always go to the current writable backend regardless of age. + * + * @param type Semantic type of the object. + * @param data Serialized payload; moved into the backend. + * @param hash 256-bit content hash; not re-verified. + * @param ledgerSeq Ignored; present for `Database` interface compatibility. + */ void store(NodeObjectType type, Blob&& data, uint256 const& hash, std::uint32_t) override; + /** Flush the writable backend to durable storage. + * + * Holds the mutex for the entire sync call. Acceptable because this is a + * maintenance path, not a latency-sensitive read/write path. + */ void sync() override; private: + /** Active backend; receives all new `store()` calls. */ std::shared_ptr writableBackend_; + + /** Read-only backend holding data from before the last rotation. */ std::shared_ptr archiveBackend_; + + /** Guards swaps of `writableBackend_` and `archiveBackend_`. + * Held only for pointer capture or swap — never across I/O. + */ mutable std::mutex mutex_; + /** Two-tier fetch with optional archive-to-writable promotion. + * + * Snapshots both backend pointers under the mutex, then tries the writable + * backend first. On a miss, tries the archive backend. If the object is + * found in the archive and @p duplicate is `true`, the writable pointer is + * refreshed under the mutex (to handle a concurrent rotation) and the + * object is written back into the current writable tier. + * + * Backend errors are handled conservatively: `DataCorrupt` is logged at + * fatal severity and returns `nullptr` (cache miss); unknown status codes + * are logged at warning level; exceptions are logged and rethrown via + * `Rethrow()`. + * + * @param hash 256-bit content hash of the desired object. + * @param ledgerSeq Ignored; accepted for `Database` virtual interface. + * @param fetchReport Out-param; `wasFound` is set to `true` on a hit. + * @param duplicate When `true`, a hit in the archive is promoted to + * the writable backend. + * @return The found `NodeObject`, or `nullptr` on miss or error. + */ std::shared_ptr fetchNodeObject(uint256 const& hash, std::uint32_t, FetchReport& fetchReport, bool duplicate) override; + /** Visit every object in both backends sequentially. + * + * Snapshots both backend pointers under the mutex, then calls + * `writable->forEach(f)` followed by `archive->forEach(f)` outside the + * lock. Used by `importInternal()` during bulk import. + * + * @param f Callable invoked with each `NodeObject`; must not call any + * method that acquires `mutex_` to avoid deadlock. + * @note Not safe to call concurrently with `rotate()` or other writes if + * the backend's `for_each` re-opens the database (e.g. NuDB). + */ void forEach(std::function)> f) override; }; diff --git a/include/xrpl/nodestore/detail/DecodedBlob.h b/include/xrpl/nodestore/detail/DecodedBlob.h index 90a7b6c9cb..5990253b47 100644 --- a/include/xrpl/nodestore/detail/DecodedBlob.h +++ b/include/xrpl/nodestore/detail/DecodedBlob.h @@ -4,30 +4,84 @@ namespace xrpl::NodeStore { -/** Parsed key/value blob into NodeObject components. - - This will extract the information required to construct a NodeObject. It - also does consistency checking and returns the result, so it is possible - to determine if the data is corrupted without throwing an exception. Not - all forms of corruption are detected so further analysis will be needed - to eliminate false negatives. - - @note This defines the database format of a NodeObject! -*/ +/** Deserializes a raw backend key/value buffer into the components of a + * `NodeObject`. + * + * This is the read-direction half of the NodeStore on-disk format, paired + * with `EncodedBlob`. Together they define the canonical binary schema for + * persisted node objects; any format change must be reflected in both classes. + * + * On-disk layout (canonical reference): + * - Bytes 0–7: Unused prefix. Historically stored a ledger index; written + * as eight zero bytes today and silently ignored on read. + * - Byte 8: `NodeObjectType` discriminant (one-byte enum value). + * - Bytes 9+: Raw serialized object payload. + * + * Validation is intentionally minimal and non-throwing: the constructor sets + * an internal success flag rather than raising an exception, allowing callers + * to handle corruption gracefully (see `wasOk()`). Not all corruption is + * detected — this is a fast sanity check, not a cryptographic integrity proof. + * + * `DecodedBlob` holds non-owning pointers into the caller-supplied buffers; + * the backing storage must remain valid until `createObject()` is called or + * the `DecodedBlob` is destroyed. + * + * @note This class defines the database format of a `NodeObject`. + * @see EncodedBlob for the write-direction counterpart. + */ class DecodedBlob { public: - /** Construct the decoded blob from raw data. */ + /** Parse a raw backend buffer into its constituent NodeObject fields. + * + * Validates the on-disk layout without performing any heap allocation. + * `key_` and `objectData_` are set to non-owning pointers into the + * caller-supplied buffers; the actual payload copy is deferred to + * `createObject()`. The caller must keep both buffers alive for the + * lifetime of this object. + * + * Parsing succeeds (`wasOk()` returns `true`) only when `valueBytes > 9` + * and the type byte at offset 8 is one of the four recognised values: + * `hotUNKNOWN`, `hotLEDGER`, `hotACCOUNT_NODE`, or `hotTRANSACTION_NODE`. + * `hotDUMMY` (value 512) and any unrecognised byte leave the object in a + * failed state without throwing. + * + * @param key Pointer to the 32-byte hash that was used as the + * storage key; not validated or dereferenced here. + * @param value Pointer to the raw value buffer retrieved from the + * backend. + * @param valueBytes Total byte length of `value`. Values of 9 or fewer + * bytes produce a failed parse. + */ DecodedBlob(void const* key, void const* value, int valueBytes); - /** Determine if the decoding was successful. */ + /** Returns `true` if the constructor successfully parsed a well-formed + * buffer with a recognised `NodeObjectType`. + * + * Must be checked before calling `createObject()`. Calling `createObject()` + * on a failed `DecodedBlob` fires `XRPL_ASSERT` in debug builds. + */ [[nodiscard]] bool wasOk() const noexcept { return success_; } - /** Create a NodeObject from this data. */ + /** Allocate and return a `NodeObject` from the previously parsed fields. + * + * Copies the payload slice into an owning `Blob` and reconstructs the + * full hash key from the stored pointer. This is the only heap allocation + * in the decode path. The returned `NodeObject` owns its data + * independently, so the caller may release the backend fetch buffer + * immediately after this call returns. + * + * @pre `wasOk()` must return `true`. Calling this on a failed parse fires + * `XRPL_ASSERT` in debug builds; in release builds a null + * `shared_ptr` is returned as a defensive fallback. + * @return A fully constructed `NodeObject`, or `nullptr` if the parse had + * failed (release-build defensive path — callers must always check + * `wasOk()` first). + */ std::shared_ptr createObject(); diff --git a/include/xrpl/nodestore/detail/EncodedBlob.h b/include/xrpl/nodestore/detail/EncodedBlob.h index 343e1720a0..1f74631b47 100644 --- a/include/xrpl/nodestore/detail/EncodedBlob.h +++ b/include/xrpl/nodestore/detail/EncodedBlob.h @@ -11,51 +11,80 @@ namespace xrpl::NodeStore { -/** Convert a NodeObject from in-memory to database format. - - The (suboptimal) database format consists of: - - - 8 prefix bytes which will typically be 0, but don't assume that's the - case; earlier versions of the code would use these bytes to store the - ledger index either once or twice. - - A single byte denoting the type of the object. - - The payload. - - @note This class is typically instantiated on the stack, so the size of - the object does not matter as much as it normally would since the - allocation is, effectively, free. - - We leverage that fact to preallocate enough memory to handle most - payloads as part of this object, eliminating the need for dynamic - allocation. As of this writing ~94% of objects require fewer than - 1024 payload bytes. +/** Serializes a `NodeObject` to the binary wire format expected by storage + * backends (NuDB, RocksDB). + * + * This is the write-direction half of the NodeStore on-disk format, paired + * with `DecodedBlob`. Together they define the canonical binary schema for + * persisted node objects; any format change must be reflected in both classes. + * + * On-disk layout (canonical reference): + * - Bytes 0–7: Eight zero bytes. Historically stored the ledger index; + * zeroed since that field was removed. Readers must not assume all + * zeros — older databases may contain non-zero values here. + * - Byte 8: `NodeObjectType` cast to a single `uint8_t`. + * - Bytes 9+: Raw serialized payload from `NodeObject::getData()`. + * + * The 32-byte `uint256` hash is the storage key and is kept separate from + * the value payload. `getKey()` and `getData()` expose these two pieces as + * `void const*` pointers suitable for direct hand-off to NuDB or RocksDB + * slice APIs. + * + * Instances are intended to be constructed immediately before a backend + * insert call and destroyed immediately after, keeping any heap-allocated + * overflow buffer alive for exactly as long as needed. + * + * @note This class is non-copyable. `ptr_` is a `const` raw pointer whose + * ownership is conditional: it points into the inline `payload_` buffer + * when the serialized size fits within 1033 bytes (~94% of real objects), + * and into a heap buffer otherwise. Copying would require duplicating + * that conditional ownership, so no copy or move constructor is provided. + * + * @see DecodedBlob for the read-direction counterpart. */ class EncodedBlob { - /** The 32-byte key of the serialized object. */ + /** Storage key: the object's 32-byte `uint256` hash. */ std::array key_{}; - /** A pre-allocated buffer for the serialized object. - - The buffer is large enough for the 9 byte prefix and at least - 1024 more bytes. The precise size is calculated automatically - at compile time so as to avoid wasting space on padding bytes. + /** Inline stack buffer covering the 9-byte header plus up to 1024 bytes + * of payload. + * + * Sized at compile time via `boost::alignment::align_up` to the next + * `uint32_t`-aligned boundary, eliminating any trailing padding that a + * naive `9 + 1024` array would incur. When `size_` does not exceed this + * array's capacity, `ptr_` aliases `payload_.data()` and no heap + * allocation occurs. */ std::array payload_{}; - /** The size of the serialized data. */ + /** Total byte length of the serialized value (header + payload). */ std::uint32_t size_; - /** A pointer to the serialized data. - - This may point to the pre-allocated buffer (if it is sufficiently - large) or to a dynamically allocated buffer. + /** Pointer to the serialized value buffer. + * + * Set once at construction and never changed (`const`). Points into + * `payload_` when `size_ <= payload_.size()`, or into a heap allocation + * otherwise. The destructor uses `ptr_ != payload_.data()` to decide + * whether to `delete[]`. */ std::uint8_t* const ptr_; public: + /** Serialize `obj` into the on-disk wire format. + * + * Fills `key_` with the object's hash, writes the 9-byte header into + * `ptr_`, then copies the payload. If the total serialized size exceeds + * the inline `payload_` buffer capacity the constructor heap-allocates + * an exact-fit buffer; otherwise the inline buffer is used directly. + * + * @param obj The node object to serialize. Must be non-null: a null + * `shared_ptr` fires `XRPL_ASSERT` in debug builds and throws + * `std::runtime_error` in all builds. + * @throws std::runtime_error if `obj` is null. + */ explicit EncodedBlob(std::shared_ptr const& obj) : size_([&obj]() { XRPL_ASSERT(obj, "xrpl::NodeStore::EncodedBlob::EncodedBlob : non-null input"); @@ -73,6 +102,14 @@ public: std::copy_n(obj->getHash().data(), obj->getHash().size(), key_.data()); } + /** Releases any heap-allocated overflow buffer. + * + * If `ptr_` points outside `payload_` (i.e., a heap buffer was + * allocated because the serialized size exceeded 1033 bytes), the buffer + * is freed with `delete[]`. An `XRPL_ASSERT` verifies that the pointer + * and size fields are mutually consistent before the free, catching any + * state drift that would otherwise cause a double-free or memory leak. + */ ~EncodedBlob() { XRPL_ASSERT( @@ -85,18 +122,41 @@ public: delete[] ptr_; } + /** Returns a pointer to the 32-byte storage key (the object's hash). + * + * The pointer is valid for the lifetime of this `EncodedBlob` and may + * be passed directly to NuDB or RocksDB key-slice APIs. + * + * @return `void const*` pointing to the 32-byte key buffer. + */ [[nodiscard]] void const* getKey() const noexcept { return static_cast(key_.data()); } + /** Returns the total byte length of the serialized value buffer. + * + * This is `obj->getData().size() + 9`: nine header bytes (eight + * zero-prefix bytes plus the type byte) followed by the raw payload. + * + * @return Byte count of the buffer returned by `getData()`. + */ [[nodiscard]] std::size_t getSize() const noexcept { return size_; } + /** Returns a pointer to the serialized value buffer. + * + * The buffer layout is: eight zero bytes, one `NodeObjectType` byte, + * then the raw object payload. The pointer is valid for the lifetime of + * this `EncodedBlob` and may be passed directly to NuDB compression + * helpers or RocksDB value-slice APIs. + * + * @return `void const*` pointing to `getSize()` bytes of serialized data. + */ [[nodiscard]] void const* getData() const noexcept { diff --git a/include/xrpl/nodestore/detail/ManagerImp.h b/include/xrpl/nodestore/detail/ManagerImp.h index 98aec6459b..cb754a1f6b 100644 --- a/include/xrpl/nodestore/detail/ManagerImp.h +++ b/include/xrpl/nodestore/detail/ManagerImp.h @@ -1,9 +1,37 @@ +/** @file + * Declares `ManagerImp`, the concrete Meyers-singleton implementation of the + * NodeStore `Manager` interface, hidden in `detail/` as an implementation + * detail not intended for direct use outside the nodestore subsystem. + */ #pragma once #include namespace xrpl::NodeStore { +/** Concrete singleton implementation of the NodeStore backend registry. + * + * `ManagerImp` maintains a runtime registry of `Factory` objects and + * orchestrates `Backend` and `Database` construction from configuration data. + * The four built-in backends (NuDB, RocksDB, Memory, Null) are registered + * during construction by calling their respective `register*Factory` free + * functions, each of which holds a function-local static `Factory` that + * self-registers via `insert()`. This avoids relying on global-variable + * destruction order across translation units, which would be undefined + * behaviour if a `Factory` destructor called `erase()` after `ManagerImp` + * had already been destroyed. + * + * The registry is a `std::vector` of non-owning pointers protected + * by a `std::mutex`. Ownership of each `Factory` remains with the static + * storage managed by the `register*Factory` functions, which are guaranteed + * to outlive this singleton. + * + * @note Callers outside the nodestore subsystem should use `Manager::instance()` + * rather than `ManagerImp::instance()` to avoid depending on this + * implementation-detail type. + * + * @see Manager, Factory, DatabaseNodeImp + */ class ManagerImp : public Manager { private: @@ -11,25 +39,55 @@ private: std::vector list_; public: + /** Return the process-wide ManagerImp singleton. + * + * Uses a Meyers function-local static for thread-safe, once-only + * construction under C++11 and later. All four built-in backend factories + * are registered before the reference is returned for the first time. + * + * @return Reference to the single ManagerImp instance. + */ static ManagerImp& instance(); + /** Throw a user-facing error when the backend configuration is absent or + * names an unrecognised type. + * + * Both the missing-`type`-key and the unrecognised-type code paths in + * `makeBackend()` converge on this helper so the operator-facing message + * is consistent. + * + * @throws std::runtime_error Always — message directs the operator to add + * or correct the `[node_db]` section in `xrpld.cfg`. + */ static void missingBackend(); + /** Register all built-in backend factories. + * + * Calls `registerNuDBFactory`, `registerRocksDBFactory`, + * `registerNullFactory`, and `registerMemoryFactory`. Each function + * creates a function-local static `Factory` that calls `insert()` on this + * manager. The function-local-static lifetime guarantee ensures all + * factories are destroyed before this `ManagerImp`. + */ ManagerImp(); ~ManagerImp() override = default; + /** @copydoc Manager::find */ Factory* find(std::string const& name) override; + /** @copydoc Manager::insert */ void insert(Factory& factory) override; + /** @copydoc Manager::erase */ void erase(Factory& factory) override; + /** @copydoc Manager::makeBackend */ std::unique_ptr makeBackend( Section const& parameters, @@ -37,6 +95,7 @@ public: Scheduler& scheduler, beast::Journal journal) override; + /** @copydoc Manager::makeDatabase */ std::unique_ptr makeDatabase( std::size_t burstSize, diff --git a/include/xrpl/nodestore/detail/codec.h b/include/xrpl/nodestore/detail/codec.h index 4dedc54902..4681b6e95e 100644 --- a/include/xrpl/nodestore/detail/codec.h +++ b/include/xrpl/nodestore/detail/codec.h @@ -1,3 +1,33 @@ +/** @file + * Compression codec for NodeStore blobs written to and read from NuDB. + * + * Every `NodeObject` value stored in the NuDB backend passes through either + * `nodeobjectCompress` or `nodeobjectDecompress`. The on-disk format is a + * leading varint type tag followed by a type-specific payload: + * + * | Tag | Format | + * |-----|--------| + * | 0 | Uncompressed (legacy; readable but never written) | + * | 1 | LZ4-compressed payload | + * | 2 | Sparse inner-node (16-bit presence bitmask + non-zero hashes) | + * | 3 | Full inner-node (all 16 hashes, no bitmask) | + * + * SHAMap inner nodes (exactly 525 bytes with `HashPrefix::InnerNode`) receive + * a specialized encoding that out-performs LZ4 on their typical hash density. + * All other objects are LZ4-compressed (type 1). The codec reconstructs inner + * nodes with `index`, `unused`, and `kind` fields zeroed, so those fields are + * not preserved across a round-trip. + * + * All functions follow the `BufferFactory` pattern: callers supply a callable + * `void*(std::size_t)` that allocates output memory. The codec never frees + * memory; ownership remains with the caller's factory object. + * + * @note This header is an implementation detail of the NuDB backend and the + * NodeStore import tool. It is not part of the public NodeStore API. + * + * @see nodeobjectCompress, nodeobjectDecompress, filterInner + */ + #pragma once // Disable lz4 deprecation warning due to incompatibility with clang attributes @@ -19,6 +49,25 @@ namespace xrpl::NodeStore { +/** Decompress an LZ4-compressed blob produced by `lz4Compress`. + * + * Reads a leading varint that encodes the original uncompressed size, + * allocates exactly that many bytes via `bf`, then calls + * `LZ4_decompress_safe` into the allocated buffer. + * + * @tparam BufferFactory Callable with signature `void*(std::size_t n)` that + * allocates `n` bytes and returns a pointer to them. The codec does not + * free this memory; lifetime is governed by the caller. + * @param in Pointer to the compressed input buffer (varint prefix + LZ4 data). + * @param inSize Number of bytes at `in`. + * @param bf Factory used to allocate the decompressed output buffer. + * @return Pair of (pointer to decompressed data, decompressed byte count). + * The pointer is the buffer returned by `bf`. + * @throws std::runtime_error if `inSize` would overflow `int`, if the leading + * varint is missing or occupies the entire buffer, if the decompressed + * size would overflow `int`, or if `LZ4_decompress_safe` returns a byte + * count that does not match the expected output size. + */ template std::pair lz4Decompress(void const* in, std::size_t inSize, BufferFactory&& bf) @@ -48,6 +97,24 @@ lz4Decompress(void const* in, std::size_t inSize, BufferFactory&& bf) return {out, outSize}; } +/** Compress a raw blob using LZ4 and prepend the uncompressed size as a varint. + * + * Allocates a single output buffer via `bf` sized for the varint prefix plus + * `LZ4_compressBound(inSize)` bytes (worst-case LZ4 output), then writes the + * varint followed by the compressed payload. The returned size reflects the + * actual compressed size, not the worst-case bound. + * + * @tparam BufferFactory Callable with signature `void*(std::size_t n)` that + * allocates `n` bytes and returns a pointer to them. The codec does not + * free this memory; lifetime is governed by the caller. + * @param in Pointer to the uncompressed input data. + * @param inSize Number of bytes at `in`. + * @param bf Factory used to allocate the output buffer. + * @return Pair of (pointer to compressed output, compressed byte count + * including the varint prefix). The pointer is the buffer returned by `bf`. + * @throws std::runtime_error if `LZ4_compress_default` returns 0 (compression + * failure). + */ template std::pair lz4Compress(void const* in, std::size_t inSize, BufferFactory&& bf) @@ -69,17 +136,29 @@ lz4Compress(void const* in, std::size_t inSize, BufferFactory&& bf) return result; } -//------------------------------------------------------------------------------ - -/* - object types: - - 0 = Uncompressed - 1 = lz4 compressed - 2 = inner node compressed - 3 = full inner node -*/ - +/** Decompress a NodeStore blob encoded by `nodeobjectCompress`. + * + * Reads the leading varint type tag and dispatches to the appropriate decoder: + * - Type 0: uncompressed legacy data — returned as a non-owning view into `in`. + * - Type 1: delegates to `lz4Decompress`. + * - Type 2: sparse inner-node — reads a 16-bit bitmask, then reconstructs a + * 525-byte SHAMap inner-node blob with only the non-zero child hashes + * filled in and `index`/`unused`/`kind` fields zeroed. + * - Type 3: full inner-node — reads all 512 bytes of child hashes directly and + * reconstructs the 525-byte blob with metadata fields zeroed. + * + * @tparam BufferFactory Callable with signature `void*(std::size_t n)` that + * allocates `n` bytes and returns a pointer to them. Not invoked for type 0 + * (the returned pointer into `in` is valid only as long as `in` is alive). + * @param in Pointer to the encoded input buffer. + * @param inSize Number of bytes at `in`. + * @param bf Factory used to allocate decoded output for types 1–3. + * @return Pair of (pointer to decoded data, decoded byte count). + * @throws std::runtime_error if the type varint is missing, if any size check + * fails during inner-node reconstruction, or if the type tag is unrecognized. + * @note For type 0 the returned pointer aliases `in`; for types 1–3 it points + * into the buffer supplied by `bf`. + */ template std::pair nodeobjectDecompress(void const* in, std::size_t inSize, BufferFactory&& bf) @@ -184,6 +263,14 @@ nodeobjectDecompress(void const* in, std::size_t inSize, BufferFactory&& bf) return result; } +/** Return a pointer to a zero-initialized 32-byte static buffer. + * + * Used by `nodeobjectCompress` as a sentinel to detect empty child-hash + * slots in a SHAMap inner node via `memcmp`. The buffer is function-local + * static so it is initialized exactly once and lives for the process lifetime. + * + * @return Pointer to a 32-byte buffer whose contents are all zero bytes. + */ template void const* zero32() @@ -192,6 +279,31 @@ zero32() return kV.data(); } +/** Compress a raw NodeStore blob into the NodeStore on-disk wire format. + * + * Detects SHAMap inner nodes (exactly 525 bytes with `HashPrefix::InnerNode` + * at byte offset 9) and applies a specialized encoding: + * - Sparse (type 2): fewer than 16 child slots occupied — stores a 16-bit + * presence bitmask (bit 0x8000 = slot 0) followed by only the non-zero + * hashes packed contiguously. + * - Full (type 3): all 16 slots occupied — stores all 512 hash bytes directly, + * skipping the bitmask. + * + * All other blobs are LZ4-compressed (type 1) via `lz4Compress`. Type 0 + * (uncompressed) is never written; the `kCODEC_TYPE` constant is fixed at 1. + * + * @tparam BufferFactory Callable with signature `void*(std::size_t n)` that + * allocates `n` bytes and returns a pointer to them. The codec does not + * free this memory; lifetime is governed by the caller. + * @param in Pointer to the uncompressed NodeStore blob. + * @param inSize Number of bytes at `in`. + * @param bf Factory used to allocate the encoded output buffer. + * @return Pair of (pointer to encoded data, encoded byte count). + * @throws std::runtime_error if LZ4 compression fails. + * @note Inner-node reconstruction zeros `index`, `unused`, and `kind` fields, + * so those fields are not preserved across a compress/decompress round-trip. + * Call `filterInner` on the source blob before round-trip verification. + */ template std::pair nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) @@ -199,7 +311,6 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) using std::runtime_error; using namespace nudb::detail; - // Check for inner node v1 if (inSize == 525) { istream is(in, inSize); @@ -228,7 +339,6 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) std::pair result; if (n < 16) { - // 2 = v1 inner node compressed auto const type = 2U; auto const vs = sizeVarint(type); result.second = vs + field::size + // mask @@ -241,7 +351,6 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) write(os, vh.data(), n * 32); return result; } - // 3 = full v1 inner node auto const type = 3U; auto const vs = sizeVarint(type); result.second = vs + (n * 32); // hashes @@ -261,7 +370,6 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) std::pair result; switch (kCODEC_TYPE) { - // case 0 was uncompressed data; we always compress now. case 1: // lz4 { std::uint8_t* p = nullptr; @@ -280,17 +388,29 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) return result; } -// Modifies an inner node to erase the ledger -// sequence and type information so the codec -// verification can pass. -// +/** Normalize an inner-node blob in place before codec round-trip verification. + * + * `nodeobjectCompress` reconstructs inner nodes with `index`, `unused`, and + * `kind` zeroed (those fields are not stored on disk). Comparing a raw source + * blob against the decompressed output would therefore fail unless the source + * is first normalized by zeroing the same fields. This function performs that + * normalization in place. + * + * The function is a no-op for any blob that is not exactly 525 bytes or does + * not carry the `HashPrefix::InnerNode` marker at byte offset 9. + * + * @param in Pointer to the blob to normalize. Modified in place when the blob + * is identified as a SHAMap inner node. + * @param inSize Number of bytes at `in`. + * @note This function is used by the NodeStore import tool prior to calling + * `nodeobjectCompress` so that the verification `memcmp` succeeds. + */ template void filterInner(void* in, std::size_t inSize) { using namespace nudb::detail; - // Check for inner node if (inSize == 525) { istream is(in, inSize); diff --git a/include/xrpl/nodestore/detail/varint.h b/include/xrpl/nodestore/detail/varint.h index c98b36e322..fa3ce158e7 100644 --- a/include/xrpl/nodestore/detail/varint.h +++ b/include/xrpl/nodestore/detail/varint.h @@ -1,3 +1,23 @@ +/** @file + * Variable-length integer (varint) encoding for the NodeStore serialization + * layer. + * + * Provides a base-127 variant of the Protocol Buffers LEB128 varint format. + * Small values (0–126) occupy exactly one byte; larger values expand up to + * 10 bytes for a full 64-bit quantity. Used by `codec.h` for two purposes: + * the one-byte object-type discriminant prefix on every stored blob, and the + * decompressed-size prefix that precedes LZ4-compressed payloads. + * + * @note The encoding uses base-127, not the standard base-128, so the byte + * value `0x7F` never appears as a payload byte. The continuation flag + * remains bit 7 (`0x80`), matching the structural appearance of protobuf + * varints. + * + * @note All multi-definition functions (`readVarint`, `writeVarint`) are + * function templates with a defaulted `` parameter solely + * to satisfy the ODR when the header is included in multiple translation + * units. They carry no template behaviour beyond that. + */ #pragma once #include @@ -7,30 +27,60 @@ namespace xrpl::NodeStore { -// This is a variant of the base128 varint format from -// google protocol buffers: -// https://developers.google.com/protocol-buffers/docs/encoding#varints - -// field tag +/** Tag type used to select the varint overloads of `read` and `write`. + * + * Pass as the explicit template argument at call sites: + * @code + * read(is, u); + * write(os, type); + * @endcode + * The tag distinguishes these overloads from NuDB's built-in typed + * `read`/`write` functions for `uint8_t`, `uint16_t`, etc. + */ struct varint; -// Metafuncton to return largest -// possible size of T represented as varint. -// T must be unsigned +/** Compile-time upper bound on the encoded byte width of type `T`. + * + * `kMAX` is the maximum number of bytes that any value of unsigned type `T` + * can occupy when encoded as a base-127 varint. Use it to allocate + * stack-local buffers without dynamic allocation: + * @code + * std::array::kMAX> buf{}; + * @endcode + * + * @tparam T An unsigned integer type. Instantiation with a signed type is + * disabled via SFINAE. + */ template > struct varint_traits; +/** Specialisation enabled for unsigned types. */ template struct varint_traits { explicit varint_traits() = default; + /** Maximum encoded byte count for type `T` under base-127 encoding. */ static std::size_t constexpr kMAX = (8 * sizeof(T) + 6) / 7; }; -// Returns: Number of bytes consumed or 0 on error, -// if the buffer was too small or t overflowed. -// +/** Decode a base-127 varint from a raw byte buffer. + * + * Scans `buf` for continuation bytes (bit 7 set), then decodes using + * Horner's method from most-significant to least-significant byte so that + * `t = t * 127 + (byte & 0x7F)` reconstructs the original value. + * + * @param buf Pointer to the first byte of the encoded varint. + * @param buflen Number of bytes available in `buf`. + * @param t Output parameter set to the decoded value on success; + * unmodified on error. + * @return Number of bytes consumed from `buf`, or `0` on error. Error + * conditions: `buflen == 0`, the continuation chain extends past + * `buflen`, or arithmetic overflow during accumulation. + * @note The zero value is handled as a special case because the + * overflow guard (`t <= t0`) would otherwise trigger spuriously when + * `t` remains zero after processing a single zero byte. + */ template std::size_t readVarint(void const* buf, std::size_t buflen, std::size_t& t) @@ -67,6 +117,16 @@ readVarint(void const* buf, std::size_t buflen, std::size_t& t) return used; } +/** Compute the encoded byte width of `v` without writing anything. + * + * Mirrors the byte count that `writeVarint` would return for the same value. + * Use this to pre-compute output buffer sizes before encoding. + * + * @tparam T An unsigned integer type. + * @param v The value whose encoded size is needed. + * @return Number of bytes required to encode `v` as a base-127 varint + * (always >= 1). + */ template >* = nullptr> std::size_t sizeVarint(T v) @@ -80,6 +140,17 @@ sizeVarint(T v) return n; } +/** Encode `v` into the buffer at `p0` as a base-127 varint. + * + * Writes bytes in least-significant-first order. Each byte carries a 7-bit + * payload in bits 0–6 (range 0–126); bit 7 is set on all bytes except the + * last, signalling that more bytes follow. + * + * @param p0 Destination buffer. Must have capacity of at least + * `sizeVarint(v)` bytes; no bounds check is performed. + * @param v The value to encode. + * @return Number of bytes written (same as `sizeVarint(v)`). + */ template std::size_t writeVarint(void* p0, std::size_t v) @@ -97,8 +168,17 @@ writeVarint(void* p0, std::size_t v) return p - reinterpret_cast(p0); } -// input stream - +/** Read a varint from a NuDB input stream into `u`. + * + * Advances the stream one byte at a time until a byte without the + * continuation bit is consumed, then delegates to `readVarint` over the + * accumulated span. + * + * @tparam T Must be `varint`; the tag selects this overload over NuDB's + * built-in typed `read` functions. + * @param is The NuDB input stream to read from. + * @param u Output parameter set to the decoded value. + */ template >* = nullptr> void read(nudb::detail::istream& is, std::size_t& u) @@ -110,8 +190,16 @@ read(nudb::detail::istream& is, std::size_t& u) readVarint(p0, p1 - p0, u); } -// output stream - +/** Write `t` as a varint into a NuDB output stream. + * + * Reserves exactly `sizeVarint(t)` bytes in the stream and encodes `t` + * directly into that region via `writeVarint`. + * + * @tparam T Must be `varint`; the tag selects this overload over NuDB's + * built-in typed `write` functions. + * @param os The NuDB output stream to write into. + * @param t The value to encode. + */ template >* = nullptr> void write(nudb::detail::ostream& os, std::size_t t) diff --git a/include/xrpl/protocol/AMMCore.h b/include/xrpl/protocol/AMMCore.h index 9d8f8c62b0..07c1bc386b 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -1,3 +1,13 @@ +/** @file + * Protocol-level constants, LP-token identity derivation, input-validation + * helpers, and fee-conversion utilities for the XRP Ledger Automated Market + * Maker (AMM) feature. + * + * Every AMM transactor (`AMMCreate`, `AMMDeposit`, `AMMWithdraw`, `AMMBid`, + * `AMMVote`) and `AMMHelpers.h` depend on this header as the single + * authoritative source for numeric parameter encoding and preflight checks. + */ + #pragma once #include @@ -8,40 +18,116 @@ namespace xrpl { +/** Maximum trading fee, in tenths of a basis point. + * + * Fee integers are in the range `[0, kTRADING_FEE_THRESHOLD]` where + * 1 unit = 0.001% (1/10 bps) and 1000 = 1%. + */ std::uint16_t constexpr kTRADING_FEE_THRESHOLD = 1000; // 1% -// Auction slot +// --- Auction slot parameters --- + +/** Duration of a single auction slot window, in seconds (24 hours). */ std::uint32_t constexpr kTOTAL_TIME_SLOT_SECS = 24 * 3600; + +/** Number of equal time intervals the 24-hour auction window is divided into. + * + * The slot index (0–19) determines how much of the bid price is refunded to + * the outgoing holder when a new bidder takes over mid-window. + */ std::uint16_t constexpr kAUCTION_SLOT_TIME_INTERVALS = 20; + +/** Maximum number of additional accounts a slot holder may authorise to trade + * at the discounted fee. + */ std::uint16_t constexpr kAUCTION_SLOT_MAX_AUTH_ACCOUNTS = 4; + +/** Divisor used to convert a fee integer to the fee fraction `f`. + * + * `f = tfee / kAUCTION_SLOT_FEE_SCALE_FACTOR`. Chosen so that + * `kTRADING_FEE_THRESHOLD / kAUCTION_SLOT_FEE_SCALE_FACTOR == 0.01` (1%). + */ std::uint32_t constexpr kAUCTION_SLOT_FEE_SCALE_FACTOR = 100000; + +/** Denominator for the slot holder's discounted fee. + * + * The effective fee for a slot holder is `tradingFee / kAUCTION_SLOT_DISCOUNTED_FEE_FRACTION`. + */ std::uint32_t constexpr kAUCTION_SLOT_DISCOUNTED_FEE_FRACTION = 10; + +/** Denominator used to compute the minimum bid price for the auction slot. + * + * Minimum bid = `lptAMMBalance × tradingFee / kAUCTION_SLOT_MIN_FEE_FRACTION`. + */ std::uint32_t constexpr kAUCTION_SLOT_MIN_FEE_FRACTION = 25; + +/** Duration of one auction slot interval, in seconds (72 minutes). + * + * Derived as `kTOTAL_TIME_SLOT_SECS / kAUCTION_SLOT_TIME_INTERVALS`. + */ std::uint32_t constexpr kAUCTION_SLOT_INTERVAL_DURATION = kTOTAL_TIME_SLOT_SECS / kAUCTION_SLOT_TIME_INTERVALS; -// Votes +// --- Fee-governance vote parameters --- + +/** Maximum number of simultaneous fee-vote records in an AMM object. */ std::uint16_t constexpr kVOTE_MAX_SLOTS = 8; + +/** Scale factor for LP vote weights. + * + * Each LP's proportional vote weight is stored as an integer in + * `[0, kVOTE_WEIGHT_SCALE_FACTOR]`, avoiding division until the + * weighted-average fee is computed. + */ std::uint32_t constexpr kVOTE_WEIGHT_SCALE_FACTOR = 100000; class STObject; class STAmount; class Rules; -/** Calculate Liquidity Provider Token (LPT) Currency. +/** Derive the deterministic LP token `Currency` code for an asset pair. + * + * The two assets are sorted canonically before hashing, so + * `ammLPTCurrency(a, b) == ammLPTCurrency(b, a)` for any asset pair. + * The resulting 20-byte currency has `0x03` as its first byte (the AMM + * currency sentinel), followed by 19 bytes taken from + * `sha512Half(canonicalId(min), canonicalId(max))`. For IOU/XRP assets the + * canonical identifier is the `Currency` field; for MPT assets it is the + * `MPTID`. + * + * @param asset1 One of the two pool assets. + * @param asset2 The other pool asset. + * @return A `Currency` value that uniquely identifies the LP token for this + * pair on the ledger and is distinct from any normal IOU or XRP currency. */ Currency ammLPTCurrency(Asset const& asset1, Asset const& asset2); -/** Calculate LPT Issue from AMM asset pair. +/** Construct the full LP token `Issue` (currency + issuer) for an asset pair. + * + * Combines the deterministic currency from `ammLPTCurrency` with the AMM + * account's `AccountID` to produce the `Issue` that `STAmount` operations + * require. + * + * @param asset1 One of the two pool assets. + * @param asset2 The other pool asset. + * @param ammAccountID The `AccountID` of the AMM ledger object. + * @return An `Issue` identifying the LP token for this AMM pool. */ Issue ammLPTIssue(Asset const& asset1, Asset const& asset2, AccountID const& ammAccountID); -/** Validate the amount. - * If validZero is false and amount is beast::zero then invalid amount. - * Return error code if invalid amount. - * If pair then validate amount's issue matches one of the pair's issue. +/** Validate an `STAmount` for use in an AMM transaction (preflight check). + * + * Delegates asset-level validation to `invalidAMMAsset`, then additionally + * rejects negative values and, unless `validZero` is true, zero values. + * + * @param amount The amount to validate. + * @param pair When provided, the amount's asset must match one of the + * two assets in the pair; otherwise `temBAD_AMM_TOKENS` is returned. + * @param validZero If `false` (the default), a zero amount is rejected with + * `temBAD_AMOUNT`. + * @return `tesSUCCESS` if valid; a `tem*` error code otherwise. */ NotTEC invalidAMMAmount( @@ -49,30 +135,81 @@ invalidAMMAmount( std::optional> const& pair = std::nullopt, bool validZero = false); +/** Validate a single asset for use in an AMM transaction (preflight check). + * + * - MPT assets with a zero issuer → `temBAD_MPT`. + * - XRP with a non-zero issuer → `temBAD_ISSUER`. + * - Malformed currency codes → `temBAD_CURRENCY`. + * - Asset not matching either element of `pair` (when provided) → `temBAD_AMM_TOKENS`. + * + * @param asset The asset to validate. + * @param pair When provided, `asset` must equal `pair->first` or + * `pair->second`; used to confirm the asset belongs to a specific pool. + * @return `tesSUCCESS` if valid; a `tem*` error code otherwise. + */ NotTEC invalidAMMAsset( Asset const& asset, std::optional> const& pair = std::nullopt); +/** Validate a pair of assets for use in an AMM transaction (preflight check). + * + * Rejects identical assets (`temBAD_AMM_TOKENS`) before delegating each + * asset to `invalidAMMAsset`. + * + * @param asset1 First asset of the pair. + * @param asset2 Second asset of the pair. + * @param pair When provided, each asset must match one element of this + * known-good pair; passed through to `invalidAMMAsset`. + * @return `tesSUCCESS` if valid; a `tem*` error code otherwise. + */ NotTEC invalidAMMAssetPair( Asset const& asset1, Asset const& asset2, std::optional> const& pair = std::nullopt); -/** Get time slot of the auction slot. +/** Compute the zero-based time-slot index for an active auction slot. + * + * Derives the slot start from `auctionSlot[sfExpiration] - kTOTAL_TIME_SLOT_SECS`, + * then integer-divides elapsed seconds by `kAUCTION_SLOT_INTERVAL_DURATION`. + * Returns `std::nullopt` when `current` is before the slot start or at or + * after `sfExpiration`, indicating the slot has expired or has not yet begun. + * + * @param current Current ledger time (NetClock seconds). + * @param auctionSlot The `STObject` representing the AMM's auction slot; + * must contain `sfExpiration`. + * @return Slot index in `[0, kAUCTION_SLOT_TIME_INTERVALS)`, or + * `std::nullopt` if the slot is not currently active. + * @note An `XRPL_ASSERT` fires if `sfExpiration < kTOTAL_TIME_SLOT_SECS`, + * which is considered an impossible ledger state. */ std::optional ammAuctionTimeSlot(std::uint64_t current, STObject const& auctionSlot); -/** Return true if required AMM amendments are enabled +/** Return true if the network has enabled both AMM amendments. + * + * Requires both `featureAMM` and `fixUniversalNumber`. The second + * amendment is a hard dependency: AMM arithmetic relies on the corrected + * high-precision numeric library introduced by `fixUniversalNumber`, and + * allowing AMM transactions on networks without it would cause overflow or + * precision loss in intermediate swap calculations. + * + * @param rules Snapshot of currently enabled amendments. + * @return `true` only when both `featureAMM` and `fixUniversalNumber` are + * active. */ bool ammEnabled(Rules const&); -/** Convert to the fee from the basis points - * @param tfee trading fee in {0, 1000} - * 1 = 1/10bps or 0.001%, 1000 = 1% +/** Convert a trading fee integer to the fee fraction `f`. + * + * Divides `tfee` by `kAUCTION_SLOT_FEE_SCALE_FACTOR` (100,000) to produce + * the dimensionless fraction used in swap arithmetic. At the maximum fee + * `kTRADING_FEE_THRESHOLD = 1000`, `getFee` returns `0.01` (1%). + * + * @param tfee Trading fee integer in `[0, kTRADING_FEE_THRESHOLD]`. + * @return Fee fraction `f = tfee / 100000`. */ inline Number getFee(std::uint16_t tfee) @@ -80,8 +217,15 @@ getFee(std::uint16_t tfee) return Number{tfee} / kAUCTION_SLOT_FEE_SCALE_FACTOR; } -/** Get fee multiplier (1 - tfee) - * @tfee trading fee in basis points +/** Compute the full-fee swap multiplier `(1 - f)`. + * + * Applied to the input amount when the complete trading fee is charged, + * i.e. during ordinary swaps. In `AMMDeposit`, this is the `f1` factor + * in the single-asset constant-product formula. + * + * @param tfee Trading fee integer in `[0, kTRADING_FEE_THRESHOLD]`. + * @return `1 - getFee(tfee)`. + * @see feeMultHalf */ inline Number feeMult(std::uint16_t tfee) @@ -89,8 +233,15 @@ feeMult(std::uint16_t tfee) return 1 - getFee(tfee); } -/** Get fee multiplier (1 - tfee / 2) - * @tfee trading fee in basis points +/** Compute the half-fee swap multiplier `(1 - f/2)`. + * + * Used during single-asset deposits where only half the implied fee is + * deducted. In `AMMDeposit`, the combined factor is + * `f2 = feeMultHalf(tfee) / feeMult(tfee)`. + * + * @param tfee Trading fee integer in `[0, kTRADING_FEE_THRESHOLD]`. + * @return `1 - getFee(tfee) / 2`. + * @see feeMult */ inline Number feeMultHalf(std::uint16_t tfee) diff --git a/include/xrpl/protocol/AccountID.h b/include/xrpl/protocol/AccountID.h index 0b15f651bc..7dc18dcd85 100644 --- a/include/xrpl/protocol/AccountID.h +++ b/include/xrpl/protocol/AccountID.h @@ -1,3 +1,8 @@ +/** @file + * Defines the AccountID type, serialization helpers, sentinel constants, + * and the optional base58 encoding cache for XRP Ledger account identities. + */ + #pragma once #include @@ -16,6 +21,12 @@ namespace xrpl { namespace detail { +/** Phantom tag type that makes AccountID a distinct strong type. + * + * Passed as the second template argument to `BaseUInt<160, Tag>` so that + * a 160-bit account hash cannot be silently used where a raw hash or node ID + * is expected, and vice versa. The class has no data members or behaviour. + */ class AccountIDTag { public: @@ -24,47 +35,109 @@ public: } // namespace detail -/** A 160-bit unsigned that uniquely identifies an account. */ +/** A 160-bit identifier that uniquely addresses an XRP Ledger account. + * + * Stored as five `uint32_t` values in big-endian byte order — a layout + * that is part of the binary serialization protocol and cannot be changed. + * Derived from a public key via SHA-256 + RIPEMD-160 (`calcAccountID()`). + * + * The phantom tag `detail::AccountIDTag` makes this a distinct C++ type, + * preventing accidental mixing with other 160-bit quantities at compile time. + * + * @see calcAccountID(), toBase58(), parseBase58() + */ using AccountID = BaseUInt<160, detail::AccountIDTag>; -/** Convert AccountID to base58 checked string */ +/** Encode an AccountID as a Base58Check string. + * + * Prepends `TokenType::AccountID` (value 0) before encoding. When the + * global cache has been initialised via `initAccountIdCache()`, the result + * is served from the cache to avoid repeated SHA-256 checksum computation. + * + * @param v The account identifier to encode. + * @return The Base58Check-encoded string (always 25–34 printable characters). + * @see initAccountIdCache(), parseBase58() + */ std::string toBase58(AccountID const& v); -/** Parse AccountID from checked, base58 string. - @return std::nullopt if a parse error occurs -*/ +/** Decode a Base58Check string into an AccountID. + * + * Validates the `TokenType::AccountID` prefix and requires the decoded + * payload to be exactly 20 bytes. Input that fails either check returns + * `std::nullopt` rather than throwing, because external input is frequently + * untrusted. + * + * @param s The Base58Check-encoded account string to parse. + * @return The decoded AccountID, or `std::nullopt` on any parse failure. + * @see toBase58() + */ template <> std::optional parseBase58(std::string const& s); -/** Compute AccountID from public key. - - The account ID is computed as the 160-bit hash of the - public key data. This excludes the version byte and - guard bytes included in the base58 representation. - -*/ +/** Compute the AccountID for a public key using SHA-256 + RIPEMD-160. + * + * Applies `RipeshaHasher` to the raw public-key bytes (no version byte). + * The double-hash matches Bitcoin's derivation: SHA-256 prevents + * length-extension attacks, and RIPEMD-160 is considered safe at 160 bits. + * XRPL adopted the scheme to avoid any claim of weaker security relative + * to Bitcoin. + * + * @note Declaration lives in `PublicKey.h`; the implementation is in + * `AccountID.cpp`. + */ // VFALCO In PublicKey.h for now // AccountID // calcAccountID (PublicKey const& pk); -/** A special account that's used as the "issuer" for XRP. */ +/** Return the canonical XRP issuer sentinel: the all-zero AccountID. + * + * Used as the issuer field in XRP `STAmount` values. Code that needs to + * test whether an amount is native XRP should prefer checking the native + * flag or the currency directly rather than comparing the issuer against + * this value — see the deprecated `isXRP(AccountID)` overload. + * + * @return A function-local static `AccountID` equal to `beast::kZERO`. + * Returned by `const&` to avoid copies; lifetime is the process lifetime. + */ AccountID const& xrpAccount(); -/** A placeholder for empty accounts. */ +/** Return the "no account" sentinel: `AccountID(1)`. + * + * Used as a placeholder in offer and trust-line fields that have no + * meaningful account value (e.g., an uninitialized or absent counterparty). + * Distinct from `xrpAccount()` (all zeros) so the two sentinels cannot + * be confused. + * + * @return A function-local static `AccountID` with value 1. + * Returned by `const&` to avoid copies; lifetime is the process lifetime. + */ AccountID const& noAccount(); -/** Convert hex or base58 string to AccountID. - - @return `true` if the parsing was successful. -*/ +/** Parse a hex or Base58Check string into an AccountID. + * + * Tries hex first (`parseHex`), then falls back to Base58Check. Used + * in legacy configuration parsing where the encoding is not guaranteed. + * + * @param issuer Output: receives the parsed AccountID on success. + * @param s The hex (40 chars) or Base58Check string to parse. + * @return `true` if parsing succeeded and `issuer` was written. + * @deprecated Prefer `parseBase58()` for user-facing input. + */ // DEPRECATED bool toIssuer(AccountID&, std::string const&); +/** Test whether an AccountID equals the XRP issuer sentinel (all zeros). + * + * @param c The account identifier to test. + * @return `true` if `c` equals `beast::kZERO` (i.e., equals `xrpAccount()`). + * @deprecated Check the currency field or the native/integral flag instead; + * relying on the zero-account-as-issuer convention is a leaky abstraction. + */ // DEPRECATED Should be checking the currency or native flag inline bool isXRP(AccountID const& c) @@ -72,6 +145,12 @@ isXRP(AccountID const& c) return c == beast::kZERO; } +/** Convert an AccountID to its Base58Check string representation. + * + * @param account The account identifier to convert. + * @return The Base58Check-encoded string. + * @deprecated Use `toBase58()` directly. + */ // DEPRECATED inline std::string to_string(AccountID const& account) @@ -79,6 +158,14 @@ to_string(AccountID const& account) return toBase58(account); } +/** Write the Base58Check encoding of an AccountID to an output stream. + * + * @param os The stream to write to. + * @param x The account identifier to encode. + * @return `os`, to allow chaining. + * @deprecated Prefer explicit `toBase58()` calls; stream output silently + * invokes Base58 encoding and can be surprising in logging contexts. + */ // DEPRECATED inline std::ostream& operator<<(std::ostream& os, AccountID const& x) @@ -87,17 +174,22 @@ operator<<(std::ostream& os, AccountID const& x) return os; } -/** Initialize the global cache used to map AccountID to base58 conversions. - - The cache is optional and need not be initialized. But because conversion - is expensive (it requires a SHA-256 operation) in most cases the overhead - of the cache is worth the benefit. - - @param count The number of entries the cache should accommodate. Zero will - disable the cache, releasing any memory associated with it. - - @note The function will only initialize the cache the first time it is - invoked. Subsequent invocations do nothing. +/** Initialize the global AccountID → Base58Check encoding cache. + * + * Base58Check encoding requires a SHA-256 checksum on every call, which is + * expensive at transaction-processing throughput. The cache uses a + * direct-mapped open-addressing table with 64 spinlocks packed into a single + * `atomic` (via `PackedSpinlock`) to allow concurrent access with + * minimal memory overhead. The index hash is `hardened_hash<>` (DoS- + * resistant seeded hash) to prevent crafted workloads from degrading lookups. + * + * The cache is strictly optional: if never initialised, `toBase58()` falls + * through to `encodeBase58Token` on every call. + * + * @param count The number of cache slots to allocate. Pass 0 to leave the + * cache disabled (no-op if already disabled). + * @note This function initialises the cache at most once. Subsequent calls + * with any `count` value are silently ignored. */ void initAccountIdCache(std::size_t count); @@ -106,6 +198,21 @@ initAccountIdCache(std::size_t count); //------------------------------------------------------------------------------ namespace json { + +/** Extract and parse an AccountID from a JSON object field. + * + * Reads `field` from `v` as a string, then decodes it as a Base58Check + * account address. Throws `JsonTypeMismatchError` if the field is absent, + * not a string, or not a valid AccountID encoding — the same error type + * raised for any other JSON type mismatch, enabling uniform error handling + * in RPC and transaction-parsing code. + * + * @param v The JSON object to read from. + * @param field The SField identifying the key to look up. + * @return The decoded AccountID. + * @throws JsonTypeMismatchError if the field is missing, not a string, or + * cannot be decoded as a valid Base58Check AccountID. + */ template <> inline xrpl::AccountID getOrThrow(json::Value const& v, xrpl::SField const& field) @@ -123,6 +230,15 @@ getOrThrow(json::Value const& v, xrpl::SField const& field) namespace std { +/** `std::hash` specialization for AccountID, delegating to `hardened_hash<>`. + * + * Maintains compatibility with standard-library unordered containers that + * key on `AccountID`. The underlying hasher uses a random seed (DoS- + * resistant), so hash values differ across process restarts. + * + * @deprecated Prefer `beast::uhash` or XRPL's hardened unordered containers + * (`UnorderedMap`, `UnorderedSet`) for new code. + */ // DEPRECATED // VFALCO Use beast::uhash or a hardened container template <> diff --git a/include/xrpl/protocol/AmountConversions.h b/include/xrpl/protocol/AmountConversions.h index 53b7dace3c..dc0ae988ec 100644 --- a/include/xrpl/protocol/AmountConversions.h +++ b/include/xrpl/protocol/AmountConversions.h @@ -1,5 +1,17 @@ #pragma once +/** @file + * Conversion utilities between the four XRPL amount representations. + * + * The protocol defines four amount types, each optimized for a different + * concern: `XRPAmount` (integer drops), `MPTAmount` (integer MPT units), + * `IOUAmount` (normalized floating-point), and `STAmount` (wire-level union + * over all three). Generic algorithms — AMM pricing, pathfinding, offer + * crossing — need to work across all four without duplicating logic. This + * header provides the glue: inline conversion functions that move freely + * between representations. No arithmetic or business logic lives here. + */ + #include #include #include @@ -9,6 +21,20 @@ namespace xrpl { +/** Wrap an `IOUAmount` in a serializable `STAmount` tagged with the given asset. + * + * `IOUAmount` stores a signed mantissa; `STAmount` stores an unsigned mantissa + * with a separate sign bit. This overload performs that split manually and + * constructs via `STAmount::Unchecked()` to skip re-canonicalization — + * `IOUAmount` is already normalized so re-canonicalizing would be wasted work. + * + * @param iou The IOU value to wrap. + * @param asset The asset identity to embed; must hold an `Issue` (not XRP + * or MPT) — verified by assertion. + * @return An `STAmount` encoding the same value and sign as `iou`. + * @note Passing an XRP or MPT asset silently produces wrong data in release + * builds; the assertion catches this only in debug builds. + */ inline STAmount toSTAmount(IOUAmount const& iou, Asset const& asset) { @@ -18,12 +44,26 @@ toSTAmount(IOUAmount const& iou, Asset const& asset) return STAmount(asset, umant, iou.exponent(), isNeg, STAmount::Unchecked()); } +/** Wrap an `IOUAmount` in an `STAmount` with a placeholder `noIssue()` asset. + * + * Convenience overload for contexts where the true asset identity is not + * available at the call site. The resulting `STAmount` carries `noIssue()` + * as its asset tag and should not be used in wire serialization. + * + * @param iou The IOU value to wrap. + * @return An `STAmount` encoding the value of `iou` with `noIssue()` asset. + */ inline STAmount toSTAmount(IOUAmount const& iou) { return toSTAmount(iou, noIssue()); } +/** Wrap an `XRPAmount` in a serializable `STAmount`. + * + * @param xrp The XRP drop count to wrap; may be negative. + * @return A native `STAmount` encoding the same value and sign as `xrp`. + */ inline STAmount toSTAmount(XRPAmount const& xrp) { @@ -32,6 +72,16 @@ toSTAmount(XRPAmount const& xrp) return STAmount(umant, isNeg); } +/** Wrap an `XRPAmount` in an `STAmount` given an explicit `Asset`. + * + * Exists to give generic code a uniform `toSTAmount(amount, asset)` call + * signature; delegates immediately to the asset-less overload after asserting + * that `asset` is XRP. + * + * @param xrp The XRP drop count to wrap. + * @param asset Must be the XRP asset — verified by assertion. + * @return A native `STAmount` encoding the same value as `xrp`. + */ inline STAmount toSTAmount(XRPAmount const& xrp, Asset const& asset) { @@ -39,12 +89,25 @@ toSTAmount(XRPAmount const& xrp, Asset const& asset) return toSTAmount(xrp); } +/** Wrap an `MPTAmount` in an `STAmount` with a placeholder `noMPT()` asset. + * + * @param mpt The MPT unit count to wrap. + * @return An `STAmount` encoding the value of `mpt` with `noMPT()` asset. + */ inline STAmount toSTAmount(MPTAmount const& mpt) { return STAmount(mpt, noMPT()); } +/** Wrap an `MPTAmount` in an `STAmount` tagged with the given MPT asset. + * + * @param mpt The MPT unit count to wrap. + * @param asset The asset identity to embed; must hold an `MPTIssue` — + * verified by assertion. + * @return An `STAmount` encoding the value of `mpt` with the given + * `MPTIssue` identity. + */ inline STAmount toSTAmount(MPTAmount const& mpt, Asset const& asset) { @@ -52,10 +115,24 @@ toSTAmount(MPTAmount const& mpt, Asset const& asset) return STAmount(mpt, asset.get()); } +/** Primary template for `STAmount` → lean-type extraction; intentionally deleted. + * + * Calling `toAmount(stamt)` with an unsupported `T` is a hard compile + * error rather than a linker error or silent mis-conversion. Only the + * explicit specializations below (`STAmount`, `IOUAmount`, `XRPAmount`, + * `MPTAmount`) are valid. + * + * @tparam T Target amount type. + */ template T toAmount(STAmount const& amt) = delete; +/** Identity conversion: return the `STAmount` unchanged. + * + * @param amt The `STAmount` to return. + * @return `amt` unchanged. + */ template <> inline STAmount toAmount(STAmount const& amt) @@ -63,6 +140,16 @@ toAmount(STAmount const& amt) return amt; } +/** Extract the IOU value from an `STAmount` as an `IOUAmount`. + * + * Reconstitutes the signed mantissa (STAmount stores it unsigned + sign bit) + * and constructs an `IOUAmount` directly without re-canonicalization. + * + * @param amt The source `STAmount`; must not be a native XRP amount — + * verified by assertion. Mantissa must fit in `int64_t` — verified + * by assertion. + * @return An `IOUAmount` with the same signed mantissa and exponent. + */ template <> inline IOUAmount toAmount(STAmount const& amt) @@ -77,6 +164,13 @@ toAmount(STAmount const& amt) return IOUAmount(sMant, amt.exponent()); } +/** Extract the XRP drop count from an `STAmount` as an `XRPAmount`. + * + * @param amt The source `STAmount`; must be a native XRP amount — + * verified by assertion. Mantissa must fit in `int64_t` — verified + * by assertion. + * @return An `XRPAmount` holding the signed drop count. + */ template <> inline XRPAmount toAmount(STAmount const& amt) @@ -91,6 +185,20 @@ toAmount(STAmount const& amt) return XRPAmount(sMant); } +/** Extract the MPT unit count from an `STAmount` as an `MPTAmount`. + * + * MPT amounts are integers: the exponent must be exactly 0 and the + * mantissa must not exceed `kMAX_MP_TOKEN_AMOUNT`. Both constraints are + * checked in debug builds (assertion) and in release builds (exception), + * because a violation indicates data corruption or a ledger encoding bug + * that should surface loudly rather than silently truncate. + * + * @param amt The source `STAmount`; must hold an `MPTIssue`, have exponent + * 0, and mantissa ≤ `kMAX_MP_TOKEN_AMOUNT`. + * @return An `MPTAmount` holding the signed unit count. + * @throws std::runtime_error if `amt.exponent() != 0` or + * `amt.mantissa() > kMAX_MP_TOKEN_AMOUNT`. + */ template <> inline MPTAmount toAmount(STAmount const& amt) @@ -106,10 +214,24 @@ toAmount(STAmount const& amt) return MPTAmount(sMant); } +/** Primary template for `IOUAmount` → same-type extraction; intentionally deleted. + * + * Only the `IOUAmount` identity specialization below is valid. + * + * @tparam T Target amount type. + */ template T toAmount(IOUAmount const& amt) = delete; +/** Identity conversion: return the `IOUAmount` unchanged. + * + * Allows generic code to call `toAmount(iouValue)` without + * branching on whether the source is already the target type. + * + * @param amt The `IOUAmount` to return. + * @return `amt` unchanged. + */ template <> inline IOUAmount toAmount(IOUAmount const& amt) @@ -117,10 +239,24 @@ toAmount(IOUAmount const& amt) return amt; } +/** Primary template for `XRPAmount` → same-type extraction; intentionally deleted. + * + * Only the `XRPAmount` identity specialization below is valid. + * + * @tparam T Target amount type. + */ template T toAmount(XRPAmount const& amt) = delete; +/** Identity conversion: return the `XRPAmount` unchanged. + * + * Allows generic code to call `toAmount(xrpValue)` without + * branching on whether the source is already the target type. + * + * @param amt The `XRPAmount` to return. + * @return `amt` unchanged. + */ template <> inline XRPAmount toAmount(XRPAmount const& amt) @@ -128,10 +264,24 @@ toAmount(XRPAmount const& amt) return amt; } +/** Primary template for `MPTAmount` → same-type extraction; intentionally deleted. + * + * Only the `MPTAmount` identity specialization below is valid. + * + * @tparam T Target amount type. + */ template T toAmount(MPTAmount const& amt) = delete; +/** Identity conversion: return the `MPTAmount` unchanged. + * + * Allows generic code to call `toAmount(mptValue)` without + * branching on whether the source is already the target type. + * + * @param amt The `MPTAmount` to return. + * @return `amt` unchanged. + */ template <> inline MPTAmount toAmount(MPTAmount const& amt) @@ -139,6 +289,27 @@ toAmount(MPTAmount const& amt) return amt; } +/** Convert a `Number` intermediate result to a typed amount, applying a + * caller-specified rounding mode for XRP. + * + * Used by AMM pricing and pathfinding after performing arithmetic in + * `Number` space. The rounding mode override is applied **only for XRP**: + * XRP is an integer count of drops, so converting a rational intermediate + * requires a deterministic rounding decision. IOU and MPT types handle + * normalization internally and do not require external rounding control. + * `SaveNumberRoundMode` restores the previous thread-local rounding mode + * on destruction, even if the conversion throws. + * + * @tparam T Target amount type: `IOUAmount`, `XRPAmount`, `MPTAmount`, or + * `STAmount`. Any other type is a compile error. + * @param asset The asset identity for the result; must be consistent with + * `T` (e.g., XRP asset with `XRPAmount`). + * @param n The intermediate `Number` value to convert. + * @param mode Rounding mode applied when `T` is `XRPAmount` or when + * `T` is `STAmount` with an XRP asset. Defaults to the current + * thread-local rounding mode. + * @return The converted amount of type `T`. + */ template T toAmount(Asset const& asset, Number const& n, Number::RoundingMode mode = Number::getround()) @@ -172,6 +343,17 @@ toAmount(Asset const& asset, Number const& n, Number::RoundingMode mode = Number } } +/** Return the maximum representable value for a given amount type and asset. + * + * Dispatches at compile time on `T`. For `STAmount` the result depends on + * the runtime asset: XRP uses `kMAX_NATIVE_N` drops; IOU uses + * `(kMAX_VALUE, kMAX_OFFSET)`; MPT uses `kMAX_MP_TOKEN_AMOUNT`. + * + * @tparam T Target amount type: `IOUAmount`, `XRPAmount`, `MPTAmount`, or + * `STAmount`. Any other type is a compile error. + * @param asset The asset identity; consulted only when `T` is `STAmount`. + * @return The maximum representable value of type `T`. + */ template T toMaxAmount(Asset const& asset) @@ -205,12 +387,38 @@ toMaxAmount(Asset const& asset) } } +/** Convert a `Number` intermediate to an `STAmount` with a given asset and rounding mode. + * + * Thin wrapper around `toAmount(asset, n, mode)` provided so + * callers that always work with `STAmount` can use a non-template name. + * + * @param asset The asset identity for the result. + * @param n The intermediate `Number` value to convert. + * @param mode Rounding mode applied when `asset` is XRP. Defaults to the + * current thread-local rounding mode. + * @return An `STAmount` encoding `n` tagged with `asset`. + * @see toAmount + */ inline STAmount toSTAmount(Asset const& asset, Number const& n, Number::RoundingMode mode = Number::getround()) { return toAmount(asset, n, mode); } +/** Return a placeholder `Asset` for a given amount type. + * + * For `STAmount` this delegates to `amt.asset()` and returns the true asset. + * For lean types — `IOUAmount`, `XRPAmount`, `MPTAmount` — which do not + * carry asset identity, a sentinel is returned: `noIssue()`, `xrpIssue()`, + * or `noMPT()` respectively. Callers such as AMM helpers use this to + * produce an `Asset` argument for a subsequent `toAmount(asset, ...)` call + * where the true asset is known from context. + * + * @tparam T Source amount type. Any other type is a compile error. + * @param amt The amount whose asset identity is requested. + * @return The true `Asset` for `STAmount`; a type-appropriate sentinel + * for lean types. + */ template Asset getAsset(T const& amt) @@ -238,6 +446,19 @@ getAsset(T const& amt) } } +/** Extract a typed value from an `STAmount` by delegating to the + * appropriate accessor. + * + * Dispatches at compile time: `IOUAmount` → `a.iou()`, `XRPAmount` → + * `a.xrp()`, `MPTAmount` → `a.mpt()`, `STAmount` → identity. The + * `static_assert` in the else branch uses a type-dependent expression + * so it fires only when the unsupported branch is actually instantiated, + * not on every parse of the template. + * + * @tparam T Target lean type or `STAmount`. Any other type is a compile error. + * @param a The source `STAmount`. + * @return The value of `a` expressed as type `T`. + */ template constexpr T get(STAmount const& a) diff --git a/include/xrpl/protocol/ApiVersion.h b/include/xrpl/protocol/ApiVersion.h index 4d68cdaa5a..b31ca3db75 100644 --- a/include/xrpl/protocol/ApiVersion.h +++ b/include/xrpl/protocol/ApiVersion.h @@ -8,43 +8,76 @@ #include #include -namespace xrpl { - /** - * API version numbers used in later API versions + * @file ApiVersion.h + * @brief Single source of truth for the XRPL RPC API versioning scheme. * - * Requests with a version number in the range - * [apiMinimumSupportedVersion, apiMaximumSupportedVersion] - * are supported. + * Defines the compile-time integer constants that bound the accepted API + * version range, JSON parsing and serialization helpers that enforce those + * bounds at the RPC ingress point, and compile-time iteration templates + * (`forApiVersions`, `forAllApiVersions`) that let the rest of the codebase + * generate version-aware code paths without runtime switches. * - * If [beta_rpc_api] is enabled in config, the version numbers - * in the range [apiMinimumSupportedVersion, apiBetaVersion] - * are supported. - * - * Network Requests without explicit version numbers use - * apiVersionIfUnspecified. apiVersionIfUnspecified is 1, - * because all the RPC requests with a version >= 2 must - * explicitly specify the version in the requests. - * Note that apiVersionIfUnspecified will be lower than - * apiMinimumSupportedVersion when we stop supporting API - * version 1. - * - * Command line Requests use apiCommandLineVersion. + * The versioning constants dictate the size and index mapping of every + * `MultiApiJson` array in the system — changing them automatically adjusts + * every data structure that stores per-version output. */ +namespace xrpl { + namespace RPC { +/** + * @brief Typed version-constant factory. + * + * Produces an `std::integral_constant` tag for the given + * version number. Using a distinct type per version allows overload resolution + * and `if constexpr` branching at compile time while still implicitly decaying + * to `unsigned` in arithmetic and comparison contexts. + * + * @tparam Version The API version number to encode as a type. + */ template constexpr static std::integral_constant kAPI_VERSION = {}; +/** Sentinel returned by `getAPIVersionNumber()` when parsing fails or the + * supplied version falls outside the supported range. Callers that receive + * this value must reject the request before any handler dispatch. */ constexpr static auto kAPI_INVALID_VERSION = kAPI_VERSION<0>; + +/** Oldest API version still accepted from network clients. Requests with a + * lower version are rejected; the floor advances when old versions are + * retired. */ constexpr static auto kAPI_MINIMUM_SUPPORTED_VERSION = kAPI_VERSION<1>; + +/** Newest stable API version. Network requests are capped here unless the + * `[beta_rpc_api]` configuration flag is set, in which case + * `kAPI_BETA_VERSION` becomes the effective ceiling. */ constexpr static auto kAPI_MAXIMUM_SUPPORTED_VERSION = kAPI_VERSION<2>; + +/** Implicit version assigned when a request omits the `api_version` field. + * Fixed at 1 because any request at version 2 or above must carry an + * explicit field; omitting it is treated as a version-1 request rather than + * an error. This constant will fall below `kAPI_MINIMUM_SUPPORTED_VERSION` + * once version-1 support is retired. */ constexpr static auto kAPI_VERSION_IF_UNSPECIFIED = kAPI_VERSION<1>; -constexpr static auto kAPI_COMMAND_LINE_VERSION = kAPI_VERSION<1>; // TODO Bump to 2 later + +/** Version used for command-line invocations. + * @note TODO: bump to 2 in a future release. */ +constexpr static auto kAPI_COMMAND_LINE_VERSION = kAPI_VERSION<1>; + +/** Experimental version gated behind the `[beta_rpc_api]` configuration flag. + * Completely invisible to clients connecting to a production node that has + * not opted in. */ constexpr static auto kAPI_BETA_VERSION = kAPI_VERSION<3>; + +/** Absolute ceiling for template range loops; always equal to + * `kAPI_BETA_VERSION`. Drives the size of `MultiApiJson` arrays and the + * upper bound of `forAllApiVersions`. */ constexpr static auto kAPI_MAXIMUM_VALID_VERSION = kAPI_BETA_VERSION; +// --- Version-range invariants (load-bearing; update assertions when bumping +// any constant above) --- static_assert(kAPI_INVALID_VERSION < kAPI_MINIMUM_SUPPORTED_VERSION); static_assert( kAPI_VERSION_IF_UNSPECIFIED >= kAPI_MINIMUM_SUPPORTED_VERSION && @@ -56,6 +89,28 @@ static_assert(kAPI_MAXIMUM_SUPPORTED_VERSION >= kAPI_MINIMUM_SUPPORTED_VERSION); static_assert(kAPI_BETA_VERSION >= kAPI_MAXIMUM_SUPPORTED_VERSION); static_assert(kAPI_MAXIMUM_VALID_VERSION >= kAPI_MAXIMUM_SUPPORTED_VERSION); +/** + * @brief Populate the `version` sub-object in an RPC response. + * + * The output format diverges by negotiated version to maintain backwards + * compatibility: + * - **Version 1** (legacy): emits `first`, `good`, and `last` as semver + * strings (e.g. `"1.0.0"`). Static `SemanticVersion` objects are used to + * avoid repeated string parsing on every call. + * - **Version 2+**: emits `first` as the minimum supported version integer + * and `last` as either `kAPI_BETA_VERSION` or `kAPI_MAXIMUM_SUPPORTED_VERSION` + * depending on `betaEnabled`. + * + * The primary consumer is `VersionHandler` in + * `src/xrpld/rpc/handlers/server_info/Version.h`. + * + * @param parent The JSON object into which the `version` key is written. + * @param apiVersion The negotiated API version for the current request; must + * not be `kAPI_INVALID_VERSION`. + * @param betaEnabled Whether the `[beta_rpc_api]` configuration flag is set, + * which extends the reported `last` version to include the + * beta version. + */ inline void setVersion(json::Value& parent, unsigned int apiVersion, bool betaEnabled) { @@ -65,7 +120,7 @@ setVersion(json::Value& parent, unsigned int apiVersion, bool betaEnabled) if (apiVersion == kAPI_VERSION_IF_UNSPECIFIED) { - // API version numbers used in API version 1 + // Legacy semver-string format required by API version 1 clients. static beast::SemanticVersion const kFIRST_VERSION{"1.0.0"}; static beast::SemanticVersion const kGOOD_VERSION{"1.0.0"}; static beast::SemanticVersion const kLAST_VERSION{"1.0.0"}; @@ -82,18 +137,28 @@ setVersion(json::Value& parent, unsigned int apiVersion, bool betaEnabled) } /** - * Retrieve the api version number from the json value + * @brief Extract and validate the API version from an incoming RPC request. * - * Note that APIInvalidVersion will be returned if - * 1) the version number field has a wrong format - * 2) the version number retrieved is out of the supported range - * 3) the version number is unspecified and - * APIVersionIfUnspecified is out of the supported range + * Called at the RPC ingress point (`ServerHandler.cpp`) on every HTTP and + * WebSocket request before handler dispatch. The function inspects the + * top-level `api_version` field of `jv`: + * - If the field is absent, returns `kAPI_VERSION_IF_UNSPECIFIED`. + * - If the field is present but not an integer, returns `kAPI_INVALID_VERSION`. + * - If the integer value falls outside + * `[kAPI_MINIMUM_SUPPORTED_VERSION, maxVersion]`, returns + * `kAPI_INVALID_VERSION`. + * - Otherwise returns the integer value directly. * - * @param jv a Json value that may or may not specify - * the api version number - * @param betaEnabled if the beta API version is enabled - * @return the api version number + * Callers must treat a `kAPI_INVALID_VERSION` return as a signal to reject + * the request immediately with an appropriate error. + * + * @param jv The top-level JSON object of the incoming request. + * @param betaEnabled When `false`, the effective ceiling is + * `kAPI_MAXIMUM_SUPPORTED_VERSION`; when `true`, the ceiling + * extends to `kAPI_BETA_VERSION`. Reflects the + * `BETA_RPC_API` configuration flag of the serving node. + * @return The negotiated API version, or `kAPI_INVALID_VERSION` if the + * request must be rejected. */ inline unsigned int getAPIVersionNumber(json::Value const& jv, bool betaEnabled) @@ -125,6 +190,39 @@ getAPIVersionNumber(json::Value const& jv, bool betaEnabled) } // namespace RPC +/** + * @brief Invoke a callable once for each API version in `[MinVer, MaxVer]`, + * passing the version as a distinct `std::integral_constant` type. + * + * The range is expanded into a parameter pack at compile time via + * `std::make_index_sequence`, and the callable is called once per version in + * order. Because each invocation receives a different type + * (`std::integral_constant`), the callable may use + * `if constexpr (Version >= 2)` to eliminate dead branches at compile time + * rather than relying on a runtime switch. + * + * The C++20 `requires` clause enforces three constraints statically: + * - `MaxVer >= MinVer` (non-empty range), + * - `MinVer >= kAPI_MINIMUM_SUPPORTED_VERSION` (floor bound), + * - `MaxVer <= kAPI_MAXIMUM_VALID_VERSION` (ceiling bound). + * A caller that attempts to iterate outside the known valid range fails to + * compile rather than producing a runtime out-of-bounds error. + * + * @note The `NOLINTBEGIN/NOLINTEND` block suppresses a spurious + * `bugprone-use-after-move` warning that clang-tidy raises on the fold + * expression when `Args` contains move-only types; the fold is safe + * because perfect-forwarding within a comma-expression does not actually + * move from the same argument twice. + * + * @tparam MinVer First version in the iteration range (inclusive). + * @tparam MaxVer Last version in the iteration range (inclusive). + * @tparam Fn Callable type; must be invocable with + * `(std::integral_constant, Args&&...)` for + * every `V` in `[MinVer, MaxVer]`. + * @tparam Args Additional arguments forwarded verbatim to each invocation. + * @param fn The callable to invoke for each version. + * @param args Additional arguments forwarded to each invocation of `fn`. + */ template void forApiVersions(Fn const& fn, Args&&... args) @@ -146,6 +244,28 @@ forApiVersions(Fn const& fn, Args&&... args) }(std::make_index_sequence{}); } +/** + * @brief Invoke a callable once for every supported API version + * (`[kAPI_MINIMUM_SUPPORTED_VERSION, kAPI_MAXIMUM_VALID_VERSION]`). + * + * Thin wrapper around `forApiVersions` that fixes the range to the full set + * of known versions (currently 1–3). This is the standard way to: + * - Run a test scenario against every version in CI. + * - Populate all slots of a `MultiApiJson` fan-out in a single pass (e.g. + * `NetworkOPs.cpp` uses it to build per-subscriber data when notifying of + * new transactions, calling `insertDeliverMax` only for versions where that + * field is defined). + * + * Each invocation of `fn` receives a distinct + * `std::integral_constant` type for the version, enabling + * compile-time branching inside the lambda body. + * + * @tparam Fn Callable type; must satisfy the constraints of + * `forApiVersions` for the full version range. + * @tparam Args Additional arguments forwarded verbatim to each invocation. + * @param fn The callable to invoke for each version. + * @param args Additional arguments forwarded to each invocation of `fn`. + */ template void forAllApiVersions(Fn const& fn, Args&&... args) diff --git a/include/xrpl/protocol/Asset.h b/include/xrpl/protocol/Asset.h index e0f4aa08a2..dc32a0cb9b 100644 --- a/include/xrpl/protocol/Asset.h +++ b/include/xrpl/protocol/Asset.h @@ -1,3 +1,18 @@ +/** + * @file Asset.h + * @brief Unified asset abstraction for XRP, IOU, and MPT value types. + * + * Introduces the `Asset` type, a `std::variant` wrapper that + * represents all three kinds of transferable value on the XRP Ledger: native + * XRP, IOU issued currencies, and Multi-Purpose Tokens (MPT). `Issue` covers + * both XRP and IOU (distinguished by `Issue::native()`), so the variant has + * two arms but three logical asset kinds. + * + * Conversions *to* `Asset` are implicit (from `Issue`, `MPTIssue`, or `MPTID`) + * to preserve backward compatibility with legacy `Issue`-taking APIs. + * Conversions *out* are explicit via `get()` or guarded with `holds()`. + */ + #pragma once #include @@ -11,6 +26,19 @@ namespace xrpl { class STAmount; +/** + * @brief Empty tag type encoding an amount's numeric kind as a template + * parameter. + * + * Carries no data; its sole purpose is to convey compile-time type information + * through a runtime `std::variant`. Code that needs to dispatch on the numeric + * kind of an `Asset` calls `Asset::getAmountType()`, which returns a + * `std::variant, AmountType, + * AmountType>`, and then `std::visit`s over it to select the + * correct templated path. + * + * @tparam T Must be one of `XRPAmount`, `IOUAmount`, or `MPTAmount`. + */ template requires( std::is_same_v || std::is_same_v || @@ -20,13 +48,26 @@ struct AmountType using amount_type = T; }; -/* Used to check for an asset with either badCurrency() - * or MPT with 0 account. +/** + * @brief Sentinel tag used to test whether an `Asset` holds an invalid value. + * + * An `Asset` is "bad" when it holds an `Issue` whose currency equals + * `badCurrency()`, or an `MPTIssue` whose issuer equals `xrpAccount()` (the + * zero-account sentinel). Use `operator==(BadAsset const&, Asset const&)` or + * compare against `badAsset()` rather than inspecting the sub-type directly. + * + * This pattern avoids a separate validity flag or `std::optional`: + * invalid states are represented as well-known sentinel values. */ struct BadAsset { }; +/** + * @brief Returns a reference to the singleton `BadAsset` sentinel. + * + * Prefer `badAsset() == myAsset` over constructing a temporary `BadAsset{}`. + */ inline BadAsset const& badAsset() { @@ -34,16 +75,30 @@ badAsset() return kA; } -/* Asset is an abstraction of three different issue types: XRP, IOU, MPT. - * For historical reasons, two issue types XRP and IOU are wrapped in Issue - * type. Many functions and classes there were first written for Issue - * have been rewritten for Asset. +/** + * @brief Unified representation of an XRP Ledger asset: XRP, IOU, or MPT. + * + * Wraps `std::variant`. Because `Issue` already encodes both + * XRP (via `Issue::native()`) and IOU, the variant has two arms but three + * logical asset kinds. Value semantics and `constexpr` comparisons are + * preserved — no vtables, no heap allocation. + * + * Implicit conversions *from* `Issue`, `MPTIssue`, and `MPTID` allow callers + * to pass those types anywhere an `Asset` is expected. Extraction of the + * concrete sub-type is explicit: guard with `holds()` then call + * `get()`, or use `visit()` for exhaustive dispatch. + * + * `STAmount` stores an `Asset` as its type-identity half and delegates + * `native()`, `integral()`, `holds<>()`, and `get<>()` directly to it. */ class Asset { public: + /** Underlying storage type: one of `Issue` (XRP or IOU) or `MPTIssue`. */ using value_type = std::variant; + /** Currency or MPTID, depending on the active arm. */ using token_type = std::variant; + /** Runtime amount-kind discriminant returned by `getAmountType()`. */ using AmtType = std::variant, AmountType, AmountType>; @@ -51,66 +106,176 @@ private: value_type issue_; public: + /** Constructs a default (XRP) asset. */ Asset() = default; - /** Conversions to Asset are implicit and conversions to specific issue - * type are explicit. This design facilitates the use of Asset. + /** + * @brief Constructs an Asset from an `Issue` (XRP or IOU). + * + * Implicit to preserve backward compatibility with APIs that previously + * accepted `Issue` directly. + * + * @param issue The XRP or IOU issue to wrap. */ Asset(Issue const& issue) : issue_(issue) { } + /** + * @brief Constructs an Asset from an `MPTIssue`. + * + * Implicit so callers can pass an `MPTIssue` wherever `Asset` is expected. + * + * @param mptIssue The MPT issuance to wrap. + */ Asset(MPTIssue const& mptIssue) : issue_(mptIssue) { } + /** + * @brief Constructs an Asset from a raw `MPTID`. + * + * Convenience implicit conversion that wraps the issuance ID in an + * `MPTIssue` before storing it. + * + * @param issuanceID The 192-bit MPT issuance identifier. + */ Asset(MPTID const& issuanceID) : issue_(MPTIssue{issuanceID}) { } + /** + * @brief Returns the issuer of this asset. + * + * For XRP, returns the zero `AccountID` (no real issuer). For IOU, returns + * the issuing account. For MPT, returns the sequence-owner encoded in the + * MPTID. + */ [[nodiscard]] AccountID const& getIssuer() const; + /** + * @brief Returns a const reference to the active sub-type. + * + * @tparam TIss `Issue` or `MPTIssue`. + * @throws std::logic_error if the asset does not hold `TIss`. Guard with + * `holds()` before calling, or use `visit()` for exhaustive + * dispatch. + */ template constexpr TIss const& get() const; + /** + * @brief Returns a mutable reference to the active sub-type. + * + * @tparam TIss `Issue` or `MPTIssue`. + * @throws std::logic_error if the asset does not hold `TIss`. + */ template TIss& get(); + /** + * @brief Tests whether the asset currently holds the given sub-type. + * + * @tparam TIss `Issue` or `MPTIssue`. + * @return `true` if the active arm matches `TIss`. + */ template [[nodiscard]] constexpr bool holds() const; + /** + * @brief Returns a human-readable string identifying the asset. + * + * Delegates to the underlying `Issue` or `MPTIssue` text representation. + */ [[nodiscard]] std::string getText() const; + /** + * @brief Returns a const reference to the underlying `variant` storage. + * + * Prefer `visit()` or `get()` for type-safe access; this accessor + * is available for callers that must interact with the variant directly. + */ [[nodiscard]] constexpr value_type const& value() const; + /** + * @brief Returns the currency token identity of this asset. + * + * For XRP and IOU assets, returns the `Currency`. For MPT assets, returns + * the `MPTID`. Useful when identity must be compared independently of the + * issuer. + */ [[nodiscard]] constexpr token_type token() const; + /** + * @brief Serializes the asset into a JSON value. + * + * For IOU: emits `currency` and `issuer` keys (no issuer for XRP). + * For MPT: emits `mpt_issuance_id`. + * + * @param jv Output JSON object; populated in place. + */ void setJson(json::Value& jv) const; + /** + * @brief Constructs an `STAmount` from this asset and a raw numeric value. + * + * Convenience operator enabling concise amount construction: + * `myAsset(someNumber)`. The `Number` is interpreted according to the + * asset's kind (XRP drops, IOU mantissa/exponent, MPT integer). + * + * @param n The numeric value to associate with this asset. + * @return An `STAmount` holding this asset and the given value. + */ STAmount operator()(Number const&) const; + /** + * @brief Returns a tag-variant encoding the runtime amount kind. + * + * The returned variant holds one of `AmountType`, + * `AmountType`, or `AmountType`. `std::visit` over + * this result to select the correct templated arithmetic path without + * inspecting the asset sub-type manually. + */ [[nodiscard]] constexpr AmtType getAmountType() const; - // Custom, generic visit implementation + /** + * @brief Applies a set of lambdas to the active `Issue` or `MPTIssue` arm. + * + * Combines the provided callables into a single overload set using + * `detail::visit` (the `CombineVisitors` trick from `Concepts.h`) and + * forwards to `std::visit` over the internal variant. Example: + * @code + * asset.visit( + * [](Issue const& issue) { / * XRP or IOU * / }, + * [](MPTIssue const& mpt) { / * MPT * / }); + * @endcode + * + * @tparam Visitors Callable types whose signatures cover `Issue` and `MPTIssue`. + * @return The return value of the matching visitor. + */ template constexpr auto visit(Visitors&&... visitors) const -> decltype(auto) { - // Simple delegation to the reusable utility, passing the internal - // variant data. return detail::visit(issue_, std::forward(visitors)...); } + /** + * @brief Returns `true` if and only if the asset is native XRP. + * + * MPT always returns `false`; IOU always returns `false`; only the XRP + * arm of `Issue` returns `true`. + */ [[nodiscard]] constexpr bool native() const { @@ -119,6 +284,14 @@ public: [&](MPTIssue const&) { return false; }); } + /** + * @brief Returns `true` if the asset has an integer (non-fractional) amount + * representation. + * + * Both XRP (drops) and MPT amounts are always whole numbers. IOU amounts + * use a floating-point mantissa/exponent encoding and are not integral. + * This distinction affects serialization and arithmetic rounding. + */ [[nodiscard]] bool integral() const { @@ -127,32 +300,83 @@ public: [&](MPTIssue const&) { return true; }); } + /** + * @brief Equality: `true` when both assets hold the same sub-type and + * compare equal within that sub-type. + * + * Cross-type comparisons (e.g., `Issue` vs `MPTIssue`) always return + * `false`. For IOU, both currency and issuer must match. Use + * `equalTokens()` to compare ignoring issuer. + */ friend constexpr bool operator==(Asset const& lhs, Asset const& rhs); + /** + * @brief Total order over assets for use in sorted containers. + * + * When both assets hold the same variant arm, ordering is delegated to + * that arm's natural `<=>`. When arms differ, `Issue` sorts greater than + * `MPTIssue` (an arbitrary but stable convention). + */ friend constexpr std::weak_ordering operator<=>(Asset const& lhs, Asset const& rhs); + /** + * @brief Tests whether the asset holds an `Issue` with the given currency. + * + * Returns `false` for any `MPTIssue` asset regardless of `lhs`. + * + * @param lhs The currency to compare against. + * @param rhs The asset to inspect. + */ friend constexpr bool operator==(Currency const& lhs, Asset const& rhs); - // rhs is either badCurrency() or MPT issuer is 0 + /** + * @brief Tests whether the asset represents an invalid (sentinel) value. + * + * Returns `true` when `rhs` holds an `Issue` with `badCurrency()`, or an + * `MPTIssue` whose issuer is `xrpAccount()` (the zero-account sentinel). + * + * @param lhs Unused sentinel tag; use `badAsset()` as the left operand. + * @param rhs The asset to test. + */ friend constexpr bool operator==(BadAsset const& lhs, Asset const& rhs); - /** Return true if both assets refer to the same currency (regardless of - * issuer) or MPT issuance. Otherwise return false. + /** + * @brief Returns `true` if both assets refer to the same token type, + * regardless of issuer. + * + * For `Issue`-vs-`Issue` comparisons only the `Currency` field is checked; + * issuers are ignored. For `MPTIssue`-vs-`MPTIssue` the full `MPTID` is + * compared (issuer is already encoded in the ID, so there is no + * issuer-free concept). Cross-type comparisons always return `false`. + * + * Used in path-finding and offer-matching where token type must match but + * trust lines from different issuers in the same currency are acceptable. */ friend constexpr bool equalTokens(Asset const& lhs, Asset const& rhs); }; +/** @brief `true` when `TIss` is `Issue`. Helper for `operator<=>`. */ template constexpr bool kIS_ISSUE_V = std::is_same_v; +/** @brief `true` when `TIss` is `MPTIssue`. Helper for `operator<=>`. */ template constexpr bool kIS_MPTISSUE_V = std::is_same_v; +/** + * @brief Converts an asset to a `json::Value` representation. + * + * For IOU: produces `{currency, issuer}` (no issuer key for XRP). + * For MPT: produces `{mpt_issuance_id}`. + * + * @param asset The asset to serialize. + * @return A `json::Value` object describing the asset. + */ inline json::Value toJson(Asset const& asset) { @@ -293,21 +517,65 @@ equalTokens(Asset const& lhs, Asset const& rhs) rhs.issue_); } +/** + * @brief Returns `true` if the asset is native XRP. + * + * Thin wrapper around `Asset::native()` for readability at call sites. + * + * @param asset The asset to test. + */ inline bool isXRP(Asset const& asset) { return asset.native(); } +/** + * @brief Returns a human-readable string representation of an asset. + * + * Delegates to the underlying `Issue` or `MPTIssue` text form. Suitable for + * logging and error messages; not for wire serialization. + * + * @param asset The asset to stringify. + * @return A descriptive string identifying the asset. + */ std::string to_string(Asset const& asset); +/** + * @brief Validates that a JSON object encodes a well-formed asset. + * + * Enforces the protocol rule that an asset JSON object must contain exactly + * one of `currency` or `mpt_issuance_id`, but not both. + * + * @param jv The JSON value to validate. + * @return `true` if the JSON represents a valid asset; `false` otherwise. + */ bool validJSONAsset(json::Value const& jv); +/** + * @brief Parses an `Asset` from a JSON value. + * + * Accepts either `{currency[, issuer]}` for XRP/IOU or + * `{mpt_issuance_id}` for MPT. Throws on malformed input. + * + * @param jv The JSON object describing the asset. + * @return The parsed `Asset`. + * @throws std::runtime_error (or equivalent) if `jv` is not a valid asset. + */ Asset assetFromJson(json::Value const& jv); +/** + * @brief Returns `true` if the asset's internal fields are mutually consistent. + * + * For XRP/IOU assets, delegates to `Issue::isConsistent()` which checks that + * XRP has no account component. MPT assets are always considered consistent. + * Less strict than `validAsset()` — does not reject sentinel currencies. + * + * @param asset The asset to check. + */ inline bool isConsistent(Asset const& asset) { @@ -316,6 +584,15 @@ isConsistent(Asset const& asset) [](MPTIssue const&) { return true; }); } +/** + * @brief Returns `true` if the asset is a well-formed, non-sentinel value. + * + * Stricter than `isConsistent()`: additionally rejects `badCurrency()` for + * IOU/XRP assets and the zero-issuer sentinel for MPT assets. Use this to + * validate user-provided or deserialized assets before operating on them. + * + * @param asset The asset to validate. + */ inline bool validAsset(Asset const& asset) { @@ -324,6 +601,17 @@ validAsset(Asset const& asset) [](MPTIssue const& issue) { return issue.getIssuer() != xrpAccount(); }); } +/** + * @brief Appends an asset's hash contribution to a Hasher. + * + * Enables `Asset` as a key in `beast::uhash`-based and `std::unordered_*` + * containers. Dispatches to the active arm's own `hash_append` specialization, + * so `Issue` and `MPTIssue` assets produce distinct hash domains. + * + * @tparam Hasher A `beast::hash_append`-compatible hasher type. + * @param h The hasher to accumulate into. + * @param r The asset to hash. + */ template void hash_append(Hasher& h, Asset const& r) @@ -334,6 +622,15 @@ hash_append(Hasher& h, Asset const& r) [&](MPTIssue const& issue) { hash_append(h, issue); }); } +/** + * @brief Stream-inserts a human-readable asset description. + * + * Equivalent to `os << to_string(x)`. Intended for logging and diagnostics. + * + * @param os The output stream. + * @param x The asset to write. + * @return `os`, for chaining. + */ std::ostream& operator<<(std::ostream& os, Asset const& x); diff --git a/include/xrpl/protocol/Batch.h b/include/xrpl/protocol/Batch.h index fa7641af70..b10bd4d889 100644 --- a/include/xrpl/protocol/Batch.h +++ b/include/xrpl/protocol/Batch.h @@ -1,9 +1,50 @@ +/** @file + * Defines the canonical wire-format serialization for batch signing payloads. + * + * A batch payload is the exact byte sequence that every co-signer of a + * `ttBATCH` transaction signs and that validators verify. The format is + * protocol-stable: any reordering of the four serialized fields would + * invalidate all previously issued batch signatures. + */ + #include #include #include namespace xrpl { +/** Serialize the signable payload for a batch transaction. + * + * Appends four fields to `msg` in a fixed order: + * 1. `HashPrefix::batch` — 4-byte domain separator that places batch hashes + * in their own hash-space, preventing cross-type signature collisions. + * 2. `flags` — the outer batch transaction's execution-policy flags (e.g. + * `tfAllOrNothing`). Signing over the flags ensures a signer cannot have + * the execution policy changed after they have committed. + * 3. `txids.size()` — the inner-transaction count as a `uint32_t`. Explicit + * serialization of the count prevents an adversary from extending or + * truncating the list without invalidating signatures. + * 4. Each `uint256` in `txids` — the hash of each inner transaction, in + * order. Signers commit to the exact set of inner transactions by ID. + * + * Both `checkBatchSingleSign()` and `checkBatchMultiSign()` in `STTx.cpp` + * call this function to build the verification payload, and test signing + * helpers do the same, so signing and verification share a single + * serialization path. For multi-sign, `serializeBatch()` is called once + * and `finishMultiSigningData()` appends each per-signer account ID suffix + * without re-serializing the inner transaction list. + * + * @param msg Serializer that receives the batch payload bytes. The caller + * is responsible for passing the resulting `msg.slice()` to the + * appropriate signature primitive. + * @param flags The `uint32_t` flags field of the outer batch transaction, + * as returned by `STTx::getFlags()`. + * @param txids Ordered list of inner-transaction IDs, as returned by + * `STTx::getBatchTransactionIDs()`. + * + * @note `HashPrefix::batch` is a protocol constant. Changing it would + * invalidate all existing batch signatures and requires an amendment. + */ inline void serializeBatch(Serializer& msg, std::uint32_t const& flags, std::vector const& txids) { diff --git a/include/xrpl/protocol/Book.h b/include/xrpl/protocol/Book.h index fc36abddc4..873c31a766 100644 --- a/include/xrpl/protocol/Book.h +++ b/include/xrpl/protocol/Book.h @@ -1,3 +1,9 @@ +/** @file + * Defines the `Book` type — the identity of an XRPL DEX order book — together + * with `std::hash` and `boost::hash` specializations for `Issue`, `MPTIssue`, + * `Asset`, and `Book` needed by unordered containers throughout the codebase. + */ + #pragma once #include @@ -8,34 +14,110 @@ namespace xrpl { -/** Specifies an order book. - The order book is a pair of Issues called in and out. - @see Issue. -*/ +/** Identity of an XRPL order book: a directed pair of assets. + * + * An order book is the set of all open offers to exchange one asset for + * another in a specific direction. `in` is the asset a taker spends; + * `out` is the asset a taker receives. Because `Asset` is a variant of + * `Issue` and `MPTIssue`, a `Book` can represent any combination of XRP, + * IOU, and MPT asset classes. + * + * When `domain` is set, the book is scoped to a `PermissionedDomain` + * ledger entry identified by that `uint256` index. A domain-scoped book + * and the corresponding open book are distinct even when their `in`/`out` + * assets are identical — equality, ordering, and hashing all include + * `domain`. + * + * Inherits `CountedObject` for diagnostic instance counting only; + * this has no effect on protocol logic. + * + * @invariant A well-formed book satisfies `isConsistent(*this)`: + * both legs are individually consistent and `in != out`. + * @see isConsistent, reversed + */ class Book final : public CountedObject { public: + /** The asset being spent by the taker (offered). */ Asset in; + + /** The asset being received by the taker (wanted). */ Asset out; + + /** Optional permissioned-domain scope for this order book. + * + * When present, the `uint256` is the ledger index of a + * `PermissionedDomain` object that gates participation. Absent means + * the book is open to all accounts. + */ std::optional domain; Book() = default; + /** Construct a Book from explicit asset legs and an optional domain. + * + * @param in Asset being spent by the taker. + * @param out Asset being received by the taker. + * @param domain Ledger index of the `PermissionedDomain` that scopes + * this book, or `std::nullopt` for an open book. + */ Book(Asset const& in, Asset const& out, std::optional const& domain) : in(in), out(out), domain(domain) { } }; +/** Check that a Book is self-consistent. + * + * A book is consistent when both `in` and `out` are individually consistent + * (e.g., no XRP currency paired with a non-XRP issuer) and `in != out`. + * A book with identical legs would represent trading an asset against itself + * and would cause infinite-loop offer matching. + * + * @note `book.domain` is not validated here; semantic validity of the domain + * identifier belongs to higher-level transaction processing. + * @param book The order book to validate. + * @return `true` if both legs are individually consistent and `in != out`. + */ bool isConsistent(Book const& book); +/** Format a Book as a human-readable string for logging and diagnostics. + * + * Produces a string of the form `"->"` using the `to_string` + * representations of each asset leg. The arrow makes directionality + * explicit. This format is not part of the wire protocol. + * + * @param book The order book to convert. + * @return A string of the form `"->"`. + */ std::string to_string(Book const& book); +/** Write a Book to an output stream in diagnostic form. + * + * Delegates to `to_string(book)`. + * + * @param os The output stream to write to. + * @param x The order book to write. + * @return `os`, to allow chaining. + */ std::ostream& operator<<(std::ostream& os, Book const& x); +/** Append a Book to a cryptographic hash state (beast hash pipeline). + * + * Hashes `in` and `out` unconditionally, then appends `domain` only when + * present. A book with a domain and one without — even with identical + * asset legs — therefore produce different digests. This matters for + * ledger index derivation in `Indexes.cpp`, where the presence of a domain + * conditionally changes the hash inputs used to locate the book's offer + * directory. + * + * @tparam Hasher A beast-compatible hash accumulator. + * @param h The hash state to append to. + * @param b The order book whose fields are appended. + */ template void hash_append(Hasher& h, Book const& b) @@ -46,11 +128,26 @@ hash_append(Hasher& h, Book const& b) hash_append(h, *(b.domain)); } +/** Return the mirror-image order book with `in` and `out` swapped. + * + * Preserves `domain` unchanged — a domain-scoped market is the same market + * when viewed from either direction. Used by the Subscribe/Unsubscribe RPC + * handlers when a client requests the `both` flag so that updates from both + * the bid and ask sides are delivered. + * + * @param book The order book to reverse. + * @return A new `Book` with `in` and `out` exchanged and `domain` unchanged. + */ Book reversed(Book const& book); -/** Equality comparison. */ /** @{ */ +/** Test two Books for equality. + * + * Two books are equal only when `in`, `out`, and `domain` all compare equal. + * A domain-scoped book is never equal to an open book with the same asset + * legs. + */ [[nodiscard]] constexpr bool operator==(Book const& lhs, Book const& rhs) { @@ -58,8 +155,19 @@ operator==(Book const& lhs, Book const& rhs) } /** @} */ -/** Strict weak ordering. */ /** @{ */ +/** Three-way comparison establishing a strict weak ordering over Books. + * + * Orders first by `in`, then by `out`, then by `domain`. An absent domain + * compares less than any present domain. This ordering is used by sorted + * containers such as subscription routing tables and by `BookDirs` traversal + * to iterate over offer directories deterministically. + * + * @note The optional comparison is performed manually (rather than relying + * on the standard library's spaceship support for `optional`) to + * guarantee a `std::weak_ordering` return type consistent with the + * `Asset` spaceship result. + */ [[nodiscard]] constexpr std::weak_ordering operator<=>(Book const& lhs, Book const& rhs) { @@ -68,15 +176,15 @@ operator<=>(Book const& lhs, Book const& rhs) if (auto const c{lhs.out <=> rhs.out}; c != 0) return c; - // Manually compare optionals + // Manually compare optionals: absent domain sorts before any present domain. if (lhs.domain && rhs.domain) - return *lhs.domain <=> *rhs.domain; // Compare values if both exist + return *lhs.domain <=> *rhs.domain; if (!lhs.domain && rhs.domain) - return std::weak_ordering::less; // Empty is considered less + return std::weak_ordering::less; if (lhs.domain && !rhs.domain) - return std::weak_ordering::greater; // Non-empty is greater + return std::weak_ordering::greater; - return std::weak_ordering::equivalent; // Both are empty + return std::weak_ordering::equivalent; } /** @} */ @@ -86,6 +194,13 @@ operator<=>(Book const& lhs, Book const& rhs) namespace std { +/** `std::hash` specialization for `xrpl::Issue`. + * + * Combines the currency hash with the account (issuer) hash via + * `boost::hash_combine`. For XRP, the issuer field is ignored because + * all XRP issuers are equivalent by protocol definition, ensuring that + * all representations of XRP hash identically. + */ template <> struct hash : private boost::base_from_member, 0>, private boost::base_from_member, 1> @@ -110,6 +225,12 @@ public: } }; +/** `std::hash` specialization for `xrpl::MPTIssue`. + * + * Hashes only the 192-bit `MPTID` (32-bit sequence number concatenated with + * the 160-bit issuer account), which is the canonical unique identifier for + * an MPT issuance. + */ template <> struct hash : private boost::base_from_member, 0> { @@ -130,6 +251,11 @@ public: } }; +/** `std::hash` specialization for `xrpl::Asset`. + * + * Visits the underlying variant and dispatches to the appropriate + * `std::hash` or `std::hash` specialization. + */ template <> struct hash { @@ -163,6 +289,14 @@ public: //------------------------------------------------------------------------------ +/** `std::hash` specialization for `xrpl::Book`. + * + * Seeds from `hash(in)`, combines `hash(out)` via `boost::hash_combine`, + * then conditionally combines `hash(domain)` when a domain is present. + * A domain-scoped book and an otherwise-identical open book therefore + * produce distinct hash values, which is required for correct keying in + * subscription routing tables and offer-directory indexes. + */ template <> struct hash { @@ -198,6 +332,16 @@ public: namespace boost { +/** `boost::hash` adapter for `xrpl::Issue`. + * + * Inherits `std::hash` so that Boost.Unordered and + * Boost.MultiIndex containers resolve the same hash function as standard + * unordered containers, avoiding divergence between the two hash registries. + * + * @note Constructor inheritance (`using Base::Base`) is omitted because it + * was broken in Visual Studio 2012; an explicit defaulted constructor + * is provided instead. + */ template <> struct hash : std::hash { @@ -208,6 +352,11 @@ struct hash : std::hash // using Base::Base; // inherit ctors }; +/** `boost::hash` adapter for `xrpl::MPTIssue`. + * + * Delegates to `std::hash` so that Boost containers use + * the same hash logic as standard containers. + */ template <> struct hash : std::hash { @@ -216,6 +365,11 @@ struct hash : std::hash using Base = std::hash; }; +/** `boost::hash` adapter for `xrpl::Asset`. + * + * Delegates to `std::hash` so that Boost containers use the + * same variant-dispatching hash logic as standard containers. + */ template <> struct hash : std::hash { @@ -224,6 +378,15 @@ struct hash : std::hash using Base = std::hash; }; +/** `boost::hash` adapter for `xrpl::Book`. + * + * Delegates to `std::hash` so that Boost containers use the + * same domain-aware hash logic as standard containers. + * + * @note Constructor inheritance (`using Base::Base`) is omitted because it + * was broken in Visual Studio 2012; an explicit defaulted constructor + * is provided instead. + */ template <> struct hash : std::hash { diff --git a/include/xrpl/protocol/BuildInfo.h b/include/xrpl/protocol/BuildInfo.h index 47a27339a8..744baa27cf 100644 --- a/include/xrpl/protocol/BuildInfo.h +++ b/include/xrpl/protocol/BuildInfo.h @@ -1,76 +1,135 @@ +/** @file + * Version identity and wire-encoding utilities for the xrpld binary. + * + * Owns the canonical SemVer version string, the composite `systemName-version` + * identifier used in HTTP headers and peer-protocol handshakes, and a compact + * 64-bit encoding that lets validators compare software versions during + * consensus via a plain integer comparison — no string parsing required at + * runtime. + * + * At every flag ledger (every 256 ledgers) `RCLConsensus` writes the result of + * `getEncodedVersion()` into `sfServerVersion` in each validation message it + * broadcasts. `LedgerMaster` then inspects incoming validations, calling + * `isXrpldVersion()` and `isNewerVersion()` to count how many UNL validators + * are running a newer build, which can surface an upgrade notification. + */ + #pragma once #include #include -/** Versioning information for this build. */ +/** Version identity, wire encoding, and peer-comparison utilities for xrpld. + * + * @deprecated The `BuildInfo` sub-namespace is expected to be dissolved; these + * utilities will eventually be promoted directly into `xrpl`. + */ // VFALCO The namespace is deprecated namespace xrpl::BuildInfo { -/** Server version. - Follows the Semantic Versioning Specification: - http://semver.org/ -*/ +/** Return the canonical SemVer version string for this build. + * + * The result is memoized; the initializer runs exactly once (C++11 + * thread-safe static-init guarantee). On first call the hard-coded + * `versionString` constant is round-tripped through `beast::SemanticVersion`: + * if it fails to parse, or if its canonical re-serialization differs from the + * original, `LogicError` is thrown and the process terminates. This acts as a + * start-up invariant check — a malformed version constant is caught + * immediately rather than producing silently wrong encoded integers. + * + * In `DEBUG` or sanitizer builds, SemVer build metadata (commit hash, + * `DEBUG`, sanitizer names) is appended as a `+`-separated suffix, e.g. + * `"3.2.0-b0+abc1234.DEBUG"`. + * + * @return a reference to the cached, validated version string (e.g. + * `"3.2.0-b0"`). + * @throw LogicError on first call if `versionString` is malformed or + * not in canonical SemVer form. + */ std::string const& getVersionString(); -/** Full server version string. - This includes the name of the server. It is used in the peer - protocol hello message and also the headers of some HTTP replies. -*/ +/** Return the composite `systemName-version` string for this build. + * + * Prepends `systemName()` (always `"xrpld"`) to `getVersionString()`, + * separated by `"-"`, e.g. `"xrpld-3.2.0-b0"`. This string appears verbatim + * in the `User-Agent` and `Server` HTTP headers during peer-protocol + * handshakes and in all HTTP responses from the JSON-RPC server. + * + * @return a reference to the cached composite version string. + */ std::string const& getFullVersionString(); -/** Encode an arbitrary server software version in a 64-bit integer. - - The general format is: - - ........-........-........-........-........-........-........-........ - XXXXXXXX-XXXXXXXX-YYYYYYYY-YYYYYYYY-YYYYYYYY-YYYYYYYY-YYYYYYYY-YYYYYYYY - - X: 16 bits identifying the particular implementation - Y: 48 bits of data specific to the implementation - - The xrpld-specific format (implementation ID is: 0x18 0x3B) is: - - 00011000-00111011-MMMMMMMM-mmmmmmmm-pppppppp-TTNNNNNN-00000000-00000000 - - M: 8-bit major version (0-255) - m: 8-bit minor version (0-255) - p: 8-bit patch version (0-255) - T: 11 if neither an RC nor a beta - 10 if an RC - 01 if a beta - N: 6-bit rc/beta number (1-63) - - @param the version string - @return the encoded version in a 64-bit integer -*/ +/** Encode a SemVer string into the 64-bit wire format used in `sfServerVersion`. + * + * The resulting integer has the following bit layout: + * + * ``` + * [63:48] implementation identifier (0x183B for xrpld) + * [47:40] major version (8 bits, 0-255) + * [39:32] minor version (8 bits, 0-255) + * [31:24] patch version (8 bits, 0-255) + * [23:22] pre-release type (0b11 = release, 0b10 = RC, 0b01 = beta) + * [21:16] pre-release number (6 bits, 0-63; 0 for releases) + * [15:0] reserved zeros + * ``` + * + * The pre-release type bits are deliberately ordered so that a plain integer + * comparison on the full `uint64_t` yields correct semantic ordering: + * release (`0b11`) > RC (`0b10`) > beta (`0b01`). A malformed pre-release + * identifier (missing number, non-numeric suffix, number out of range + * [0, 63]) silently yields zero for bits [23:16], which sorts below any + * recognizable pre-release type. If `versionStr` does not parse as valid + * SemVer at all, the return value contains only the xrpld fingerprint + * (`0x183B`) in bits [63:48] and zeros elsewhere. + * + * @param versionStr a SemVer-formatted version string (e.g. `"3.2.0-b0"`). + * @return the packed version as a `uint64_t`; see bit layout above. + */ std::uint64_t encodeSoftwareVersion(std::string_view versionStr); -/** Returns this server's version packed in a 64-bit integer. */ +/** Return this node's own encoded version, cached from `getVersionString()`. + * + * Calls `encodeSoftwareVersion(getVersionString())` exactly once and caches + * the result as a function-local static. This value is written into + * `sfServerVersion` in every validation message emitted on flag ledgers by + * `RCLConsensus`. + * + * @return the cached 64-bit encoded version for this build. + */ std::uint64_t getEncodedVersion(); -/** Check if the encoded software version is an xrpld software version. - - @param version another node's encoded software version - @return true if the version is an xrpld software version, false otherwise -*/ +/** Return true if `version` carries the xrpld implementation fingerprint. + * + * Checks only the upper 16 bits against the xrpld identifier `0x183B`. This + * must be called before any numeric comparison of version values: a non-xrpld + * peer could advertise an arbitrarily large integer that would otherwise + * appear "newer", so `isNewerVersion()` calls this guard unconditionally. + * + * @param version an encoded software version read from `sfServerVersion`. + * @return true iff the upper 16 bits of `version` equal `0x183B`. + */ bool isXrpldVersion(std::uint64_t version); -/** Check if the version is newer than the local node's xrpld software - version. - - @param version another node's encoded software version - @return true if the version is newer than the local node's xrpld software - version, false otherwise. - - @note This function only understands version numbers that are generated by - xrpld. Please see the encodeSoftwareVersion() function for detail. -*/ +/** Return true if `version` represents a strictly newer xrpld release than + * this node. + * + * Guards against non-xrpld peers by calling `isXrpldVersion()` first: any + * value whose upper 16 bits differ from `0x183B` unconditionally returns + * false, regardless of its numeric magnitude. For confirmed xrpld versions, + * a plain integer comparison is sufficient because `encodeSoftwareVersion()` + * places major, minor, patch, and release-type bits in descending order of + * significance. + * + * @param version an encoded software version read from `sfServerVersion`. + * @return true iff `version` is an xrpld version strictly greater than + * `getEncodedVersion()`. + * @see isXrpldVersion(), encodeSoftwareVersion() + */ bool isNewerVersion(std::uint64_t version); diff --git a/include/xrpl/protocol/Concepts.h b/include/xrpl/protocol/Concepts.h index e909aff805..6601de3f9b 100644 --- a/include/xrpl/protocol/Concepts.h +++ b/include/xrpl/protocol/Concepts.h @@ -1,3 +1,12 @@ +/** @file + * Compile-time type vocabulary for the XRPL protocol layer. + * + * Centralises all C++20 concept definitions that constrain the three payment + * asset families (XRP, IOU, and MPT) and provides the `detail::CombineVisitors` + * utility used by `Asset::visit()` and `PathAsset::visit()`. Keeping every + * constraint in one place means that adding a new asset family requires + * updates in a single file; the compiler propagates errors to every call site. + */ #pragma once #include @@ -14,20 +23,73 @@ class IOUAmount; class XRPAmount; class MPTAmount; +/** Constrains the three numeric types used as individual payment-step quantities. + * + * `EitherAmount` (the type-erased amount carrier used by the path-finding + * engine) restricts its constructor, `holds()`, and `get()` with this + * concept, so the compiler rejects any attempt to store or query an amount + * type outside the sanctioned set at instantiation time. + * + * @tparam A One of `XRPAmount`, `IOUAmount`, or `MPTAmount`. + */ template concept StepAmount = std::is_same_v || std::is_same_v || std::is_same_v; +/** Constrains template parameters to the two issue types held by `Asset`. + * + * Gates `Asset::get()` and `Asset::holds()` to exactly `Issue` and + * `MPTIssue` — the two alternatives of `Asset`'s internal + * `std::variant`. Also used by the `kIS_ISSUE_V` and + * `kIS_MPTISSUE_V` boolean constants in `Asset.h` that drive `if constexpr` + * branches in comparison operators. + * + * @tparam TIss `Issue` or `MPTIssue`. + */ template concept ValidIssueType = std::is_same_v || std::is_same_v; +/** Constrains template parameters to any type convertible to a known asset representation. + * + * Broader than `ValidIssueType`: uses `is_convertible_v` rather than + * `is_same_v`, so it accepts any type with an implicit conversion path to + * `Asset`, `Issue`, `MPTIssue`, or `MPTID`. This enables generic code that + * accepts any "asset-like" value without requiring callers to normalise to a + * canonical form first. + * + * @tparam A Any type implicitly convertible to `Asset`, `Issue`, `MPTIssue`, or `MPTID`. + */ template concept AssetType = std::is_convertible_v || std::is_convertible_v || std::is_convertible_v || std::is_convertible_v; +/** Constrains template parameters to the two token-identity types used in payment paths. + * + * `PathAsset` carries only the currency/token specifier inside a payment path + * element — it explicitly does not carry issuer information. `Currency` covers + * both XRP (the zero currency) and IOU tokens; `MPTID` covers MPT issuances. + * This concept gates `PathAsset::get()`, `PathAsset::holds()`, and the + * `kIS_CURRENCY_V`/`kIS_MPTID_V` helper constants in `PathAsset.h`. + * + * @tparam T `Currency` or `MPTID`. + */ template concept ValidPathAsset = (std::is_same_v || std::is_same_v); +/** Constrains a pair of step-amount types to a legal DEX trading pair. + * + * Both sides must independently be one of `XRPAmount`, `IOUAmount`, or + * `MPTAmount`, but the XRP/XRP combination is structurally illegal on the + * XRPL order book — an offer cannot have both `TakerPays` and `TakerGets` + * denominated in XRP. `OfferStream::shouldRmSmallIncreasedQOffer()` uses this + * concept to encode that invariant at the type system level rather than as a + * runtime assertion. + * + * @tparam TTakerPays The amount type for what the taker pays; must satisfy `StepAmount`. + * @tparam TTakerGets The amount type for what the taker receives; must satisfy `StepAmount`. + * @note The constraint is equivalent to: both sides are valid step-amount types + * AND NOT (TTakerPays == XRPAmount AND TTakerGets == XRPAmount). + */ template concept ValidTaker = ((std::is_same_v || std::is_same_v || @@ -38,46 +100,70 @@ concept ValidTaker = namespace detail { -// This template combines multiple callable objects (lambdas) into a single -// object that std::visit can use for overload resolution. +/** Combines multiple callable objects (lambdas) into a single overload set for `std::visit`. + * + * Implements the classical *overloaded* pattern: by inheriting from every + * lambda type and pulling each `operator()` into the derived scope, this + * struct becomes a single callable that overload-resolution can dispatch + * correctly based on the active variant alternative at runtime. + * + * Prefer constructing instances via `makeCombineVisitors()` rather than + * direct construction; the factory applies `std::decay_t` and uses function + * template argument deduction, which is more portable than CTAD for variadic + * class templates. + * + * @tparam Ts Lambda (or other callable) types to merge into one overload set. + * @see makeCombineVisitors + */ template struct CombineVisitors : Ts... { - // Bring all operator() overloads from base classes into this scope. - // It's the mechanism that makes the CombineVisitors struct function - // as a single callable object with multiple overloads. using Ts::operator()...; - // Perfect forwarding constructor to correctly initialize the base class - // lambdas + /** Initialises each base-class lambda by perfect-forwarding its argument. */ constexpr CombineVisitors(Ts&&... ts) : Ts(std::forward(ts))... { } }; -// This function forces function template argument deduction, which is more -// robust than class template argument deduction (CTAD) via the deduction guide. +/** Creates a `CombineVisitors` from a pack of callables. + * + * Preferred over a CTAD deduction guide because function template argument + * deduction handles parameter packs more robustly than class-template + * argument deduction (CTAD) across compilers. `std::decay_t` strips + * references and cv-qualifiers from lambda types before they become base + * classes, ensuring the inherited `operator()` calls have the correct value + * categories. + * + * @tparam Ts Callable types; typically lambdas. + * @param ts Callables to combine. + * @return A `CombineVisitors...>` holding all overloads. + */ template constexpr CombineVisitors...> makeCombineVisitors(Ts&&... ts) { - // std::decay_t is used to remove references/constness from the lambda - // types before they are passed as template arguments to the CombineVisitors - // struct. return CombineVisitors...>{std::forward(ts)...}; } -// This function takes ANY variant and ANY number of visitors, and performs the -// visit. It is the reusable core logic. +/** Visits a variant with a set of per-alternative callables. + * + * Combines `visitors...` into a single overload set via `makeCombineVisitors` + * and delegates to `std::visit`. This is the reusable core called by + * `Asset::visit()` and `PathAsset::visit()`; callers should go through those + * member functions rather than invoking this directly. + * + * @tparam Variant A `std::variant` specialisation. + * @tparam Visitors Callable types, one per variant alternative. + * @param v The variant to dispatch on. + * @param visitors Callables covering each alternative of `v`. + * @return The return value of the selected visitor. + */ template constexpr auto visit(Variant&& v, Visitors&&... visitors) -> decltype(auto) { - // Use the function template helper instead of raw CTAD. auto visitorSet = makeCombineVisitors(std::forward(visitors)...); - - // Delegate to std::visit, perfectly forwarding the variant and the visitor - // set. return std::visit(visitorSet, std::forward(v)); } diff --git a/include/xrpl/protocol/ErrorCodes.h b/include/xrpl/protocol/ErrorCodes.h index a91adfc55a..738f693f64 100644 --- a/include/xrpl/protocol/ErrorCodes.h +++ b/include/xrpl/protocol/ErrorCodes.h @@ -1,5 +1,16 @@ #pragma once +/** @file + * Single source of truth for every RPC error the XRPL node can emit. + * + * Defines the stable numeric code space (`ErrorCodeI`), a parallel warning + * code space (`WarningCodeI`), the `ErrorInfo` struct that binds each code + * to a token and HTTP status, and the complete vocabulary of JSON helpers + * used by RPC handlers to produce well-formed error responses. Every + * component that rejects an RPC call — from malformed-parameter checks to + * ledger-not-found conditions — funnels through this file. + */ + #include #include @@ -7,158 +18,174 @@ namespace xrpl { // VFALCO NOTE These are outside the RPC namespace -// NOTE: Although the precise numeric values of these codes were never -// intended to be stable, several API endpoints include the numeric values. -// Some users came to rely on the values, meaning that renumbering would be -// a breaking change for those users. -// -// We therefore treat the range of values as stable although they are not -// and are subject to change. -// -// Please only append to this table. Do not "fill-in" gaps and do not re-use -// or repurpose error code values. +/** Numeric codes for every named RPC error the XRPL node can return. + * + * Values are used as machine-readable error identifiers in RPC responses + * (the `error_code` field). They were never formally promised to be stable, + * but real API consumers depend on them, so the range is now treated as + * **append-only**: new codes go at the end, gaps are never filled, and + * retired values are commented out rather than reassigned. + * + * Codes are grouped thematically (general failures, networking, ledger + * state, malformed commands, bad parameters, internal errors) to guide + * maintainers when choosing where a new code belongs. + * + * `RpcLast` must always equal the highest assigned code; the compile-time + * validation in `ErrorCodes.cpp` enforces this and will fail to compile if + * it is not updated when a new code is added. + * + * @note `RpcUnknown` (-1) is returned by `getErrorInfo()` for any code + * that falls outside the range `(RpcSuccess, RpcLast]`. + */ // Protocol-wide, 50+ files // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum ErrorCodeI { // -1 represents codes not listed in this enumeration - RpcUnknown = -1, + RpcUnknown = -1, /**< Sentinel for out-of-range or unrecognised codes. */ - RpcSuccess = 0, + RpcSuccess = 0, /**< No error. */ - RpcBadSyntax = 1, - RpcJsonRpc = 2, - RpcForbidden = 3, + // General failures + RpcBadSyntax = 1, /**< Request could not be parsed as valid JSON-RPC. */ + RpcJsonRpc = 2, /**< JSON-RPC transport-level error. */ + RpcForbidden = 3, /**< Credentials rejected. */ - RpcWrongNetwork = 4, + RpcWrongNetwork = 4, /**< Request arrived on the wrong network. */ // Misc failure // unused 5, - RpcNoPermission = 6, - RpcNoEvents = 7, + RpcNoPermission = 6, /**< Caller lacks permission for this command. */ + RpcNoEvents = 7, /**< Transport does not support event subscriptions. */ // unused 8, - RpcTooBusy = 9, - RpcSlowDown = 10, - RpcHighFee = 11, - RpcNotEnabled = 12, - RpcNotReady = 13, - RpcAmendmentBlocked = 14, + RpcTooBusy = 9, /**< Server load too high to serve the request now. */ + RpcSlowDown = 10, /**< Caller is sending requests too rapidly. */ + RpcHighFee = 11, /**< Current fee exceeds the caller's stated limit. */ + RpcNotEnabled = 12, /**< Feature not enabled in the server's configuration. */ + RpcNotReady = 13, /**< Server is not yet ready to handle this request. */ + RpcAmendmentBlocked = 14, /**< Node needs an upgrade; amendment-blocked. */ // Networking - RpcNoClosed = 15, - RpcNoCurrent = 16, - RpcNoNetwork = 17, - RpcNotSynced = 18, + RpcNoClosed = 15, /**< Closed ledger is unavailable. */ + RpcNoCurrent = 16, /**< Current ledger is unavailable. */ + RpcNoNetwork = 17, /**< Not synced to the network. */ + RpcNotSynced = 18, /**< Not synced to the network. */ // Ledger state - RpcActNotFound = 19, + RpcActNotFound = 19, /**< Specified account does not exist in the ledger. */ // unused 20, - RpcLgrNotFound = 21, - RpcLgrNotValidated = 22, - RpcMasterDisabled = 23, + RpcLgrNotFound = 21, /**< Requested ledger does not exist. */ + RpcLgrNotValidated = 22, /**< Requested ledger exists but has not yet been validated. */ + RpcMasterDisabled = 23, /**< Master key is disabled on this account. */ // unused 24, // unused 25, // unused 26, // unused 27, // unused 28, - RpcTxnNotFound = 29, - RpcInvalidHotwallet = 30, + RpcTxnNotFound = 29, /**< Transaction not found. */ + RpcInvalidHotwallet = 30, /**< Specified hotwallet address is invalid. */ // Malformed command - RpcInvalidParams = 31, - RpcUnknownCommand = 32, - RpcNoPfRequest = 33, + RpcInvalidParams = 31, /**< One or more request parameters are invalid. */ + RpcUnknownCommand = 32, /**< The requested command is not recognised. */ + RpcNoPfRequest = 33, /**< No pathfinding request is currently in progress. */ // Bad parameter // NOT USED DO NOT USE AGAIN rpcACT_BITCOIN = 34, - RpcActMalformed = 35, - RpcAlreadyMultisig = 36, - RpcAlreadySingleSig = 37, + RpcActMalformed = 35, /**< Account address is malformed. */ + RpcAlreadyMultisig = 36, /**< Account is already set up for multi-signing. */ + RpcAlreadySingleSig = 37, /**< Account is already single-signed. */ // unused 38, // unused 39, - RpcBadFeature = 40, - RpcBadIssuer = 41, - RpcBadMarket = 42, - RpcBadSecret = 43, - RpcBadSeed = 44, - RpcChannelMalformed = 45, - RpcChannelAmtMalformed = 46, - RpcCommandMissing = 47, - RpcDstActMalformed = 48, - RpcDstActMissing = 49, - RpcDstActNotFound = 50, - RpcDstAmtMalformed = 51, - RpcDstAmtMissing = 52, - RpcDstIsrMalformed = 53, + RpcBadFeature = 40, /**< Unknown or invalid amendment feature. */ + RpcBadIssuer = 41, /**< Issuer account address is malformed. */ + RpcBadMarket = 42, /**< Requested order-book does not exist. */ + RpcBadSecret = 43, /**< Secret key does not match the specified account. */ + RpcBadSeed = 44, /**< Seed value is disallowed. */ + RpcChannelMalformed = 45, /**< Payment channel identifier is malformed. */ + RpcChannelAmtMalformed = 46, /**< Payment channel amount is malformed. */ + RpcCommandMissing = 47, /**< Request object is missing the command field. */ + RpcDstActMalformed = 48, /**< Destination account address is malformed. */ + RpcDstActMissing = 49, /**< Destination account was not provided. */ + RpcDstActNotFound = 50, /**< Destination account does not exist in the ledger. */ + RpcDstAmtMalformed = 51, /**< Destination amount, currency, or issuer is malformed. */ + RpcDstAmtMissing = 52, /**< Destination amount, currency, or issuer was not provided. */ + RpcDstIsrMalformed = 53, /**< Destination issuer is malformed. */ // unused 54, // unused 55, // unused 56, - RpcLgrIdxsInvalid = 57, - RpcLgrIdxMalformed = 58, + RpcLgrIdxsInvalid = 57, /**< Ledger index range is invalid. */ + RpcLgrIdxMalformed = 58, /**< Individual ledger index is malformed. */ // unused 59, // unused 60, // unused 61, - RpcPublicMalformed = 62, - RpcSigningMalformed = 63, - RpcSendmaxMalformed = 64, - RpcSrcActMalformed = 65, - RpcSrcActMissing = 66, - RpcSrcActNotFound = 67, - RpcDelegateActNotFound = 68, - RpcSrcCurMalformed = 69, - RpcSrcIsrMalformed = 70, - RpcStreamMalformed = 71, - RpcAtxDeprecated = 72, + RpcPublicMalformed = 62, /**< Public key is malformed. */ + RpcSigningMalformed = 63, /**< Transaction signing data is malformed. */ + RpcSendmaxMalformed = 64, /**< SendMax amount is malformed. */ + RpcSrcActMalformed = 65, /**< Source account address is malformed. */ + RpcSrcActMissing = 66, /**< Source account was not provided. */ + RpcSrcActNotFound = 67, /**< Source account does not exist in the ledger. */ + RpcDelegateActNotFound = 68, /**< Delegate account does not exist in the ledger. */ + RpcSrcCurMalformed = 69, /**< Source currency is malformed. */ + RpcSrcIsrMalformed = 70, /**< Source issuer is malformed. */ + RpcStreamMalformed = 71, /**< Subscription stream specification is malformed. */ + RpcAtxDeprecated = 72, /**< Deprecated API endpoint; use the current API. */ // Internal error (should never happen) - RpcInternal = 73, // Generic internal error. - RpcNotImpl = 74, - RpcNotSupported = 75, - RpcBadKeyType = 76, - RpcDbDeserialization = 77, - RpcExcessiveLgrRange = 78, - RpcInvalidLgrRange = 79, - RpcExpiredValidatorList = 80, + RpcInternal = 73, /**< Generic internal server error. */ + RpcNotImpl = 74, /**< Feature not yet implemented. */ + RpcNotSupported = 75, /**< Operation not supported by this server. */ + RpcBadKeyType = 76, /**< Key type is not supported. */ + RpcDbDeserialization = 77, /**< Failed to deserialize an object from the database. */ + RpcExcessiveLgrRange = 78, /**< Requested ledger range exceeds the 1000-ledger limit. */ + RpcInvalidLgrRange = 79, /**< Requested ledger range bounds are logically invalid. */ + RpcExpiredValidatorList = 80, /**< Validator list has expired; node needs an updated UNL. */ // unused = 90, // DEPRECATED. New code must not use this value. - RpcReportingUnsupported = 91, + RpcReportingUnsupported = 91, /**< @deprecated Reporting-mode-only command sent to a non-reporting node. */ - RpcObjectNotFound = 92, + RpcObjectNotFound = 92, /**< Requested ledger object was not found. */ // AMM - RpcIssueMalformed = 93, + RpcIssueMalformed = 93, /**< AMM asset issue specification is malformed. */ // Oracle - RpcOracleMalformed = 94, + RpcOracleMalformed = 94, /**< Oracle request is malformed. */ // deposit_authorized + credentials - RpcBadCredentials = 95, + RpcBadCredentials = 95, /**< Credentials do not exist, are not accepted, or have expired. */ // Simulate - RpcTxSigned = 96, + RpcTxSigned = 96, /**< Simulate rejected a pre-signed transaction. */ // Pathfinding - RpcDomainMalformed = 97, + RpcDomainMalformed = 97, /**< Domain field is malformed. */ // ledger_entry - RpcEntryNotFound = 98, - RpcUnexpectedLedgerType = 99, + RpcEntryNotFound = 98, /**< Requested ledger entry was not found. */ + RpcUnexpectedLedgerType = 99, /**< Ledger entry type does not match the request. */ - RpcLast = RpcUnexpectedLedgerType // rpcLAST should always equal the last code. + RpcLast = RpcUnexpectedLedgerType /**< Sentinel: always equal to the highest assigned code. */ }; -/** Codes returned in the `warnings` array of certain RPC commands. - - These values need to remain stable. -*/ +/** Numeric codes returned in the `warnings` array of certain RPC responses. + * + * Warning codes appear alongside a successful result (not in the top-level + * `error` field) and inform the caller of advisory conditions such as + * imminent amendment blocking or a deprecated field being used. + * + * Values start at 1001 to be clearly distinct from `ErrorCodeI` values and + * must remain **stable** — external implementations such as Clio hardcode + * specific values (notably `WarnRpcFieldsDeprecated = 2004`). + */ // Protocol-wide, 50+ files // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum WarningCodeI { - WarnRpcUnsupportedMajority = 1001, - WarnRpcAmendmentBlocked = 1002, - WarnRpcExpiredValidatorList = 1003, + WarnRpcUnsupportedMajority = 1001, /**< A non-default amendment has gained majority support. */ + WarnRpcAmendmentBlocked = 1002, /**< Node is amendment-blocked and needs an upgrade. */ + WarnRpcExpiredValidatorList = 1003, /**< Validator list has expired. */ // unused = 1004 - WarnRpcFieldsDeprecated = 2004, // xrpld needs to maintain - // compatibility with Clio on this code. + WarnRpcFieldsDeprecated = 2004, /**< Request used one or more deprecated fields. + * @note Value must stay fixed at 2004; Clio hardcodes it. */ }; //------------------------------------------------------------------------------ @@ -167,144 +194,298 @@ enum WarningCodeI { namespace RPC { -/** Maps an rpc error code to its token, default message, and HTTP status. */ +/** Binds an `ErrorCodeI` to its human-readable token, default message, and HTTP status. + * + * Instances are stored in a compile-time-validated array in `ErrorCodes.cpp` + * and returned by reference from `getErrorInfo()`. `Json::StaticString` fields + * hold raw `const char*` pointers to string literals, avoiding heap allocation + * when the token is written into a JSON response. + * + * The `http_status` field drives load-balancer failover semantics: errors that + * indicate a node is temporarily unable to serve (e.g., amendment-blocked, + * too-busy) use 5xx/429 so a proxy can redirect to a healthy peer; client-fault + * errors use 4xx; everything else defaults to 200 for backward compatibility. + */ struct ErrorInfo { - // Default ctor needed to produce an empty std::array during constexpr eval. + /** Default-constructs an unknown-error entry. + * + * Required so that `std::array` can be value-initialised + * during `constexpr` evaluation of the lookup table. + */ constexpr ErrorInfo() : code(RpcUnknown), token("unknown"), message("An unknown error code."), http_status(200) { } + /** Constructs an `ErrorInfo` with HTTP status defaulting to 200. + * + * @param code The `ErrorCodeI` value this entry represents. + * @param token Short machine-readable string token (e.g., `"invalidParams"`). + * @param message Default human-readable error message. + */ constexpr ErrorInfo(ErrorCodeI code, char const* token, char const* message) : code(code), token(token), message(message), http_status(200) { } + /** Constructs an `ErrorInfo` with an explicit HTTP status. + * + * @param code The `ErrorCodeI` value this entry represents. + * @param token Short machine-readable string token. + * @param message Default human-readable error message. + * @param httpStatus HTTP status code returned to clients and load balancers. + */ constexpr ErrorInfo(ErrorCodeI code, char const* token, char const* message, int httpStatus) : code(code), token(token), message(message), http_status(httpStatus) { } - ErrorCodeI code; - json::StaticString token; - json::StaticString message; - int http_status; + ErrorCodeI code; /**< Numeric error code. */ + json::StaticString token; /**< Short machine-readable string token (e.g., `"invalidParams"`). */ + json::StaticString message; /**< Default human-readable error message. */ + int http_status; /**< HTTP status for this error; 200 unless overridden. */ }; -/** Returns an ErrorInfo that reflects the error code. */ +/** Look up the `ErrorInfo` for a given error code. + * + * Performs a single bounds check followed by a direct array subscript — + * O(1) with no hash table or binary search. + * + * @param code The error code to look up. + * @return A `const` reference to the matching `ErrorInfo`, or to an + * internal unknown-error sentinel if @p code is outside the range + * `(RpcSuccess, RpcLast]`. + */ ErrorInfo const& getErrorInfo(ErrorCodeI code); -/** Add or update the json update to reflect the error code. */ +/** Stamp `error`, `error_code`, and `error_message` fields onto a JSON object. + * + * Uses the default message registered for @p code. Any existing values for + * those three fields are overwritten. + * + * @param code The RPC error code whose metadata to inject. + * @param json The JSON object to mutate. + */ /** @{ */ void injectError(ErrorCodeI code, json::Value& json); +/** Stamp `error`, `error_code`, and `error_message` fields onto a JSON object, + * replacing the default message with a caller-supplied string. + * + * The machine-readable `error` token and numeric `error_code` are taken from + * the registry; only `error_message` is overridden, enabling context-specific + * diagnostics (e.g., naming the exact malformed field) while keeping the + * stable fields intact. + * + * @param code The RPC error code whose token and numeric code to inject. + * @param message Context-specific human-readable message. + * @param json The JSON object to mutate. + */ void injectError(ErrorCodeI code, std::string const& message, json::Value& json); /** @} */ -/** Returns a new json object that reflects the error code. */ +/** Construct a fresh JSON error object for the given code. + * + * Convenience wrapper around `injectError` for handlers that build a response + * from scratch rather than annotating an existing object. + * + * @param code The RPC error code. + * @return A new `Json::Value` object with `error`, `error_code`, and + * `error_message` populated from the registry. + */ /** @{ */ json::Value makeError(ErrorCodeI code); + +/** Construct a fresh JSON error object with a caller-supplied message. + * + * @param code The RPC error code. + * @param message Context-specific message written to `error_message`. + * @return A new `Json::Value` object with `error` and `error_code` from + * the registry and `error_message` set to @p message. + */ json::Value makeError(ErrorCodeI code, std::string const& message); /** @} */ -/** Returns a new json object that indicates invalid parameters. */ -/** @{ */ +/** Construct an `rpcINVALID_PARAMS` error object with a caller-supplied message. + * + * Thin wrapper around `makeError(RpcInvalidParams, message)` used by the + * field-error helper family below to avoid repetitive code at every + * parameter-validation site. + * + * @param message Human-readable description of the parameter problem. + * @return A new `Json::Value` error object for `RpcInvalidParams`. + */ inline json::Value makeParamError(std::string const& message) { return makeError(RpcInvalidParams, message); } +/** Format a "missing field" diagnostic string. + * + * @param name The field name that was absent. + * @return The string `"Missing field ''."`. + */ inline std::string missingFieldMessage(std::string const& name) { return "Missing field '" + name + "'."; } +/** Return an `rpcINVALID_PARAMS` error for a missing required field. + * + * @param name The name of the missing field. + * @return A new JSON error object with a "Missing field" message. + */ inline json::Value missingFieldError(std::string const& name) { return makeParamError(missingFieldMessage(name)); } +/** @copydoc missingFieldError(std::string const&) + * + * @param name The name of the missing field as a `Json::StaticString`. + */ inline json::Value missingFieldError(json::StaticString name) { return missingFieldError(std::string(name)); } +/** Format a "field is not an object" diagnostic string. + * + * @param name The field name that was expected to be an object. + * @return The string `"Invalid field '', not object."`. + */ inline std::string objectFieldMessage(std::string const& name) { return "Invalid field '" + name + "', not object."; } +/** Return an `rpcINVALID_PARAMS` error for a field that must be an object. + * + * @param name The name of the field with the wrong type. + * @return A new JSON error object with a "not object" message. + */ inline json::Value objectFieldError(std::string const& name) { return makeParamError(objectFieldMessage(name)); } +/** @copydoc objectFieldError(std::string const&) + * + * @param name The field name as a `Json::StaticString`. + */ inline json::Value objectFieldError(json::StaticString name) { return objectFieldError(std::string(name)); } +/** Format a generic "invalid field" diagnostic string. + * + * @param name The field name that was invalid. + * @return The string `"Invalid field ''."`. + */ inline std::string invalidFieldMessage(std::string const& name) { return "Invalid field '" + name + "'."; } +/** @copydoc invalidFieldMessage(std::string const&) + * + * @param name The field name as a `Json::StaticString`. + */ inline std::string invalidFieldMessage(json::StaticString name) { return invalidFieldMessage(std::string(name)); } +/** Return an `rpcINVALID_PARAMS` error for a field that failed generic validation. + * + * @param name The name of the invalid field. + * @return A new JSON error object with an "Invalid field" message. + */ inline json::Value invalidFieldError(std::string const& name) { return makeParamError(invalidFieldMessage(name)); } +/** @copydoc invalidFieldError(std::string const&) + * + * @param name The field name as a `Json::StaticString`. + */ inline json::Value invalidFieldError(json::StaticString name) { return invalidFieldError(std::string(name)); } +/** Format a "field has wrong type" diagnostic string. + * + * @param name The field name. + * @param type The expected type description (e.g., `"unsigned integer"`). + * @return The string `"Invalid field '', not ."`. + */ inline std::string expectedFieldMessage(std::string const& name, std::string const& type) { return "Invalid field '" + name + "', not " + type + "."; } +/** @copydoc expectedFieldMessage(std::string const&, std::string const&) + * + * @param name The field name as a `Json::StaticString`. + * @param type The expected type description. + */ inline std::string expectedFieldMessage(json::StaticString name, std::string const& type) { return expectedFieldMessage(std::string(name), type); } +/** Return an `rpcINVALID_PARAMS` error for a field whose value has the wrong type. + * + * @param name The name of the field with the wrong type. + * @param type The expected type description (e.g., `"unsigned integer"`). + * @return A new JSON error object with a "not " message. + */ inline json::Value expectedFieldError(std::string const& name, std::string const& type) { return makeParamError(expectedFieldMessage(name, type)); } +/** @copydoc expectedFieldError(std::string const&, std::string const&) + * + * @param name The field name as a `Json::StaticString`. + * @param type The expected type description. + */ inline json::Value expectedFieldError(json::StaticString name, std::string const& type) { return expectedFieldError(std::string(name), type); } +/** Return an `rpcINVALID_PARAMS` error for commands that require a validator node. + * + * Used by the handful of commands (e.g., `validator_info`) that are only + * meaningful when the local node is a validator. + * + * @return A new JSON error object with the message `"not a validator"`. + */ inline json::Value notValidatorError() { @@ -313,17 +494,47 @@ notValidatorError() /** @} */ -/** Returns `true` if the json contains an rpc error specification. */ +/** Return `true` if @p json represents an RPC error response. + * + * The canonical test used throughout the RPC layer to distinguish error + * responses from successful ones. Only the presence of the `"error"` key + * is checked; the specific code is not inspected. + * + * @param json The JSON value to probe. + * @return `true` if @p json is an object containing an `"error"` member. + * @see getErrorInfo() for code-level branching on a known error. + */ bool containsError(json::Value const& json); -/** Returns http status that corresponds to the error code. */ +/** Return the HTTP status integer associated with an error code. + * + * Used by the HTTP transport layer when constructing response headers. + * HTTP status assignments follow load-balancer failover semantics: transient + * server-side conditions (amendment-blocked, too-busy, not-synced) use 5xx + * or 429 so proxies can retry on a healthy peer; client-fault errors use + * 4xx; codes with no explicit assignment default to 200. + * + * @param code The RPC error code. + * @return HTTP status integer (e.g., 200, 400, 403, 503). + */ int errorCodeHttpStatus(ErrorCodeI code); } // namespace RPC -/** Returns a single string with the contents of an RPC error. */ +/** Concatenate the `error` token and `error_message` from a JSON error value. + * + * Convenience helper for producing logging and diagnostic strings from an + * already-constructed RPC error object. + * + * @param jv A `Json::Value` that must contain an RPC error + * (i.e., `RPC::containsError(jv)` is `true`). + * @return The `error` token string concatenated with the `error_message` + * string, with no separator. + * @note An `XRPL_ASSERT` fires in debug builds if @p jv does not contain + * an error, making misuse diagnosable early. + */ std::string rpcErrorString(json::Value const& jv); diff --git a/include/xrpl/protocol/Feature.h b/include/xrpl/protocol/Feature.h index 281e598bf7..5d9c40b83c 100644 --- a/include/xrpl/protocol/Feature.h +++ b/include/xrpl/protocol/Feature.h @@ -64,23 +64,37 @@ namespace xrpl { -// Feature names must not exceed this length (in characters, excluding the null terminator). +/** Maximum allowed length of a feature name in characters, excluding the null terminator. */ static constexpr std::size_t kMAX_FEATURE_NAME_SIZE = 63; -// Reserve this exact feature-name length (in characters/bytes, excluding the null terminator) -// so that a 32-byte uint256 (for example, in WASM or other interop contexts) can be used -// as a compact, fixed-size feature selector without conflicting with human-readable names. + +/** Feature-name length (in bytes, excluding the null terminator) reserved for + * raw `uint256` hash selectors. + * + * A `uint256` is 32 bytes. Allowing a human-readable name that is exactly 32 + * characters long would create an ambiguous namespace collision with compact + * feature selectors used in WASM or other interop contexts. Names of this + * exact length are rejected at compile time by `validFeatureNameSize()`. + */ static constexpr std::size_t kRESERVED_FEATURE_NAME_SIZE = 32; -// Both validFeatureNameSize and validFeatureName are consteval functions that can be used in -// static_asserts to validate feature names at compile time. They are only used inside -// enforceValidFeatureName in Feature.cpp, but are exposed here for testing. The expected -// parameter `auto fn` is a constexpr lambda which returns a const char*, making it available -// for compile-time evaluation. Read more in https://accu.org/journals/overload/30/172/wu/ +/** Validate a feature name's length at compile time. + * + * Returns `true` iff the name produced by `fn` satisfies both: + * - length ≤ `kMAX_FEATURE_NAME_SIZE` (63 characters), and + * - length ≠ `kRESERVED_FEATURE_NAME_SIZE` (32 characters). + * + * The parameter `fn` must be a `constexpr` lambda returning `const char*`, + * which makes the string literal available for compile-time evaluation. + * See https://accu.org/journals/overload/30/172/wu/ for the idiom. + * + * @param fn A `consteval`-compatible nullary callable returning `const char*`. + * @return `true` if the name length is valid, `false` otherwise. + * @note `std::strlen` is not `constexpr`; a manual loop computes the length. + */ consteval auto validFeatureNameSize(auto fn) -> bool { constexpr char const* kN = fn(); - // Note, std::strlen is not constexpr, we need to implement our own here. constexpr std::size_t kLEN = [](auto n) { std::size_t ret = 0; for (auto ptr = n; *ptr != '\0'; ret++, ++ptr) @@ -91,14 +105,22 @@ validFeatureNameSize(auto fn) -> bool kLEN <= kMAX_FEATURE_NAME_SIZE; } +/** Validate that a feature name contains only printable ASCII characters. + * + * Returns `true` iff every character in the name produced by `fn` has value + * ≥ 0x20 and the high bit (0x80) clear. Rejects: + * - Control characters (below 0x20, e.g. `\t`, `\n`). + * - Non-ASCII bytes (high bit set), which appear in UTF-8 multibyte sequences + * and Unicode identifiers that C++ technically permits but that are visually + * confusable with ASCII characters (e.g. Greek Capital Alpha vs. `'A'`). + * + * @param fn A `consteval`-compatible nullary callable returning `const char*`. + * @return `true` if all characters are printable ASCII, `false` otherwise. + */ consteval auto validFeatureName(auto fn) -> bool { constexpr char const* kN = fn(); - // Prevent the use of visually confusable characters and enforce that feature names - // are always valid ASCII. This is needed because C++ allows Unicode identifiers. - // Characters below 0x20 are nonprintable control characters, and characters with the 0x80 bit - // set are non-ASCII (e.g. UTF-8 encoding of Unicode), so both are disallowed. for (auto ptr = kN; *ptr != '\0'; ++ptr) { if (*ptr & 0x80 || *ptr < 0x20) @@ -107,10 +129,48 @@ validFeatureName(auto fn) -> bool return true; } -enum class VoteBehavior : int { Obsolete = -1, DefaultNo = 0, DefaultYes = 1 }; -enum class AmendmentSupport : int { Retired = -1, Supported = 0, Unsupported = 1 }; +/** Controls whether this server votes for an amendment it supports. + * + * Governs the server's default stance during the amendment voting round. + * The winning value for most amendments progresses from `DefaultNo` + * (governance decides timing) to optionally `DefaultYes` (critical fixes), + * and then to `Obsolete` if the amendment is abandoned without activating. + */ +enum class VoteBehavior : int { + Obsolete = -1, /**< Amendment supported but no longer voted for; retained + * for ledger compatibility only. */ + DefaultNo = 0, /**< Server supports but abstains by default; external + * governance decides when to activate. */ + DefaultYes = 1, /**< Server actively votes for activation; reserved for + * critical bug fixes after off-chain consensus. */ +}; -/** All amendments libxrpl knows about. */ +/** Records how well this build understands a given amendment. + * + * Used by `allAmendments()` to report the full picture of what the server + * knows about each amendment, including retired ones whose conditional code + * has been removed. + */ +enum class AmendmentSupport : int { + Retired = -1, /**< Conditional code removed; amendment remains registered + * so nodes stay amendment-compatible with old ledgers. */ + Supported = 0, /**< Amendment is recognized and the server may vote for it. */ + Unsupported = 1, /**< Amendment is known but this build does not implement it. */ +}; + +/** Return every amendment this build has ever known about, including retired ones. + * + * Maps each amendment's string name to its `AmendmentSupport` status: + * `Supported` (recognized and votable), `Unsupported` (declared but not + * implemented by this build), or `Retired` (conditional code removed, + * retained for ledger compatibility). The returned reference is stable for + * the process lifetime. + * + * @return A sorted map of amendment name → `AmendmentSupport`. + * @note This function must only be called after static initialization + * completes. Calling it during static initialization of another + * translation unit risks querying before the registry is sealed. + */ std::map const& allAmendments(); @@ -132,10 +192,16 @@ namespace detail { #define XRPL_RETIRE_FIX(name) +1 // NOLINTEND(bugprone-macro-parentheses) -// This value SHOULD be equal to the number of amendments registered in -// Feature.cpp. Because it's only used to reserve storage, and determine how -// large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than -// the actual number of amendments. A LogicError on startup will verify this. +/** Compile-time upper bound on the total number of registered amendments. + * + * Used as the `std::bitset` template parameter for `FeatureBitset`. SHOULD + * equal the actual count of entries in `features.macro`, but MAY be larger + * (reserving headroom for future additions). MUST NOT be less than the actual + * count — a `LogicError` on startup verifies this. + * + * @note This is a ceiling, not an exact count. Do not use it as an iteration + * bound or to infer the number of active amendments. + */ static constexpr std::size_t kNUM_FEATURES = (0 + #include @@ -150,40 +216,110 @@ static constexpr std::size_t kNUM_FEATURES = #undef XRPL_FEATURE #pragma pop_macro("XRPL_FEATURE") -/** Amendments that this server supports and the default voting behavior. - Whether they are enabled depends on the Rules defined in the validated - ledger */ +/** Return amendments this build supports and their default vote stance. + * + * Maps each supported amendment's name to its `VoteBehavior`. An amendment + * appearing here is recognized by this build; whether it is actually active + * depends on the `Rules` derived from the validated ledger's Amendments + * object. Retired amendments (`VoteBehavior::Obsolete`) appear here but are + * not voted for. + * + * @return A sorted map of amendment name → `VoteBehavior`. + */ std::map const& supportedAmendments(); -/** Amendments that this server won't vote for by default. - - This function is only used in unit tests. -*/ +/** Return the count of supported amendments this server will NOT vote for. + * + * Includes both `VoteBehavior::DefaultNo` and `VoteBehavior::Obsolete` + * entries. Used in unit tests to verify the vote-tally invariant: + * `numDownVotedAmendments() + numUpVotedAmendments() == supportedAmendments().size()`. + * + * @return Count of amendments this server abstains from or treats as obsolete. + */ std::size_t numDownVotedAmendments(); -/** Amendments that this server will vote for by default. - - This function is only used in unit tests. -*/ +/** Return the count of supported amendments this server will vote for. + * + * Counts only `VoteBehavior::DefaultYes` entries. Used in unit tests to + * verify the vote-tally invariant alongside `numDownVotedAmendments()`. + * + * @return Count of amendments this server actively votes to activate. + */ std::size_t numUpVotedAmendments(); } // namespace detail +/** Look up a registered amendment by name and return its on-chain identifier. + * + * @param name The amendment's string name (e.g. `"Checks"`). + * @return The `uint256` hash computed as `sha512Half(name)`, or `std::nullopt` + * if no amendment with that name has been registered. + * @note Feature names are case-sensitive. Querying an unknown name returns + * `nullopt`; it does not throw. + */ std::optional getRegisteredFeature(std::string const& name); +/** Translate an amendment's `uint256` identifier to its `FeatureBitset` bit position. + * + * This is the hot-path translation used by every `FeatureBitset` operation. + * The result is stable for the process lifetime because the registry is sealed + * before any calls can be made. + * + * @param f A registered amendment identifier. + * @return The zero-based bit index within `FeatureBitset`. + * @throws LogicError if `f` is not a registered amendment. + */ size_t featureToBitsetIndex(uint256 const& f); +/** Translate a `FeatureBitset` bit position back to the amendment's `uint256`. + * + * Inverse of `featureToBitsetIndex()`. Used by `foreachFeature()` to convert + * set bits back into identifiers for callers. + * + * @param i A zero-based bit index within `FeatureBitset`. + * @return The `uint256` hash of the amendment registered at that position. + * @throws LogicError if `i` is out of bounds (≥ the number of registered amendments). + */ uint256 bitsetIndexToFeature(size_t i); +/** Return the human-readable name for an amendment, or its hex representation. + * + * Useful for diagnostics and logging when a `uint256` amendment ID needs to be + * displayed. + * + * @param f The amendment identifier to look up. + * @return The registered string name (e.g. `"Checks"`), or `to_string(f)` if + * `f` is not in the registry. + */ std::string featureToName(uint256 const& f); +/** A set of active amendments, represented as a bitset indexed by amendment ID. + * + * Wraps `std::bitset` and replaces integer-index access + * with `uint256`-based access. Externally every amendment is a `uint256` hash; + * internally `featureToBitsetIndex()` maps it to a compact sequential bit + * position, so all set operations run in O(1). + * + * The full suite of bitwise operators is provided for set algebra: + * - `operator&` — intersection (features enabled in both sets) + * - `operator|` — union (features enabled in either set) + * - `operator^` — symmetric difference + * - `operator-` — **set difference** (`lhs & ~rhs`), used in amendment voting + * to compute "amendments I support that are not yet enabled" + * + * Overloads accepting a bare `uint256` on either side construct a temporary + * single-element `FeatureBitset` for the operation. + * + * @see foreachFeature() to iterate all set bits. + * @see Rules::enabled() for the per-transaction query path. + */ class FeatureBitset : private std::bitset { using base = std::bitset; @@ -215,13 +351,30 @@ public: using base::to_ullong; using base::to_ulong; + /** Construct an empty feature set (no amendments enabled). */ FeatureBitset() = default; + /** Construct from a raw `std::bitset`, asserting no bits are lost. + * + * @param b A bitset whose bit layout matches the amendment registry's + * insertion order. Intended for internal use (e.g. bitwise operators). + */ explicit FeatureBitset(base const& b) : base(b) { XRPL_ASSERT(b.count() == count(), "xrpl::FeatureBitset::FeatureBitset(base) : count match"); } + /** Construct from one or more amendment identifiers. + * + * Each `uint256` is translated to its bitset position via + * `featureToBitsetIndex()`. Asserts that all supplied features are + * distinct (the resulting count equals the number of arguments). + * + * @param f First amendment identifier. + * @param fs Additional amendment identifiers (variadic). + * @throws LogicError (via `featureToBitsetIndex`) if any identifier is + * not registered. + */ template explicit FeatureBitset(uint256 const& f, Fs&&... fs) { @@ -232,6 +385,16 @@ public: "sizeof... do match"); } + /** Construct from any range of `uint256` amendment identifiers. + * + * Iterates `fs` and sets the corresponding bit for each element. + * Asserts that the resulting popcount equals `fs.size()` (all distinct). + * + * @tparam Col A range whose elements are convertible to `uint256`. + * @param fs A collection of amendment identifiers. + * @throws LogicError (via `featureToBitsetIndex`) if any identifier is + * not registered. + */ template explicit FeatureBitset(Col const& fs) { @@ -243,18 +406,35 @@ public: "size do match"); } + /** Return a reference to the bit corresponding to amendment `f`. + * + * @param f A registered amendment identifier. + * @throws LogicError if `f` is not registered. + */ auto operator[](uint256 const& f) { return base::operator[](featureToBitsetIndex(f)); } + /** Return the value of the bit corresponding to amendment `f`. + * + * @param f A registered amendment identifier. + * @throws LogicError if `f` is not registered. + */ auto operator[](uint256 const& f) const { return base::operator[](featureToBitsetIndex(f)); } + /** Set (or clear) the bit for amendment `f`. + * + * @param f A registered amendment identifier. + * @param value `true` to enable the amendment, `false` to disable. + * @return `*this`, for chaining. + * @throws LogicError if `f` is not registered. + */ FeatureBitset& set(uint256 const& f, bool value = true) { @@ -262,6 +442,12 @@ public: return *this; } + /** Clear the bit for amendment `f`. + * + * @param f A registered amendment identifier. + * @return `*this`, for chaining. + * @throws LogicError if `f` is not registered. + */ FeatureBitset& reset(uint256 const& f) { @@ -269,6 +455,12 @@ public: return *this; } + /** Toggle the bit for amendment `f`. + * + * @param f A registered amendment identifier. + * @return `*this`, for chaining. + * @throws LogicError if `f` is not registered. + */ FeatureBitset& flip(uint256 const& f) { @@ -276,6 +468,7 @@ public: return *this; } + /** Intersect this set with `rhs` in-place. */ FeatureBitset& operator&=(FeatureBitset const& rhs) { @@ -283,6 +476,7 @@ public: return *this; } + /** Union this set with `rhs` in-place. */ FeatureBitset& operator|=(FeatureBitset const& rhs) { @@ -290,79 +484,95 @@ public: return *this; } + /** Return the complement: every registered amendment NOT in this set. */ FeatureBitset operator~() const { return FeatureBitset{base::operator~()}; } + /** Return the intersection of two feature sets. */ friend FeatureBitset operator&(FeatureBitset const& lhs, FeatureBitset const& rhs) { return FeatureBitset{static_cast(lhs) & static_cast(rhs)}; } + /** Return the intersection of a feature set and a single amendment. */ friend FeatureBitset operator&(FeatureBitset const& lhs, uint256 const& rhs) { return lhs & FeatureBitset{rhs}; } + /** Return the intersection of a single amendment and a feature set. */ friend FeatureBitset operator&(uint256 const& lhs, FeatureBitset const& rhs) { return FeatureBitset{lhs} & rhs; } + /** Return the union of two feature sets. */ friend FeatureBitset operator|(FeatureBitset const& lhs, FeatureBitset const& rhs) { return FeatureBitset{static_cast(lhs) | static_cast(rhs)}; } + /** Return the union of a feature set and a single amendment. */ friend FeatureBitset operator|(FeatureBitset const& lhs, uint256 const& rhs) { return lhs | FeatureBitset{rhs}; } + /** Return the union of a single amendment and a feature set. */ friend FeatureBitset operator|(uint256 const& lhs, FeatureBitset const& rhs) { return FeatureBitset{lhs} | rhs; } + /** Return the symmetric difference of two feature sets. */ friend FeatureBitset operator^(FeatureBitset const& lhs, FeatureBitset const& rhs) { return FeatureBitset{static_cast(lhs) ^ static_cast(rhs)}; } + /** Return the symmetric difference of a feature set and a single amendment. */ friend FeatureBitset operator^(FeatureBitset const& lhs, uint256 const& rhs) { return lhs ^ FeatureBitset{rhs}; } + /** Return the symmetric difference of a single amendment and a feature set. */ friend FeatureBitset operator^(uint256 const& lhs, FeatureBitset const& rhs) { return FeatureBitset{lhs} ^ rhs; } - // set difference + /** Return the set difference: amendments in `lhs` that are not in `rhs` (`lhs & ~rhs`). + * + * Used in amendment voting to compute "amendments this server supports + * that have not yet been enabled on the network". + */ friend FeatureBitset operator-(FeatureBitset const& lhs, FeatureBitset const& rhs) { return lhs & ~rhs; } + /** Return the set difference of a feature set minus a single amendment. */ friend FeatureBitset operator-(FeatureBitset const& lhs, uint256 const& rhs) { return lhs - FeatureBitset{rhs}; } + /** Return the set difference: a single amendment minus all amendments in `rhs`. */ friend FeatureBitset operator-(uint256 const& lhs, FeatureBitset const& rhs) { @@ -370,6 +580,16 @@ public: } }; +/** Invoke a callback for each amendment enabled in `bs`. + * + * Iterates all bit positions in `bs`, translates each set bit back to its + * `uint256` amendment identifier via `bitsetIndexToFeature()`, and passes it + * to `f`. Unset bits are skipped. + * + * @tparam F A callable accepting a single `uint256 const&` argument. + * @param bs The feature set to iterate. + * @param f Callback invoked once per enabled amendment. + */ template void foreachFeature(FeatureBitset bs, F&& f) @@ -381,6 +601,13 @@ foreachFeature(FeatureBitset bs, F&& f) } } +// --- Amendment identifier declarations --- +// +// A second X-macro pass over features.macro declares one `extern uint256 const` +// variable per active amendment (e.g. `featureChecks`, `fixAMMOverflowOffer`). +// These are the identifiers used throughout the codebase in +// `rules.enabled(featureName)` calls. Retired entries expand to nothing because +// their conditional code has been removed. #pragma push_macro("XRPL_FEATURE") #undef XRPL_FEATURE #pragma push_macro("XRPL_FIX") diff --git a/include/xrpl/protocol/Fees.h b/include/xrpl/protocol/Fees.h index c94ba31b8a..5c41c92365 100644 --- a/include/xrpl/protocol/Fees.h +++ b/include/xrpl/protocol/Fees.h @@ -4,24 +4,56 @@ namespace xrpl { -// Deprecated constant for backwards compatibility with pre-XRPFees amendment. -// This was the reference fee units used in the old fee calculation. +/** Reference fee cost in abstract "fee units" used before the XRPFees amendment. + * + * Prior to `featureXRPFees`, transaction costs were expressed in fee units + * rather than drops; a reference transaction cost 10 fee units and the actual + * drop cost was determined by multiplying by a per-ledger scaling factor. + * After the amendment, fees are expressed natively in drops via `XRPAmount`. + * + * This constant is retained as a compatibility shim: it is written into + * `sfReferenceFeeUnits` in validation objects and `fee_ref` in JSON + * subscription messages when `featureXRPFees` is not active, preserving the + * legacy wire format consumed by older clients. + */ inline constexpr std::uint32_t kFEE_UNITS_DEPRECATED = 10; -/** Reflects the fee settings for a particular ledger. - - The fees are always the same for any transactions applied - to a ledger. Changes to fees occur in between ledgers. -*/ +/** Snapshot of a ledger's fee schedule. + * + * Packages the three economically significant fee parameters — transaction + * cost, base account reserve, and per-object reserve increment — into a + * single value-semantic aggregate. Obtained via `ReadView::fees()` so that + * transactors, preflight checks, and RPC handlers can query fee parameters + * without knowing the concrete view type. + * + * @invariant Fee parameters are constant within a ledger; changes take + * effect only at the next ledger boundary and are driven by validator + * fee-vote consensus updating the `FeeSettings` SLE. + */ struct Fees { - /** @brief Cost of a reference transaction in drops. */ + /** Minimum fee for a reference transaction, in drops. + * + * Transactions paying fewer drops than this value are rejected. + * Zero-initialized so that a default-constructed `Fees` acts as a + * safe placeholder in tests or before a ledger is loaded. + */ XRPAmount base{0}; - /** @brief Minimum XRP an account must hold to exist on the ledger. */ + /** Minimum XRP balance every account must hold simply to exist, in drops. + * + * An account whose balance falls below its total reserve (see + * `accountReserve()`) becomes reserve-deficient and cannot send payments. + */ XRPAmount reserve{0}; - /** @brief Additional XRP reserve required per owned ledger object. */ + /** Additional reserve required for each ledger object owned by an account, + * in drops. + * + * Applies to trust lines, offers, escrows, NFT tokens, and other objects + * that consume shared ledger state. Multiplied by `ownerCount` in + * `accountReserve()` to produce the total per-object reserve charge. + */ XRPAmount increment{0}; explicit Fees() = default; @@ -29,16 +61,31 @@ struct Fees Fees& operator=(Fees const&) = default; + /** Construct a fee schedule from explicit drop amounts. + * + * @param base Minimum fee for a reference transaction, in drops. + * @param reserve Base account reserve, in drops. + * @param increment Per-owned-object reserve increment, in drops. + */ Fees(XRPAmount base, XRPAmount reserve, XRPAmount increment) : base(base), reserve(reserve), increment(increment) { } - /** Returns the account reserve given the owner count, in drops. - - The reserve is calculated as the reserve base plus - the reserve increment times the number of increments. - */ + /** Compute the total XRP reserve an account must hold, in drops. + * + * Applies the formula `reserve + ownerCount * increment`. Callers + * checking whether an account can afford a *new* object should pass + * `ownerCount + 1` — the post-creation count — so the check accounts + * for the incremental cost of the object being created. + * + * @note Pseudo-accounts (AMM, Vault, LoanBroker) are exempt from + * reserves; their callers bypass this method entirely. + * + * @param ownerCount Number of ledger objects currently owned by the + * account (from `sfOwnerCount` on the `AccountRoot` SLE). + * @return Total required balance in drops. + */ [[nodiscard]] XRPAmount accountReserve(std::size_t ownerCount) const { diff --git a/include/xrpl/protocol/HashPrefix.h b/include/xrpl/protocol/HashPrefix.h index 1b05d450a1..c9a6a86e37 100644 --- a/include/xrpl/protocol/HashPrefix.h +++ b/include/xrpl/protocol/HashPrefix.h @@ -1,3 +1,12 @@ +/** @file + * Protocol hash domain separation via 4-byte prefixes. + * + * Every XRPL hashing context prepends a `HashPrefix` constant to its input + * so that two structurally different objects that share identical serialized + * bytes can never collide in hash space. See `HashPrefix` for the full list + * of contexts and `hash_append` for the N3980-compatible integration point. + */ + #pragma once #include @@ -8,6 +17,19 @@ namespace xrpl { namespace detail { +/** Pack three ASCII characters into the high 24 bits of a `uint32_t`. + * + * The resulting value has the form `(a << 24) | (b << 16) | (c << 8)`, + * leaving the low byte as zero. The trailing zero acts as an implicit + * separator and prevents any prefix from coinciding with a valid 1- or + * 2-byte byte sequence. The ASCII mnemonics make prefixes self-documenting + * in hex dumps (e.g. `TransactionId` appears as `0x54584E00`, i.e. `TXN\0`). + * + * @param a First character of the 3-letter mnemonic. + * @param b Second character of the 3-letter mnemonic. + * @param c Third character of the 3-letter mnemonic. + * @return A `constexpr` `uint32_t` suitable for use as a `HashPrefix` value. + */ constexpr std::uint32_t makeHashPrefix(char a, char b, char c) { @@ -17,58 +39,121 @@ makeHashPrefix(char a, char b, char c) } // namespace detail -/** Prefix for hashing functions. - - These prefixes are inserted before the source material used to generate - various hashes. This is done to put each hash in its own "space." This way, - two different types of objects with the same binary data will produce - different hashes. - - Each prefix is a 4-byte value with the last byte set to zero and the first - three bytes formed from the ASCII equivalent of some arbitrary string. For - example "TXN". - - @note Hash prefixes are part of the protocol; you cannot, arbitrarily, - change the type or the value of any of these without causing breakage. -*/ +/** 4-byte domain-separation sentinels prepended to every XRPL hash input. + * + * Each enumerator identifies a distinct hashing context. Prepending the + * prefix ensures that two objects from different contexts with byte-for-byte + * identical serializations always produce different digests, closing a class + * of hash-collision attacks at the protocol layer. + * + * The prefix is consumed in one of two ways depending on the call site: + * - **Serializer prefix** (`s.add32(HashPrefix::TxSign)`): writes the raw + * `uint32_t` into a `Serializer` buffer before appending signing fields. + * - **`hash_append` composition** (`hash_append(h, HashPrefix::InnerNode)`): + * feeds the 4-byte value directly into a streaming hasher, avoiding an + * intermediate buffer. + * + * Both produce the same 4-byte prefix at position zero of the hash input. + * + * @note Hash prefixes are protocol-immutable. Changing the mnemonic letters + * or the numeric value of any enumerator breaks consensus and cross-node + * compatibility irreversibly. + */ enum class HashPrefix : std::uint32_t { - /** transaction plus signature to give transaction ID */ + /** Canonical transaction ID: SHA-512/2 of `TXN\0` followed by the + * transaction bytes including its signature field. + * Distinct from `TxSign` (which excludes the signature) so that signing + * payloads and transaction IDs operate in separate hash namespaces. + */ TransactionId = detail::makeHashPrefix('T', 'X', 'N'), - /** transaction plus metadata */ + /** Transaction-plus-metadata leaf node in the transaction SHAMap + * (`SND\0`). Used by `SHAMapTxPlusMetaLeafNode` to hash a transaction + * together with its execution metadata. Distinct from `TransactionId` + * so a raw transaction and its annotated form can never collide. + */ TxNode = detail::makeHashPrefix('S', 'N', 'D'), - /** account state */ + /** Account-state leaf node in the SHAMap (`MLN\0`). Used by + * `SHAMapAccountStateLeafNode` when computing or verifying the hash of + * a single ledger-state entry. + */ LeafNode = detail::makeHashPrefix('M', 'L', 'N'), - /** inner node in V1 tree */ + /** SHAMap inner (branch) node (`MIN\0`). Used by `SHAMapInnerNode` + * when hashing the 16 child-hash slots of a branch node. Distinct from + * `LeafNode` so inner-node hashes never collide with leaf-node hashes. + */ InnerNode = detail::makeHashPrefix('M', 'I', 'N'), - /** ledger master data for signing */ + /** Ledger header signing payload (`LWR\0`). Prepended to the serialized + * ledger header before computing the ledger hash that validators sign and + * that serves as the canonical ledger identifier. + */ LedgerMaster = detail::makeHashPrefix('L', 'W', 'R'), - /** inner transaction to sign */ + /** Single-signature transaction signing payload (`STX\0`). Prepended to + * the serialized transaction body (with signing fields, without the + * signature itself) before a regular key or master key signs. A + * `TxSign` blob cannot be replayed as a `TxMultiSign` contribution + * because the two prefixes produce different digests. + */ TxSign = detail::makeHashPrefix('S', 'T', 'X'), - /** inner transaction to multi-sign */ + /** Multi-signature transaction signing payload (`SMT\0`). Prepended to + * the serialized transaction body plus the signer's `AccountID` suffix + * before each individual signer's key signs. Distinct from `TxSign` to + * prevent a single-sig blob from being replayed as a multi-sig share. + */ TxMultiSign = detail::makeHashPrefix('S', 'M', 'T'), - /** validation for signing */ + /** Validator validation message signing payload (`VAL\0`). Used by + * `STValidation::getSigningHash` to produce the digest that a validator + * signs when asserting agreement on a ledger. + */ Validation = detail::makeHashPrefix('V', 'A', 'L'), - /** proposal for signing */ + /** Consensus proposal signing payload (`PRP\0`). Used by + * `ConsensusProposal` and `RCLCxPeerPos` when signing or verifying a + * peer's position on a candidate ledger during the consensus round. + */ Proposal = detail::makeHashPrefix('P', 'R', 'P'), - /** Manifest */ + /** Validator manifest signing payload (`MAN\0`). Used by the manifest + * system to sign and verify the binding between a validator's master key + * and its rotating ephemeral signing key. + */ Manifest = detail::makeHashPrefix('M', 'A', 'N'), - /** Payment Channel Claim */ + /** Off-ledger payment channel claim payload (`CLM\0`). Prepended to the + * channel ID and authorized amount before the channel owner signs an + * off-ledger claim that a counterparty can later submit on-chain. + */ PaymentChannelClaim = detail::makeHashPrefix('C', 'L', 'M'), - /** Batch */ + /** Batch transaction signing payload (`BCH\0`). Prepended to the outer + * batch flags, inner transaction count, and list of inner transaction + * IDs before signing a batch. See `serializeBatch()` in `Batch.h`. + */ Batch = detail::makeHashPrefix('B', 'C', 'H'), }; +/** Feed a `HashPrefix` into a N3980-compatible streaming hasher. + * + * Casts the prefix to its underlying `uint32_t` representation and forwards + * it to `beast::hash_append`, allowing a `HashPrefix` to be composed with + * other arguments in a single variadic `sha512Half` call: + * @code + * sha512Half(HashPrefix::transactionID, data) + * @endcode + * No temporary allocation or explicit serialization step is required; the + * 4-byte prefix is fed directly into the running digest state. + * + * @tparam Hasher A type satisfying the N3980 `hash_append` protocol + * (e.g. `sha512_half_hasher`). + * @param h The hasher instance to update. + * @param hp The prefix value to append. + */ template void hash_append(Hasher& h, HashPrefix const& hp) noexcept diff --git a/include/xrpl/protocol/IOUAmount.h b/include/xrpl/protocol/IOUAmount.h index 6ce773fabd..0e07062d72 100644 --- a/include/xrpl/protocol/IOUAmount.h +++ b/include/xrpl/protocol/IOUAmount.h @@ -11,16 +11,35 @@ namespace xrpl { -/** Floating point representation of amounts with high dynamic range - - Amounts are stored as a normalized signed mantissa and an exponent. The - range of the normalized exponent is [-96,80] and the range of the absolute - value of the normalized mantissa is [1000000000000000, 9999999999999999]. - - Arithmetic operations can throw std::overflow_error during normalization - if the amount exceeds the largest representable amount, but underflows - will silently truncate to zero. -*/ +/** Fixed-precision decimal floating-point type for IOU (non-native) balances. + * + * Encodes a value as `mantissa × 10^exponent` using a 64-bit signed mantissa + * and an integer exponent. Canonical form requires the absolute value of the + * mantissa to lie in `[10^15, 10^16−1]` (i.e., `[1000000000000000, + * 9999999999999999]`) and the exponent to lie in `[-96, 80]`. These bounds + * match the on-wire limits in `STAmount`, so a normalized `IOUAmount` is + * always serializable. + * + * Zero is the sentinel `{mantissa=0, exponent=-100}`. The exponent `-100` is + * chosen to be below the minimum representable non-zero exponent (`-96`), so + * that numeric ordering via the exponent field correctly places zero below + * the smallest positive amount. + * + * Arithmetic operations can throw `std::overflow_error` during normalization + * if the result exceeds the largest representable amount; underflows silently + * truncate to zero. This asymmetry is intentional: overflow indicates a + * programming error, while sub-minimum amounts arise naturally from interest + * calculations and must degrade gracefully. + * + * The class privately inherits `boost::totally_ordered` and + * `boost::additive` to derive the full set of comparison and binary + * arithmetic operators from the handful of hand-written primitives + * (`operator==`, `operator<`, `operator+=`, `operator-`). + * + * @note Normalization has two code paths selected by `getSTNumberSwitchover()`: + * the legacy in-place loop and the modern path delegating to + * `Number::normalizeToRange`. Use `NumberSO` to scope either path. + */ class IOUAmount : private boost::totally_ordered, private boost::additive { private: @@ -30,39 +49,124 @@ private: exponent_type exponent_{}; /** Adjusts the mantissa and exponent to the proper range. - - This can throw if the amount cannot be normalized, or is larger than - the largest value that can be represented as an IOU amount. Amounts - that are too small to be represented normalize to 0. - */ + * + * Scales the mantissa up (multiply by 10, decrement exponent) or down + * (divide by 10, increment exponent) until the absolute value of the + * mantissa is in `[10^15, 10^16−1]` and the exponent is in `[-96, 80]`. + * + * Which algorithm is used depends on `getSTNumberSwitchover()`: when + * false, a legacy digit-by-digit loop; when true (the default), delegates + * to `Number::normalizeToRange`. + * + * @throws std::overflow_error if the value is too large to be represented. + * @note Underflow silently rounds to zero rather than throwing. + */ void normalize(); + /** Convert a `Number` to an `IOUAmount` by fitting its mantissa into + * the IOU `10^15` precision range via `Number::normalizeToRange`. + * + * @param number The `Number` value to convert. + * @return The nearest representable `IOUAmount`. + */ static IOUAmount fromNumber(Number const& number); public: + /** Default-constructs a zero amount (`{mantissa=0, exponent=0}`). + * + * @note The raw fields are zero-initialized but `normalize()` is not + * called; use `IOUAmount{beast::kZERO}` to get the canonical zero + * sentinel `{mantissa=0, exponent=-100}`. + */ IOUAmount() = default; + + /** Construct from a `Number`, fitting its mantissa into IOU precision. + * + * @param other The `Number` to convert. Delegates to `fromNumber()`. + */ explicit IOUAmount(Number const& other); + + /** Construct the canonical zero sentinel `{mantissa=0, exponent=-100}`. */ IOUAmount(beast::Zero); + + /** Construct from raw mantissa and exponent, then normalize. + * + * @param mantissa The signed mantissa; sign determines the amount's sign. + * @param exponent The power-of-ten exponent. + * @throws std::overflow_error if the value cannot be normalized to the + * representable range after scaling. + */ IOUAmount(mantissa_type mantissa, exponent_type exponent); + /** Reset to the canonical zero sentinel `{mantissa=0, exponent=-100}`. + * + * The exponent `-100` ensures zero sorts below the smallest positive + * amount whose minimum exponent is `-96`. + */ IOUAmount& operator=(beast::Zero); + /** Implicit conversion to `Number`. + * + * Constructs `Number{mantissa_, exponent_}`, bridging the legacy IOU + * type into the modern arithmetic layer. Conversion to `Number` is + * intentionally implicit; the reverse (from `Number`) is explicit. + */ operator Number() const; + /** Add another amount in-place. + * + * When `getSTNumberSwitchover()` is true, routes through + * `Number` arithmetic for correct handling across the two normalization + * regimes. Otherwise, performs manual exponent alignment. + * + * @param other The amount to add. + * @return Reference to `*this` after normalization. + * @throws std::overflow_error if the result exceeds the representable range. + */ IOUAmount& operator+=(IOUAmount const& other); + /** Subtract another amount in-place. + * + * Implemented as `*this += -other`. + * + * @param other The amount to subtract. + * @return Reference to `*this` after normalization. + * @throws std::overflow_error if the result exceeds the representable range. + */ IOUAmount& operator-=(IOUAmount const& other); + /** Negate the amount without calling `normalize()`. + * + * Flips the sign of the mantissa. Safe because the negation of a + * normalized value is also normalized; negating zero leaves the + * `{0, -100}` sentinel unchanged. + * + * @return A new `IOUAmount` with the same magnitude and opposite sign. + */ IOUAmount operator-() const; + /** Returns true if both amounts have identical mantissa and exponent. + * + * Valid because every non-zero value has a unique canonical + * representation after normalization, and zero is always `{0, -100}`. + * + * @param other The amount to compare. + */ bool operator==(IOUAmount const& other) const; + /** Returns true if this amount is strictly less than `other`. + * + * Delegates to `Number` comparison, which handles the zero sentinel and + * cross-regime comparisons correctly. + * + * @param other The amount to compare against. + */ bool operator<(IOUAmount const& other) const; @@ -74,12 +178,26 @@ public: [[nodiscard]] int signum() const noexcept; + /** Return the raw (normalized) exponent. + * + * The value is in `[-96, 80]` for non-zero amounts, or `-100` for zero. + */ [[nodiscard]] exponent_type exponent() const noexcept; + /** Return the raw (normalized) signed mantissa. + * + * For non-zero amounts, the absolute value is in `[10^15, 10^16−1]`. + * Zero returns `0`. + */ [[nodiscard]] mantissa_type mantissa() const noexcept; + /** Return the smallest representable positive `IOUAmount`. + * + * Corresponds to `{mantissa = 10^15, exponent = -96}`, the lower-left + * corner of the normalized canonical range. + */ static IOUAmount minPositiveAmount(); @@ -104,8 +222,6 @@ inline IOUAmount::IOUAmount(mantissa_type mantissa, exponent_type exponent) inline IOUAmount& IOUAmount::operator=(beast::Zero) { - // The -100 is used to allow 0 to sort less than small positive values - // which will have a large negative exponent. mantissa_ = 0; exponent_ = -100; return *this; @@ -168,29 +284,73 @@ IOUAmount::mantissa() const noexcept return mantissa_; } +/** Format an `IOUAmount` as a human-readable decimal string. + * + * Produces integer notation when the exponent is non-negative (e.g. `"2e20"` + * for very large values), or decimal notation for fractional amounts (e.g. + * `"0.025"`). Scientific notation is used when the decimal form would be + * impractical. + * + * @param amount The amount to format. + * @return A decimal string representation of `amount`. + */ std::string to_string(IOUAmount const& amount); -/* Return num*amt/den - This function keeps more precision than computing - num*amt, storing the result in an IOUAmount, then - dividing by den. -*/ +/** Compute `amt × num / den` with higher precision than sequential operations. + * + * Intermediate products are held in a 128-bit unsigned integer to avoid + * overflow when multiplying a 64-bit mantissa by a 32-bit numerator. The + * quotient and remainder are then rescaled to fit the 64-bit IOU mantissa + * range, with the rounding direction determined by `roundUp`. + * + * @param amt The base amount to scale. + * @param num Numerator of the scaling ratio; may be zero to produce zero. + * @param den Denominator of the scaling ratio; must not be zero. + * @param roundUp When true, rounds toward positive infinity for positive + * results and toward negative infinity for negative results (directed + * rounding semantics). When false, rounds toward zero. + * @return The scaled amount `amt × num / den`. + * @throws std::overflow_error if the result exceeds the representable range. + * @throws std::domain_error (or similar) if `den` is zero. + */ IOUAmount mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundUp); -// Since many uses of the number class do not have access to a ledger, -// getSTNumberSwitchover needs to be globally accessible. - +/** Return the current coroutine-local STNumber switchover flag. + * + * When true, `IOUAmount::normalize()` and `operator+=` use the modern + * `Number`-based code path; when false, the legacy in-place loop is used. + * The flag is stored in a `LocalValue` so each coroutine has its own + * independent copy. Globally accessible because most callers do not have + * access to a ledger/rules context. + * + * @return true if the `Number` normalization path is active. + * @see setSTNumberSwitchover, NumberSO + */ bool getSTNumberSwitchover(); +/** Set the coroutine-local STNumber switchover flag. + * + * Prefer the `NumberSO` RAII guard for scoped changes. + * + * @param v true to enable the `Number` normalization path; false for legacy. + * @see getSTNumberSwitchover, NumberSO + */ void setSTNumberSwitchover(bool v); -/** RAII class to set and restore the Number switchover. +/** RAII guard that temporarily overrides the coroutine-local STNumber + * switchover flag and restores the previous value on destruction. + * + * Construct with `true` to force the `Number`-based normalization path, or + * `false` to force the legacy loop. Useful in tests and ledger-replay code + * that must exercise a specific path without global side effects. + * + * @note Non-copyable; intended for stack-only use. + * @see getSTNumberSwitchover, setSTNumberSwitchover */ - class NumberSO { bool saved_; diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 14e7d95e23..b2455a4795 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -1,3 +1,18 @@ +/** @file + * Single authoritative source for computing the 256-bit ledger-state addresses + * of every object type in the XRP Ledger. + * + * All key derivations use "tagged hashing": a `sha512Half` over a type-specific + * `LedgerNameSpace` discriminator prepended to the object's identifying + * parameters. This prevents cross-type key collisions even when two object + * types share identical parameter values. The namespace discriminators are + * protocol-immutable; changing them constitutes a hard fork. + * + * The primary API is the `xrpl::keylet` namespace, whose functions return + * `Keylet` values pairing a 256-bit key with its expected `LedgerEntryType`. + * Free functions below the namespace (`getBookBase`, `getQuality`, etc.) are + * deprecated predecessors retained for backward compatibility. + */ #pragma once #include @@ -17,84 +32,141 @@ namespace xrpl { class SeqProxy; -/** Keylet computation functions. - Entries in the ledger are located using 256-bit locators. The locators are - calculated using a wide range of parameters specific to the entry whose - locator we are calculating (e.g. an account's locator is derived from the - account's address, whereas the locator for an offer is derived from the - account and the offer sequence.) - - To enhance type safety during lookup and make the code more robust, we use - keylets, which contain not only the locator of the object but also the type - of the object being referenced. - - These functions each return a type-specific keylet. -*/ +/** Keylet computation functions for every XRPL ledger object type. + * + * Entries in the ledger are located using 256-bit keys derived by hashing + * object-specific parameters under a type-specific namespace discriminator. + * Each function in this namespace returns a `Keylet` — a pair of the derived + * key and the expected `LedgerEntryType` — enabling type-safe ledger lookups + * that catch category errors at retrieval time via `Keylet::check()`. + * + * @note All namespace discriminator values are part of the consensus protocol + * and must never be changed. Adding a new keylet function requires + * assigning a new, previously unused discriminator character. + */ namespace keylet { -/** AccountID root */ +/** Return the keylet for an AccountRoot ledger entry. + * + * @param id The account address. + * @return Keylet typed `ltACCOUNT_ROOT`. + */ Keylet account(AccountID const& id) noexcept; -/** The index of the amendment table */ +/** Return the keylet for the singleton amendments table. + * + * The amendments object has no parameters; its key is computed once and + * returned as a reference to a function-local static (Meyers singleton). + * + * @return Reference to a static `Keylet` typed `ltAMENDMENTS`. + */ Keylet const& amendments() noexcept; -/** Any item that can be in an owner dir. */ +/** Return a wildcard keylet for any item that can appear in an owner directory. + * + * Uses `ltCHILD` so that `Keylet::check()` accepts any entry type — + * useful when iterating a directory without knowing the contained type. + * + * @param key Raw 256-bit ledger key of the directory child entry. + * @return Keylet typed `ltCHILD`. + */ Keylet child(uint256 const& key) noexcept; -/** The index of the "short" skip list - - The "short" skip list is a node (at a fixed index) that holds the hashes - of ledgers since the last flag ledger. It will contain, at most, 256 hashes. -*/ +/** Return the keylet for the "short" ledger-hash skip list. + * + * The short skip list is a singleton object holding the hashes of ledgers + * since the last flag ledger (at most 256 entries). Its key is computed + * once and returned as a reference to a function-local static. + * + * @return Reference to a static `Keylet` typed `ltLEDGER_HASHES`. + */ Keylet const& skip() noexcept; -/** The index of the long skip for a particular ledger range. - - The "long" skip list is a node that holds the hashes of (up to) 256 flag - ledgers. - - It can be used to efficiently skip back to any ledger using only two hops: - the first hop gets the "long" skip list for the ledger it wants to retrieve - and uses it to get the hash of the flag ledger whose short skip list will - contain the hash of the requested ledger. -*/ +/** Return the keylet for a "long" ledger-hash skip list page. + * + * Each long skip list page stores hashes of up to 256 flag ledgers within + * a 65536-ledger range. Together with the short skip list, any historical + * ledger can be located in at most two hops: one to the long skip list for + * the target range, one to the short skip list around the target ledger. + * + * @param ledger Any ledger index within the desired 65536-ledger range; + * only the upper 16 bits determine the page key. + * @return Keylet typed `ltLEDGER_HASHES` for the corresponding skip-list page. + */ Keylet skip(LedgerIndex ledger) noexcept; -/** The (fixed) index of the object containing the ledger fees. */ +/** Return the keylet for the singleton fee-settings object. + * + * Its key is computed once and returned as a reference to a function-local + * static (Meyers singleton). + * + * @return Reference to a static `Keylet` typed `ltFEE_SETTINGS`. + */ Keylet const& fees() noexcept; -/** The (fixed) index of the object containing the ledger negativeUNL. */ +/** Return the keylet for the singleton negative-UNL object. + * + * Its key is computed once and returned as a reference to a function-local + * static (Meyers singleton). + * + * @return Reference to a static `Keylet` typed `ltNEGATIVE_UNL`. + */ Keylet const& negativeUNL() noexcept; -/** The beginning of an order book */ +/** Functor that returns the root keylet for an order book directory. + * + * The returned keylet encodes quality 0 in the last 8 bytes of the key, + * making it the floor of the book's range in the SHAMap. Use `kBOOK` + * (the pre-constructed singleton instance) rather than constructing directly. + * + * @see keylet::quality + */ struct BookT { explicit BookT() = default; + /** Return the keylet for the root directory page of @p b. + * + * @param b Order book specifying the in/out asset pair and optional domain. + * @return Keylet typed `ltDIR_NODE` with quality 0 in the last 8 bytes. + */ Keylet operator()(Book const& b) const; }; static BookT const kBOOK{}; -/** The index of a trust line for a given currency - - Note that a trustline is *shared* between two accounts (commonly referred - to as the issuer and the holder); if Alice sets up a trust line to Bob for - BTC, and Bob trusts Alice for BTC, here is only a single BTC trust line - between them. -*/ +/** Return the keylet for a trust line (RippleState) between two accounts. + * + * A trust line is a bilateral ledger object shared by both accounts. The + * two account IDs are sorted before hashing so that `line(Alice, Bob, USD)` + * and `line(Bob, Alice, USD)` produce the same key. + * + * @note `id0 == id1` is permitted (TrustSet may look up and delete malformed + * self-trust lines); the absence of a strict inequality assert is intentional. + * + * @param id0 One account on the trust line. + * @param id1 The other account on the trust line. + * @param currency Currency of the trust line. + * @return Keylet typed `ltRIPPLE_STATE`. + */ /** @{ */ Keylet line(AccountID const& id0, AccountID const& id1, Currency const& currency) noexcept; +/** Return the keylet for the trust line between @p id and the issuer of @p issue. + * + * @param id One of the two accounts on the trust line. + * @param issue Issue whose account and currency identify the trust line. + * @return Keylet typed `ltRIPPLE_STATE`. + */ inline Keylet line(AccountID const& id, Issue const& issue) noexcept { @@ -102,11 +174,21 @@ line(AccountID const& id, Issue const& issue) noexcept } /** @} */ -/** An offer from an account */ +/** Return the keylet for an offer placed by an account. + * + * @param id Account that placed the offer. + * @param seq Sequence number of the OfferCreate transaction. + * @return Keylet typed `ltOFFER`. + */ /** @{ */ Keylet offer(AccountID const& id, std::uint32_t seq) noexcept; +/** Return a typed keylet for an offer from its pre-computed key. + * + * @param key Pre-computed 256-bit offer key. + * @return Keylet typed `ltOFFER`. + */ inline Keylet offer(uint256 const& key) noexcept { @@ -114,31 +196,77 @@ offer(uint256 const& key) noexcept } /** @} */ -/** The initial directory page for a specific quality */ +/** Return the keylet for an order-book directory page at a specific quality. + * + * Writes @p q as a big-endian 64-bit value into the last 8 bytes of the + * book's base key. Because `uint256` keys sort as big-endian integers in + * the SHAMap, adjacent quality levels occupy adjacent addresses, enabling + * O(1) price-level iteration without a secondary index. + * + * @param k Base keylet for the order book (must be `ltDIR_NODE`). + * @param q 64-bit quality value (inverted exchange rate) to embed. + * @return Keylet typed `ltDIR_NODE` with @p q encoded in the last 8 bytes. + */ Keylet quality(Keylet const& k, std::uint64_t q) noexcept; -/** The directory for the next lower quality */ +/** Functor that advances a book-directory keylet to the next quality level. + * + * Adds a unit to the 64-bit quality field embedded in the last 8 bytes of + * the key, stepping to the directory for the next higher quality tier in + * the same order book. Use `kNEXT` (the pre-constructed singleton instance) + * rather than constructing directly. + * + * @see keylet::quality + */ struct NextT { explicit NextT() = default; + /** Return the keylet for the next quality tier above @p k. + * + * @param k A directory keylet (must be `ltDIR_NODE`) whose last 8 bytes + * encode a quality value. + * @return Keylet typed `ltDIR_NODE` with quality incremented by 1. + */ Keylet operator()(Keylet const& k) const; }; static NextT const kNEXT{}; -/** A ticket belonging to an account */ +/** Functor that computes ticket keylets. + * + * Use `kTICKET` (the pre-constructed singleton instance) rather than + * constructing directly. + */ struct TicketT { explicit TicketT() = default; + /** Return the keylet for a ticket owned by @p id. + * + * @param id Owner of the ticket. + * @param ticketSeq Sequence number consumed when the ticket was created. + * @return Keylet typed `ltTICKET`. + */ Keylet operator()(AccountID const& id, std::uint32_t ticketSeq) const; + /** Return the keylet for a ticket owned by @p id, resolved via a SeqProxy. + * + * @param id Owner of the ticket. + * @param ticketSeq SeqProxy in ticket mode; asserts if it represents a + * plain sequence number. + * @return Keylet typed `ltTICKET`. + */ Keylet operator()(AccountID const& id, SeqProxy ticketSeq) const; + /** Return a typed keylet for a ticket from its pre-computed key. + * + * @param key Pre-computed 256-bit ticket key. + * @return Keylet typed `ltTICKET`. + */ Keylet operator()(uint256 const& key) const { @@ -147,15 +275,29 @@ struct TicketT }; static TicketT const kTICKET{}; -/** A SignerList */ +/** Return the keylet for an account's multi-signature signer list. + * + * @param account Account whose signer list is being addressed. + * @return Keylet typed `ltSIGNER_LIST` for page 0 (the only allocated page). + */ Keylet signers(AccountID const& account) noexcept; -/** A Check */ +/** Return the keylet for a Check issued by an account. + * + * @param id Account that created the check (via CheckCreate). + * @param seq Sequence number of the CheckCreate transaction. + * @return Keylet typed `ltCHECK`. + */ /** @{ */ Keylet check(AccountID const& id, std::uint32_t seq) noexcept; +/** Return a typed keylet for a check from its pre-computed key. + * + * @param key Pre-computed 256-bit check key. + * @return Keylet typed `ltCHECK`. + */ inline Keylet check(uint256 const& key) noexcept { @@ -163,16 +305,45 @@ check(uint256 const& key) noexcept } /** @} */ -/** A DepositPreauth */ +/** Return the keylet for a deposit pre-authorization record. + * + * Two overloads exist for the two pre-authorization modes — account-to-account + * and credential-set — which hash under distinct namespace discriminators to + * prevent key collisions even when the `owner` is identical. + */ /** @{ */ +/** Return the keylet for a single-account deposit pre-authorization. + * + * @param owner Account granting the pre-authorization. + * @param preauthorized Account being pre-authorized to deposit. + * @return Keylet typed `ltDEPOSIT_PREAUTH`. + */ Keylet depositPreauth(AccountID const& owner, AccountID const& preauthorized) noexcept; +/** Return the keylet for a credential-set deposit pre-authorization. + * + * Each credential in @p authCreds is hashed individually as + * `sha512Half(issuer, credentialType)`; the resulting hashes are then passed + * to the outer hash under the `DepositPreauthCredentials` namespace, which + * is distinct from the account-to-account `DepositPreauth` namespace. + * Because `authCreds` is a `std::set`, iteration order is deterministic and + * the key is stable regardless of insertion order. + * + * @param owner Account granting the pre-authorization. + * @param authCreds Sorted set of (issuer AccountID, credentialType) pairs. + * @return Keylet typed `ltDEPOSIT_PREAUTH`. + */ Keylet depositPreauth( AccountID const& owner, std::set> const& authCreds) noexcept; +/** Return a typed keylet for a deposit pre-auth entry from its pre-computed key. + * + * @param key Pre-computed 256-bit deposit-preauth key. + * @return Keylet typed `ltDEPOSIT_PREAUTH`. + */ inline Keylet depositPreauth(uint256 const& key) noexcept { @@ -182,19 +353,48 @@ depositPreauth(uint256 const& key) noexcept //------------------------------------------------------------------------------ -/** Any ledger entry */ +/** Return a keylet for any ledger entry without type enforcement. + * + * Uses `ltANY` so `Keylet::check()` accepts any entry type. Intended for + * low-level read paths that need to fetch an entry before its type is known. + * + * @param key Raw 256-bit ledger key. + * @return Keylet typed `ltANY`. + */ Keylet unchecked(uint256 const& key) noexcept; -/** The root page of an account's directory */ +/** Return the keylet for the root page of an account's owner directory. + * + * The owner directory lists all objects owned by the account (offers, trust + * lines, escrows, etc.). Subsequent pages beyond page 0 are keyed via + * `keylet::page`. + * + * @param id Account whose owner directory is being addressed. + * @return Keylet typed `ltDIR_NODE`. + */ Keylet ownerDir(AccountID const& id) noexcept; -/** A page in a directory */ +/** Return the keylet for a specific page within a directory. + * + * Page 0 is stored at the root key itself; pages 1+ are stored at keys + * derived by hashing the root key with the page index. + * + * @param root 256-bit key of the directory's root page. + * @param index Zero-based page index; 0 returns the root key unchanged. + * @return Keylet typed `ltDIR_NODE`. + */ /** @{ */ Keylet page(uint256 const& root, std::uint64_t index = 0) noexcept; +/** Return the keylet for a specific page within a directory, from a root keylet. + * + * @param root Keylet of the directory's root page (must be `ltDIR_NODE`). + * @param index Zero-based page index. + * @return Keylet typed `ltDIR_NODE`. + */ inline Keylet page(Keylet const& root, std::uint64_t index = 0) noexcept { @@ -203,176 +403,477 @@ page(Keylet const& root, std::uint64_t index = 0) noexcept } /** @} */ -/** An escrow entry */ +/** Return the keylet for an escrow conditional payment. + * + * @param src Account that created the escrow. + * @param seq Sequence number of the EscrowCreate transaction. + * @return Keylet typed `ltESCROW`. + */ Keylet escrow(AccountID const& src, std::uint32_t seq) noexcept; -/** A PaymentChannel */ +/** Return the keylet for an XRP payment channel. + * + * @param src Funding (source) account. + * @param dst Receiving (destination) account. + * @param seq Sequence number of the PaymentChannelCreate transaction. + * @return Keylet typed `ltPAYCHAN`. + */ Keylet payChan(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept; -/** NFT page keylets - - Unlike objects whose ledger identifiers are produced by hashing data, - NFT page identifiers are composite identifiers, consisting of the owner's - 160-bit AccountID, followed by a 96-bit value that determines which NFT - tokens are candidates for that page. +/** NFT page keylets. + * + * Unlike other ledger objects whose keys are produced by hashing, NFT page + * keys are composite values: the high 160 bits hold the owner's `AccountID` + * and the low 96 bits are a range tag derived from an NFToken ID. This + * composite structure enables bounded range scans over all of an owner's NFT + * pages in the SHAMap without a linked-list traversal. */ /** @{ */ -/** A keylet for the owner's first possible NFT page. */ +/** Return the keylet for the owner's lowest possible NFT page (low 96 bits = 0). + * + * This is the floor of the owner's page range. It is normally impossible to + * create an actual NFT page at this key, but it is used in invariant tests + * to exercise the full page range. + * + * @param owner Account that owns the NFT collection. + * @return Keylet typed `ltNFTOKEN_PAGE` with low 96 bits all zero. + */ Keylet nftpageMin(AccountID const& owner); -/** A keylet for the owner's last possible NFT page. */ +/** Return the keylet for the owner's highest possible NFT page (low 96 bits = all ones). + * + * Together with `nftpageMin`, this defines the closed interval covering every + * NFT page belonging to this owner. + * + * @param owner Account that owns the NFT collection. + * @return Keylet typed `ltNFTOKEN_PAGE` with low 96 bits all one. + */ Keylet nftpageMax(AccountID const& owner); +/** Return the keylet for the NFT page that should contain @p token. + * + * Preserves the owner prefix from @p k (high 160 bits) and replaces the + * range tag (low 96 bits) with the corresponding bits of @p token masked + * by `nft::pageMask`. + * + * @param k An NFT page keylet for the same owner (must be `ltNFTOKEN_PAGE`). + * @param token 256-bit NFToken ID whose low 96 bits determine the target page. + * @return Keylet typed `ltNFTOKEN_PAGE` for the page whose range covers @p token. + */ Keylet nftpage(Keylet const& k, uint256 const& token); /** @} */ -/** An offer from an account to buy or sell an NFT */ +/** Return the keylet for an NFToken buy or sell offer. + * + * @param owner Account that created the offer. + * @param seq Sequence number of the NFTokenCreateOffer transaction. + * @return Keylet typed `ltNFTOKEN_OFFER`. + */ +/** @{ */ Keylet nftoffer(AccountID const& owner, std::uint32_t seq); +/** Return a typed keylet for an NFToken offer from its pre-computed key. + * + * @param offer Pre-computed 256-bit NFToken offer key. + * @return Keylet typed `ltNFTOKEN_OFFER`. + */ inline Keylet nftoffer(uint256 const& offer) { return {ltNFTOKEN_OFFER, offer}; } +/** @} */ -/** The directory of buy offers for the specified NFT */ +/** Return the keylet for the directory of buy offers for an NFToken. + * + * @param id 256-bit NFToken ID. + * @return Keylet typed `ltDIR_NODE` for the buy-offer directory. + */ Keylet nftBuys(uint256 const& id) noexcept; -/** The directory of sell offers for the specified NFT */ +/** Return the keylet for the directory of sell offers for an NFToken. + * + * @param id 256-bit NFToken ID. + * @return Keylet typed `ltDIR_NODE` for the sell-offer directory. + */ Keylet nftSells(uint256 const& id) noexcept; -/** AMM entry */ +/** Return the keylet for an AMM pool, keyed by its two pooled assets. + * + * The two assets are sorted via `std::minmax` before hashing, so + * `amm(A, B)` and `amm(B, A)` always produce the same keylet. + * All four combinations of `Issue`/`MPTIssue` asset pairs are supported. + * + * @param issue1 One of the two pooled assets. + * @param issue2 The other pooled asset. + * @return Keylet typed `ltAMM`. + */ +/** @{ */ Keylet amm(Asset const& issue1, Asset const& issue2) noexcept; +/** Return the keylet for an AMM pool from a pre-computed 256-bit AMM ID. + * + * Use this overload when the AMM ID is already available (e.g. stored in + * `sfAMMID` on another SLE) to avoid redundant hashing. + * + * @param amm Pre-computed 256-bit AMM identifier. + * @return Keylet typed `ltAMM`. + */ Keylet amm(uint256 const& amm) noexcept; +/** @} */ -/** A keylet for Delegate object */ +/** Return the keylet for a delegation grant from one account to another. + * + * @param account Account granting delegated authority. + * @param authorizedAccount Account receiving the delegated authority. + * @return Keylet typed `ltDELEGATE`. + */ Keylet delegate(AccountID const& account, AccountID const& authorizedAccount) noexcept; +/** Return the keylet for a cross-chain bridge object. + * + * A door account may host multiple bridges. The key encodes the door + * account and the currency appropriate to @p chainType, ensuring at most + * one bridge per currency per side. + * + * @param bridge Bridge descriptor containing door accounts and issues. + * @param chainType Selects whether to key on the locking or issuing chain side. + * @return Keylet typed `ltBRIDGE`. + */ Keylet bridge(STXChainBridge const& bridge, STXChainBridge::ChainType chainType); -// `seq` is stored as `sfXChainClaimID` in the object +/** Return the keylet for a cross-chain claim ID. + * + * The key encodes the full bridge identity plus the sequential claim ID + * stored as `sfXChainClaimID` in the object. + * + * @param bridge Bridge descriptor. + * @param seq Sequential claim ID (`sfXChainClaimID`). + * @return Keylet typed `ltXCHAIN_OWNED_CLAIM_ID`. + */ Keylet xChainClaimID(STXChainBridge const& bridge, std::uint64_t seq); -// `seq` is stored as `sfXChainAccountCreateCount` in the object +/** Return the keylet for a cross-chain create-account claim ID. + * + * Analogous to `xChainClaimID` but for the create-account workflow. The + * sequential counter is stored as `sfXChainAccountCreateCount` in the object. + * + * @param bridge Bridge descriptor. + * @param seq Sequential create-account claim ID (`sfXChainAccountCreateCount`). + * @return Keylet typed `ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID`. + */ Keylet xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq); +/** Return the keylet for an account's DID (Decentralized Identifier) document. + * + * @param account Account that owns the DID. + * @return Keylet typed `ltDID`. + */ Keylet did(AccountID const& account) noexcept; +/** Return the keylet for a price oracle owned by an account. + * + * An account may own multiple oracles distinguished by unique document IDs. + * + * @param account Account that owns the oracle. + * @param documentID Application-defined identifier distinguishing oracles + * within the same account (`sfOracleDocumentID`). + * @return Keylet typed `ltORACLE`. + */ Keylet oracle(AccountID const& account, std::uint32_t const& documentID) noexcept; +/** Return the keylet for a verifiable credential. + * + * @param subject Account the credential was issued to. + * @param issuer Account that issued the credential. + * @param credType Application-defined credential type byte string. + * @return Keylet typed `ltCREDENTIAL`. + */ +/** @{ */ Keylet credential(AccountID const& subject, AccountID const& issuer, Slice const& credType) noexcept; +/** Return a typed keylet for a credential from its pre-computed key. + * + * @param key Pre-computed 256-bit credential key. + * @return Keylet typed `ltCREDENTIAL`. + */ inline Keylet credential(uint256 const& key) noexcept { return {ltCREDENTIAL, key}; } +/** @} */ +/** Return the keylet for an MPT issuance, identified by sequence and issuer. + * + * Constructs the `MPTID` from @p seq and @p issuer via `makeMptID`, then + * delegates to the `MPTID` overload. + * + * @param seq Issuer's account sequence number at the time of issuance creation. + * @param issuer Account that created the issuance. + * @return Keylet typed `ltMPTOKEN_ISSUANCE`. + */ +/** @{ */ Keylet mptIssuance(std::uint32_t seq, AccountID const& issuer) noexcept; +/** Return the keylet for an MPT issuance from a pre-built MPTID. + * + * @param issuanceID 192-bit MPT issuance identifier (see `makeMptID`). + * @return Keylet typed `ltMPTOKEN_ISSUANCE`. + */ Keylet mptIssuance(MPTID const& issuanceID) noexcept; +/** Return a typed keylet for an MPT issuance from its pre-computed key. + * + * @param issuanceKey Pre-computed 256-bit issuance key. + * @return Keylet typed `ltMPTOKEN_ISSUANCE`. + */ inline Keylet mptIssuance(uint256 const& issuanceKey) { return {ltMPTOKEN_ISSUANCE, issuanceKey}; } +/** @} */ +/** Return the keylet for a holder's MPToken balance entry. + * + * MPToken entries are keyed under the `MPToken` namespace by hashing the + * issuance's 256-bit ledger key together with the holder's `AccountID`. + * This naturally groups all token balances under their issuance in the + * SHAMap hash space. + * + * @param issuanceID 192-bit MPTID identifying the issuance. + * @param holder Account holding the MPToken balance. + * @return Keylet typed `ltMPTOKEN`. + */ +/** @{ */ Keylet mptoken(MPTID const& issuanceID, AccountID const& holder) noexcept; +/** Return a typed keylet for an MPToken entry from its pre-computed key. + * + * @param mptokenKey Pre-computed 256-bit MPToken key. + * @return Keylet typed `ltMPTOKEN`. + */ inline Keylet mptoken(uint256 const& mptokenKey) { return {ltMPTOKEN, mptokenKey}; } +/** Return the keylet for a holder's MPToken balance entry, identified by issuance key. + * + * Use this overload when the issuance's 256-bit ledger key is already available + * to avoid redundant hashing through `makeMptID` and `mptIssuance`. + * + * @param issuanceKey 256-bit key of the `MPTokenIssuance` SLE. + * @param holder Account holding the MPToken balance. + * @return Keylet typed `ltMPTOKEN`. + */ Keylet mptoken(uint256 const& issuanceKey, AccountID const& holder) noexcept; +/** @} */ +/** Return the keylet for a single-asset vault. + * + * @param owner Account that created the vault. + * @param seq Sequence number of the VaultCreate transaction. + * @return Keylet typed `ltVAULT`. + */ +/** @{ */ Keylet vault(AccountID const& owner, std::uint32_t seq) noexcept; +/** Return a typed keylet for a vault from its pre-computed key. + * + * @param vaultKey Pre-computed 256-bit vault key. + * @return Keylet typed `ltVAULT`. + */ inline Keylet vault(uint256 const& vaultKey) { return {ltVAULT, vaultKey}; } +/** @} */ +/** Return the keylet for a loan broker created by an account. + * + * @param owner Account that created the loan broker. + * @param seq Sequence number of the LoanBrokerCreate transaction. + * @return Keylet typed `ltLOAN_BROKER`. + */ +/** @{ */ Keylet loanbroker(AccountID const& owner, std::uint32_t seq) noexcept; +/** Return a typed keylet for a loan broker from its pre-computed key. + * + * @param key Pre-computed 256-bit loan broker key. + * @return Keylet typed `ltLOAN_BROKER`. + */ inline Keylet loanbroker(uint256 const& key) { return {ltLOAN_BROKER, key}; } +/** @} */ +/** Return the keylet for an individual loan issued by a loan broker. + * + * @param loanBrokerID 256-bit key of the parent `LoanBroker` SLE. + * @param loanSeq Sequential loan number assigned by the broker. + * @return Keylet typed `ltLOAN`. + */ +/** @{ */ Keylet loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept; +/** Return a typed keylet for a loan from its pre-computed key. + * + * @param key Pre-computed 256-bit loan key. + * @return Keylet typed `ltLOAN`. + */ inline Keylet loan(uint256 const& key) { return {ltLOAN, key}; } +/** @} */ +/** Return the keylet for a permissioned domain owned by an account. + * + * @param account Account that created the permissioned domain. + * @param seq Sequence number of the PermissionedDomainSet transaction. + * @return Keylet typed `ltPERMISSIONED_DOMAIN`. + */ +/** @{ */ Keylet permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept; +/** Return the keylet for a permissioned domain from its pre-computed ID. + * + * Use this overload when the domain ID is already known (e.g. stored in + * `sfDomainID` on another SLE) to avoid recomputing the hash. + * + * @param domainID Pre-computed 256-bit domain key. + * @return Keylet typed `ltPERMISSIONED_DOMAIN`. + */ Keylet permissionedDomain(uint256 const& domainID) noexcept; +/** @} */ + } // namespace keylet // Everything below is deprecated and should be removed in favor of keylets: +/** Return the base 256-bit key for an order book directory (deprecated). + * + * The returned key has quality 0 embedded in its last 8 bytes. Prefer + * `keylet::kBOOK(book)` for new code, which wraps this result in a typed keylet. + * + * @param book Order book identifying the in/out asset pair and optional domain. + * @return Raw 256-bit key for the book's root directory. + * @deprecated Use `keylet::kBOOK(book)` instead. + */ uint256 getBookBase(Book const& book); +/** Advance a book-directory key to the next quality level (deprecated). + * + * Adds a unit to the 64-bit quality field embedded in the last 8 bytes of + * @p uBase. Prefer `keylet::kNEXT(k)` for new code. + * + * @param uBase A book-directory key, typically from `getBookBase`. + * @return Key with quality incremented by 1. + * @deprecated Use `keylet::kNEXT(k)` instead. + */ uint256 getQualityNext(uint256 const& uBase); +/** Extract the 64-bit quality value from a book-directory key (deprecated). + * + * Reads the last 8 bytes of @p uBase as a big-endian `uint64_t`, exploiting + * `base_uint`'s big-endian internal layout. + * + * @param uBase A book-directory key produced by `getBookBase` or `keylet::quality`. + * @return The 64-bit quality (inverted exchange rate) embedded in the key. + * @deprecated Callers should use `keylet::quality` for construction instead. + */ // VFALCO This name could be better std::uint64_t getQuality(uint256 const& uBase); +/** Return the 256-bit ledger key for a ticket (deprecated). + * + * @param account Owner of the ticket. + * @param uSequence Sequence number consumed when the ticket was created. + * @return Raw key under the `Ticket` namespace. + * @deprecated Use `keylet::kTICKET(account, seq)` instead. + */ uint256 getTicketIndex(AccountID const& account, std::uint32_t uSequence); +/** Return the 256-bit ledger key for a ticket from a SeqProxy (deprecated). + * + * @param account Owner of the ticket. + * @param ticketSeq SeqProxy in ticket mode. + * @return Raw key under the `Ticket` namespace. + * @deprecated Use `keylet::kTICKET(account, ticketSeq)` instead. + */ uint256 getTicketIndex(AccountID const& account, SeqProxy ticketSeq); +/** Descriptor binding a keylet factory to its expected ledger-entry type name. + * + * Used exclusively by invariant tests (`Invariants_test.cpp`) to enumerate + * keylet functions and verify that the objects they address have the correct + * ledger entry type. Not part of the production ledger-access API. + * + * @tparam KeyletParams Parameter types of the wrapped keylet factory function. + */ template // NOLINTNEXTLINE(cppcoreguidelines-pro-type-member-init) struct KeyletDesc { + /** Keylet factory function for one ledger object type. */ std::function function; + /** Expected `LedgerEntryType` name as a JSON static string, used to + * validate the type of the SLE retrieved at the computed key. */ json::StaticString expectedLEName; + /** Whether to include this keylet in invariant test coverage. */ bool includeInTests{}; }; -// This list should include all of the keylet functions that take a single -// AccountID parameter. +/** All keylet functions that accept a single `AccountID` parameter. + * + * This array drives invariant tests that verify the ledger-entry type of + * the object addressed by each keylet function. When adding a new single- + * `AccountID` keylet, add an entry here so invariant tests automatically + * exercise it. + * + * @note `nftpageMin` is listed even though creating an actual NFT page at + * that key is normally impossible — the invariant checker tests for it + * regardless. + */ std::array, 6> const kDIRECT_ACCOUNT_KEYLETS{ {{.function = &keylet::account, .expectedLEName = jss::AccountRoot, .includeInTests = false}, {.function = &keylet::ownerDir, .expectedLEName = jss::DirectoryNode, .includeInTests = true}, @@ -383,6 +884,17 @@ std::array, 6> const kDIRECT_ACCOUNT_KEYLETS{ {.function = &keylet::nftpageMax, .expectedLEName = jss::NFTokenPage, .includeInTests = true}, {.function = &keylet::did, .expectedLEName = jss::DID, .includeInTests = true}}}; +/** Construct a 192-bit MPT issuance identifier from a sequence number and issuer. + * + * Packs a big-endian 32-bit @p sequence into the first 4 bytes of the `MPTID`, + * followed by the 20-byte @p account. The explicit endian conversion ensures + * canonical byte order for on-ledger storage and byte-by-byte comparison. + * + * @param sequence The issuer's account sequence number at issuance creation + * (stored as `sfSequence` in the `MPTokenIssuance` SLE). + * @param account The issuing account. + * @return 192-bit `MPTID` uniquely addressing this MPT issuance. + */ MPTID makeMptID(std::uint32_t sequence, AccountID const& account); diff --git a/include/xrpl/protocol/InnerObjectFormats.h b/include/xrpl/protocol/InnerObjectFormats.h index 9d07a21d1c..62a0e1626a 100644 --- a/include/xrpl/protocol/InnerObjectFormats.h +++ b/include/xrpl/protocol/InnerObjectFormats.h @@ -4,20 +4,66 @@ namespace xrpl { -/** Manages the list of known inner object formats. +/** Singleton registry of field schemas for all XRPL inner object types. + * + * Inner objects are the structured sub-objects that appear nested inside + * transactions and ledger entries — for example, `sfSigner`, `sfSignerEntry`, + * `sfNFToken`, `sfAuctionSlot`, and `sfPriceData`. This registry plays the + * same role for those nested objects as `TxFormats` plays for top-level + * transactions: it maps each inner object's `SField` code to an `SOTemplate` + * that declares which child fields are `soeREQUIRED`, `soeOPTIONAL`, or + * `soeDEFAULT`. + * + * The key type is `int` (the integer field code returned by + * `SField::getCode()`) rather than a dedicated enum, because inner objects + * are already identified by their `SField` descriptors in wire format. + * + * The registry is complete and immutable after the first call to + * `getInstance()`. Duplicate key registration triggers a `LogicError` at + * static-init time. The returned `const&` from `getInstance()` is safe for + * concurrent reads without additional locking. + * + * @see TxFormats, LedgerFormats, SOTemplate */ class InnerObjectFormats : public KnownFormats { private: - /** Create the object. - This will load the object with all the known inner object formats. - */ + /** Register all known inner object schemas. + * + * Each `add()` call maps an `SField`'s JSON name and integer field code + * to an `SOTemplate` that specifies the required, optional, and default + * child fields. The `SField` code doubles as the registry key, so no + * separate enumeration is needed. + */ InnerObjectFormats(); public: + /** Return the process-wide singleton instance. + * + * Initialized on first call via a Meyer's function-local static; safe + * for concurrent access after construction. The object is immutable + * after it is returned for the first time. + * + * @return A `const` reference to the singleton registry. + */ static InnerObjectFormats const& getInstance(); + /** Look up the field schema for a structured inner object. + * + * Translates an `SField` to its registered `SOTemplate` by matching on + * the field's integer code. The returned pointer is stable for the + * lifetime of the process; callers may cache it safely. + * + * Called by `STObject::makeInnerObject()` (amendment-gated on + * `fixInnerObjTemplate` / `fixInnerObjTemplate2`) and by + * `STObject::applyTemplateFromSField()` to enforce field-presence rules + * during construction and deserialization. + * + * @param sField The `SField` identifying the inner object type. + * @return A pointer to the matching `SOTemplate`, or `nullptr` if + * `sField` is not a registered inner object type. + */ [[nodiscard]] SOTemplate const* findSOTemplateBySField(SField const& sField) const; }; diff --git a/include/xrpl/protocol/Issue.h b/include/xrpl/protocol/Issue.h index 3b74b9132a..d8dc161568 100644 --- a/include/xrpl/protocol/Issue.h +++ b/include/xrpl/protocol/Issue.h @@ -6,36 +6,113 @@ namespace xrpl { -/** A currency issued by an account. - @see Currency, AccountID, Issue, Book -*/ +/** Identifies a specific currency as issued by a specific account. + * + * `Issue` is the minimal token identity tuple in the XRPL type system: a + * 160-bit `Currency` paired with a 160-bit `AccountID`. It is the building + * block for trust lines, order books, offer matching, and AMM pools. + * + * XRP is represented as a special-case `Issue` whose `currency` and + * `account` fields both carry their respective zero/sentinel values + * (`xrpCurrency()` and `xrpAccount()`). The equality and ordering + * operators ignore `account` when `currency` is XRP, so all XRP issues + * form a single equivalence class even if `account` carries a stale value. + * + * @note `Issue` is a peer to `MPTIssue`; both satisfy the `IssueType` + * concept in `Concepts.h` and can be held inside an `Asset` variant. + * @see MPTIssue, Asset, Book + */ class Issue { public: + /** The 160-bit currency code. `xrpCurrency()` (all-zero) denotes XRP. */ Currency currency; + + /** The account that issued this currency. + * + * Meaningful only for non-XRP issues. For XRP issues this field + * should carry `xrpAccount()` (all-zero); `isConsistent()` enforces + * that invariant, but the comparison operators are lenient and ignore + * this field when `currency` is XRP. + */ AccountID account; Issue() = default; + /** Constructs an issue from an explicit currency and issuer account. + * + * @param c The currency code. + * @param a The issuing account. Pass `xrpAccount()` when `c` is + * `xrpCurrency()`; use `isConsistent()` to verify the pair. + */ Issue(Currency const& c, AccountID const& a) : currency(c), account(a) { } + /** Returns the issuing account. + * + * Provides a uniform accessor shared with `MPTIssue`, enabling generic + * algorithms to retrieve the issuer without a type dispatch. For XRP + * issues the returned value is `xrpAccount()` (all-zero). + * + * @return A reference to `account`. + */ [[nodiscard]] AccountID const& getIssuer() const { return account; } + /** Returns a human-readable diagnostic string of the form + * `currency[/account]`. + * + * For XRP, only the currency string is returned. For IOU issues the + * account is appended after a slash, substituting `"0"` for + * `xrpAccount()` and `"1"` for `noAccount()` so structurally + * inconsistent issues are detectable in logs. + * + * @note Field order is `currency/account`, which is the reverse of + * `to_string(Issue)`. Use this for logging; use `setJson()` for + * canonical wire output. + * @return Diagnostic string; never empty. + */ [[nodiscard]] std::string getText() const; + /** Writes the canonical JSON representation into an existing object. + * + * Always sets `jv["currency"]`. Sets `jv["issuer"]` as a Base58Check + * account string only for non-XRP issues; XRP omits `"issuer"` + * entirely, which is the authoritative form expected by transaction + * JSON, RPC responses, and the binary codec. + * + * @param jv Output JSON object; existing keys are not cleared. + */ void setJson(json::Value& jv) const; + /** Returns `true` if this issue represents XRP, the native asset. + * + * Implemented as a full equality comparison against `xrpIssue()`. + * The underlying `operator==` short-circuits on `currency` alone for + * XRP, so `account` is not consulted. + * + * @return `true` iff `*this == xrpIssue()`. + */ [[nodiscard]] bool native() const; + /** Returns `true` if amounts of this issue are stored as integers. + * + * For `Issue`, only XRP uses integer (drop) representation; all IOU + * currencies use mantissa/exponent floating-point. Delegates entirely + * to `native()`. + * + * @note `MPTIssue::integral()` always returns `true`. The shared + * method name allows generic code to query integer-vs-float + * semantics without a type dispatch. + * @return `true` iff `native()`. + */ [[nodiscard]] bool integral() const; @@ -43,21 +120,92 @@ public: operator<=>(Issue const& lhs, Issue const& rhs); }; +/** Returns `true` if `ac.currency` and `ac.account` agree on XRP-ness. + * + * A well-formed XRP issue must carry `xrpCurrency()` and `xrpAccount()` + * (both all-zero). A well-formed IOU issue must carry a non-zero currency + * and a non-zero account. Cross-contamination — XRP currency with a real + * account, or a real currency with the XRP account sentinel — silently + * corrupts amount comparisons and offer-book matching. + * + * @note The equality and ordering operators are intentionally more lenient + * than this check; they ignore `account` whenever `currency` is XRP. + * Call `isConsistent()` on any `Issue` sourced from external input. + * @param ac The issue to validate. + * @return `true` iff `isXRP(ac.currency) == isXRP(ac.account)`. + */ bool isConsistent(Issue const& ac); +/** Returns a string of the form `account/currency`, or just the currency + * for XRP issues. + * + * @note Field order is `account/currency`, which is the reverse of + * `Issue::getText()` (`currency/account`). Both formats are in active + * use in different parts of the codebase; this one matches + * offer-book log lines and stream output. + * @param ac The issue to render. + * @return A non-empty string identifying the issue. + */ std::string to_string(Issue const& ac); +/** Returns the canonical wire-format JSON representation of an issue. + * + * Convenience wrapper around `Issue::setJson()`. The returned object + * contains a `"currency"` field and, for non-XRP issues, an `"issuer"` + * field holding the Base58Check-encoded account. + * + * @param is The issue to serialise. + * @return A new JSON object representing the issue. + */ json::Value toJson(Issue const& is); +/** Parses and validates an `Issue` from a JSON object. + * + * Performs layered validation in strict order: + * 1. `v` must be a JSON object. + * 2. `mpt_issuance_id` must be absent — its presence means the caller + * has accidentally routed MPT data into the wrong parser. + * 3. `"currency"` must be a string that parses to neither `badCurrency()` + * nor `noCurrency()`. + * 4. For XRP currency, `"issuer"` must be absent. + * 5. For non-XRP currencies, `"issuer"` must be a valid Base58Check + * account string. + * + * @param v The JSON value to parse; must be a JSON object. + * @return The parsed `Issue`. + * @throws std::runtime_error if `v` is not an object, or if + * `mpt_issuance_id` is present. + * @throws Json::error if any field is missing, the wrong type, or carries + * an invalid currency code or account string. + * @see toJson for the inverse operation. + */ Issue issueFromJson(json::Value const& v); +/** Writes the issue to a stream using the `to_string(Issue)` format. + * + * @param os The output stream. + * @param x The issue to write. + * @return `os`. + */ std::ostream& operator<<(std::ostream& os, Issue const& x); +/** Appends both `currency` and `account` to the hasher unconditionally. + * + * @note The XRP special case from `operator==` (ignoring `account` when + * `currency` is XRP) is deliberately not applied here. Consistent + * data — ensured by `isConsistent()` at ingestion — guarantees that + * XRP issues always carry `xrpAccount()`, so hashes are stable. + * Hashing an inconsistent XRP issue could produce a hash that matches + * equality but diverges from a canonical XRP issue's hash. + * @tparam Hasher A type satisfying the `beast::hash_append` concept. + * @param h The hasher to append to. + * @param r The issue whose fields are appended. + */ template void hash_append(Hasher& h, Issue const& r) @@ -66,17 +214,35 @@ hash_append(Hasher& h, Issue const& r) hash_append(h, r.currency, r.account); } -/** Equality comparison. */ -/** @{ */ +/** Returns `true` if two issues represent the same asset. + * + * Currencies are compared first. When both currencies are XRP (all-zero), + * the `account` field is ignored — all XRP issues are equal regardless of + * any stale or partially-constructed account value. For IOU issues both + * fields must match exactly. + * + * @param lhs Left-hand issue. + * @param rhs Right-hand issue. + * @return `true` iff the two issues identify the same asset. + */ [[nodiscard]] constexpr bool operator==(Issue const& lhs, Issue const& rhs) { return (lhs.currency == rhs.currency) && (isXRP(lhs.currency) || lhs.account == rhs.account); } -/** @} */ -/** Strict weak ordering. */ -/** @{ */ +/** Provides a strict weak ordering over `Issue` values. + * + * Sorts by `currency` first. When currencies are equal and the currency is + * XRP, `std::weak_ordering::equivalent` is returned immediately so that all + * XRP issues form a single equivalence class regardless of the `account` + * field. For IOU issues with equal currencies, `account` is the + * tiebreaker. + * + * @param lhs Left-hand issue. + * @param rhs Right-hand issue. + * @return A `std::weak_ordering` value consistent with `operator==`. + */ [[nodiscard]] constexpr std::weak_ordering operator<=>(Issue const& lhs, Issue const& rhs) { @@ -88,11 +254,18 @@ operator<=>(Issue const& lhs, Issue const& rhs) return (lhs.account <=> rhs.account); } -/** @} */ //------------------------------------------------------------------------------ -/** Returns an asset specifier that represents XRP. */ +/** Returns the canonical `Issue` sentinel that represents XRP. + * + * The returned instance holds `xrpCurrency()` and `xrpAccount()` (both + * all-zero 160-bit values). The singleton is initialised once and returned + * by `const&`, which is thread-safe under C++11 guaranteed-initialisation + * semantics. + * + * @return A reference to the process-lifetime XRP issue singleton. + */ inline Issue const& xrpIssue() { @@ -100,7 +273,13 @@ xrpIssue() return kISSUE; } -/** Returns an asset specifier that represents no account and currency. */ +/** Returns an `Issue` sentinel that represents the absence of an issue. + * + * Holds `noCurrency()` and `noAccount()`. Used in contexts where a + * missing or invalid issue must be represented without `std::optional`. + * + * @return A reference to the process-lifetime "no issue" singleton. + */ inline Issue const& noIssue() { @@ -108,6 +287,14 @@ noIssue() return kISSUE; } +/** Returns `true` if `issue` represents XRP, the native asset. + * + * Thin wrapper over `issue.native()`, providing the naming convention + * used throughout the codebase for XRP detection at all abstraction levels. + * + * @param issue The issue to test. + * @return `true` iff `issue.native()`. + */ inline bool isXRP(Issue const& issue) { diff --git a/include/xrpl/protocol/KeyType.h b/include/xrpl/protocol/KeyType.h index c709eb897a..041e28f077 100644 --- a/include/xrpl/protocol/KeyType.h +++ b/include/xrpl/protocol/KeyType.h @@ -1,3 +1,12 @@ +/** @file + * Discriminant enum and conversion utilities for XRPL's two signature schemes. + * + * Every key-management function in the protocol (generation, signing, + * verification) accepts a `KeyType` to select between the secp256k1 and + * ed25519 algorithms. This header is included by virtually every + * cryptographic interface in the protocol layer. + */ + #pragma once #include @@ -5,11 +14,38 @@ namespace xrpl { +/** Selects the cryptographic signature algorithm for a key pair. + * + * The XRPL supports two independent signature schemes: the Bitcoin-lineage + * secp256k1 elliptic curve and the modern ed25519 Edwards curve. The + * explicit integer values provide stable identifiers for internal storage and + * configuration that maps an integer to a key type, even though `KeyType` + * itself is not serialized directly on the wire. + * + * The choice of algorithm is self-describing in serialized key material: + * secp256k1 public keys begin with a compressed-point prefix byte (`0x02` or + * `0x03`), while ed25519 public keys carry a sentinel byte `0xED`. + * + * @see publicKeyType for recovering the algorithm from a serialized public key. + */ enum class KeyType { - Secp256k1 = 0, - Ed25519 = 1, + Secp256k1 = 0, /**< Bitcoin-lineage elliptic curve; XRPL uses a custom + seed-to-key-pair derivation path. */ + Ed25519 = 1, /**< Modern Edwards curve; uses a direct derivation from the + seed with no intermediate generator step. */ }; +/** Parse a canonical string name into a `KeyType`. + * + * Recognises exactly `"secp256k1"` and `"ed25519"` (lower-case). Returns an + * empty optional for any other input rather than throwing, so callers at + * configuration-parse or RPC-request boundaries can compose the result with + * their own error-reporting logic without requiring exception handling. + * + * @param s The string to parse. + * @return The corresponding `KeyType`, or `std::nullopt` if `s` is not a + * recognised algorithm name. + */ inline std::optional keyTypeFromString(std::string const& s) { @@ -22,6 +58,17 @@ keyTypeFromString(std::string const& s) return {}; } +/** Return the canonical lower-case string name for a `KeyType`. + * + * Returns `"INVALID"` — rather than `nullptr` or undefined behaviour — for + * any value that matches neither known enumerator. This defensive path is + * reachable because C++ permits arbitrary integers to be cast to an + * `enum class`, so a corrupt or adversarially crafted value must not cause + * unsafe access in logging or diagnostic paths. + * + * @param type The key type to convert. + * @return `"secp256k1"`, `"ed25519"`, or `"INVALID"`. + */ inline char const* to_string(KeyType type) { @@ -34,6 +81,18 @@ to_string(KeyType type) return "INVALID"; } +/** Write a `KeyType` to a stream as its canonical string name. + * + * Templated on `Stream` rather than fixed to `std::ostream` so the operator + * works with Beast logging streams, test-harness formatters, and any other + * stream-like type without coupling this header to a concrete stream + * hierarchy. + * + * @tparam Stream Any type that supports `operator<<(char const*)`. + * @param s The destination stream. + * @param type The key type to write. + * @return `s`, to allow chaining. + */ template inline Stream& operator<<(Stream& s, KeyType type) diff --git a/include/xrpl/protocol/Keylet.h b/include/xrpl/protocol/Keylet.h index 19704e2a11..085136dcbf 100644 --- a/include/xrpl/protocol/Keylet.h +++ b/include/xrpl/protocol/Keylet.h @@ -7,24 +7,72 @@ namespace xrpl { class STLedgerEntry; -/** A pair of SHAMap key and LedgerEntryType. - - A Keylet identifies both a key in the state map - and its ledger entry type. - - @note Keylet is a portmanteau of the words key - and LET, an acronym for LedgerEntryType. -*/ +/** Bundles the 256-bit SHAMap locator of a ledger object with its expected + * `LedgerEntryType`, making ledger lookups type-safe by construction. + * + * The name is a portmanteau of "key" and "LET" (LedgerEntryType). Callers + * never build a `Keylet` by hand — they use one of the factory functions in + * the `keylet::` namespace (see `Indexes.h`), each of which encapsulates the + * correct SHA-512Half derivation for a specific object type and returns a + * `Keylet` already annotated with the matching `LedgerEntryType`. + * + * The ledger view's `read()` method accepts a `Keylet` and calls `check()` + * before returning an entry, so an incorrect type annotation surfaces at + * the access point rather than silently yielding a mistyped object. + * + * Two sentinel types participate in the matching protocol but are never + * stored on-ledger: + * - `ltANY` — wildcard; bypasses type checking entirely (used by + * `keylet::unchecked`). + * - `ltCHILD` — matches any entry that is not a directory node, reflecting + * the semantics of owner-directory children. + * + * @see keylet namespace in `Indexes.h` for all factory functions. + */ struct Keylet { + /** 256-bit SHAMap key that locates the ledger entry in the state tree. */ uint256 key; + + /** Expected `LedgerEntryType` of the entry at `key`. + * + * May be the sentinel `ltANY` (wildcard) or `ltCHILD` (any non-directory + * entry); all other values are concrete on-ledger types whose numeric + * identities are protocol-stable and consensus-critical. + */ LedgerEntryType type; + /** Constructs a keylet from an explicit type and key. + * + * Prefer the factory functions in the `keylet::` namespace over calling + * this constructor directly; they ensure the correct key derivation + * formula is used for each ledger object type. + * + * @param type The expected `LedgerEntryType` of the addressed entry. + * @param key The 256-bit SHAMap key of the entry. + */ Keylet(LedgerEntryType type, uint256 const& key) : key(key), type(type) { } - /** Returns true if the SLE matches the type */ + /** Validates that a deserialized ledger entry corresponds to this keylet. + * + * Applies a three-tier match ordered from most-permissive to most-strict: + * - `ltANY`: always returns `true`; the caller bears full responsibility + * for type safety. + * - `ltCHILD`: returns `true` for any entry whose concrete type is not + * `ltDIR_NODE`. Directory nodes are structural bookkeeping objects; a + * directory child is definitionally something other than a directory. + * - Concrete type: requires both `sle.getType() == type` and + * `sle.key() == key`. This is the common case and gives the strongest + * safety guarantee. + * + * @param sle The deserialized ledger entry retrieved from the state map. + * @return `true` if `sle` legitimately corresponds to this keylet. + * @note `sle` must not itself carry `ltANY` or `ltCHILD` as its stored + * type; those are query-side sentinels, not real on-ledger types. + * An `XRPL_ASSERT` enforces this precondition in debug builds. + */ [[nodiscard]] bool check(STLedgerEntry const&) const; }; diff --git a/include/xrpl/protocol/KnownFormats.h b/include/xrpl/protocol/KnownFormats.h index 9aa914ba97..323f16cb08 100644 --- a/include/xrpl/protocol/KnownFormats.h +++ b/include/xrpl/protocol/KnownFormats.h @@ -1,3 +1,13 @@ +/** + * @file KnownFormats.h + * @brief Template base for XRPL protocol format registries. + * + * Declares `KnownFormats`, the shared infrastructure used + * by `TxFormats`, `LedgerFormats`, and `InnerObjectFormats` to register and + * look up the field schemas (`SOTemplate`) for every transaction type, ledger + * object type, and inner object type in the protocol. + */ + #pragma once #include @@ -11,22 +21,60 @@ namespace xrpl { -/** Manages a list of known formats. - - Each format has a name, an associated KeyType (typically an enumeration), - and a predefined @ref SOElement. - - @tparam KeyType The type of key identifying the format. -*/ +/** Registry of protocol format schemas, keyed by a wire-protocol discriminant. + * + * Each concrete registry (transaction, ledger entry, inner object) inherits + * from this template and populates it with one `Item` per recognized format. + * At runtime the serialization and validation layers look up `Item` instances + * via `findByType()` or `findTypeByName()` to obtain the `SOTemplate` that + * governs which fields are required, optional, or default for that format. + * + * Concrete subclasses are singletons constructed during static initialization; + * registering the same `KeyType` value twice is a programming error caught via + * `logicError()` (process abort) at startup rather than at request time. + * + * @note `begin()` / `end()` expose the raw `forward_list` for use in tests. + * They are not part of the normal lookup API. + * + * @tparam KeyType Integral or enum type whose values are the wire-protocol + * discriminants for this family of formats (e.g. `TxType`, + * `LedgerEntryType`, or `int` for inner objects). The `static_assert` + * inside `Item`'s constructor enforces this constraint at compile time. + * @tparam Derived The concrete subclass (CRTP). Used solely to embed the + * subclass name in diagnostic messages via `beast::typeName()`. + * + * @see TxFormats, LedgerFormats, InnerObjectFormats, SOTemplate + */ template class KnownFormats { public: - /** A known format. + /** A registered protocol format: name, wire-key, and field schema. + * + * Each `Item` bundles the human-readable format name (e.g. `"Payment"`), + * its `KeyType` discriminant (the integer embedded in the wire protocol), + * and the `SOTemplate` that specifies every field's presence requirement. + * `Item` instances are owned by `KnownFormats` and are never moved after + * construction; callers may hold `Item const*` pointers indefinitely. */ class Item { public: + /** Construct a format item and merge unique and common field lists. + * + * `uniqueFields` are specific to this format; `commonFields` are + * shared across all formats in the registry (e.g. ledger metadata + * fields). Both lists are forwarded into the `SOTemplate`. + * + * @note A `static_assert` enforces at compile time that `KeyType` is + * integral or an enum, preventing accidental use of arbitrary + * types as wire-protocol discriminants. + * + * @param name Human-readable format name (e.g. `"Payment"`). + * @param type Wire-protocol discriminant value. + * @param uniqueFields Fields specific to this format. + * @param commonFields Fields shared by all formats in this registry. + */ Item( char const* name, KeyType type, @@ -36,13 +84,13 @@ public: , name_(name) , type_(type) { - // Verify that KeyType is appropriate. + // KeyType must map directly to a wire integer value. static_assert( std::is_enum_v || std::is_integral_v, "KnownFormats KeyType must be integral or enum."); } - /** Retrieve the name of the format. + /** Return the human-readable name of this format (e.g. `"Payment"`). */ [[nodiscard]] std::string const& getName() const @@ -50,7 +98,11 @@ public: return name_; } - /** Retrieve the transaction type this format represents. + /** Return the wire-protocol discriminant identifying this format. + * + * The returned value is the `KeyType` constant that was supplied at + * registration time — for example `ttPayment` for a transaction + * format, or `ltOFFER` for a ledger entry format. */ [[nodiscard]] KeyType getType() const @@ -58,6 +110,13 @@ public: return type_; } + /** Return the field schema for this format. + * + * The `SOTemplate` enumerates every field that may appear in a + * serialized object of this type, together with its `SOEStyle` + * (`soeREQUIRED`, `soeOPTIONAL`, or `soeDEFAULT`). The returned + * reference is stable for the lifetime of the process. + */ [[nodiscard]] SOTemplate const& getSOTemplate() const { @@ -70,32 +129,40 @@ public: KeyType const type_; }; - /** Create the known formats object. - - Derived classes will load the object with all the known formats. - */ private: + /** Construct the registry and capture the concrete subclass name. + * + * The subclass name (obtained via `beast::typeName()`) is + * stored in `name_` and prepended to diagnostic messages emitted by + * `findTypeByName()`, enabling errors like + * `"TxFormats: Unknown format name 'BadName'"`. + * + * Only `Derived` may construct this base (enforced by the `friend` + * declaration and the private access specifier). + */ KnownFormats() : name_(beast::typeName()) { } public: - /** Destroy the known formats object. - - The defined formats are deleted. - */ virtual ~KnownFormats() = default; KnownFormats(KnownFormats const&) = delete; KnownFormats& operator=(KnownFormats const&) = delete; - /** Retrieve the type for a format specified by name. - - If the format name is unknown, an exception is thrown. - - @param name The name of the type. - @return The type. - */ + /** Return the wire-protocol key for a format looked up by name. + * + * Intended for use when parsing externally supplied strings (e.g. JSON + * RPC input or configuration files). An unknown name is treated as a + * recoverable application-level error and is reported as a + * `std::runtime_error` whose message includes the registry name and the + * (possibly truncated to 32 characters) unrecognized name. + * + * @param name The human-readable format name to look up. + * @return The `KeyType` value registered under that name. + * @throws std::runtime_error If `name` is not registered in this + * registry. + */ [[nodiscard]] KeyType findTypeByName(std::string const& name) const { @@ -106,7 +173,16 @@ public: name.substr(0, std::min(name.size(), std::size_t(32))) + "'"); } - /** Retrieve a format based on its type. + /** Return the `Item` registered for the given wire-protocol key, or + * `nullptr` if no such format has been registered. + * + * Returns `nullptr` on a miss so that callers in internal code paths can + * handle an absent format with an idiomatic null check rather than a + * caught exception. Contrast with `findTypeByName()`, which throws for + * unknown names supplied from external input. + * + * @param type The wire-protocol discriminant to look up. + * @return Pointer to the matching `Item`, or `nullptr`. */ [[nodiscard]] Item const* findByType(KeyType type) const @@ -117,13 +193,21 @@ public: return itr->second; } - // begin() and end() are provided for testing purposes. + /** Return an iterator to the first registered `Item`. + * + * @note Exposed for testing only; do not rely on iteration order, which + * reflects reverse-registration sequence due to `emplace_front`. + */ [[nodiscard]] typename std::forward_list::const_iterator begin() const { return formats_.begin(); } + /** Return a past-the-end iterator for the registered `Item` sequence. + * + * @note Exposed for testing only. + */ [[nodiscard]] typename std::forward_list::const_iterator end() const { @@ -131,7 +215,14 @@ public: } protected: - /** Retrieve a format based on its name. + /** Return the `Item` registered under the given name, or `nullptr`. + * + * Protected so that external callers are directed to the public + * `findTypeByName()`, which enforces the exception-on-miss contract for + * externally supplied names. + * + * @param name The human-readable format name to look up. + * @return Pointer to the matching `Item`, or `nullptr`. */ [[nodiscard]] Item const* findByName(std::string const& name) const @@ -142,15 +233,28 @@ protected: return itr->second; } - /** Add a new format. - - @param name The name of this format. - @param type The type of this format. - @param uniqueFields A std::vector of unique fields - @param commonFields A std::vector of common fields - - @return The created format. - */ + /** Register a new format with this registry. + * + * Creates an `Item` by combining `uniqueFields` (specific to this + * format) and `commonFields` (shared across all formats in the registry) + * into a single `SOTemplate`. The new `Item` is inserted at the front + * of the owning `forward_list` to preserve pointer stability, then + * indexed by both name and type in the two `flat_map` lookup tables. + * + * Registering a `type` value that is already present is a programming + * error: `logicError()` (process abort) is called immediately, making + * the failure visible at static-initialization time before any requests + * are served. + * + * @param name Human-readable format name (e.g. `"Payment"`). + * @param type Wire-protocol discriminant; must be unique within + * this registry. + * @param uniqueFields Fields specific to this format. + * @param commonFields Fields shared by all formats in this registry; + * defaults to empty. + * @return A stable `const` reference to the newly created + * `Item`. + */ Item const& add(char const* name, KeyType type, @@ -174,14 +278,22 @@ protected: } private: + /** Concrete subclass name, captured at construction for diagnostic messages. */ std::string name_; - // One of the situations where a std::forward_list is useful. We want to - // store each Item in a place where its address won't change. So a node- - // based container is appropriate. But we don't need searchability. + /** Owning store for all registered `Item` instances. + * + * `std::forward_list` is used because node-based containers never + * relocate existing elements, keeping `Item` addresses stable after + * insertion. The `flat_map` indices below store raw pointers into this + * list; pointer stability is therefore a hard requirement. + */ std::forward_list formats_{}; + /** Name-to-item index for O(log n) lookup by human-readable format name. */ boost::container::flat_map names_{}; + + /** Type-to-item index for O(log n) lookup by wire-protocol discriminant. */ boost::container::flat_map types_{}; friend Derived; }; diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 99d5d818f1..0e0712dd9a 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -1,3 +1,27 @@ +/** @file + * Authoritative registry for every object type that can live in the XRP Ledger. + * + * Defines three tightly-coupled, protocol-level artifacts: + * + * 1. `LedgerEntryType` — the `uint16_t` wire discriminants stored inside every + * serialized ledger object. + * 2. `LedgerSpecificFlags` / per-object flag accessor functions / `getAllLedgerFlags()` + * — flag bitmasks that modify ledger object behavior, together with Meyer's-singleton + * accessors consumed by the `server_definitions` RPC endpoint. + * 3. `LedgerFormats` — the singleton registry that maps each `LedgerEntryType` to its + * `SOTemplate` (field presence schema). + * + * The `ledger_entries.macro` X-macro file is the single source of truth for all + * per-type data; this header and `LedgerFormats.cpp` each include it with a different + * macro definition to derive the enum and the format registration from the same table. + * + * @warning All numeric values defined here are embedded in serialized ledger objects + * and transmitted over the wire. Changing them without corresponding amendment + * machinery causes a hard fork. + * + * @ingroup protocol + */ + #pragma once // NOLINTBEGIN(readability-identifier-naming) @@ -9,28 +33,35 @@ #include namespace xrpl { -/** Identifiers for on-ledger objects. - Each ledger object requires a unique type identifier, which is stored within the object itself; - this makes it possible to iterate the entire ledger and determine each object's type and verify - that the object you retrieved from a given hash matches the expected type. - - @warning Since these values are stored inside objects stored on the ledger they are part of the - protocol. - **Changing them should be avoided because without special handling, this will result in a hard - fork.** - - @note Values outside this range may be used internally by the code for various purposes, but - attempting to use such values to identify on-ledger objects will result in an invariant failure. - - @note When retiring types, the specific values should not be removed but should be marked as - [[deprecated]]. This is to avoid accidental reuse of identifiers. - - @todo The C++ language does not enable checking for duplicate values here. - If it becomes possible then we should do this. - - @ingroup protocol -*/ +/** Numeric type identifiers for every object type that can exist in the XRP Ledger. + * + * Each ledger object embeds its `LedgerEntryType` in the serialized form; this allows + * the ledger layer to determine an object's type during iteration and to verify that + * a hash lookup returned the expected kind of object. + * + * The concrete values are generated by the `ledger_entries.macro` X-macro, which is + * the single source of truth for (tag, value, name, fields) tuples across the enum, + * the `LedgerFormats` constructor, and any auto-generated protocol bindings. + * + * Beyond the macro-generated members, two sentinel pseudo-types (`ltANY`, `ltCHILD`) + * are defined manually for use in keylet lookups where the precise object type is + * unknown or irrelevant. + * + * @warning These values are stored in serialized ledger objects and are part of the + * protocol. Changing them without special amendment handling causes a hard fork. + * + * @note Values outside the known range may be used internally, but passing them to + * ledger-object APIs will result in an invariant failure. + * + * @note When retiring an entry type, mark its enumerator `[[deprecated]]` rather than + * removing it. Removal would free the numeric slot for accidental reuse. + * + * @todo C++ enums cannot enforce uniqueness of values at compile time; duplicate IDs + * can silently coexist. If the language gains that capability it should be used here. + * + * @ingroup protocol + */ // Protocol-critical, hundreds of usages // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum LedgerEntryType : std::uint16_t { @@ -96,16 +127,29 @@ enum LedgerEntryType : std::uint16_t { 0x0067, }; -/** Ledger object flags. - - These flags are specified in ledger objects and modify their behavior. - - @warning Ledger object flags form part of the protocol. - **Changing them should be avoided because without special handling, this will result in a hard - fork.** - - @ingroup protocol -*/ +/** Flat enum of all per-object flag bitmasks across every ledger entry type. + * + * Each enumerator is a named bit constant (e.g. `lsfRequireDestTag`, `lsfGlobalFreeze`) + * that modifies the behavior of a specific ledger object type. The constants are + * generated via the `XMACRO` / `TO_VALUE` pass below, which strips object-type grouping + * and collects every flag name and value into a single flat enum. + * + * @note `LSF_FLAG2` is used when the same bit value appears in more than one object + * type (currently `lsfMPTLocked = 0x00000001` shared between `MPTokenIssuance` and + * `MPToken`). The second occurrence is silently omitted from this enum via the + * `NULL_OUTPUT` helper to avoid a duplicate-enumerator warning, while still appearing + * in the per-object flag maps returned by the getter functions below. + * + * @note Most object types use flag bits starting at `0x00010000`, reserving the low 16 + * bits for future use. `DirNode`, `NFTokenOffer`, and the MPToken family deviate + * from this convention and use the low-order bits — a legacy of their original + * feature designs. + * + * @warning These values are stored in serialized ledger objects and form part of the + * protocol. Changing them without amendment machinery causes a hard fork. + * + * @ingroup protocol + */ #pragma push_macro("XMACRO") #pragma push_macro("TO_VALUE") #pragma push_macro("VALUE_TO_MAP") @@ -222,19 +266,17 @@ enum LedgerEntryType : std::uint16_t { // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum LedgerSpecificFlags : std::uint32_t { XMACRO(NULL_NAME, TO_VALUE, NULL_OUTPUT) }; -// Create getter functions for each set of flags using Meyer's singleton pattern. -// This avoids static initialization order fiasco while still providing efficient access. -// This is used below in `getAllLedgerFlags()` to generate the server_definitions RPC output. -// -// example: -// inline LedgerFlagMap const& getAccountRootFlags() { -// static LedgerFlagMap const flags = { -// {"lsfPasswordSpent", 0x00010000}, -// {"lsfRequireDestTag", 0x00020000}, -// ...}; -// return flags; -// } +/** Map from flag name string to its `uint32_t` bitmask value for a single ledger object type. + * + * Each entry has the form `{"lsfFlagName", 0xXXXXXXXX}`. Per-object maps are returned + * by the `getFlags()` inline functions generated below and are aggregated by + * `getAllLedgerFlags()` for the `server_definitions` RPC response. + */ using LedgerFlagMap = std::map; + +// Per-object flag getter functions: getAccountRootFlags(), getOfferFlags(), etc. +// Each returns a const LedgerFlagMap& initialized once via Meyer's singleton. +// See getAllLedgerFlags() for the aggregating accessor. #define VALUE_TO_MAP(name, value) {#name, value}, #define TO_MAP(name, values) \ inline LedgerFlagMap const& get##name##Flags() \ @@ -244,16 +286,17 @@ using LedgerFlagMap = std::map; } XMACRO(TO_MAP, VALUE_TO_MAP, VALUE_TO_MAP) -// Create a getter function for all ledger flag maps using Meyer's singleton pattern. -// This is used to generate the server_definitions RPC output. -// -// example: -// inline std::vector> const& getAllLedgerFlags() { -// static std::vector> const flags = { -// {"AccountRoot", getAccountRootFlags()}, -// ...}; -// return flags; -// } +/** Return the flags for all ledger object types, keyed by object type name. + * + * Aggregates every per-object `LedgerFlagMap` (produced by the `getFlags()` + * inline functions above) into a single vector, where each element is a pair of + * `(object-type-name, flag-map)`. The vector is initialized once via Meyer's singleton. + * + * This function is the sole data source for the `server_definitions` RPC endpoint, + * which exposes the complete ledger flag catalogue to external API consumers. + * + * @return A stable `const` reference to the process-wide flag catalogue. + */ #define ALL_LEDGER_FLAGS(name, values) {#name, get##name##Flags()}, inline std::vector> const& getAllLedgerFlags() @@ -280,21 +323,64 @@ getAllLedgerFlags() //------------------------------------------------------------------------------ -/** Holds the list of known ledger entry formats. +/** Singleton registry mapping every `LedgerEntryType` to its canonical field schema. + * + * Inherits from `KnownFormats` (CRTP), which provides + * O(log n) lookup by type and by name, duplicate-registration detection, and stable + * `Item` pointer identity. + * + * The registry is populated once, during static initialization, via the private + * constructor's X-macro pass over `ledger_entries.macro`. Every registered entry + * receives an `SOTemplate` built from its type-specific fields plus the three + * common fields returned by `getCommonFields()` (`sfLedgerIndex`, `sfLedgerEntryType`, + * `sfFlags`). + * + * Callers in the serialization, deserialization, and invariant-checking layers + * access the registry through `getInstance()` to look up schemas by type. + * + * @see KnownFormats, LedgerEntryType, SOTemplate + * @ingroup protocol */ class LedgerFormats : public KnownFormats { private: - /** Create the object. - This will load the object with all the known ledger formats. - */ + /** Populate the registry with all known ledger entry formats. + * + * Uses an X-macro pass over `ledger_entries.macro`, registering each entry type + * by calling `KnownFormats::add()` with the entry's name, `LedgerEntryType` + * discriminant, type-specific fields, and the common fields from `getCommonFields()`. + * + * If `ledger_entries.macro` contains a duplicate numeric type ID, `add()` calls + * `logicError()` (process abort) during static initialization rather than silently + * corrupting the registry. + */ LedgerFormats(); public: + /** Return the process-wide `LedgerFormats` singleton. + * + * Uses a function-local static (Meyer's singleton) for thread-safe, once-only + * initialization guaranteed by C++11. The first call constructs the registry and + * registers every known ledger entry type; subsequent calls return the same instance. + * + * @return A `const` reference to the global `LedgerFormats` instance. + */ static LedgerFormats const& getInstance(); - // Fields shared by all ledger entry formats: + /** Return the three fields that every ledger entry must carry. + * + * The common fields are: + * - `sfLedgerIndex` (`soeOPTIONAL`) — key of the entry in the SHAMap. + * - `sfLedgerEntryType` (`soeREQUIRED`) — wire discriminant; must always be present. + * - `sfFlags` (`soeREQUIRED`) — object flag bitmask; must always be present. + * + * These fields are injected into every `SOTemplate` by the constructor, so they do + * not need to be listed in each entry type's individual field set in `ledger_entries.macro`. + * The vector is initialized once on first call (function-local static). + * + * @return A stable `const` reference to the common-fields vector. + */ static std::vector const& getCommonFields(); }; diff --git a/include/xrpl/protocol/LedgerHeader.h b/include/xrpl/protocol/LedgerHeader.h index 68dd5e24b9..d67a89a94a 100644 --- a/include/xrpl/protocol/LedgerHeader.h +++ b/include/xrpl/protocol/LedgerHeader.h @@ -1,3 +1,13 @@ +/** @file + * Defines `LedgerHeader`, the compact canonical summary of a single XRP + * Ledger, together with the serialization, deserialization, and hash + * calculation functions that operate on it. + * + * Every ledger — open, closed, or validated — is identified and + * authenticated through this structure. The serialized form is + * protocol-immutable: 118 bytes without the trailing hash, 150 with it. + */ + #pragma once #include @@ -9,7 +19,23 @@ namespace xrpl { -/** Information about the notional ledger backing the view. */ +/** Canonical metadata block that identifies a single XRP Ledger. + * + * Fields are split into two groups: those valid for all ledgers (including + * open ones that are still accumulating transactions) and those that are + * only meaningful once the ledger is closed (transaction set finalized). + * + * `LedgerHeader` is embedded inside the `ReadView`/`ApplyView` hierarchy + * and is accessible via `view.info()`. The struct is intentionally small so + * it can be cheaply copied, compared, and transmitted without loading the + * full account-state SHAMap. + * + * @note `validated` is `mutable` because it transitions one-way from + * `false` to `true` and must remain settable even on `const`-qualified + * ledger objects. This is a known design wart. + * + * @see calculateLedgerHash, addRaw, deserializeHeader + */ struct LedgerHeader { explicit LedgerHeader() = default; @@ -18,7 +44,12 @@ struct LedgerHeader // For all ledgers // + /** Monotonically increasing sequence number that identifies this ledger's + * position in the chain. */ LedgerIndex seq = 0; + + /** Close time of the parent (previous) ledger, in `NetClock` seconds + * (epoch: 2000-01-01 00:00:00 UTC). */ NetClock::time_point parentCloseTime; // @@ -26,53 +57,158 @@ struct LedgerHeader // // Closed means "tx set already determined" + + /** This ledger's own identity hash, computed by `calculateLedgerHash`. + * Meaningful only after the ledger is closed. */ uint256 hash = beast::kZERO; + + /** SHAMap root hash of the transaction set for this ledger. */ uint256 txHash = beast::kZERO; + + /** SHAMap root hash of the account-state tree after applying this + * ledger's transaction set. */ uint256 accountHash = beast::kZERO; + + /** Hash of the immediately preceding ledger; links this ledger into the + * chain and is included in the signed hash. */ uint256 parentHash = beast::kZERO; + /** Total XRP in existence at this ledger, in drops (1 XRP = 10^6 drops). */ XRPAmount drops = beast::kZERO; - // If validated is false, it means "not yet validated." - // Once validated is true, it will never be set false at a later time. - // VFALCO TODO Make this not mutable + /** Whether this ledger has been confirmed by a quorum of validators. + * Transitions one-way from `false` to `true`; never reverts. + * Declared `mutable` so it can be set on `const`-qualified objects. */ bool mutable validated = false; + + /** Whether this node has accepted the ledger's transaction set, + * independent of network-wide validation. */ bool accepted = false; - // flags indicating how this ledger close took place + /** Bitmask of close-time flags produced by the consensus round. + * The only defined bit is `kS_LCF_NO_CONSENSUS_TIME` (0x01). + * Serialized as a single `uint8_t`; only the low 8 bits are meaningful. */ int closeFlags = 0; - // the resolution for this ledger close time (2-120 seconds) + /** Granularity to which the close time was rounded, in seconds (2–120). + * Determined by the consensus algorithm and stored per-ledger. */ NetClock::duration closeTimeResolution = {}; - // For closed ledgers, the time the ledger - // closed. For open ledgers, the time the ledger - // will close if there's no transactions. - // + /** For closed ledgers: the time at which the ledger closed, in + * `NetClock` seconds. For open ledgers: the projected close time if + * no transactions arrive. */ NetClock::time_point closeTime; }; -// ledger close flags +/** Close-flag bit set when consensus could not agree on a close time. + * + * Written into `LedgerHeader::closeFlags` during `Ledger::setAccepted()` + * when the `correctCloseTime` argument is `false`. Queried via + * `getCloseAgree()`. + */ static std::uint32_t const kS_LCF_NO_CONSENSUS_TIME = 0x01; +/** Return `true` if the consensus round agreed on a close time for this + * ledger. + * + * Returns `false` when `kS_LCF_NO_CONSENSUS_TIME` is set in + * `info.closeFlags`, which indicates the validator set was unable to + * reach agreement (e.g., significant clock skew or a very small validator + * set). Callers that require a reliable close time — such as the ledger + * replay subsystem — must guard on this value. + * + * @param info The ledger header to inspect. + * @return `true` if `kS_LCF_NO_CONSENSUS_TIME` is not set; `false` + * otherwise. + */ inline bool getCloseAgree(LedgerHeader const& info) { return (info.closeFlags & kS_LCF_NO_CONSENSUS_TIME) == 0; } +/** Append the ledger header to a serializer in canonical network byte order. + * + * Field order (protocol-immutable): `seq` (32-bit), `drops` (64-bit), + * `parentHash`, `txHash`, `accountHash` (each 256-bit), `parentCloseTime`, + * `closeTime` (each 32-bit epoch seconds), `closeTimeResolution` (8-bit), + * `closeFlags` (8-bit). Total: 118 bytes. When `includeHash` is `true`, + * `hash` is appended as an additional 32 bytes. + * + * The hash is omitted by default to avoid circularity: it is derived from + * all other fields and must not be part of the input to + * `calculateLedgerHash`. Pass `includeHash = true` when persisting to the + * node store or transmitting over the wire so receivers can skip + * recomputing it. + * + * @note The field order here must exactly mirror `calculateLedgerHash`. + * They are not mechanically linked; a divergence silently breaks + * consensus-level hash agreement across the network. + * @note `validated` and `accepted` are runtime-only flags and are not + * written to the serializer. + * + * @param info The ledger header to serialize. + * @param s Accumulator that receives the serialized bytes. + * @param includeHash If `true`, append `info.hash` after all other fields. + */ void addRaw(LedgerHeader const&, Serializer&, bool includeHash = false); -/** Deserialize a ledger header from a byte array. */ +/** Deserialize a ledger header from a raw byte buffer. + * + * Reads fields in the same order that `addRaw` writes them. Time fields + * are raw 32-bit epoch counts (seconds since 2000-01-01 00:00:00 UTC) + * and are restored to typed `NetClock::time_point` values. The runtime-only + * fields `validated` and `accepted` are left at their default values. + * + * No semantic validation is performed beyond what `SerialIter` enforces. + * The caller is responsible for verifying that the deserialized `hash` + * matches `calculateLedgerHash` before trusting the data. + * + * @param data View over the raw bytes to deserialize. + * @param hasHash If `true`, read a trailing 256-bit value into + * `LedgerHeader::hash`. Must match how the header was serialized. + * @return A populated `LedgerHeader`. + * @throws std::runtime_error (via `SerialIter`) if @p data is shorter + * than the expected field sequence. + */ LedgerHeader deserializeHeader(Slice data, bool hasHash = false); -/** Deserialize a ledger header (prefixed with 4 bytes) from a byte array. */ +/** Deserialize a ledger header that is preceded by a 4-byte prefix. + * + * Skips the leading `HashPrefix` tag that is prepended when a ledger + * header is stored in the node database or transmitted in a peer-protocol + * message, then delegates to `deserializeHeader`. + * + * @param data View over the raw bytes, including the 4-byte prefix. + * @param hasHash Forwarded to `deserializeHeader`; see its documentation. + * @return A populated `LedgerHeader` as returned by `deserializeHeader`. + * @throws std::runtime_error (via `SerialIter`) if the buffer after + * skipping the prefix is too short. + */ LedgerHeader deserializePrefixedHeader(Slice data, bool hasHash = false); -/** Calculate the hash of a ledger header. */ +/** Compute the canonical 256-bit identity hash for a ledger header. + * + * Feeds all header fields (except `hash` itself, `validated`, and + * `accepted`) into `sha512Half` — the first 256 bits of a SHA-512 digest + * — prepended with `HashPrefix::LedgerMaster` (`LWR\0`). The four-byte + * prefix provides hash-domain separation, preventing collisions with + * hashes computed over other XRPL object types. + * + * Each field is explicitly cast to its protocol-defined wire width before + * hashing, preventing silent integer widening from diverging from the + * network. + * + * @note The field order and widths here must exactly mirror `addRaw`. + * They are not mechanically linked; a divergence causes this node to + * compute hashes that disagree with the rest of the network. + * + * @param info The ledger header to hash. + * @return The 256-bit canonical ledger hash. + */ uint256 calculateLedgerHash(LedgerHeader const& info); diff --git a/include/xrpl/shamap/Family.h b/include/xrpl/shamap/Family.h index b9dd85443a..56ccbdd909 100644 --- a/include/xrpl/shamap/Family.h +++ b/include/xrpl/shamap/Family.h @@ -9,6 +9,27 @@ namespace xrpl { +/** Abstract collaborator bundle for SHAMap I/O, caching, and recovery. + * + * Every `SHAMap` holds a `Family&` reference and routes all storage and + * caching decisions through it. The interface bundles four external + * collaborators — a persistent `NodeStore::Database`, two in-memory caches, + * and a logging journal — into a single coherent dependency with a shared + * lifetime, ensuring they are always consistent with each other. + * + * The abstraction decouples the pure Merkle-radix-tree logic from + * application infrastructure, allowing different implementations for + * production (`NodeFamily`) and unit tests (lighter-weight in-memory + * variants). + * + * @note `Family` instances are non-copyable and non-movable by design. + * `SHAMap` stores a `Family&` reference (not a pointer), and multiple + * maps may share the same `Family`. Allowing move would dangle those + * references. Stable addresses are guaranteed for the object's full + * lifetime. + * + * @see NodeFamily + */ class Family { public: @@ -24,42 +45,93 @@ public: explicit Family() = default; virtual ~Family() = default; + /** Return the persistent node-store database backing this family. */ virtual NodeStore::Database& db() = 0; + /** Return the persistent node-store database backing this family. */ [[nodiscard]] virtual NodeStore::Database const& db() const = 0; + /** Return the journal used for diagnostic logging within this family. */ virtual beast::Journal const& journal() = 0; - /** Return a pointer to the Family Full Below Cache */ + /** Return the FullBelowCache for this family. + * + * An entry in this cache means every descendant of the keyed tree node + * is already stored locally. During traversal or sync, a cache hit on a + * node's hash allows the entire subtree beneath it to be skipped. + * + * The cache is generation-stamped: `clear()` increments the generation, + * invalidating all in-memory per-node markers without a per-entry purge. + * `reset()` additionally resets the generation back to 1 for full + * rebuild scenarios. + */ virtual std::shared_ptr getFullBelowCache() = 0; - /** Return a pointer to the Family Tree Node Cache */ + /** Return the TreeNodeCache for this family. + * + * Holds deserialized `SHAMapTreeNode` objects keyed by hash. Nodes + * read from the `NodeStore::Database` are placed here after + * deserialization; subsequent lookups by the same hash retrieve the + * already-decoded object, avoiding redundant disk reads. + * + * Uses `SharedWeakUnionPtr` internally so that nodes can be evicted + * from the cache while live `SHAMap` trees that hold strong pointers + * to them continue to operate normally. + */ virtual std::shared_ptr getTreeNodeCache() = 0; + /** Expire stale entries from both caches. + * + * Called periodically by the application's maintenance loop to + * prevent unbounded memory growth. This is a routine background + * operation and does not invalidate the cache generation. + */ virtual void sweep() = 0; - /** Acquire ledger that has a missing node by ledger sequence + /** Trigger peer acquisition of a ledger identified by sequence number. * - * @param refNum Sequence of ledger to acquire. - * @param nodeHash Hash of missing node to report in throw. + * Called when a tree traversal reaches a node hash that is absent from + * both the cache and the local database, signalling an incomplete + * ledger. Implementations should forward to the inbound-ledger + * acquisition pipeline. + * + * @note Implementations typically maintain a high-water `maxSeq_` + * under a mutex to suppress redundant acquisition requests when + * many concurrent SHAMap operations discover the same missing node. + * + * @param refNum Sequence number of the ledger that owns the missing node. + * @param nodeHash Hash of the missing node, used for diagnostic logging. */ virtual void missingNodeAcquireBySeq(std::uint32_t refNum, uint256 const& nodeHash) = 0; - /** Acquire ledger that has a missing node by ledger hash + /** Trigger peer acquisition of a ledger identified by its hash. * - * @param refHash Hash of ledger to acquire. - * @param refNum Ledger sequence with missing node. + * Alternative to `missingNodeAcquireBySeq` for callers — typically + * sync flows — that have the ledger hash but not the sequence number + * as the primary identifier. + * + * @param refHash Hash of the ledger that owns the missing node. + * @param refNum Sequence number of the same ledger, for logging and + * deduplication. */ virtual void missingNodeAcquireByHash(uint256 const& refHash, std::uint32_t refNum) = 0; + /** Tear down all cache state and reset the FullBelowCache generation. + * + * Used when the family's data is being rebuilt from scratch (e.g., + * after a database wipe or during ledger-replaying scenarios). More + * destructive than `sweep()`: all cached data is discarded and the + * FullBelowCache generation is reset to 1, invalidating every + * in-memory per-node marker that references a prior generation. + */ virtual void reset() = 0; }; diff --git a/include/xrpl/shamap/FullBelowCache.h b/include/xrpl/shamap/FullBelowCache.h index c52434ca05..9ed5a4cba7 100644 --- a/include/xrpl/shamap/FullBelowCache.h +++ b/include/xrpl/shamap/FullBelowCache.h @@ -1,3 +1,13 @@ +/** @file + * Subtree-completeness cache for SHAMap synchronization. + * + * During ledger acquisition, traversing a SHAMap to discover absent nodes + * is expensive: every inner node may require 16 child lookups, each of which + * can hit the database. `BasicFullBelowCache` short-circuits this walk by + * recording the hashes of inner nodes whose entire subtree is confirmed + * present in local storage. + */ + #pragma once #include @@ -13,27 +23,61 @@ namespace xrpl { namespace detail { -/** Remembers which tree keys have all descendants resident. - This optimizes the process of acquiring a complete tree. -*/ +/** Remembers which SHAMap inner-node hashes have all descendants resident. + * + * Once a subtree rooted at an inner node is confirmed complete, the node's + * hash is inserted here. Future sync traversals call `touchIfExists` before + * descending; a hit skips the entire subtree without further DB lookups. + * + * A two-layer scheme avoids redundant work at different granularities: + * - **This cache** (inter-pass, cross-SHAMap): entries survive across + * multiple `getMissingNodes` calls and are shared among all SHAMaps in + * the same `Family`. + * - **Per-node `fullBelowGen_`** (intra-pass): `SHAMapInnerNode` stores the + * generation at which it was marked complete; `isFullBelow(gen)` returns + * true without a cache lookup when the generation still matches. + * + * Entries expire automatically after the configured duration (default two + * minutes), handling the case where a previously-complete subtree is later + * invalidated by database eviction. + * + * All public methods are thread-safe; thread-safety is inherited from the + * underlying `KeyCache` (`TaggedCache`). Only `backed_` + * SHAMaps (those integrated with a `NodeStore`) consult this cache; in-memory + * maps bypass it. + * + * @see SHAMapInnerNode::isFullBelow + * @see SHAMapInnerNode::setFullBelowGen + * @see Family::getFullBelowCache + */ class BasicFullBelowCache { private: using CacheType = KeyCache; public: + /** Target size passed to the constructor to request an unbounded cache. */ static constexpr auto kDEFAULT_CACHE_TARGET_SIZE = 0; using key_type = uint256; using clock_type = typename CacheType::clock_type; /** Construct the cache. - - @param name A label for diagnostics and stats reporting. - @param collector The collector to use for reporting stats. - @param targetSize The cache target size. - @param targetExpirationSeconds The expiration time for items. - */ + * + * The generation counter is initialised to 1. All `SHAMapInnerNode` + * instances start with `fullBelowGen_ = 0`, so `isFullBelow(1)` returns + * false for every node until explicitly marked. + * + * @param name A label used in diagnostics and stats reporting. + * @param clock The clock used for entry expiration. + * @param j Journal for internal cache logging. + * @param collector Collector for stats export; defaults to a no-op + * collector when not provided. + * @param targetSize Maximum number of entries to retain; 0 means + * unbounded (see `kDEFAULT_CACHE_TARGET_SIZE`). + * @param expiration Duration after which an entry is eligible for + * eviction by `sweep()`; defaults to two minutes. + */ BasicFullBelowCache( std::string const& name, clock_type& clock, @@ -52,58 +96,102 @@ public: return cache_.clock(); } - /** Return the number of elements in the cache. - Thread safety: - Safe to call from any thread. - */ + /** Return the number of entries currently held in the cache. + * + * @note Safe to call from any thread. + */ std::size_t size() const { return cache_.size(); } - /** Remove expired cache items. - Thread safety: - Safe to call from any thread. - */ + /** Evict entries whose age exceeds the configured expiration duration. + * + * Called periodically by `NodeFamily::sweep()` on the same housekeeping + * cycle as the tree-node cache sweep. + * + * @note Safe to call from any thread. + */ void sweep() { cache_.sweep(); } - /** Refresh the last access time of an item, if it exists. - Thread safety: - Safe to call from any thread. - @param key The key to refresh. - @return `true` If the key exists. - */ + /** Test whether a hash is cached and, if so, reset its expiration timer. + * + * This is the hot path in `SHAMap::getMissingNodes` and + * `SHAMap::addKnownNode`: before descending into a child subtree the + * caller checks the cache; a `true` return means the entire subtree is + * locally complete and can be skipped, returning + * `SHAMapAddNode::duplicate()`. + * + * @param key Hash of the inner node whose subtree completeness is queried. + * @return `true` if the hash is present in the cache (subtree complete); + * `false` if absent or expired. + * @note Safe to call from any thread. + */ bool touchIfExists(key_type const& key) { return cache_.touchIfExists(key); } - /** Insert a key into the cache. - If the key already exists, the last access time will still - be refreshed. - Thread safety: - Safe to call from any thread. - @param key The key to insert. - */ + /** Record that the subtree rooted at `key` is fully present locally. + * + * Called after a complete depth-first traversal of a subtree confirms no + * missing nodes. Subsequent calls to `touchIfExists` with the same hash + * will return `true` until the entry expires or `clear()`/`reset()` is + * called. + * + * If the key is already present its expiration timer is refreshed. + * + * @param key Hash of the inner node whose subtree has been verified + * complete. + * @note Safe to call from any thread. + */ void insert(key_type const& key) { cache_.insert(key); } - /** generation determines whether cached entry is valid */ + /** Return the current generation counter. + * + * The generation is threaded through a `getMissingNodes` traversal and + * compared against `SHAMapInnerNode::fullBelowGen_` via + * `isFullBelow(generation)`. A match means the node was marked complete + * during the current or a still-valid prior pass and its subtree can be + * skipped without a cache lookup. + * + * The generation is incremented by `clear()` and reset to 1 by `reset()`, + * both of which globally invalidate all in-memory per-node markers at + * zero cost — no tree walk is needed to clear them. + * + * @return The current generation value. + * @note Safe to call from any thread; `gen_` is `std::atomic`. + */ std::uint32_t getGeneration(void) const { return gen_; } + /** Purge all cache entries and invalidate all in-memory per-node markers. + * + * Increments the generation counter so that every `SHAMapInnerNode` + * whose `fullBelowGen_` was set to the old generation will return + * `false` from `isFullBelow` on the next traversal, forcing a fresh + * descent. No tree walk is required; the stale markers are invalidated + * implicitly by the generation mismatch. + * + * Called by `NodeFamily::reset()` when the family is torn down and + * rebuilt (e.g., after missing-node recovery or between ledger replays). + * + * @note Safe to call from any thread. + * @see reset() + */ void clear() { @@ -111,6 +199,21 @@ public: ++gen_; } + /** Purge all cache entries and reset the generation counter to 1. + * + * Semantically equivalent to `clear()` but sets `m_gen = 1` instead of + * incrementing it. Used at initial construction (via the member + * initialiser) and on full application restart, where returning to a + * canonical baseline generation is preferable to retaining a growing + * counter. + * + * Any `SHAMapInnerNode` carrying `fullBelowGen_ > 1` will not match the + * reset-to-1 state, correctly marking every in-memory marker stale. + * Those nodes are expected to be recreated fresh after a hard reset. + * + * @note Safe to call from any thread. + * @see clear() + */ void reset() { @@ -125,6 +228,12 @@ private: } // namespace detail +/** Thread-safe cache recording which SHAMap subtrees are fully present locally. + * + * Public alias for `detail::BasicFullBelowCache`. Used throughout the codebase + * via `Family::getFullBelowCache()` to short-circuit expensive tree traversals + * during ledger synchronization. + */ using FullBelowCache = detail::BasicFullBelowCache; } // namespace xrpl diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index cca800fa40..3e11332016 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -1,3 +1,17 @@ +/** @file + * Defines `SHAMap`, the authenticated radix-Merkle tree that underlies every + * XRP Ledger snapshot. + * + * Every ledger carries two `SHAMap` instances: a transaction tree mapping + * transaction IDs to serialized transaction data, and a state tree mapping + * account-state object keys to their serialized values. The root hash of + * each tree is what validators sign — two nodes hold the same ledger if and + * only if their root hashes match. + * + * This header also defines `SHAMap::ConstIterator`, the forward iterator over + * leaf nodes in key order, and the `SHAMapState` enum that governs which + * operations are legal on a given map instance. + */ #pragma once #include @@ -23,7 +37,13 @@ namespace xrpl { class SHAMapNodeID; class SHAMapSyncFilter; -/** Describes the current state of a given SHAMap */ +/** Lifecycle state of a SHAMap instance. + * + * Determines which mutation operations are legal. The state may advance + * forward but never backward (except `Synching` → `Modifying` via + * `clearSynching()`). Attempting to mutate a map in `Immutable` or + * `Invalid` state is guarded by asserts. + */ enum class SHAMapState { /** The map is in flux and objects can be added and removed. @@ -50,28 +70,32 @@ enum class SHAMapState { Invalid = 3, }; -/** A SHAMap is both a radix tree with a fan-out of 16 and a Merkle tree. - - A radix tree is a tree with two properties: - - 1. The key for a node is represented by the node's position in the tree - (the "prefix property"). - 2. A node with only one child is merged with that child - (the "merge property") - - These properties result in a significantly smaller memory footprint for - a radix tree. - - A fan-out of 16 means that each node in the tree has at most 16 - children. See https://en.wikipedia.org/wiki/Radix_tree - - A Merkle tree is a tree where each non-leaf node is labelled with the hash - of the combined labels of its children nodes. - - A key property of a Merkle tree is that testing for node inclusion is - O(log(N)) where N is the number of nodes in the tree. - - See https://en.wikipedia.org/wiki/Merkle_tree +/** Authenticated 16-way radix Merkle trie over 256-bit keys. + * + * As a radix tree (fan-out 16), the key for a leaf is encoded entirely by + * its position in the tree (prefix property), and any inner node with only + * one descendant is collapsed upward (merge property). Keys are consumed + * 4 bits (one nibble) per level, giving a fixed leaf depth of 64. + * + * As a Merkle tree, every inner node's hash is derived from the combined + * hashes of all 16 child slots (zero-hash for empty slots). This allows + * O(log N) membership proofs and makes the root hash a cryptographic + * commitment to the entire ledger state. + * + * **Copy-on-write (CoW)**: snapshots share physical nodes. Each + * `SHAMapTreeNode` carries a `cowid_`; nodes owned exclusively by this map + * can be mutated in place. Shared nodes are cloned on first write via + * `unshareNode()`. `snapShot(isMutable=false)` on an immutable map is O(1) + * with zero node copies. + * + * **Backing**: when `backed_ == true` (the default) the map integrates with + * the `Family`-provided `NodeStore` for persistent storage and cache lookups. + * Call `setUnbacked()` for transient in-memory trees (e.g., during + * transaction processing) that must not touch the database. + * + * @note Not copyable or movable. Use `snapShot()` to create a CoW + * derivative. + * @see SHAMapState, SHAMapItem, SHAMapInnerNode, SHAMapLeafNode, Family */ class SHAMap { @@ -92,15 +116,23 @@ private: mutable bool full_ = false; // Map is believed complete in database public: - /** Number of children each non-leaf node has (the 'radix tree' part of the - * map) */ + /** Number of children each inner node may have (the radix fan-out). */ static constexpr unsigned int kBRANCH_FACTOR = SHAMapInnerNode::kBRANCH_FACTOR; - /** The depth of the hash map: data is only present in the leaves */ + /** Tree depth at which leaves reside; keys are 256 bits consumed 4 bits + * per level, so leaves are always at depth 64. + */ static constexpr unsigned int kLEAF_DEPTH = 64; + /** A (before, after) pair of `SHAMapItem` pointers representing one changed + * entry in a delta computation. A null `first` means the item was added; + * a null `second` means it was deleted; both non-null means it was + * modified. + */ using DeltaItem = std::pair, boost::intrusive_ptr>; + + /** Map from item key to its before/after state, produced by `compare()`. */ using Delta = std::map; SHAMap() = delete; @@ -108,22 +140,54 @@ public: SHAMap& operator=(SHAMap const&) = delete; - // Take a snapshot of the given map: + /** Construct a CoW snapshot of `other`. + * + * No tree nodes are copied. The new map shares `root_` with `other` and + * takes ownership of subsequent mutations through CoW cloning. If either + * map is mutable, `unshare()` is called immediately so that later writes + * on one map cannot corrupt the other. An immutable snapshot of an + * immutable map is O(1) with zero copies. + * + * Prefer `snapShot()` over calling this constructor directly. + * + * @param other The source map to snapshot. + * @param isMutable If `true`, the new map is in `Modifying` state; + * otherwise `Immutable`. + */ SHAMap(SHAMap const& other, bool isMutable); - // build new map + /** Construct a new, empty map in `Modifying` state. + * + * @param t Whether this map holds transactions (`SHAMapType::Transaction`) + * or account state (`SHAMapType::State`). + * @param f The `Family` providing the NodeStore, caches, and journal. + */ SHAMap(SHAMapType t, Family& f); + /** Construct a map in `Synching` state for a ledger whose root hash is + * known but whose nodes must still be fetched from peers. + * + * The `hash` parameter is not stored internally; it serves as a + * documentation signal that callers should subsequently call + * `fetchRoot(hash, filter)` to install the root node. + * + * @param t Whether this map holds transactions or account state. + * @param hash The expected root hash (not stored; used to select this + * overload over the two-argument form). + * @param f The `Family` providing the NodeStore, caches, and journal. + */ SHAMap(SHAMapType t, uint256 const& hash, Family& f); ~SHAMap() = default; + /** Return the `Family` that backs this map's storage and caches. */ Family const& family() const { return f_; } + /** Return the `Family` that backs this map's storage and caches. */ Family& family() { @@ -132,124 +196,257 @@ public: //-------------------------------------------------------------------------- - /** Iterator to a SHAMap's leaves - This is always a const iterator. - Meets the requirements of ForwardRange. - */ + /** Forward iterator over leaf nodes in ascending key order. + * + * Always const — see `ConstIterator` for details. Satisfies + * `std::forward_iterator`. + */ class ConstIterator; + /** Return an iterator to the first leaf in key order, or `end()` if empty. */ ConstIterator begin() const; + + /** Return the past-the-end sentinel iterator. */ ConstIterator end() const; //-------------------------------------------------------------------------- - // Returns a new map that's a snapshot of this one. - // Handles copy on write for mutable snapshots. + /** Return a heap-allocated CoW snapshot of this map. + * + * Delegates to the copy constructor. If `isMutable` is false and this + * map is also immutable, no nodes are copied (O(1)). If either side is + * mutable, `unshare()` is called to prevent cross-map corruption on + * subsequent writes. + * + * @param isMutable If `true`, the snapshot enters `Modifying` state and + * may be mutated independently. + * @return A `shared_ptr` to the new snapshot. + */ std::shared_ptr snapShot(bool isMutable) const; - /* Mark this SHAMap as "should be full", indicating - that the local server wants all the corresponding nodes - in durable storage. - */ + /** Mark this map as expected to be locally complete. + * + * Sets `full_ = true`, indicating the server wants all referenced nodes + * in durable storage. A subsequent cache miss clears the flag and + * triggers missing-node acquisition via the `Family`. + */ void setFull(); + /** Associate this map with a specific ledger sequence number. + * + * The sequence number is passed to `NodeStore` fetch calls and to the + * missing-node acquisition callback so that the inbound-ledger pipeline + * can identify which ledger needs repair. + * + * @param lseq The ledger sequence number. + */ void setLedgerSeq(std::uint32_t lseq); + /** Fetch and install the root node for a map in `Synching` state. + * + * Attempts to locate the node identified by `hash` via the tiered fetch + * path (cache → NodeStore → `filter`). On success, `root_` is replaced + * and the map is ready for further node ingestion via `addKnownNode()`. + * + * @param hash Expected hash of the root node. + * @param filter Optional sync filter consulted if the node is not in the + * cache or NodeStore; may be `nullptr`. + * @return `true` if the root was successfully installed. + */ bool fetchRoot(SHAMapHash const& hash, SHAMapSyncFilter* filter); - // normal hash access functions - - /** Does the tree have an item with the given ID? */ + /** Return `true` if the map contains an item whose key equals `id`. */ bool hasItem(uint256 const& id) const; + /** Remove the item with key `id` from the map. + * + * If the deletion leaves a parent inner node with only one child, that + * node is collapsed upward (merge property). The map must be in + * `Modifying` state. + * + * @param id Key of the item to remove. + * @return `true` if the item was found and removed; `false` if not found. + */ bool delItem(uint256 const& id); + /** Insert a new item into the map, taking shared ownership of `item`. + * + * Forwards to `addGiveItem()`. The map must be in `Modifying` state and + * must not already contain an item with the same key. + * + * @param type Leaf type determining the hash prefix and wire format. + * @param item The item to insert. + * @return `true` if the item was inserted; `false` if a duplicate key + * already exists. + */ bool addItem(SHAMapNodeType type, boost::intrusive_ptr item); + /** Compute and return the root hash of this map. + * + * If any node hashes are dirty the tree is re-hashed bottom-up before + * returning. This involves a `const_cast` internally (logical const, + * physical mutate) — acknowledged design compromise documented in + * `SHAMap.cpp`. + * + * @return The `SHAMapHash` of the root node. + */ SHAMapHash getHash() const; - // save a copy if you have a temporary anyway + /** Replace an existing item in the map, transferring ownership of `item`. + * + * Locates the leaf whose key matches `item->key()` and replaces its + * payload. Calls `dirtyUp()` only when `setItem()` reports the hash + * changed, avoiding spurious rehashing for no-op updates. The map must + * be in `Modifying` state. + * + * @param type Leaf type (must match the type the item was inserted with). + * @param item Replacement payload; its `key()` must match an existing + * entry. + * @return `true` if the item was found and updated; `false` if not found. + */ bool updateGiveItem(SHAMapNodeType type, boost::intrusive_ptr item); + /** Insert a new item into the map, transferring ownership of `item`. + * + * If the target leaf position is empty, a new leaf is created there. If + * it collides with an existing leaf whose key differs, inner nodes are + * created at deeper levels until the two keys' nibbles diverge (respecting + * the merge property). The map must be in `Modifying` state. + * + * @param type Leaf type determining the hash prefix and wire format. + * @param item The item to insert; its `key()` must not already exist. + * @return `true` if the item was inserted; `false` if a duplicate key + * already exists. + */ bool addGiveItem(SHAMapNodeType type, boost::intrusive_ptr item); - // Save a copy if you need to extend the life - // of the SHAMapItem beyond this SHAMap + /** Return a reference to the intrusive pointer for the item with key `id`. + * + * The reference is valid only while the map is not mutated. To keep the + * item alive beyond the map's next mutation, copy the intrusive pointer. + * + * @param id Key to look up. + * @return Reference to the stored `intrusive_ptr`, or to a null pointer + * if `id` is not found. + */ boost::intrusive_ptr const& peekItem(uint256 const& id) const; + + /** Return a reference to the intrusive pointer for `id`, also supplying + * the leaf's hash. + * + * @param id Key to look up. + * @param hash Receives the hash of the leaf node on success; unchanged + * on miss. + * @return Reference to the stored `intrusive_ptr`, or to a null pointer + * if `id` is not found. + */ boost::intrusive_ptr const& peekItem(uint256 const& id, SHAMapHash& hash) const; - // traverse functions - /** Find the first item after the given item. - - @param id the identifier of the item. - - @note The item does not need to exist. + /** Return an iterator to the first leaf whose key is strictly greater than + * `id`. + * + * `id` does not need to exist in the map. + * + * @param id Lower-bound key (exclusive). + * @return Iterator to the first leaf with key > `id`, or `end()`. */ ConstIterator upperBound(uint256 const& id) const; - /** Find the object with the greatest object id smaller than the input id. - - @param id the identifier of the item. - - @note The item does not need to exist. + /** Return an iterator to the first leaf whose key is greater than or equal + * to `id`. + * + * `id` does not need to exist in the map. + * + * @param id Lower-bound key (inclusive). + * @return Iterator to the first leaf with key >= `id`, or `end()`. */ ConstIterator lowerBound(uint256 const& id) const; - /** Visit every node in this SHAMap - - @param function called with every node visited. - If function returns false, visitNodes exits. - */ + /** Invoke `function` on every node (inner and leaf) in the tree. + * + * Uses an explicit stack to avoid recursion on 64-level trees. + * Traversal order is unspecified but consistent within a single call. + * + * @param function Callable invoked with each `SHAMapTreeNode&`. Return + * `false` to stop traversal early. + */ void visitNodes(std::function const& function) const; - /** Visit every node in this SHAMap that - is not present in the specified SHAMap - - @param function called with every node visited. - If function returns false, visitDifferences exits. - */ + /** Invoke `function` on every node present in this map but absent from + * `have`. + * + * Short-circuits at hash equality, so it is O(d) in the number of + * differing nodes rather than O(n) in total nodes. + * + * @param have The map whose nodes are considered "already present"; + * may be `nullptr` (treats every node in `this` as absent from `have`). + * @param function Callable invoked with each differing `SHAMapTreeNode + * const&`. Return `false` to stop traversal early. + */ void visitDifferences(SHAMap const* have, std::function const&) const; - /** Visit every leaf node in this SHAMap - - @param function called with every non inner node visited. - */ + /** Invoke `function` on the payload of every leaf node in the tree. + * + * Delegates to `visitNodes`, filtering out inner nodes. + * + * @param function Callable invoked with each leaf's + * `boost::intrusive_ptr`. + */ void visitLeaves(std::function const&)> const&) const; - // comparison/sync functions - - /** Check for nodes in the SHAMap not available - - Traverse the SHAMap efficiently, maximizing I/O - concurrency, to discover nodes referenced in the - SHAMap but not available locally. - - @param maxNodes The maximum number of found nodes to return - @param filter The filter to use when retrieving nodes - @param return The nodes known to be missing - */ + /** Traverse the tree to discover nodes that are referenced but not + * available locally, maximizing I/O concurrency via async fetches. + * + * Uses the `MissingNodes` DFS engine with bounded async concurrency + * (`maxDefer = 512`). Subtrees confirmed complete via the + * `FullBelowCache` are skipped. Random start nibbles per inner node + * spread concurrent callers across different parts of the tree. + * + * @param maxNodes Stop after collecting this many missing-node entries. + * @param filter Optional sync filter consulted for each missing hash; + * may be `nullptr`. + * @return Vector of `(SHAMapNodeID, hash)` pairs for nodes that could not + * be located. + */ std::vector> getMissingNodes(int maxNodes, SHAMapSyncFilter* filter); + /** Serialize a node and a bounded-depth subtree for peer delivery. + * + * Bundles the target node with adjacent inner nodes up to `depth` levels + * deep. The depth budget is decremented only when an inner node has more + * than one non-empty child; single-child chains (compressed radix paths) + * are traversed for free. This amortizes per-message round-trip cost + * during tree synchronization. + * + * @param wanted The `SHAMapNodeID` of the node the peer requested. + * @param data Output vector; each appended entry is a + * `(SHAMapNodeID, wire-serialized blob)` pair. + * @param fatLeaves If `true`, leaf nodes adjacent to traversed inner nodes + * are also included; otherwise only inner nodes are emitted. + * @param depth Maximum number of additional levels to bundle. + * @return `true` if the requested node was found and at least one entry + * was appended; `false` if the node could not be located. + */ bool getNodeFat( SHAMapNodeID const& wanted, @@ -257,71 +454,220 @@ public: bool fatLeaves, std::uint32_t depth) const; - /** - * Get the proof path of the key. The proof path is every node on the path - * from leaf to root. Sibling hashes are stored in the parent nodes. - * @param key key of the leaf - * @return the proof path if found + /** Collect the Merkle proof path for `key`. + * + * Walks from the root toward `key`, collecting the serialized form of + * every node on the path (leaf first, root last). Sibling hashes are + * encoded inside each parent's serialization, enabling the recipient to + * reconstruct and verify the root hash without the full tree. + * + * @param key 256-bit key of the target leaf. + * @return A vector of serialized node blobs from leaf to root if `key` + * is present in the map; `std::nullopt` if not found. */ std::optional> getProofPath(uint256 const& key) const; - /** - * Verify the proof path - * @param rootHash root hash of the map - * @param key key of the leaf - * @param path the proof path - * @return true if verified successfully + /** Verify a Merkle proof path without a live tree. + * + * Recomputes the root hash from `path` (leaf-to-root order) using the + * nibbles of `key` to select the correct branch at each level, and + * compares the result against `rootHash`. Bounded to 65 levels; network + * input is wrapped in try/catch to handle malformed blobs. + * + * @param rootHash Expected root hash of the tree. + * @param key 256-bit key that the path was generated for. The + * verifier uses this key's nibbles to navigate — a wrong key at any + * level produces a false negative. + * @param path Serialized nodes from leaf to root, as returned by + * `getProofPath()`. + * @return `true` if the recomputed root matches `rootHash`. */ static bool verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector const& path); - /** Serializes the root in a format appropriate for sending over the wire */ + /** Serialize the root node into `s` in the wire format used for peer + * messages. + * + * @param s Serializer to append the encoded root node to. + */ void serializeRoot(Serializer& s) const; + /** Install the root node received from a peer during synchronization. + * + * Deserializes `rootNode`, verifies its hash matches `hash`, and + * installs it as `root_` if the map is in `Synching` state and the + * current root is empty. Notifies `filter` on success so it can persist + * or cache the data. + * + * @param hash Expected hash of the root node. + * @param rootNode Wire-serialized root node received from a peer. + * @param filter Optional sync filter for notification; may be `nullptr`. + * @return A `SHAMapAddNode` tri-state: useful (new root installed), + * duplicate (root already present), or invalid (hash mismatch or + * deserialization failure). + */ SHAMapAddNode addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter* filter); + + /** Install a known interior or leaf node received from a peer. + * + * Performs two integrity checks before installing: (1) the deserialized + * node's hash must match the hash recorded in its parent branch; (2) for + * leaves at `kLEAF_DEPTH`, the reconstructed `SHAMapNodeID` from the + * leaf's own key must match `nodeID`, closing a theoretical + * hash-collision-at-wrong-position attack. A mismatch on either check + * transitions the map to `Invalid` rather than crashing. + * + * Skips descent into subtrees already confirmed complete via the + * `FullBelowCache`. + * + * @param nodeID Tree address of the node being provided. + * @param rawNode Wire-serialized node data. + * @param filter Optional sync filter; may be `nullptr`. + * @return `SHAMapAddNode` tri-state: useful, duplicate, or invalid. + * @note Returning `invalid` is normal for stale or mismatched peer data; + * callers must handle it gracefully without crashing. + */ SHAMapAddNode addKnownNode(SHAMapNodeID const& nodeID, Slice const& rawNode, SHAMapSyncFilter* filter); - // status functions + /** Freeze the map, forbidding all future writes. + * + * Advances the state from `Modifying` or `Synching` to `Immutable`. + * Asserts that the current state is not `Invalid` — only coherent maps + * may be frozen. After this call, any attempt to mutate the map will + * trigger an assertion failure. + */ void setImmutable(); + + /** Return `true` if the map is in `Synching` state. */ bool isSynching() const; + + /** Advance the map to `Synching` state to begin peer synchronization. */ void setSynching(); + + /** Revert the map from `Synching` back to `Modifying` state. + * + * Used when synchronization is abandoned before completion so the map + * can be used for local modifications again. + */ void clearSynching(); + + /** Return `true` if the map is in any state other than `Invalid`. */ bool isValid() const; - // caution: otherMap must be accessed only by this function - // return value: true=successfully completed, false=too different + /** Compute the symmetric difference between this map and `otherMap`. + * + * Walks both trees concurrently, short-circuiting at subtree roots whose + * hashes match (O(d) in the number of differences, not O(n) in total + * items). Results are recorded in `differences` as `DeltaItem` pairs. + * + * @param otherMap The map to compare against. Must not be accessed + * concurrently by any other caller for the duration of this call. + * @param differences Receives one entry per key that differs between the + * two maps. Entries are appended; the caller is responsible for + * starting with an empty map if that is desired. + * @param maxCount Maximum number of differences to record before + * returning early. Pass `INT_MAX` for unlimited. + * @return `true` if the comparison completed without hitting `maxCount`; + * `false` if truncated. + */ bool compare(SHAMap const& otherMap, Delta& differences, int maxCount) const; - /** Convert any modified nodes to shared. */ + /** Set `cowid_` to zero on every node, making them all shareable. + * + * After this call the map's nodes may be safely shared across CoW + * snapshots without cloning. Does not write to the NodeStore. + * + * @return The number of nodes processed. + */ int unshare(); - /** Flush modified nodes to the nodestore and convert them to shared. */ + /** Serialize and persist dirty nodes, then mark them as shared. + * + * Performs a post-order DFS with an explicit stack to avoid stack + * overflow on 64-deep trees. Per node: clones if CoW-shared, recomputes + * hash, clears `cowid_`, and writes to `Family::db()` if `backed_` is + * `true`. + * + * @param t The `NodeObjectType` tag used when writing to the NodeStore. + * @return The number of nodes written. + */ int flushDirty(NodeObjectType t); + /** Single-threaded completeness check; records missing nodes up to a limit. + * + * Traverses the tree, attempting to fetch each referenced node via + * `descendNoStore` (returns null instead of throwing on a miss). Any + * unreachable node is appended to `missingNodes`. + * + * @param missingNodes Output vector; missing nodes are appended. + * @param maxMissing Stop after recording this many missing nodes. + */ void walkMap(std::vector& missingNodes, int maxMissing) const; + + /** Parallel completeness check using one thread per top-level branch. + * + * Partitions at depth 1 — up to 16 `std::thread`s, one per non-empty, + * non-leaf top-level child. Each thread has its own node stack; + * `missingNodes` and an internal exceptions vector are mutex-protected. + * + * @param missingNodes Output vector; missing nodes are appended (all + * threads share it under a mutex). + * @param maxMissing Stop after recording this many missing nodes. + * @return `true` if no worker thread threw an exception; `false` + * otherwise. A `false` return does NOT mean there are no missing + * nodes — always inspect `missingNodes` regardless of the return + * value. + * @note Worker threads catch `SHAMapMissingNode` to avoid `std::terminate`; + * uncaught exceptions are stored internally and reflected only in + * the return value. Callers must inspect both the return value and + * `missingNodes` for a complete picture. + */ bool walkMapParallel(std::vector& missingNodes, int maxMissing) const; - bool - deepCompare(SHAMap& other) const; // Intended for debug/test only + /** Item-by-item equality check for debug and test use only. + * + * Slower than hash comparison; intended for validating test fixtures. + * + * @param other The map to compare against. + * @return `true` if both maps contain identical items. + */ + bool + deepCompare(SHAMap& other) const; + + /** Detach this map from the NodeStore, making all I/O in-memory only. + * + * Sets `backed_ = false`. Subsequent fetches consult only the in-process + * `TreeNodeCache`; all writes are suppressed. Use for transient trees + * (e.g., during transaction processing) that must not touch the database. + */ void setUnbacked(); + /** Log every node in the tree to the journal for debugging. */ void dump(bool withHashes = false) const; + + /** Assert that all structural invariants hold across the entire tree. + * + * Checks that every node satisfies its own `invariants()`, that inner + * nodes' `isBranch_` bitmask is consistent with their children, and that + * no leaf appears above `kLEAF_DEPTH`. Intended for test and debug + * builds; a violation triggers an assertion failure. + */ void invariants() const; @@ -459,8 +805,13 @@ private: int walkSubTree(bool doWrite, NodeObjectType t); - // Structure to track information about call to - // getMissingNodes while it's in progress + /** Mutable traversal state for a single call to `getMissingNodes()`. + * + * Bundles the DFS stack, async I/O bookkeeping, and result accumulation + * for one invocation of the missing-node discovery algorithm. Non- + * copyable; created on the stack inside `getMissingNodes()` and passed by + * reference to the two helper functions. + */ struct MissingNodes { MissingNodes() = delete; @@ -468,21 +819,31 @@ private: MissingNodes& operator=(MissingNodes const&) = delete; - // basic parameters + /** Maximum number of missing nodes to collect before returning. */ int max; + + /** Optional sync filter consulted for each missing hash. */ SHAMapSyncFilter* filter; + + /** Maximum number of async reads to keep in flight simultaneously. */ int const maxDefer; + + /** `FullBelowCache` generation at traversal start; subtrees whose + * `fullBelowGen_` matches this value are skipped as already complete. + */ std::uint32_t generation; - // nodes we have discovered to be missing + /** Accumulated (SHAMapNodeID, hash) pairs for nodes confirmed absent. */ std::vector> missingNodes; + + /** Hashes of missing nodes, for deduplication. */ std::set missingHashes; - // nodes we are in the process of traversing + /** One entry per inner node actively being traversed. */ using StackEntry = std::tuple< SHAMapInnerNode*, // pointer to the node SHAMapNodeID, // the node's ID - int, // while child we check first + int, // which child we check first int, // which child we check next bool>; // whether we've found any missing children yet @@ -493,20 +854,28 @@ private: // such as std::vector, can't be used here. std::stack> stack; - // nodes we may have acquired from deferred reads + /** One completed async read awaiting processing. */ using DeferredNode = std::tuple< SHAMapInnerNode*, // parent node SHAMapNodeID, // parent node ID int, // branch - intr_ptr::SharedPtr>; // node + intr_ptr::SharedPtr>; // fetched node + /** Count of async reads currently in flight. */ int deferred; + + /** Guards `finishedReads` and `deferred` for async callbacks. */ std::mutex deferLock; + + /** Signalled by async callbacks when `finishedReads` is non-empty. */ std::condition_variable deferCondVar; + + /** Completed async reads waiting to be installed into the tree. */ std::vector finishedReads; - // nodes we need to resume after we get their children from deferred - // reads + /** Inner nodes that must be revisited once their deferred children + * have been installed. + */ std::map resumes; MissingNodes(int max, SHAMapSyncFilter* filter, int maxDefer, std::uint32_t generation) @@ -579,6 +948,21 @@ SHAMap::setUnbacked() //------------------------------------------------------------------------------ +/** Forward iterator over `SHAMap` leaf nodes in ascending key order. + * + * Carries its own `SharedPtrNodeStack` — a path from the root to the current + * position — so that `operator++` can resume descent without rescanning from + * the root. Always const: the Merkle invariant requires that any write + * re-hashes the entire path to the root, so a non-const iterator would either + * silently break the invariant or force expensive re-hashing on every + * dereference. + * + * Satisfies `std::forward_iterator`. Default-constructing is not permitted; + * obtain iterators from `SHAMap::begin()` and `SHAMap::end()`. + * + * @note Comparing iterators from different `SHAMap` instances is undefined + * and will trigger an assertion in debug builds. + */ class SHAMap::ConstIterator { public: @@ -602,13 +986,23 @@ public: ~ConstIterator() = default; + /** Dereference the iterator, returning the current `SHAMapItem`. */ reference operator*() const; + + /** Dereference the iterator, returning a pointer to the current item. */ pointer operator->() const; + /** Advance to the next leaf in key order and return a reference to this + * iterator. + */ ConstIterator& operator++(); + + /** Advance to the next leaf in key order and return the prior iterator + * value. + */ ConstIterator operator++(int); @@ -676,6 +1070,10 @@ SHAMap::ConstIterator::operator++(int) return tmp; } +/** Return `true` if `x` and `y` refer to the same leaf (or both are end). + * + * @note Asserts in debug builds that both iterators belong to the same map. + */ inline bool operator==(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) { @@ -686,6 +1084,7 @@ operator==(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) return x.item_ == y.item_; } +/** Return `true` if `x` and `y` do not refer to the same leaf. */ inline bool operator!=(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) { diff --git a/include/xrpl/shamap/SHAMapAccountStateLeafNode.h b/include/xrpl/shamap/SHAMapAccountStateLeafNode.h index c006b2de37..0db46b88d3 100644 --- a/include/xrpl/shamap/SHAMapAccountStateLeafNode.h +++ b/include/xrpl/shamap/SHAMapAccountStateLeafNode.h @@ -8,17 +8,57 @@ namespace xrpl { -/** A leaf node for a state object. */ +/** SHAMap leaf node holding a serialized ledger state object. + * + * Represents account roots, offers, trust lines, and other ledger objects + * in the SHAMap state trie. One of three concrete leaf types alongside + * `SHAMapTxLeafNode` and `SHAMapTxPlusMetaLeafNode`; each encodes + * type-specific hashing and serialization rules statically via virtual + * dispatch, eliminating runtime branching in the hot path. + * + * Unlike transaction leaves, the Merkle hash commits to both the payload + * and the item key (see `updateHash()`). This is required because state + * object keys (e.g. account address, offer index) are externally assigned + * and may not appear verbatim in the serialized blob — omitting the key + * would allow two distinct objects with identical payloads to collide. + * + * All mutable state lives in the base classes. This class is a stateless + * policy layer: it supplies only the hash formula, clone factory, type + * tag, and wire format for account-state leaves. + * + * @see SHAMapLeafNode + * @see SHAMapTxLeafNode + * @see SHAMapTxPlusMetaLeafNode + */ class SHAMapAccountStateLeafNode final : public SHAMapLeafNode, public CountedObject { public: + /** Construct a new account-state leaf and compute its hash. + * + * Use this constructor when creating a brand-new node from a freshly + * produced item. `updateHash()` is called immediately so the node is + * valid for insertion into the trie. + * + * @param item The ledger state object payload; must be non-null. + * @param cowid Copy-on-write owner ID of the creating SHAMap instance. + */ SHAMapAccountStateLeafNode(boost::intrusive_ptr item, std::uint32_t cowid) : SHAMapLeafNode(std::move(item), cowid) { updateHash(); } + /** Construct an account-state leaf with a pre-computed hash. + * + * Used by `clone()` when the underlying item has not changed: forwarding + * the existing hash avoids a redundant SHA-512 computation. + * + * @param item The ledger state object payload; must be non-null. + * @param cowid Copy-on-write owner ID for the new node. + * @param hash Known hash of `item`; must be consistent with the item's + * current content or the Merkle tree will be corrupted. + */ SHAMapAccountStateLeafNode( boost::intrusive_ptr item, std::uint32_t cowid, @@ -27,24 +67,60 @@ public: { } + /** Produce an exclusively owned copy of this node for copy-on-write mutation. + * + * The new node shares the same item and hash as the original — no + * recomputation occurs. The caller supplies the new `cowid` so the clone + * is immediately owned by the mutating SHAMap. + * + * @param cowid Copy-on-write owner ID for the cloned node. + * @return A freshly allocated `SHAMapAccountStateLeafNode` with the same + * item and hash, owned exclusively by `cowid`. + */ intr_ptr::SharedPtr clone(std::uint32_t cowid) const final { return intr_ptr::makeShared(item_, cowid, hash_); } + /** Return the node type tag for account-state leaves. + * + * @return `SHAMapNodeType::TnAccountState` + */ SHAMapNodeType getType() const final { return SHAMapNodeType::TnAccountState; } + /** Recompute and store this node's Merkle hash. + * + * Hashes the `HashPrefix::LeafNode` domain separator (`'M'`,`'L'`,`'N'`), + * the raw item payload, and the item key together via `sha512Half`. The key + * is included because state object keys are externally assigned identifiers + * that bind the object to its trie position; without the key, two objects + * with identical serialized data would produce the same hash regardless of + * their position in the ledger. + * + * @note The hash formula differs from `SHAMapTxLeafNode::updateHash()`, + * which omits the key because a transaction's ID is already derived + * from `sha512Half(prefix, blob)`. + */ void updateHash() final { hash_ = SHAMapHash{sha512Half(HashPrefix::LeafNode, item_->slice(), item_->key())}; } + /** Serialize this node for peer-to-peer sync (wire format). + * + * Writes the raw item payload, then the item key as a fixed-width bit + * string, then the single-byte wire-type tag `kWIRE_TYPE_ACCOUNT_STATE` + * (`1`). The tag at the end allows `SHAMapTreeNode::makeFromWire()` to + * reconstruct the correct concrete leaf type on the receiving peer. + * + * @param s Serializer to append to. + */ void serializeForWire(Serializer& s) const final { @@ -53,6 +129,15 @@ public: s.add8(kWIRE_TYPE_ACCOUNT_STATE); } + /** Serialize this node in the canonical hashing format. + * + * Writes the 4-byte `HashPrefix::LeafNode` domain separator, the raw item + * payload, and the item key. This matches the input fed to `sha512Half` in + * `updateHash()` and is used for Merkle proof verification where the hash + * prefix already encodes the node type (no wire-type tag is appended). + * + * @param s Serializer to append to. + */ void serializeWithPrefix(Serializer& s) const final { diff --git a/include/xrpl/shamap/SHAMapAddNode.h b/include/xrpl/shamap/SHAMapAddNode.h index 87222f74d8..4522daf051 100644 --- a/include/xrpl/shamap/SHAMapAddNode.h +++ b/include/xrpl/shamap/SHAMapAddNode.h @@ -4,7 +4,25 @@ namespace xrpl { -// results of adding nodes +/** Accumulates the outcome of adding one or more nodes to a SHAMap during sync. + * + * During ledger synchronization, raw trie nodes received from peers are + * classified as useful (new and hash-verified), duplicate (already present), + * or invalid (corrupt, hash-mismatched, or structurally wrong). This class + * collects those three counts so callers can assess whether a peer's + * contribution was helpful, harmless, or harmful. + * + * Instances are typically constructed via the static factory methods + * (`useful()`, `duplicate()`, `invalid()`) as single-count return values + * from `SHAMap::addRootNode()` and `SHAMap::addKnownNode()`. They are then + * aggregated with `operator+=` across a batch of nodes in higher-level + * acquisition code such as `InboundLedger::receiveNode()`. + * + * @note Duplicates count on the positive side of `isGood()` because + * receiving a node you already have is benign. Only `mBad` accumulates + * evidence of peer misbehavior. + * @see SHAMap::addRootNode, SHAMap::addKnownNode + */ class SHAMapAddNode { private: @@ -13,33 +31,114 @@ private: int duplicate_; public: + /** Construct a zero-count accumulator. */ SHAMapAddNode(); + + /** Record one invalid node (corrupt, hash-mismatched, or structurally wrong). */ void incInvalid(); + + /** Record one useful node (new, hash-verified, and written to the map). */ void incUseful(); + + /** Record one duplicate node (valid but already present in the map). */ void incDuplicate(); + + /** Reset all counters to zero. */ void reset(); + + /** Return the count of useful (good) nodes recorded. + * + * @return The number of new, hash-verified nodes added. + */ [[nodiscard]] int getGood() const; + + /** Return whether the exchange was net non-harmful. + * + * Returns `true` when `(good + duplicate) > bad`. Duplicates count + * positively because they are benign; only bad nodes are evidence of + * misbehavior. Used by `TransactionAcquire::takeNodes()` and + * `InboundLedger` to decide whether to continue processing a peer's + * contribution. + * + * @return `true` if the peer's contribution was not net-harmful. + */ [[nodiscard]] bool isGood() const; + + /** Return whether any invalid nodes were recorded. + * + * @return `true` if at least one node was classified as invalid. + */ [[nodiscard]] bool isInvalid() const; + + /** Return whether actual forward progress was made. + * + * Answers "did we receive at least one new node we didn't already have?" + * Strictly stronger than `isGood()`: duplicates do not satisfy this + * predicate. Used by `InboundLedger` to advance the `progress_` flag + * and decide whether sync moved forward. + * + * @return `true` if at least one useful (new) node was added. + */ [[nodiscard]] bool isUseful() const; + + /** Format the counters as a human-readable string for journal output. + * + * Emits only the non-zero counters, e.g., `"good:3 dupe:1"` or + * `"bad:2"`. Returns `"no nodes processed"` when all three counts are + * zero. + * + * @return A compact diagnostic string suitable for debug-level logging. + */ [[nodiscard]] std::string get() const; + /** Combine another accumulator into this one by summing all three counters. + * + * Used to aggregate results across a batch of nodes, e.g., in + * `InboundLedger::receiveNode()` where `san += map.addKnownNode(...)` is + * called in a loop. + * + * @param n The accumulator to add. + * @return A reference to this accumulator. + */ SHAMapAddNode& operator+=(SHAMapAddNode const& n); + /** Construct a single-duplicate-count instance. + * + * Returned by `addRootNode` / `addKnownNode` when the node was valid + * but already present in the map. + * + * @return An instance with `duplicate = 1`, `good = 0`, `bad = 0`. + */ static SHAMapAddNode duplicate(); + + /** Construct a single-useful-count instance. + * + * Returned by `addRootNode` / `addKnownNode` when the node was new + * and successfully verified and inserted. + * + * @return An instance with `good = 1`, `bad = 0`, `duplicate = 0`. + */ static SHAMapAddNode useful(); + + /** Construct a single-invalid-count instance. + * + * Returned by `addRootNode` / `addKnownNode` when the node failed hash + * verification, had a structural mismatch, or arrived on an empty branch. + * + * @return An instance with `bad = 1`, `good = 0`, `duplicate = 0`. + */ static SHAMapAddNode invalid(); diff --git a/include/xrpl/shamap/SHAMapInnerNode.h b/include/xrpl/shamap/SHAMapInnerNode.h index ee2a18bf03..209210dc0e 100644 --- a/include/xrpl/shamap/SHAMapInnerNode.h +++ b/include/xrpl/shamap/SHAMapInnerNode.h @@ -11,71 +11,130 @@ namespace xrpl { +/** Branching (non-leaf) node of the SHAMap authenticated Merkle radix tree. + * + * Each inner node fans out into exactly `kBRANCH_FACTOR` (16) children, one + * per hexadecimal nibble of a 256-bit key. Together with `SHAMapLeafNode`, + * inner nodes form a trie of depth at most 64 levels. + * + * Child hashes and child pointers are stored together in a single sparse + * allocation via `TaggedPointer hashesAndChildren_`. When fewer than all 16 + * branches are occupied the arrays are kept compact (packed in branch-index + * order), reducing per-node memory to roughly 25% of a dense layout for a + * typical production tree. + * + * The `lock_` field is a 16-bit atomic bitlock — one bit per child slot — + * allowing concurrent access to *different* children of the same node without + * global serialization. + * + * @note Callers must `clone()` a shared node (one with `cowid() == 0`) before + * mutating it. `setChild()` and `shareChild()` both assert `cowid_ != 0`. + * @see SHAMapTreeNode for copy-on-write ownership semantics. + * @see TaggedPointer for the sparse storage implementation. + */ class SHAMapInnerNode final : public SHAMapTreeNode, public CountedObject { public: - /** Each inner node has 16 children (the 'radix tree' part of the map) */ + /** Number of children per inner node — one per hex nibble of the key. */ static constexpr unsigned int kBRANCH_FACTOR = 16; private: - /** Opaque type that contains the `hashes` array (array of type - `SHAMapHash`) and the `children` array (array of type - `intr_ptr::SharedPtr`). + /** Co-located sparse arrays: `SHAMapHash[N]` followed by + * `SharedPtr[N]`, where N is determined by the 2-bit tag + * stored in the pointer's low bits (capacity tiers: 2, 4, 8, 16). + * + * `isBranch_` is the authoritative occupancy bitset; in sparse mode the + * arrays hold only non-empty children packed in ascending branch-index + * order. */ TaggedPointer hashesAndChildren_; + /** Generation counter used by the full-below optimization. + * + * When equal to the current sync generation, every node in the subtree + * below this node is known to be present locally and does not need to be + * fetched from peers. + */ std::uint32_t fullBelowGen_ = 0; + + /** Bitmask of occupied branches; bit `i` is set iff branch `i` is + * non-empty. This is the single source of truth for occupancy; the + * `hashesAndChildren_` arrays are sized accordingly. + */ std::uint16_t isBranch_ = 0; - /** A bitlock for the children of this node, with one bit per child */ + /** Per-child bit spinlock, one bit per physical array slot. + * + * Allows concurrent reads of *different* children without a global lock. + * `getChild()`, `getChildPointer()`, and `canonicalizeChild()` all + * acquire only the single bit corresponding to the target child's array + * index. `setChild()` and `shareChild()` skip locking because they + * require CoW ownership (asserted via `cowid_`). + */ mutable std::atomic lock_ = 0; - /** Convert arrays stored in `hashesAndChildren_` so they can store the - requested number of children. - - @param toAllocate allocate space for at least this number of children - (must be <= branchFactor) - - @note the arrays may allocate more than the requested value in - `toAllocate`. This is due to the implementation of TagPointer, which - only supports allocating arrays of 4 different sizes. + /** Resize the co-located hash and child-pointer arrays to accommodate + * at least `toAllocate` children. + * + * Existing children are preserved; the caller is responsible for + * updating `isBranch_` before and after as needed. Because + * `TaggedPointer` only supports four capacity tiers (2, 4, 8, 16), the + * actual allocation may be larger than `toAllocate`. + * + * @param toAllocate minimum required capacity; must be ≤ `kBRANCH_FACTOR`. */ void resizeChildArrays(std::uint8_t toAllocate); - /** Get the child's index inside the `hashes` or `children` array (stored in - `hashesAndChildren_`. - - These arrays may or may not be sparse). The optional will be empty is an - empty branch is requested and the arrays are sparse. - - @param i index of the requested child + /** Translate a logical branch number to a physical array index. + * + * In sparse mode, only occupied branches are stored, so the physical index + * is `popcount(isBranch_ & ((1 << i) - 1))`. In dense mode (all 16 slots + * allocated) branch number equals array index. + * + * @param i Logical branch number (0–15). + * @return The physical array index, or `std::nullopt` if the branch is + * empty and the arrays are in sparse mode. */ std::optional getChildIndex(int i) const; - /** Call the `f` callback for all 16 (branchFactor) branches - even if - the branch is empty. - - @param f a one parameter callback function. The parameter is the - child's hash. - */ + /** Invoke `f` for every one of the 16 branches, passing each branch's + * `SHAMapHash` — zero-hash for empty branches. + * + * Used by `updateHash()` and `serializeForWire()` to iterate the full + * logical child-hash array without caring about sparse vs. dense layout. + * + * @tparam F Callable with signature `void(SHAMapHash const&)`. + * @param f Callback invoked once per branch in branch-index order. + */ template void iterChildren(F&& f) const; - /** Call the `f` callback for all non-empty branches. - - @param f a two parameter callback function. The first parameter is - the branch number, the second parameter is the index into the array. - For dense formats these are the same, for sparse they may be - different. - */ + /** Invoke `f` for every non-empty branch, passing both the logical branch + * number and the physical array index. + * + * For a dense (16-element) layout the two values are identical. For a + * sparse layout the array index is the packed position, which differs + * from the branch number when lower-numbered branches are absent. + * + * @tparam F Callable with signature `void(int branchNum, int arrayIdx)`. + * @param f Callback invoked once per occupied branch. + */ template void iterNonEmptyChildIndexes(F&& f) const; public: + /** Construct an inner node owned by the given SHAMap copy-on-write epoch. + * + * @param cowid Copy-on-write identifier of the owning SHAMap; pass 0 for + * a shareable (read-only) node. + * @param numAllocatedChildren Initial array capacity; defaults to 2 + * (smallest sparse tier). The actual allocation may be rounded up to + * the next supported tier. + */ explicit SHAMapInnerNode(std::uint32_t cowid, std::uint8_t numAllocatedChildren = 2); SHAMapInnerNode(SHAMapInnerNode const&) = delete; @@ -83,87 +142,277 @@ public: operator=(SHAMapInnerNode const&) = delete; ~SHAMapInnerNode() override; - // Needed to support intrusive weak pointers + /** Release all child `SharedPtr`s before the node's memory is reclaimed. + * + * Called by the intrusive reference-count infrastructure when the strong + * count reaches zero but weak references may still exist. Explicitly + * resets every occupied child slot so that downstream reference counts are + * decremented promptly, breaking potential reference cycles even while + * the storage itself outlives its strong references. + */ void partialDestructor() override; + /** Produce an independent copy of this node assigned to a new CoW epoch. + * + * Allocates a new `SHAMapInnerNode` with `cowid`, copies all hashes and + * child pointers, and right-sizes the sparse arrays to actual occupancy. + * Hashes are copied without locking (they are immutable on shared nodes); + * child pointers are copied under `lock_` to prevent races with + * concurrent `canonicalizeChild()` calls. + * + * @param cowid Copy-on-write identifier for the new node's owning map. + * @return A fully independent `SharedPtr` to the cloned node. + */ intr_ptr::SharedPtr clone(std::uint32_t cowid) const override; + /** @return `SHAMapNodeType::TnInner`. */ SHAMapNodeType getType() const override { return SHAMapNodeType::TnInner; } + /** @return `false` — inner nodes are never leaves. */ bool isLeaf() const override { return false; } + /** @return `true` — this is always an inner node. */ bool isInner() const override { return true; } + /** @return `true` if no branches are populated (`isBranch_ == 0`). */ bool isEmpty() const; + /** Check whether a specific branch is unoccupied. + * + * @param m Branch index (0–15). + * @return `true` if branch `m` has no child. + */ bool isEmptyBranch(int m) const; + /** @return The number of populated branches (popcount of `isBranch_`). */ int getBranchCount() const; + /** Return the Merkle hash committed to branch `m`. + * + * @param m Branch index (0–15). + * @return The child's `SHAMapHash`, or the zero hash if the branch is + * empty. + */ SHAMapHash const& getChildHash(int m) const; + /** Replace the child at branch `m`, resizing the sparse arrays as needed. + * + * Passing a null `child` removes the branch; passing a non-null pointer + * installs it. Zeroes the corresponding hash entry so that the next + * `updateHash()` recomputes from the pointer. Zeroes `hash_` to mark + * this node dirty. + * + * @param m Branch index (0–15). + * @param child New child node, or null to clear the branch. + * @note Asserts `cowid_ != 0` — the node must be CoW-owned before + * mutation. Asserts `child.get() != this` to prevent self-loops. + */ void setChild(int m, intr_ptr::SharedPtr child); + /** Install a child pointer into an already-occupied branch without + * resizing arrays or dirtying the hash. + * + * Used during tree construction when the branch is known to exist (e.g., + * after `setChild` allocated the slot). Unlike `setChild`, this does not + * zero `hash_` and does not resize the arrays. + * + * @param m Branch index (0–15); must already be non-empty. + * @param child Non-null child pointer to install. + * @note Asserts `cowid_ != 0`, `child != null`, and that branch `m` is + * already occupied. + */ void shareChild(int m, intr_ptr::SharedPtr const& child); + /** Return a raw (non-owning) pointer to the child at branch `m`. + * + * Acquires the per-child bit spinlock for `m`'s physical array index, + * then reads the pointer. The returned pointer is only valid while the + * caller holds the tree in a state that prevents the node from being + * released. + * + * @param branch Branch index (0–15); must be non-empty. + * @return Raw pointer to the child node (never null for a non-empty branch + * that has been loaded from storage). + */ SHAMapTreeNode* getChildPointer(int branch); + /** Return a ref-counted pointer to the child at branch `m`. + * + * Acquires the per-child bit spinlock for the physical array index before + * copying the `SharedPtr`, ensuring the reference count is incremented + * atomically with respect to concurrent `canonicalizeChild()` calls. + * + * @param branch Branch index (0–15); must be non-empty. + * @return `SharedPtr` to the child node. + */ intr_ptr::SharedPtr getChild(int branch); + /** Deduplicate a concurrently loaded child node. + * + * When multiple threads fetch the same child from backing storage + * simultaneously, this method serializes installation under the per-child + * bit spinlock. The first caller installs `node` and gets it back; any + * subsequent caller discards its freshly-deserialized copy and receives + * the incumbent pointer instead ("winner keeps it"). The supplied node's + * hash must match the stored branch hash. + * + * @param branch Branch index (0–15); must be non-empty. + * @param node Freshly-loaded node whose hash equals `getChildHash(branch)`. + * @return The canonical (winning) pointer for this child slot — either + * `node` itself (if this caller won) or the pre-existing pointer. + * @note Asserts that `node->getHash() == getChildHash(branch)`. + */ intr_ptr::SharedPtr canonicalizeChild(int branch, intr_ptr::SharedPtr node); - // sync functions + /** Check whether the entire subtree below this node is locally complete. + * + * Returns `true` when `fullBelowGen_` equals `generation`, meaning a + * previous sync pass confirmed all descendant nodes are present in local + * storage. Because generations are monotonically increasing, a stale + * marker automatically becomes invalid on the next sync cycle. + * + * @param generation Current sync generation from `FullBelowCache`. + * @return `true` if the subtree is known to be complete for `generation`. + */ bool isFullBelow(std::uint32_t generation) const; + /** Mark the entire subtree below this node as locally complete. + * + * Records `gen` in `fullBelowGen_`. Subsequent calls to + * `isFullBelow(gen)` will return `true`, allowing traversal to skip this + * subtree when scanning for missing nodes. + * + * @param gen Current sync generation to record. + */ void setFullBelowGen(std::uint32_t gen); + /** Recompute `hash_` as SHA-512/2 of `HashPrefix::InnerNode` followed by + * all 16 child hashes (zero-hashes for empty branches). + * + * Reads hashes from the local `hashesAndChildren_` arrays; does NOT pull + * hashes from in-memory child objects. Call `updateHashDeep()` instead + * when child nodes were set via pointer without updating the hash arrays. + */ void updateHash() override; - /** Recalculate the hash of all children and this node. */ + /** Sync child hashes from in-memory child objects then recompute this + * node's hash. + * + * For every occupied branch that has a non-null child pointer, copies + * `child->getHash()` into the local hash array, then delegates to + * `updateHash()`. Needed after batch mutations where child nodes were + * installed via pointer but the corresponding hash slots were not updated. + */ void updateHashDeep(); + /** Serialize this node for peer-to-peer wire transmission. + * + * Chooses format based on occupancy: + * - Fewer than 12 populated branches: *compressed inner* format — each + * non-empty branch is emitted as 32 bytes of hash followed by 1 byte of + * branch index (33 bytes per child), terminated by + * `kWIRE_TYPE_COMPRESSED_INNER`. + * - 12 or more branches: *full inner* format — all 16 hashes in order + * (512 bytes), terminated by `kWIRE_TYPE_INNER`. + * + * @param s Serializer to append to. + * @note Asserts that the node is non-empty before serializing. + */ void serializeForWire(Serializer&) const override; + /** Serialize this node in canonical hash-input form. + * + * Prepends `HashPrefix::InnerNode` (4 bytes) then emits all 16 child + * hashes in order (512 bytes), regardless of actual occupancy. This is + * the form consumed by `updateHash()` and verified by Merkle proof + * checks. + * + * @param s Serializer to append to. + * @note Asserts that the node is non-empty before serializing. + */ void serializeWithPrefix(Serializer&) const override; + /** Build a human-readable description for debugging. + * + * Appends each non-empty branch number and its hash to the base-class + * string from `SHAMapTreeNode::getString`. + * + * @param id The tree address of this node, used by the base-class portion. + * @return Multiline string with one `bN = ` line per occupied branch. + */ std::string getString(SHAMapNodeID const&) const override; + /** Verify structural invariants in debug builds. + * + * Checks that every occupied branch has a non-zero hash, that `isBranch_` + * and the hash array agree on occupancy, and (unless this is the root) + * that `hash_` is non-zero and at least one branch is occupied. + * + * @param isRoot Pass `true` when checking the tree root; relaxes the + * non-zero-hash and minimum-count assertions that do not apply to an + * empty root. + */ void invariants(bool isRoot = false) const override; + /** Deserialize an inner node from the *full inner* wire format. + * + * Expects exactly `kBRANCH_FACTOR * 32` bytes: 16 consecutive 256-bit + * hashes. After parsing, right-sizes the sparse arrays to actual + * occupancy via `resizeChildArrays()`. + * + * @param data Raw bytes in full-inner format. + * @param hash Expected Merkle hash of this node. + * @param hashValid If `true`, assign `hash` directly; if `false`, + * recompute via `updateHash()`. + * @return A `SharedPtr` to the new inner node. + * @throws std::runtime_error if `data.size()` is not exactly 512 bytes. + */ static intr_ptr::SharedPtr makeFullInner(Slice data, SHAMapHash const& hash, bool hashValid); + /** Deserialize an inner node from the *compressed inner* wire format. + * + * Expects a sequence of 33-byte chunks: 32 bytes of hash followed by + * 1 byte of branch index. After parsing, right-sizes the sparse arrays + * and recomputes the hash via `updateHash()`. + * + * @param data Raw bytes in compressed-inner format; size must be a + * non-zero multiple of 33 and at most `33 * kBRANCH_FACTOR`. + * @return A `SharedPtr` to the new inner node. + * @throws std::runtime_error if the size is invalid or a branch index + * is ≥ `kBRANCH_FACTOR`. + */ static intr_ptr::SharedPtr makeCompressedInner(Slice data); }; diff --git a/include/xrpl/shamap/SHAMapItem.h b/include/xrpl/shamap/SHAMapItem.h index 41558197bf..3afddc33ab 100644 --- a/include/xrpl/shamap/SHAMapItem.h +++ b/include/xrpl/shamap/SHAMapItem.h @@ -1,3 +1,10 @@ +/** @file + * Defines `SHAMapItem`, the immutable, slab-allocated payload leaf of the + * XRP Ledger's SHAMap Merkle-Patricia trie, together with its slab allocator + * pool (`detail::gSlabber`), `boost::intrusive_ptr` lifetime hooks, and the + * `makeShamapitem` factory functions. + */ + #pragma once #include @@ -11,19 +18,36 @@ namespace xrpl { -// an item stored in a SHAMap +/** Immutable, slab-allocated payload item stored at the leaves of a SHAMap. + * + * Each `SHAMapItem` pairs a 256-bit trie key (`tag_`) with an opaque + * variable-length byte payload. The payload is stored via the struct-hack: + * it occupies the memory immediately after the fixed-size struct fields in + * the same allocation, avoiding a second heap allocation and keeping the + * header and payload in one contiguous block. + * + * Objects are always managed through `boost::intrusive_ptr` + * using an embedded atomic reference count. Once constructed the key and + * payload are immutable; the SHAMap copy-on-write protocol creates a new + * item rather than mutating an existing one, allowing the same item to be + * shared across CoW snapshots safely. + * + * The only valid construction path is `makeShamapitem()`. The default, + * copy, and move constructors are all deleted. `CountedObject` + * maintains a global live-object counter for diagnostics. + * + * @note Payload size is limited to 16 MiB (asserted in `makeShamapitem`). + * The struct must have `alignof` of 4 or 8 (enforced by `static_assert`) + * to satisfy the slab allocator's alignment contract. + */ class SHAMapItem : public CountedObject { - // These are used to support boost::intrusive_ptr reference counting - // These functions are used internally by boost::intrusive_ptr to handle - // lifetime management. friend void intrusive_ptr_add_ref(SHAMapItem const* x); friend void intrusive_ptr_release(SHAMapItem const* x); - // This is the interface for creating new instances of this class. friend boost::intrusive_ptr makeShamapitem(uint256 const& tag, Slice data); @@ -35,13 +59,17 @@ private: // is safe. std::uint32_t const size_; - // This is the reference count used to support boost::intrusive_ptr + // Embedded reference count for boost::intrusive_ptr. Initialised to 1 so + // makeShamapitem can pass `false` to suppress the automatic increment that + // would otherwise bring the count to 2. mutable std::atomic refcount_ = 1; - // Because of the unusual way in which SHAMapItem objects are constructed - // the only way to properly create one is to first allocate enough memory - // so we limit this constructor to codepaths that do this right and limit - // arbitrary construction. + /** Placement-new constructor; must only be called by `makeShamapitem`. + * + * Copies `data` into the memory immediately following the struct fields. + * The caller is responsible for pre-allocating `sizeof(SHAMapItem) + + * data.size()` bytes before invoking this via placement new. + */ SHAMapItem(uint256 const& tag, Slice data) : tag_(tag), size_(static_cast(data.size())) { @@ -62,24 +90,33 @@ public: SHAMapItem& operator=(SHAMapItem&&) = delete; + /** Return the 256-bit trie key that identifies this item in the SHAMap. */ uint256 const& key() const { return tag_; } + /** Return the size of the payload in bytes. */ std::size_t size() const { return size_; } + /** Return a pointer to the first byte of the payload. + * + * The payload is stored immediately after the struct in the same + * allocation (struct-hack layout). The pointer is valid for the + * lifetime of this object. + */ void const* data() const { return reinterpret_cast(this) + sizeof(*this); } + /** Return the payload as a `Slice` (pointer + length view). */ Slice slice() const { @@ -90,9 +127,17 @@ public: namespace detail { // clang-format off -// The slab cutoffs and the number of megabytes per allocation are customized -// based on the number of objects of each size we expect to need at any point -// in time and with an eye to minimize the number of slack bytes in a block. +/** Slab allocator pool backing all `SHAMapItem` allocations. + * + * Seven size tiers cover payloads of up to 1052 extra bytes (added to + * `sizeof(SHAMapItem)`). Tier cutoffs and pool sizes are tuned to the + * empirical distribution of ledger-object sizes and minimise intra-block + * padding. Each backing block is allocated at a 2 MiB boundary to allow + * Linux transparent huge-page (THP) support to engage automatically. + * + * Payloads exceeding the largest tier are served by a plain + * `new uint8_t[]` fallback in `makeShamapitem`. + */ inline SlabAllocatorSet gSlabber({ { 128, megabytes(std::size_t(60)) }, { 192, megabytes(std::size_t(46)) }, @@ -106,15 +151,39 @@ inline SlabAllocatorSet gSlabber({ } // namespace detail +/** Increment the reference count of a `SHAMapItem`. + * + * Called automatically by `boost::intrusive_ptr` when a new owning pointer + * is created. Guards against resurrection: if the count is already zero when + * the increment is attempted — indicating another thread concurrently released + * the last reference — `logicError` is called rather than silently + * resurrecting a dead object. + * + * @param x The item whose reference count should be incremented. + */ inline void intrusive_ptr_add_ref(SHAMapItem const* x) { - // This can only happen if someone releases the last reference to the - // item while we were trying to increment the refcount. if (x->refcount_++ == 0) logicError("SHAMapItem: the reference count is 0!"); } +/** Decrement the reference count of a `SHAMapItem`, destroying it when zero. + * + * Called automatically by `boost::intrusive_ptr` when an owning pointer is + * destroyed or reset. On reaching zero, performs a two-phase teardown + * required by the struct-hack layout: + * + * 1. Explicitly calls `std::destroy_at` to run the `SHAMapItem` destructor + * (needed because `CountedObject`'s destructor decrements a global + * diagnostic counter and is not trivial). The `if constexpr` guard + * eliminates this call if the destructor chain ever becomes trivial. + * 2. Returns the raw memory to `detail::gSlabber`. If the pointer was not + * slab-managed (allocated via the `new uint8_t[]` fallback), `deallocate` + * returns `false` and the memory is freed with `delete[]`. + * + * @param x The item whose reference count should be decremented. + */ inline void intrusive_ptr_release(SHAMapItem const* x) { @@ -122,7 +191,7 @@ intrusive_ptr_release(SHAMapItem const* x) { auto p = reinterpret_cast(x); - // The SHAMapItem constructor isn't trivial (because the destructor + // The SHAMapItem destructor isn't trivial (because the destructor // for CountedObject isn't) so we can't avoid calling it here, but // plan for a future where we might not need to. if constexpr (!std::is_trivially_destructible_v) @@ -135,6 +204,21 @@ intrusive_ptr_release(SHAMapItem const* x) } } +/** Allocate and construct a new `SHAMapItem`. + * + * The sole factory for `SHAMapItem` objects. Requests a slot from + * `detail::gSlabber` sized for `sizeof(SHAMapItem) + data.size()`, falling + * back to `new uint8_t[]` when no slab tier fits. The item is constructed + * in-place via placement new; the returned `intrusive_ptr` is initialised + * with `false` for the second argument to suppress the automatic reference + * increment — the constructor already sets `refcount_` to 1. + * + * @param tag The 256-bit key that identifies this item in the SHAMap trie. + * @param data The payload bytes to store; copied into the allocation. + * @return An owning `intrusive_ptr` to the newly created item with a + * reference count of 1. + * @note Asserts that `data.size() <= 16 MiB`. + */ inline boost::intrusive_ptr makeShamapitem(uint256 const& tag, Slice data) { @@ -159,6 +243,15 @@ makeShamapitem(uint256 const& tag, Slice data) static_assert(alignof(SHAMapItem) != 40); static_assert(alignof(SHAMapItem) == 8 || alignof(SHAMapItem) == 4); +/** Produce a freshly allocated copy of an existing `SHAMapItem`. + * + * Equivalent to `makeShamapitem(other.key(), other.slice())`. Provides the + * copy semantics that the class itself refuses to expose, yielding an + * independently owned item with the same key and payload. + * + * @param other The item to copy. + * @return An owning `intrusive_ptr` to the newly allocated copy. + */ inline boost::intrusive_ptr makeShamapitem(SHAMapItem const& other) { diff --git a/include/xrpl/shamap/SHAMapLeafNode.h b/include/xrpl/shamap/SHAMapLeafNode.h index af5ed29702..f3d686dd70 100644 --- a/include/xrpl/shamap/SHAMapLeafNode.h +++ b/include/xrpl/shamap/SHAMapLeafNode.h @@ -7,13 +7,65 @@ namespace xrpl { +/** Abstract base class for all SHAMap leaf nodes. + * + * Provides shared item storage, copy-on-write mutation, and identity queries + * for the three concrete leaf types: `SHAMapAccountStateLeafNode`, + * `SHAMapTxLeafNode`, and `SHAMapTxPlusMetaLeafNode`. Each concrete subclass + * supplies its own `updateHash()`, `serializeForWire()`, and + * `serializeWithPrefix()` implementations reflecting the distinct hash + * formulas and wire formats of account state, bare transactions, and + * transaction-plus-metadata respectively. + * + * Copy construction and copy assignment are deleted; duplication is always + * explicit via the virtual `clone(cowid)` method, which produces an owned + * copy ready for mutation under copy-on-write semantics. + * + * @note `item_` is `protected` rather than `private` so that concrete + * subclasses can access it directly in their inline `updateHash()` and + * `serialize*()` implementations, avoiding virtual-dispatch overhead in + * the hot hash-recomputation path. + * + * @see SHAMapAccountStateLeafNode + * @see SHAMapTxLeafNode + * @see SHAMapTxPlusMetaLeafNode + */ class SHAMapLeafNode : public SHAMapTreeNode { protected: + /** The keyed payload carried by this leaf. + * + * Deliberately `const`-qualified through the pointer: the item itself is + * immutable once created and may be shared safely across CoW snapshots. + * `setItem()` replaces this pointer; it never mutates the referent. + */ boost::intrusive_ptr item_; + /** Construct a leaf and defer hash computation to the concrete subclass. + * + * Each concrete subclass calls `updateHash()` immediately after invoking + * this constructor so the node is valid for insertion into the trie. + * Asserts `item->size() >= 12`; any well-formed XRPL serialized object is + * at least 12 bytes — shorter data indicates corruption or a caller error. + * + * @param item Non-null payload for the new leaf. + * @param cowid Copy-on-write owner ID of the creating `SHAMap` instance. + */ SHAMapLeafNode(boost::intrusive_ptr item, std::uint32_t cowid); + /** Construct a leaf with a pre-computed hash. + * + * Used by `clone()` and deserialization paths where the hash is already + * known, avoiding a redundant SHA-512 computation. The caller must ensure + * `hash` is consistent with `item`'s current content or the Merkle tree + * will be silently corrupted. + * Asserts `item->size() >= 12`. + * + * @param item Non-null payload for the new leaf. + * @param cowid Copy-on-write owner ID for the new node. + * @param hash Pre-computed hash of the leaf; passed straight through to + * `SHAMapTreeNode` without recomputation. + */ SHAMapLeafNode( boost::intrusive_ptr item, std::uint32_t cowid, @@ -24,34 +76,68 @@ public: SHAMapLeafNode& operator=(SHAMapLeafNode const&) = delete; + /** Returns `true`; sealed here so all concrete leaf subclasses inherit it. */ bool isLeaf() const final { return true; } + /** Returns `false`; sealed here so all concrete leaf subclasses inherit it. */ bool isInner() const final { return false; } + /** Assert the node's fundamental invariants. + * + * Checks that `hash_` is non-zero and `item_` is non-null. The `isRoot` + * argument is meaningful only for inner nodes and is silently ignored here. + * + * @param isRoot Ignored for leaf nodes. + */ void invariants(bool isRoot = false) const final; public: + /** Return a non-owning reference to the stored item pointer. + * + * Gives callers access to the item's key and payload without transferring + * ownership or bumping the reference count. The "peek" convention signals + * zero-cost, non-owning access throughout the XRPL codebase. + * + * @return A `const` reference to the `intrusive_ptr` holding the leaf's + * item. The reference is valid for the lifetime of this node. + */ boost::intrusive_ptr const& peekItem() const; - /** Set the item that this node points to and update the node's hash. - - @param i the new item - @return false if the change was, effectively, a noop (that is, if the - hash was unchanged); true otherwise. + /** Replace the stored item and recompute this node's hash. + * + * Asserts that `cowid_` is non-zero — the node must be exclusively owned + * by a `SHAMap` instance (i.e. previously unshared via `clone()`) before + * it may be mutated. After swapping the item, `updateHash()` is called; + * `dirtyUp()` in the caller is only necessary when this returns `true`. + * + * @param i The replacement item; must share the same key as the current + * item (cross-key swaps corrupt the trie position). + * @return `true` if the hash changed (the effective content of the leaf + * was modified); `false` if the new item produces the same hash as the + * old one, indicating a no-op update. */ bool setItem(boost::intrusive_ptr i); + /** Format this leaf's identity and payload summary as a human-readable string. + * + * Appends the node type tag (txn, txn+md, or as), the item key, the hash, + * and the item size to the base class string produced by + * `SHAMapTreeNode::getString()`. Intended for debugging and logging only. + * + * @param id The `SHAMapNodeID` identifying this node's trie position. + * @return A multi-line string describing the node. + */ std::string getString(SHAMapNodeID const&) const final; }; diff --git a/include/xrpl/shamap/SHAMapMissingNode.h b/include/xrpl/shamap/SHAMapMissingNode.h index 0bc072463e..0014ea2516 100644 --- a/include/xrpl/shamap/SHAMapMissingNode.h +++ b/include/xrpl/shamap/SHAMapMissingNode.h @@ -1,3 +1,8 @@ +/** @file + * Defines the `SHAMapType` enum and the `SHAMapMissingNode` exception, + * forming the error-signalling contract for the SHAMap subsystem. + */ + #pragma once #include @@ -10,12 +15,32 @@ namespace xrpl { +/** Classifies the role of a SHAMap within the ledger. + * + * Every closed ledger contains two SHAMaps: a `TRANSACTION` tree holding the + * set of transactions included in that ledger and a `STATE` tree holding the + * full account-state database. `FREE` trees are ephemeral and not tied to a + * finalized ledger (e.g., consensus transaction sets). + * + * @note The underlying integer values appear in wire-protocol serialization + * for sync packets and must not be changed. + */ enum class SHAMapType { - TRANSACTION = 1, // A tree of transactions - STATE = 2, // A tree of state nodes - FREE = 3, // A tree not part of a ledger + TRANSACTION = 1, /**< A tree of transactions included in a ledger. */ + STATE = 2, /**< A tree of account-state objects at a ledger's close. */ + FREE = 3, /**< An ephemeral tree not associated with a finalized ledger. */ }; +/** Convert a `SHAMapType` to a human-readable label for logging. + * + * Returns `"Transaction Tree"`, `"State Tree"`, or `"Free Tree"` for the + * three defined enumerators. For any out-of-range value the numeric + * underlying integer is returned as a string rather than producing + * undefined behavior. + * + * @param t The tree type to convert. + * @return A string label suitable for log messages. + */ inline std::string to_string(SHAMapType t) { @@ -32,14 +57,53 @@ to_string(SHAMapType t) } } +/** Exception thrown when SHAMap traversal encounters a locally absent node. + * + * This is a routine operational condition in a distributed ledger node: + * partially-synced or historical ledgers may have gaps in local storage. + * The exception propagates upward through the traversal call stack so that + * each subsystem can apply its own recovery policy: + * + * - `LedgerCleaner` and `LedgerMaster` catch it at `warn` level and schedule + * a peer fetch via `getInboundLedgers().acquire()`. + * - `RCLConsensus` treats it as fatal during consensus processing, logging at + * `error` level and re-throwing. + * - `Ledger.cpp` catches it silently and treats the incomplete tree as an + * invalid ledger. + * + * The `what()` message is built eagerly at construction time since it is the + * primary data surface available to catch sites, all of which log it directly. + * + * @see SHAMapType + */ class SHAMapMissingNode : public std::runtime_error { public: + /** Construct with the hash of the absent node. + * + * Used when the tree descends toward a child whose hash is known (stored + * in the parent inner node) but whose backing data is not in local storage. + * Produces the message `"Missing Node: : hash "`. + * + * @param t The type of tree in which the node was not found. + * @param hash The SHA-512Half digest identifying the absent node. + */ SHAMapMissingNode(SHAMapType t, SHAMapHash const& hash) : std::runtime_error("Missing Node: " + to_string(t) + ": hash " + to_string(hash)) { } + /** Construct with the item key that could not be located. + * + * Used when the failure is expressed at the level of a leaf identifier + * (e.g., an account ID or transaction ID) rather than a structural node + * hash — typically when a key-based lookup descends far enough to + * determine the leaf should exist but cannot find it. + * Produces the message `"Missing Node: : id "`. + * + * @param t The type of tree in which the item was not found. + * @param id The 256-bit key of the item that could not be located. + */ SHAMapMissingNode(SHAMapType t, uint256 const& id) : std::runtime_error("Missing Node: " + to_string(t) + ": id " + to_string(id)) { diff --git a/include/xrpl/shamap/SHAMapNodeID.h b/include/xrpl/shamap/SHAMapNodeID.h index dbc087b356..d67beafbb9 100644 --- a/include/xrpl/shamap/SHAMapNodeID.h +++ b/include/xrpl/shamap/SHAMapNodeID.h @@ -1,3 +1,12 @@ +/** @file + * Defines `SHAMapNodeID`, the (depth, masked-prefix) address type used to + * locate nodes within a SHAMap 16-way radix-Merkle trie. + * + * Every traversal, sync, and proof operation in the SHAMap subsystem + * carries a `SHAMapNodeID` alongside node pointers so the tree position + * is always unambiguous — independent of node content. + */ + #pragma once #include @@ -9,7 +18,21 @@ namespace xrpl { -/** Identifies a node inside a SHAMap */ +/** Encodes the position of a node within a SHAMap as a (depth, prefix) pair. + * + * A SHAMap has 65 levels (depth 0 = root, depth 64 = leaf level). Each + * level consumes one nibble (4 bits) of the 256-bit key, giving a branch + * factor of 16. `SHAMapNodeID` captures "which node" rather than "which + * data": two nodes with the same (depth, masked prefix) are at the same + * tree position regardless of their content or hash. + * + * @invariant `id_` carries non-zero bits only in its top `depth_` nibbles; + * all lower nibbles are zero. The constructor asserts this property. + * Use `createID()` when constructing from an unmasked leaf key. + * + * Live instance counts are tracked via `CountedObject` for diagnostic + * purposes and can be queried through `CountedObjects::getInstance()`. + */ class SHAMapNodeID : public CountedObject { private: @@ -19,6 +42,17 @@ private: public: SHAMapNodeID() = default; SHAMapNodeID(SHAMapNodeID const& other) = default; + + /** Construct a node ID at the given depth with a pre-masked prefix. + * + * The caller is responsible for supplying a correctly masked `hash`: + * only the top `depth` nibbles may be non-zero. Violating this + * invariant triggers an assertion. Prefer `createID()` when + * constructing from an unmasked leaf key. + * + * @param depth Tree level, 0 (root) through `SHAMap::kLEAF_DEPTH` (64). + * @param hash 256-bit path prefix, masked to `depth` nibbles. + */ SHAMapNodeID(unsigned int depth, uint256 const& hash); SHAMapNodeID& @@ -30,7 +64,14 @@ public: return depth_ == 0; } - // Get the wire format (256-bit nodeID, 1-byte depth) + /** Serialize this node ID to its 33-byte wire representation. + * + * Produces a 33-byte string: the 32-byte big-endian `id_` prefix + * followed by a single byte containing `depth_`. Used when + * exchanging node positions with peers during tree synchronization. + * + * @return A 33-byte string containing the wire-format node ID. + */ [[nodiscard]] std::string getRawString() const; @@ -46,28 +87,53 @@ public: return id_; } + /** Return the node ID of the child at branch `m`. + * + * Increments `depth_` by one and sets the nibble at the new depth + * to `m`, producing the canonical address of that child in the tree. + * This is the step primitive used by every traversal loop in SHAMap. + * + * @param m Branch index, 0–15. + * @return The child's `SHAMapNodeID` at `depth_ + 1`. + * @throws `std::logic_error` if called on a leaf-depth node + * (`depth_ >= SHAMap::kLEAF_DEPTH`), because a depth-65 + * node would violate the tree's structural invariant. + */ [[nodiscard]] SHAMapNodeID getChildNodeID(unsigned int m) const; - /** - * Create a SHAMapNodeID of a node with the depth of the node and - * the key of a leaf + /** Derive the ancestor node ID at `depth` from a full leaf key. * - * @param depth the depth of the node - * @param key the key of a leaf - * @return SHAMapNodeID of the node + * Applies the depth mask to `key`, discarding the lower nibbles, + * and constructs the resulting `SHAMapNodeID`. This is the correct + * factory to use when you have a leaf key and need the address of an + * intermediate ancestor — for example, during sync validation where + * `addKnownNode` reconstructs the expected position of a received + * leaf and compares it against the claimed `SHAMapNodeID`. + * + * @param depth Tree level of the desired ancestor, 0–64. + * @param key Full 256-bit leaf key; lower nibbles are masked away. + * @return The `SHAMapNodeID` at `depth` on the path to `key`. */ static SHAMapNodeID createID(int depth, uint256 const& key); // FIXME-C++20: use spaceship and operator synthesis - /** Comparison operators */ + + /** Compare node IDs lexicographically by (depth, prefix). + * + * Shallower nodes (smaller `depth_`) sort before deeper ones; among + * equal depths, ordering follows the 256-bit prefix value. This + * ordering is used when storing node IDs in `std::map` or `std::set` + * containers that track traversal or sync frontiers. + */ bool operator<(SHAMapNodeID const& n) const { return std::tie(depth_, id_) < std::tie(n.depth_, n.id_); } + /** @{ */ bool operator>(SHAMapNodeID const& n) const { @@ -97,8 +163,18 @@ public: { return !(*this == n); } + /** @} */ }; +/** Format a node ID as a human-readable string for logging. + * + * Returns `"NodeID(root)"` for the root node (depth 0) or + * `"NodeID(,)"` for all other nodes. This format + * appears in journal messages throughout the SHAMap traversal code. + * + * @param node The node ID to format. + * @return A human-readable string representation. + */ inline std::string to_string(SHAMapNodeID const& node) { @@ -108,32 +184,51 @@ to_string(SHAMapNodeID const& node) return "NodeID(" + std::to_string(node.getDepth()) + "," + to_string(node.getNodeID()) + ")"; } +/** Write a node ID to an output stream using `to_string()` format. */ inline std::ostream& operator<<(std::ostream& out, SHAMapNodeID const& node) { return out << to_string(node); } -/** Return an object representing a serialized SHAMap Node ID +/** Deserialize a `SHAMapNodeID` from a 33-byte wire buffer. * - * @param s A string of bytes - * @param data a non-null pointer to a buffer of @param size bytes. - * @param size the size, in bytes, of the buffer pointed to by @param data. - * @return A seated optional if the buffer contained a serialized SHAMap - * node ID and an unseated optional otherwise. + * Validates that `size` is exactly 33, that the depth byte (at offset 32) + * does not exceed `SHAMap::kLEAF_DEPTH` (64), and that the 32-byte prefix + * satisfies the depth-mask invariant. Any violation yields an empty + * optional rather than an exception, making this safe to call on + * untrusted peer data. + * + * @param data Pointer to the raw byte buffer. + * @param size Length of the buffer in bytes; must equal 33 for success. + * @return The decoded `SHAMapNodeID`, or an empty optional on any + * validation failure. */ /** @{ */ [[nodiscard]] std::optional deserializeSHAMapNodeID(void const* data, std::size_t size); +/** @cond */ [[nodiscard]] inline std::optional deserializeSHAMapNodeID(std::string const& s) { return deserializeSHAMapNodeID(s.data(), s.size()); } +/** @endcond */ /** @} */ -/** Returns the branch that would contain the given hash */ +/** Return the branch index (0–15) to follow from `id` toward `hash`. + * + * Extracts the nibble of `hash` at depth `id.getDepth()`, which is the + * child branch number to descend into at that level. This is the core + * traversal primitive: every lookup, insert, delete, and sync walk in + * SHAMap calls `selectBranch` to advance one level down the trie. + * + * @param id Position of the current node; its depth determines which + * nibble of `hash` to read. + * @param hash The 256-bit key being navigated toward. + * @return Branch index in [0, 15]. + */ [[nodiscard]] unsigned int selectBranch(SHAMapNodeID const& id, uint256 const& hash); diff --git a/include/xrpl/shamap/SHAMapSyncFilter.h b/include/xrpl/shamap/SHAMapSyncFilter.h index 4104220a3f..c782e0906a 100644 --- a/include/xrpl/shamap/SHAMapSyncFilter.h +++ b/include/xrpl/shamap/SHAMapSyncFilter.h @@ -4,9 +4,34 @@ #include -/** Callback for filtering SHAMap during sync. */ namespace xrpl { +/** Abstract callback interface connecting SHAMap sync traversal to node + * persistence and transient caching infrastructure. + * + * The SHAMap engine is decoupled from knowledge about where nodes come from + * or where they should go. `SHAMapSyncFilter` provides exactly two hooks that + * let the application layer supply that knowledge: + * + * - `getNode()` — pull: called when a node is needed but absent from the + * in-memory cache and backing database. The filter may satisfy the request + * from a transient source such as a peer fetch pack or consensus cache. + * + * - `gotNode()` — notify: called after a node has been successfully obtained + * and deserialized, regardless of source. The filter uses this to persist + * nodes received from the network. + * + * The two-method split keeps filter implementations small: they operate purely + * on flat `Blob` data keyed by `SHAMapHash`; all deserialization and tree + * structure are handled by the SHAMap engine. + * + * Non-copyable because implementations hold non-owning references to + * databases and caches with independent lifetimes. + * + * @see AccountStateSF + * @see TransactionStateSF + * @see ConsensusTransSetSF + */ class SHAMapSyncFilter { public: @@ -16,7 +41,30 @@ public: SHAMapSyncFilter& operator=(SHAMapSyncFilter const&) = delete; - // Note that the nodeData is overwritten by this call + /** Notify the filter that a node has been successfully obtained and + * integrated into the map. + * + * Called by `addRootNode` and `addKnownNode` (with `fromFilter = false`) + * after a peer-supplied node passes hash and position validation, and by + * the internal `checkFilter` helper (with `fromFilter = true`) after a + * node supplied by this filter's own `getNode()` has been deserialized. + * + * Implementations use `fromFilter` to avoid redundant writes: when + * `fromFilter` is `true` the data originated here and is already known + * locally, so re-storing it is unnecessary. When `fromFilter` is `false` + * the node arrived from the network and should be persisted durably. + * + * @param fromFilter `true` if the data was originally returned by this + * filter's `getNode()` call; `false` if it arrived from a peer. + * @param nodeHash Hash of the node that was received. + * @param ledgerSeq Sequence number of the ledger this node belongs to, + * allowing the filter to associate stored nodes with a specific ledger + * for expiry or prioritization decisions. + * @param nodeData Serialized node bytes. Passed by rvalue reference; + * the implementation may move or destroy the buffer — callers must + * not rely on its contents after this call returns. + * @param type Wire type of the received node. + */ virtual void gotNode( bool fromFilter, @@ -25,6 +73,21 @@ public: Blob&& nodeData, SHAMapNodeType type) const = 0; + /** Attempt to retrieve a node from a transient source. + * + * Called by the SHAMap engine when a node identified by `nodeHash` is + * not present in the in-memory cache or backing NodeStore. The filter + * may satisfy the request from an ephemeral store such as a peer fetch + * pack or consensus transaction cache, avoiding a network round-trip. + * + * If data is returned, the engine will immediately call + * `gotNode(true, nodeHash, ...)` to notify the filter that the node was + * consumed. + * + * @param nodeHash Hash of the node the engine is looking for. + * @return The raw serialized node bytes if available, or `std::nullopt` + * if the filter cannot supply the node. + */ [[nodiscard]] virtual std::optional getNode(SHAMapHash const& nodeHash) const = 0; }; diff --git a/include/xrpl/shamap/SHAMapTreeNode.h b/include/xrpl/shamap/SHAMapTreeNode.h index d50fc65ccb..ac89918bd4 100644 --- a/include/xrpl/shamap/SHAMapTreeNode.h +++ b/include/xrpl/shamap/SHAMapTreeNode.h @@ -1,3 +1,12 @@ +/** @file + * Base class for all nodes in the SHAMap authenticated Merkle radix tree, + * plus the wire-protocol type tags and the in-memory node-type enumeration. + * + * Every node stored in a `SHAMap` — branching inner node or data-carrying + * leaf — derives from `SHAMapTreeNode`. This file is therefore the single + * authoritative location for the tree's on-disk and on-wire node format. + */ + #pragma once #include @@ -12,44 +21,111 @@ namespace xrpl { -// These are wire-protocol identifiers used during serialization to encode the -// type of a node. They should not be arbitrarily be changed. +/** Wire-protocol type tag for a bare transaction node (no metadata). + * + * Appended as the final byte of a serialized node payload during peer-to-peer + * sync. Part of the XRPL wire protocol — do not change. + */ static constexpr unsigned char const kWIRE_TYPE_TRANSACTION = 0; + +/** Wire-protocol type tag for an account-state (ledger object) node. + * + * Part of the XRPL wire protocol — do not change. + */ static constexpr unsigned char const kWIRE_TYPE_ACCOUNT_STATE = 1; + +/** Wire-protocol type tag for a full inner node (all 16 hashes emitted). + * + * Part of the XRPL wire protocol — do not change. + */ static constexpr unsigned char const kWIRE_TYPE_INNER = 2; + +/** Wire-protocol type tag for a compressed inner node (sparse hash encoding). + * + * Part of the XRPL wire protocol — do not change. + */ static constexpr unsigned char const kWIRE_TYPE_COMPRESSED_INNER = 3; + +/** Wire-protocol type tag for a transaction node that includes metadata. + * + * Part of the XRPL wire protocol — do not change. + */ static constexpr unsigned char const kWIRE_TYPE_TRANSACTION_WITH_META = 4; +/** In-memory classification of a SHAMap tree node. + * + * Used for runtime dispatch and to select the correct hash prefix and wire + * format. Note that these values are distinct from the `kWIRE_TYPE_*` + * constants: the wire-type byte is appended to the serialized payload, + * while `SHAMapNodeType` lives only in memory. + */ enum class SHAMapNodeType { TnInner = 1, - TnTransactionNm = 2, // transaction, no metadata - TnTransactionMd = 3, // transaction, with metadata - TnAccountState = 4 + TnTransactionNm = 2, /**< Transaction leaf without metadata. */ + TnTransactionMd = 3, /**< Transaction leaf with metadata. */ + TnAccountState = 4 /**< Account-state (ledger object) leaf. */ }; +/** Polymorphic base for all nodes in the SHAMap authenticated Merkle radix tree. + * + * Concrete subtypes are `SHAMapInnerNode` (the 16-way branching node) and the + * three leaf types: `SHAMapTxLeafNode`, `SHAMapTxPlusMetaLeafNode`, and + * `SHAMapAccountStateLeafNode`. All share the copy-on-write ownership model + * encoded in `cowid_` and the two serialization contracts defined here. + * + * Nodes can be shared across multiple `SHAMap` instances simultaneously when + * `cowid_ == 0`. A map that needs to mutate a shared node must first call + * `clone()` to produce a private copy, then mutate only the clone. + * + * Copy and move are deleted: duplication must always go through `clone()`. + * + * @note This class inherits from `IntrusiveRefCounts`, which packs 16-bit + * strong count, 14-bit weak count, and two lifecycle-state bits into a + * single 32-bit atomic. Per-node overhead is intentionally minimal because + * a full ledger tree can contain millions of nodes. + * @see SHAMapInnerNode for the branching node implementation. + * @see SHAMapLeafNode for the abstract leaf base. + */ class SHAMapTreeNode : public IntrusiveRefCounts { protected: + /** Cached Merkle hash of this node. + * + * Zero when the node is dirty (after a mutation, before `updateHash()` is + * called). `serializeWithPrefix` feeds this value into the parent's hash + * computation. + */ SHAMapHash hash_; - /** Determines the owning SHAMap, if any. Used for copy-on-write semantics. - - If this value is 0, the node is not dirty and does not need to be - flushed. It is eligible for sharing and may be included multiple - SHAMap instances. + /** Identifies which `SHAMap` instance exclusively owns this node. + * + * A zero value means the node is clean and eligible for sharing across + * multiple `SHAMap` instances simultaneously. A non-zero value is the + * `cowid` of the one map that owns and may mutate this node. + * + * `unshare()` resets this to zero, making the node shareable again (called + * during flush, after which the node is effectively immutable). */ std::uint32_t cowid_; - /** Construct a node - - @param cowid The identifier of a SHAMap. For more, see #cowid_ - @param hash The hash associated with this node, if any. - */ /** @{ */ + /** Construct a node with the given CoW owner and an unset hash. + * + * @param cowid Copy-on-write identifier of the owning `SHAMap`; pass 0 + * for a shareable (read-only) node. + */ explicit SHAMapTreeNode(std::uint32_t cowid) noexcept : cowid_(cowid) { } + /** Construct a node with the given CoW owner and a pre-computed hash. + * + * Used when deserializing from storage where the hash is already known, + * avoiding a redundant `updateHash()` call. + * + * @param cowid Copy-on-write identifier of the owning `SHAMap`. + * @param hash Pre-validated Merkle hash for this node. + */ explicit SHAMapTreeNode(std::uint32_t cowid, SHAMapHash const& hash) noexcept : hash_(hash), cowid_(cowid) { @@ -63,32 +139,28 @@ public: SHAMapTreeNode& operator=(SHAMapTreeNode const&) = delete; - // Needed to support weak intrusive pointers + /** Release expensive sub-resources while weak references still exist. + * + * Called by the `IntrusiveRefCounts` infrastructure when the strong + * reference count reaches zero but at least one weak reference remains. + * The default implementation is a no-op; `SHAMapInnerNode` overrides it + * to release all 16 child `SharedPtr`s promptly, avoiding a cascade of + * memory retention through the child tree while weak pointers keep this + * node's storage alive. + * + * @note Callers must invoke `partialDestructorFinished()` after this + * returns, per the `IntrusiveRefCounts` contract. + */ virtual void partialDestructor() {}; - /** \defgroup SHAMap Copy-on-Write Support - - By nature, a node may appear in multiple SHAMap instances. Rather - than actually duplicating these nodes, SHAMap opts to be memory - efficient and uses copy-on-write semantics for nodes. - - Only nodes that are not modified and don't need to be flushed back - can be shared. Once a node needs to be changed, it must first be - copied and the copy must marked as not shareable. - - Note that just because a node may not be *owned* by a given SHAMap - instance does not mean that the node is NOT a part of any SHAMap. It - only means that the node is not owned exclusively by any one SHAMap. - - For more on copy-on-write, check out: - https://en.wikipedia.org/wiki/Copy-on-write - */ - /** @{ */ - /** Returns the SHAMap that owns this node. - - @return the ID of the SHAMap that owns this node, or 0 if the - node is not owned by any SHAMap and is a candidate for sharing. + /** Return the copy-on-write identifier of the owning `SHAMap`. + * + * A return value of 0 means the node is unowned and eligible for sharing + * across multiple `SHAMap` instances. A non-zero value identifies the one + * map that may mutate this node. + * + * @return The owning map's CoW ID, or 0 if the node is shareable. */ std::uint32_t cowid() const @@ -96,10 +168,14 @@ public: return cowid_; } - /** If this node is shared with another map, mark it as no longer shared. - - Only nodes that are not modified and do not need to be flushed back - should be marked as unshared. + /** Mark this node as shareable by clearing its CoW ownership. + * + * Sets `cowid_` to 0, making the node eligible for inclusion in multiple + * `SHAMap` snapshots simultaneously. Called during flush, after which the + * node is written to backing storage and must not be further mutated. + * + * @note Only call this on nodes that are clean (do not need to be flushed) + * or have already been persisted. */ void unshare() @@ -107,61 +183,180 @@ public: cowid_ = 0; } - /** Make a copy of this node, setting the owner. */ + /** Produce a deep copy of this node assigned to a new CoW epoch. + * + * The returned node carries `cowid` as its owner, allowing the caller's + * `SHAMap` to mutate it independently of any other maps that still hold + * references to the original. The original node is left intact. + * + * @param cowid Copy-on-write identifier of the map that will own the clone. + * @return A `SharedPtr` to the new, independently-owned copy. + */ virtual intr_ptr::SharedPtr clone(std::uint32_t cowid) const = 0; - /** @} */ - /** Recalculate the hash of this node. */ + /** Recompute `hash_` from the node's current contents. + * + * Each concrete subclass feeds the appropriate `HashPrefix` constant and + * payload into SHA-512/2. Must be called after any mutation before the + * node's hash is read by its parent. + */ virtual void updateHash() = 0; - /** Return the hash of this node. */ + /** Return the cached Merkle hash of this node. + * + * The hash is zero if the node is dirty (mutated since the last + * `updateHash()` call). + * + * @return The current `SHAMapHash` for this node. + */ SHAMapHash const& getHash() const { return hash_; } - /** Determines the type of node. */ + /** Return the in-memory node type used for runtime dispatch. + * + * @return One of `TnInner`, `TnTransactionNm`, `TnTransactionMd`, or + * `TnAccountState`. + */ virtual SHAMapNodeType getType() const = 0; - /** Determines if this is a leaf node. */ + /** Return whether this node is a leaf (data-carrying) node. + * + * @return `true` for all three concrete leaf types; `false` for inner + * nodes. + */ virtual bool isLeaf() const = 0; - /** Determines if this is an inner node. */ + /** Return whether this node is an inner (branching) node. + * + * @return `true` for `SHAMapInnerNode`; `false` for all leaf types. + */ virtual bool isInner() const = 0; - /** Serialize the node in a format appropriate for sending over the wire */ + /** Serialize this node for peer-to-peer wire transmission. + * + * Appends the node payload followed by a single `kWIRE_TYPE_*` type byte + * at the end of the buffer. The receiver reads the last byte to determine + * the node type before parsing the preceding payload. + * + * @param s Serializer to append to. + * @see serializeWithPrefix for the hashing/database format. + */ virtual void - serializeForWire(Serializer&) const = 0; + serializeForWire(Serializer& s) const = 0; - /** Serialize the node in a format appropriate for hashing */ + /** Serialize this node in canonical hash-input form. + * + * Prepends a 4-byte `HashPrefix` constant before the node data, allowing + * the node type to be identified from the first four bytes. This format is + * used for Merkle hash computation and for database storage. + * + * @param s Serializer to append to. + * @see serializeForWire for the wire-transmission format. + */ virtual void - serializeWithPrefix(Serializer&) const = 0; + serializeWithPrefix(Serializer& s) const = 0; + /** Return a human-readable description of this node for debugging. + * + * @param id The tree address of this node. + * @return A string representation including the node's position. + */ virtual std::string - getString(SHAMapNodeID const&) const; + getString(SHAMapNodeID const& id) const; + /** Verify structural invariants in debug builds. + * + * Each concrete subclass asserts its own preconditions (non-zero hash, + * consistent child counts, etc.). The `isRoot` parameter relaxes + * constraints that do not apply at the tree root — e.g., a root inner + * node with a single child is valid for a one-item tree. + * + * @param isRoot Pass `true` when checking the tree root; pass `false` + * (the default) for all other nodes. + */ virtual void invariants(bool isRoot = false) const = 0; + /** Deserialize a node from the hash-prefixed database/storage format. + * + * Reads the leading 4-byte `HashPrefix` to identify the node type, strips + * it, then dispatches to the appropriate private factory. The supplied + * `hash` is passed directly to the concrete node constructor (skipping + * `updateHash()`), so the caller is asserting that `hash` is correct. + * + * @param rawNode Serialized node bytes including the leading `HashPrefix`. + * @param hash Pre-validated Merkle hash for this node. + * @return A `SharedPtr` to the newly-constructed node. + * @throws std::runtime_error if `rawNode` is fewer than 4 bytes or its + * prefix does not correspond to a known node type. + */ static intr_ptr::SharedPtr makeFromPrefix(Slice rawNode, SHAMapHash const& hash); + /** Deserialize a node from the wire-transmission format. + * + * Reads the trailing type byte to identify the node type, strips it, then + * dispatches to the appropriate factory. Because the hash is not supplied, + * leaf nodes call `updateHash()` internally to compute it from the payload. + * + * @param rawNode Serialized node bytes including the trailing `kWIRE_TYPE_*` + * byte. + * @return A `SharedPtr` to the newly-constructed node, or + * an empty pointer if `rawNode` is empty. + * @throws std::runtime_error if the trailing type byte is unrecognized. + */ static intr_ptr::SharedPtr makeFromWire(Slice rawNode); private: + /** Construct a `SHAMapTxLeafNode` from raw transaction bytes. + * + * The item key is computed as `sha512Half(HashPrefix::TransactionId, data)`. + * If `hashValid` is true, `hash` is assigned directly to the new node; + * otherwise `updateHash()` is deferred to the concrete constructor. + * + * @param data Raw transaction payload. + * @param hash Pre-validated node hash (used only when `hashValid`). + * @param hashValid Whether `hash` may be trusted without recomputation. + * @return A `SharedPtr` to the new leaf node. + */ static intr_ptr::SharedPtr makeTransaction(Slice data, SHAMapHash const& hash, bool hashValid); + /** Construct a `SHAMapAccountStateLeafNode` from raw account-state bytes. + * + * The item key is read from the trailing 32 bytes of `data`, then chopped. + * Throws if `data` is too short or the extracted key is zero. + * + * @param data Raw account-state payload with 32-byte key appended. + * @param hash Pre-validated node hash (used only when `hashValid`). + * @param hashValid Whether `hash` may be trusted without recomputation. + * @return A `SharedPtr` to the new leaf node. + * @throws std::runtime_error if `data` is shorter than 32 bytes or the + * extracted key is zero. + */ static intr_ptr::SharedPtr makeAccountState(Slice data, SHAMapHash const& hash, bool hashValid); + /** Construct a `SHAMapTxPlusMetaLeafNode` from raw transaction+metadata bytes. + * + * The item key is read from the trailing 32 bytes of `data`, then chopped. + * Throws if `data` is too short. + * + * @param data Raw tx+metadata payload with 32-byte key appended. + * @param hash Pre-validated node hash (used only when `hashValid`). + * @param hashValid Whether `hash` may be trusted without recomputation. + * @return A `SharedPtr` to the new leaf node. + * @throws std::runtime_error if `data` is shorter than 32 bytes. + */ static intr_ptr::SharedPtr makeTransactionWithMeta(Slice data, SHAMapHash const& hash, bool hashValid); }; diff --git a/include/xrpl/shamap/SHAMapTxLeafNode.h b/include/xrpl/shamap/SHAMapTxLeafNode.h index eaac104c7e..36d230bfe6 100644 --- a/include/xrpl/shamap/SHAMapTxLeafNode.h +++ b/include/xrpl/shamap/SHAMapTxLeafNode.h @@ -8,16 +8,58 @@ namespace xrpl { -/** A leaf node for a transaction. No metadata is included. */ +/** SHAMap leaf node for a bare transaction without metadata. + * + * Used when building the transaction tree of an open or proposed ledger, + * before execution metadata is available. One of three concrete leaf types + * alongside `SHAMapTxPlusMetaLeafNode` (transaction + metadata) and + * `SHAMapAccountStateLeafNode` (ledger state); each encodes type-specific + * hashing and serialization rules statically via virtual dispatch, + * eliminating runtime branching in the hot path. + * + * Critically, the Merkle hash does **not** include the item key. A bare + * transaction's ID is itself derived from `sha512Half(prefix, blob)`, so + * the key is redundant — the hash fully characterises the content without + * it. This contrasts with `SHAMapTxPlusMetaLeafNode` and + * `SHAMapAccountStateLeafNode`, which must include the key because their + * keys are externally assigned identifiers that do not appear in the blob. + * + * All mutable state lives in the base classes. This class is a stateless + * policy layer: it supplies only the hash formula, clone factory, type + * tag, and wire format for no-metadata transaction leaves. + * + * @see SHAMapLeafNode + * @see SHAMapTxPlusMetaLeafNode + * @see SHAMapAccountStateLeafNode + */ class SHAMapTxLeafNode final : public SHAMapLeafNode, public CountedObject { public: + /** Construct a new bare-transaction leaf and compute its hash. + * + * Use this constructor when creating a brand-new node from a freshly + * produced item. `updateHash()` is called immediately so the node is + * valid for insertion into the trie. + * + * @param item The raw transaction payload; must be non-null. + * @param cowid Copy-on-write owner ID of the creating SHAMap instance. + */ SHAMapTxLeafNode(boost::intrusive_ptr item, std::uint32_t cowid) : SHAMapLeafNode(std::move(item), cowid) { updateHash(); } + /** Construct a bare-transaction leaf with a pre-computed hash. + * + * Used by `clone()` when the underlying item has not changed: forwarding + * the existing hash avoids a redundant SHA-512 computation. + * + * @param item The raw transaction payload; must be non-null. + * @param cowid Copy-on-write owner ID for the new node. + * @param hash Known hash of `item`; must be consistent with the item's + * current content or the Merkle tree will be corrupted. + */ SHAMapTxLeafNode( boost::intrusive_ptr item, std::uint32_t cowid, @@ -26,24 +68,61 @@ public: { } + /** Produce an exclusively owned copy of this node for copy-on-write mutation. + * + * The new node shares the same item and hash as the original — no + * recomputation occurs. The caller supplies the new `cowid` so the clone + * is immediately owned by the mutating SHAMap. + * + * @param cowid Copy-on-write owner ID for the cloned node. + * @return A freshly allocated `SHAMapTxLeafNode` with the same item and + * hash, owned exclusively by `cowid`. + */ intr_ptr::SharedPtr clone(std::uint32_t cowid) const final { return intr_ptr::makeShared(item_, cowid, hash_); } + /** Return the node type tag for bare-transaction leaves. + * + * @return `SHAMapNodeType::TnTransactionNm` + */ SHAMapNodeType getType() const final { return SHAMapNodeType::TnTransactionNm; } + /** Recompute and store this node's Merkle hash. + * + * Hashes the `HashPrefix::TransactionId` domain separator (`'T'`,`'X'`,`'N'`) + * followed by the raw transaction bytes via `sha512Half`. The item key is + * deliberately omitted: for a bare transaction, the transaction ID is + * itself `sha512Half(prefix, blob)`, so the key carries no information + * beyond what the payload already provides. + * + * @note The hash formula differs from `SHAMapTxPlusMetaLeafNode::updateHash()` + * and `SHAMapAccountStateLeafNode::updateHash()`, which both include + * `item_->key()` because their keys are externally assigned and not + * derivable from the payload alone. + */ void updateHash() final { hash_ = SHAMapHash{sha512Half(HashPrefix::TransactionId, item_->slice())}; } + /** Serialize this node for peer-to-peer sync (wire format). + * + * Writes the raw transaction bytes followed by the single-byte wire-type + * tag `kWIRE_TYPE_TRANSACTION` (`0`). The item key is not written because + * it is fully determined by the payload. The trailing tag allows + * `SHAMapTreeNode::makeFromWire()` to reconstruct the correct concrete + * leaf type on the receiving peer. + * + * @param s Serializer to append to. + */ void serializeForWire(Serializer& s) const final { @@ -51,6 +130,16 @@ public: s.add8(kWIRE_TYPE_TRANSACTION); } + /** Serialize this node in the canonical hashing format. + * + * Writes the 4-byte `HashPrefix::TransactionId` domain separator followed + * by the raw transaction bytes. This matches the input fed to `sha512Half` + * in `updateHash()` and is used for Merkle proof verification where the + * hash prefix already encodes the node type (no wire-type tag is appended). + * The item key is omitted for the same reason as in `updateHash()`. + * + * @param s Serializer to append to. + */ void serializeWithPrefix(Serializer& s) const final { diff --git a/include/xrpl/shamap/SHAMapTxPlusMetaLeafNode.h b/include/xrpl/shamap/SHAMapTxPlusMetaLeafNode.h index 5cf5b723b0..18529441f3 100644 --- a/include/xrpl/shamap/SHAMapTxPlusMetaLeafNode.h +++ b/include/xrpl/shamap/SHAMapTxPlusMetaLeafNode.h @@ -8,17 +8,64 @@ namespace xrpl { -/** A leaf node for a transaction and its associated metadata. */ +/** SHAMap leaf node for a transaction paired with its execution metadata. + * + * Represents the canonical form of a transaction entry in a validated + * (closed) ledger's transaction map. Each node stores the transaction blob + * and the `TxMeta` blob describing exactly what the transaction did to the + * ledger state. Open-ledger transaction maps use the metadata-free sibling + * `SHAMapTxLeafNode` instead; the two types are structurally incompatible + * because their hash formulas differ, making Merkle roots from the two + * contexts intrinsically distinct. + * + * Sits at the bottom of the inheritance chain: + * `SHAMapTreeNode` → `SHAMapLeafNode` → `SHAMapTxPlusMetaLeafNode`. + * This class is a stateless policy layer: all mutable state (`hash_`, + * `cowid_`, `item_`) lives in the base classes. The only behavior provided + * here is the type-specific hash formula, wire format, clone factory, and + * type tag. + * + * Also inherits `CountedObject`, which wires the + * type into the global object telemetry system for diagnosing live-instance + * counts under memory pressure. + * + * @see SHAMapLeafNode + * @see SHAMapTxLeafNode + * @see SHAMapAccountStateLeafNode + */ class SHAMapTxPlusMetaLeafNode final : public SHAMapLeafNode, public CountedObject { public: + /** Construct a new transaction-plus-metadata leaf and compute its hash. + * + * Use this constructor when creating a node from a freshly produced item. + * `updateHash()` is called immediately so the node is valid for insertion + * into the trie. + * + * @param item The serialized transaction-plus-metadata payload; must be + * non-null and at least 12 bytes. + * @param cowid Copy-on-write owner ID of the creating `SHAMap` instance. + */ SHAMapTxPlusMetaLeafNode(boost::intrusive_ptr item, std::uint32_t cowid) : SHAMapLeafNode(std::move(item), cowid) { updateHash(); } + /** Construct a transaction-plus-metadata leaf with a pre-computed hash. + * + * Used by `clone()` and deserialization paths where the hash is already + * known, avoiding a redundant SHA-512 computation. The caller must ensure + * `hash` is consistent with `item`'s content or the Merkle tree will be + * silently corrupted. + * + * @param item The serialized transaction-plus-metadata payload; must be + * non-null and at least 12 bytes. + * @param cowid Copy-on-write owner ID for the new node. + * @param hash Pre-computed hash of the leaf; passed straight through to + * `SHAMapLeafNode` without recomputation. + */ SHAMapTxPlusMetaLeafNode( boost::intrusive_ptr item, std::uint32_t cowid, @@ -27,24 +74,70 @@ public: { } + /** Produce an exclusively owned copy of this node for copy-on-write mutation. + * + * Shares the existing `item_` pointer (no deep copy) and forwards the + * current `hash_` to avoid recomputation. The caller supplies the new + * `cowid` so the clone is immediately owned by the mutating `SHAMap`. + * + * @param cowid Copy-on-write owner ID for the cloned node. + * @return A freshly allocated `SHAMapTxPlusMetaLeafNode` with the same + * item and hash, exclusively owned by `cowid`. + */ intr_ptr::SharedPtr clone(std::uint32_t cowid) const override { return intr_ptr::makeShared(item_, cowid, hash_); } + /** Return the node type tag for transaction-plus-metadata leaves. + * + * @return `SHAMapNodeType::TnTransactionMd` + */ SHAMapNodeType getType() const override { return SHAMapNodeType::TnTransactionMd; } + /** Recompute and store this node's Merkle hash. + * + * Hashes the `HashPrefix::TxNode` domain separator (`'S'`,`'N'`,`'D'`) + * followed by the raw payload slice and then the 32-byte item key, via + * `sha512Half`. Including the key is essential: unlike a bare transaction + * (whose ID is derived from the payload), a tx+meta node's key is an + * externally assigned identifier not present in the blob — omitting it + * would allow two distinct objects with identical payloads to collide. + * + * The `HashPrefix::TxNode` separator is distinct from the + * `HashPrefix::TransactionId` used by `SHAMapTxLeafNode` and + * `HashPrefix::LeafNode` used by `SHAMapAccountStateLeafNode`, ensuring + * cross-context hash collisions are structurally impossible. + * + * @note Marked `final` to signal that the hashing algorithm for this + * node type is fixed. `serializeWithPrefix()` must stay in sync with + * this formula; any drift silently corrupts hash verification. + */ void updateHash() final { hash_ = SHAMapHash{sha512Half(HashPrefix::TxNode, item_->slice(), item_->key())}; } + /** Serialize this node for peer-to-peer sync (wire format). + * + * Writes the raw payload slice, then the 32-byte item key via + * `addBitString`, then the single-byte wire-type tag + * `kWIRE_TYPE_TRANSACTION_WITH_META` (`4`). The trailing tag allows + * `SHAMapTreeNode::makeFromWire()` to reconstruct the correct concrete + * leaf type on the receiving peer. + * + * The key must be included on the wire because it does not appear in the + * payload — contrast with `SHAMapTxLeafNode::serializeForWire()`, which + * omits the key entirely and uses wire-type `0`. + * + * @param s Serializer to append to. + */ void serializeForWire(Serializer& s) const final { @@ -53,6 +146,17 @@ public: s.add8(kWIRE_TYPE_TRANSACTION_WITH_META); } + /** Serialize this node in the canonical hashing format. + * + * Writes the 4-byte `HashPrefix::TxNode` domain separator followed by + * the raw payload slice and the 32-byte item key. This matches exactly + * the input fed to `sha512Half` in `updateHash()` and is used during + * Merkle proof verification to reconstruct a hash from raw data without + * instantiating a full node object. No wire-type tag is appended because + * the hash prefix already encodes the node type. + * + * @param s Serializer to append to. + */ void serializeWithPrefix(Serializer& s) const final { diff --git a/include/xrpl/shamap/TreeNodeCache.h b/include/xrpl/shamap/TreeNodeCache.h index 4edb6348ec..88e613c686 100644 --- a/include/xrpl/shamap/TreeNodeCache.h +++ b/include/xrpl/shamap/TreeNodeCache.h @@ -1,3 +1,14 @@ +/** @file + * Defines `TreeNodeCache`, the in-memory hot cache of deserialized + * `SHAMapTreeNode` objects used by every live SHAMap. + * + * This file is intentionally minimal: it binds together `TaggedCache`'s + * two-level strong/weak eviction policy with intrusive pointer machinery + * that allows early memory reclamation and single-word strong/weak duality. + * The resulting alias is the canonical type name used throughout the + * SHAMap and `Family` interfaces. + */ + #pragma once #include @@ -6,10 +17,53 @@ namespace xrpl { +/** In-memory cache of deserialized `SHAMapTreeNode` objects, keyed by hash. + * + * Every ledger's account-state and transaction SHAMap shares a single + * `TreeNodeCache` (via `Family::getTreeNodeCache()`). Nodes fetched from + * persistent storage are placed here after deserialization; subsequent + * lookups by the same `uint256` hash return the already-decoded object, + * avoiding redundant disk reads and ensuring that identical on-disk nodes + * are represented by a single in-memory object — essential for SHAMap's + * copy-on-write scheme, where unmodified nodes are shared freely across + * ledger generations. + * + * The alias uses `intr_ptr::SharedWeakUnionPtr` and `intr_ptr::SharedPtr` + * instead of the `TaggedCache` defaults (`SharedWeakCachePointer` / + * `std::shared_ptr`) for two reasons: + * + * - **Earlier memory reclamation.** With `std::make_shared`, the control + * block and object are co-allocated, so the memory block cannot be freed + * until all weak references (held by the cache) expire. The intrusive + * model stores reference counts inside the `SHAMapTreeNode` itself, and + * `SHAMapInnerNode::partialDestructor()` releases all 16 child pointers + * the moment the strong count hits zero, even while the cache still holds + * a weak reference to the parent. + * + * - **Single-word strong/weak duality.** `SharedWeakUnionPtr` stores + * either a strong or a weak intrusive reference in one pointer-sized word, + * using the low-order bit as a tag (alignment guarantees the bit is always + * zero in a real pointer). When the cache sweeper demotes a hot entry to a + * tracking-only entry, it calls `convertToWeak()` in-place — flipping one + * bit — rather than replacing a `shared_ptr`/`weak_ptr` pair. + * + * `IsKeyCache = false` selects `TaggedCache`'s value-cache mode, where the + * map stores the actual `SHAMapTreeNode` objects (not just keys). Nodes + * remain strongly referenced while hot; they degrade to weak references as + * they age, and are removed entirely when both the cache entry expires and no + * external strong pointer holds the object live. + * + * @note Nodes retrieved from the cache have `cowid_ == 0` by invariant — + * they are shared and must not be mutated. Any map that needs to modify + * such a node must call `clone()` first to obtain a private copy. + * @see Family::getTreeNodeCache() + * @see SHAMapTreeNode::partialDestructor() + */ using TreeNodeCache = TaggedCache< uint256, SHAMapTreeNode, - /*IsKeyCache*/ false, + false, intr_ptr::SharedWeakUnionPtr, intr_ptr::SharedPtr>; + } // namespace xrpl diff --git a/include/xrpl/shamap/detail/TaggedPointer.h b/include/xrpl/shamap/detail/TaggedPointer.h index d3c0a6542f..f8fcd8cef8 100644 --- a/include/xrpl/shamap/detail/TaggedPointer.h +++ b/include/xrpl/shamap/detail/TaggedPointer.h @@ -1,3 +1,12 @@ +/** @file + * Sparse child-array manager for SHAMap inner nodes. + * + * Defines `TaggedPointer`, which packs a size-class tag into the two low bits + * of a heap pointer to manage four pool-backed capacity tiers (2, 4, 6, 16 + * slots). Also defines `popcnt16`, the popcount primitive used to translate + * branch numbers to sparse array indices. + */ + #pragma once #include @@ -9,111 +18,147 @@ namespace xrpl { -/** TaggedPointer is a combination of a pointer and a mask stored in the - lowest two bits. - - Since pointers do not have arbitrary alignment, the lowest bits in the - pointer are guaranteed to be zero. TaggedPointer stores information in these - low bits. When dereferencing the pointer, these low "tag" bits are set to - zero. When accessing the tag bits, the high "pointer" bits are set to zero. - - The "pointer" part points to the equivalent to an array of - `SHAMapHash` followed immediately by an array of - `shared_ptr`. The sizes of these arrays are - determined by the tag. The tag is an index into an array (`boundaries`, - defined in the cpp file) that specifies the size. Both arrays are the - same size. Note that the sizes may be smaller than the full 16 elements - needed to explicitly store all the children. In this case, the arrays - only store the non-empty children. The non-empty children are stored in - index order. For example, if only children `2` and `14` are non-empty, a - two-element array would store child `2` in array index 0 and child `14` - in array index 1. There are functions to convert between a child's tree - index and the child's index in a sparse array. - - The motivation for this class is saving RAM. A large percentage of inner - nodes only store a small number of children. Memory can be saved by - storing the inner node's children in sparse arrays. Measurements show - that on average a typical SHAMap's inner nodes can be stored using only - 25% of the original space. -*/ +/** Sparse co-located array pair for `SHAMapInnerNode` children. + * + * Owns a single heap allocation that holds a `SHAMapHash[]` immediately + * followed by a `SharedPtr[]`, both of the same length N. + * N is one of four capacity tiers (2, 4, 6, or 16) chosen by rounding the + * requested child count up to the nearest boundary. The tier index (0–3) is + * stored in the two low bits of the allocation pointer, which are always zero + * due to `SHAMapHash`'s minimum alignment of 4. + * + * In the **dense** tier (N == 16, tag == 3) the logical branch number is also + * the array index. In **sparse** tiers only occupied children are stored, + * packed in ascending branch-index order; callers translate via + * `getChildIndex()` (a single `popcnt16` on the occupancy bitset). The + * occupancy bitset itself (`isBranch_`) lives in `SHAMapInnerNode`, not here. + * + * Each tier is backed by its own `boost::singleton_pool` (512 KB blocks), + * keeping allocation O(1) and avoiding general-purpose heap fragmentation for + * these hot, fixed-size objects. On a typical production ledger this layout + * reduces inner-node memory to roughly 25% of a fully-dense allocation. + * + * `TaggedPointer` is move-only. A moved-from instance has `tp_ == 0`, which + * `destroyHashesAndChildren()` treats as a no-op sentinel. + * + * @note The `boundaries` array in `TaggedPointer.ipp` must have exactly 4 + * entries; a `static_assert` enforces this because the tag field is only + * 2 bits wide. + * @see SHAMapInnerNode for the owning class and its `isBranch_` occupancy + * bitset. + * @see popcnt16 for the branch-number-to-array-index primitive. + */ class TaggedPointer { private: static_assert( alignof(SHAMapHash) >= 4, "Bad alignment: Tag pointer requires low two bits to be zero."); - /** Upper bits are the pointer, lowest two bits are the tag - A moved-from object will have a tp_ of zero. - */ + + /** Combined pointer and 2-bit size-class tag. + * + * High bits (`& kPTR_MASK`) are the raw allocation address; low 2 bits + * (`& kTAG_MASK`) index into the `boundaries` array to give the array + * capacity. Set to 0 in a moved-from instance. + */ std::uintptr_t tp_ = 0; - /** bit-and with this mask to get the tag bits (lowest two bits) */ + + /** Mask to extract the 2-bit size-class tag from `tp_`. */ static constexpr std::uintptr_t kTAG_MASK = 3; - /** bit-and with this mask to get the pointer bits (mask out the tag) */ + + /** Mask to extract the raw pointer from `tp_` (clears the tag bits). */ static constexpr std::uintptr_t kPTR_MASK = ~kTAG_MASK; - /** Deallocate memory and run destructors */ + /** Run element destructors on all allocated slots and return memory to the + * pool. A no-op when `tp_ == 0` (moved-from or default state). + */ void destroyHashesAndChildren(); + /** Tag type used to select the raw-allocate constructor overload. + * + * An empty struct whose sole purpose is to make the intent of the + * private constructor explicit at each call site: the caller is taking + * responsibility for running placement-new on every allocated slot before + * any destructor can fire. + */ struct RawAllocateTag { }; - /** This constructor allocates space for the hashes and children, but - does not run constructors. - @param RawAllocateTag used to select overload only - - @param numChildren allocate space for at least this number of children - (must be <= branchFactor) - - @note Since the hashes/children destructors are always run in the - TaggedPointer destructor, this means those constructors _must_ be run - after this constructor is run. This constructor is private and only used - in places where the hashes/children constructor are subsequently run. - */ + /** Allocate pool memory for at least `numChildren` slots without running + * element constructors. + * + * The actual capacity is rounded up to the nearest tier boundary. Because + * the destructor unconditionally calls `destroyHashesAndChildren()`, which + * runs destructors on all allocated slots, every call site **must** + * follow this constructor with placement-new loops covering all allocated + * slots before any code path that could throw or early-return. + * + * @param RawAllocateTag Overload selector; pass `RawAllocateTag{}`. + * @param numChildren Minimum number of slots to allocate; must be ≤ + * `SHAMapInnerNode::kBRANCH_FACTOR`. + */ explicit TaggedPointer(RawAllocateTag, std::uint8_t numChildren); public: TaggedPointer() = delete; + + /** Allocate and default-construct arrays for at least `numChildren` slots. + * + * Rounds `numChildren` up to the nearest tier boundary, allocates from + * the corresponding pool, and runs default constructors on every + * `SHAMapHash` and `SharedPtr` slot. + * + * @param numChildren Minimum capacity; must be ≤ + * `SHAMapInnerNode::kBRANCH_FACTOR`. + */ explicit TaggedPointer(std::uint8_t numChildren); - /** Constructor is used change the number of allocated children. - - Existing children from `other` are copied (toAllocate must be >= the - number of children). The motivation for making this a constructor is it - saves unneeded copying and zeroing out of hashes if this were - implemented directly in the SHAMapInnerNode class. - - @param other children and hashes are moved from this param - - @param isBranch bitset of non-empty children in `other` - - @param toAllocate allocate space for at least this number of children - (must be <= branchFactor) - */ + /** Resize the allocation, preserving existing children from `other`. + * + * If `toAllocate` maps to the same tier as `other`'s current capacity + * the allocation is reused in-place; otherwise a new pool block is + * allocated and all non-empty children (identified by `isBranch`) are + * moved into it. Remaining slots are default-constructed. + * + * Implemented as a constructor (rather than a member function) to avoid + * unnecessary copies and zero-fills that would occur if the resize were + * performed inside `SHAMapInnerNode` directly. + * + * @param other Source `TaggedPointer`; left in a valid moved-from state. + * @param isBranch Occupancy bitset for `other`: bit `i` set means branch + * `i` is non-empty. + * @param toAllocate Minimum capacity for the result; must be ≥ + * `popcnt16(isBranch)` and ≤ `SHAMapInnerNode::kBRANCH_FACTOR`. + */ explicit TaggedPointer(TaggedPointer&& other, std::uint16_t isBranch, std::uint8_t toAllocate); - /** Given `other` with the specified children in `srcBranches`, create a - new TaggedPointer with the allocated number of children and the - children specified in `dstBranches`. - - @param other children and hashes are moved from this param - - @param srcBranches bitset of non-empty children in `other` - - @param dstBranches bitset of children to copy from `other` (or space to - leave in a sparse array - see note below) - - @param toAllocate allocate space for at least this number of children - (must be <= branchFactor) - - @note a child may be absent in srcBranches but present in dstBranches - (if dst has a sparse representation, space for the new child will be - left in the sparse array). Typically, srcBranches and dstBranches will - differ by at most one bit. The function works correctly if they differ - by more, but there are likely more efficient algorithms to consider if - this becomes a common use-case. - */ + /** Resize the allocation and simultaneously apply a branch-set delta. + * + * Constructs a new `TaggedPointer` whose logical children are the + * intersection of `other`'s children and `dstBranches`, with empty slots + * prepared for any branch in `dstBranches` that was absent in + * `srcBranches`. When the old and new tier are identical the operation is + * performed in-place (shift left to remove, shift right to insert); + * otherwise a fresh pool block is allocated and elements are copied across + * with placement-new. + * + * Used by `SHAMapInnerNode::resizeChildArrays()` when an add or remove + * operation changes the occupied count across a tier boundary. + * + * @param other Source `TaggedPointer`; left in a valid moved-from state. + * @param srcBranches Occupancy bitset of non-empty children in `other`. + * @param dstBranches Occupancy bitset for the result. A bit may be set in + * `dstBranches` but absent in `srcBranches` — in a sparse result an + * empty slot is reserved at the correct sorted position for the new + * child. `srcBranches` and `dstBranches` typically differ by one bit. + * @param toAllocate Minimum capacity for the result; must be ≥ + * `popcnt16(dstBranches)` and ≤ `SHAMapInnerNode::kBRANCH_FACTOR`. + * @note If the two bitsets differ by more than one bit the function + * remains correct but may not be the most efficient approach for that + * use-case. + */ explicit TaggedPointer( TaggedPointer&& other, std::uint16_t srcBranches, @@ -122,80 +167,137 @@ public: TaggedPointer(TaggedPointer const&) = delete; + /** Move constructor. Transfers ownership; leaves `other` in a moved-from + * state (`tp_ == 0`). + */ TaggedPointer(TaggedPointer&&); + /** Move-assignment operator. Destroys the current allocation, then + * transfers ownership from `other`, leaving `other` moved-from. + */ TaggedPointer& operator=(TaggedPointer&&); + /** Destroy all allocated slots and return memory to the pool. */ ~TaggedPointer(); - /** Decode the tagged pointer into its tag and pointer */ + /** Separate the stored pointer from its 2-bit size-class tag. + * + * @return A pair of `{tag, rawPtr}` where `tag` is the 2-bit tier index + * into `boundaries` and `rawPtr` is the untagged allocation address. + */ [[nodiscard]] std::pair decode() const; - /** Get the number of elements allocated for each array */ + /** Number of slots allocated in each array (hashes and children). + * + * This is `boundaries[tag]` — one of 2, 4, 6, or 16. + * + * @return The array capacity for the current size-class tier. + */ [[nodiscard]] std::uint8_t capacity() const; - /** Check if the arrays have a dense format. - - @note The dense format is when there is an array element for all 16 - (branchFactor) possible children. - */ + /** Return true when the allocation holds all 16 (`kBRANCH_FACTOR`) slots. + * + * In the dense layout branch number equals array index directly, so no + * popcount translation is needed. + * + * @return `true` if the tag equals the last (dense) tier index. + */ [[nodiscard]] bool isDense() const; - /** Get the number of elements in each array and a pointer to the start - of each array. - */ + /** Return the array capacity and raw pointers to both co-located arrays. + * + * The `SHAMapHash` array begins at the allocation base; the + * `SharedPtr` array begins immediately after (at + * `hashes + numAllocated`). Both arrays have `numAllocated` elements. + * + * @return A tuple of `{numAllocated, hashes, children}`. + */ [[nodiscard]] std::tuple*> getHashesAndChildren() const; - /** Get the `hashes` array */ + /** Return a pointer to the start of the `SHAMapHash` array. + * + * Equivalent to `std::get<1>(getHashesAndChildren())` but slightly + * cheaper when the children pointer is not needed. + * + * @return Pointer to the first `SHAMapHash` element. + */ [[nodiscard]] SHAMapHash* getHashes() const; - /** Get the `children` array */ + /** Return a pointer to the start of the `SharedPtr` array. + * + * @return Pointer to the first child smart-pointer element. + */ [[nodiscard]] intr_ptr::SharedPtr* getChildren() const; - /** Call the `f` callback for all 16 (branchFactor) branches - even if - the branch is empty. - - @param isBranch bitset of non-empty children - - @param f a one parameter callback function. The parameter is the - child's hash. + /** Invoke `f` for all 16 branches, supplying each branch's hash. + * + * Empty branches in a sparse layout receive a zero-valued `SHAMapHash`. + * Iterates all `kBRANCH_FACTOR` branches in ascending branch-index order, + * making this suitable for `updateHash()` which must feed all 16 hashes + * to the SHA-512 half-hash regardless of occupancy. + * + * @tparam F Callable with signature `void(SHAMapHash const&)`. + * @param isBranch Occupancy bitset; bit `i` set means branch `i` is + * non-empty and its hash is stored in the array. + * @param f Callback invoked once per branch in branch-index order. */ template void iterChildren(std::uint16_t isBranch, F&& f) const; - /** Call the `f` callback for all non-empty branches. - - @param isBranch bitset of non-empty children - - @param f a two parameter callback function. The first parameter is - the branch number, the second parameter is the index into the array. - For dense formats these are the same, for sparse they may be - different. + /** Invoke `f` for every non-empty branch with both its branch number and + * its physical array index. + * + * For a dense layout the two values are identical. For a sparse layout + * the array index is the packed position (`popcnt16` of lower set bits in + * `isBranch`), which differs from the branch number whenever any + * lower-numbered branch is absent. Callers that need to index into the + * hashes or children arrays (e.g., mutation helpers) must use the + * array index, not the branch number. + * + * @tparam F Callable with signature `void(int branchNum, int arrayIdx)`. + * @param isBranch Occupancy bitset of non-empty children. + * @param f Callback invoked once per occupied branch in ascending order. */ template void iterNonEmptyChildIndexes(std::uint16_t isBranch, F&& f) const; - /** Get the child's index inside the `hashes` or `children` array (which - may or may not be sparse). The optional will be empty if an empty - branch is requested and the children are sparse. - - @param isBranch bitset of non-empty children - - @param i index of the requested child + /** Translate a logical branch number to a physical array index. + * + * In the dense layout returns `i` directly. In a sparse layout, counts + * the number of occupied branches below `i` via `popcnt16`, which gives + * the packed array position of branch `i`. + * + * @param isBranch Occupancy bitset of non-empty children. + * @param i Logical branch number (0–15) to look up. + * @return The array index, or `std::nullopt` if the arrays are sparse and + * branch `i` is not occupied (empty branches have no array slot in + * sparse mode). */ [[nodiscard]] std::optional getChildIndex(std::uint16_t isBranch, int i) const; }; +/** Count the number of set bits in a 16-bit value. + * + * Used by `TaggedPointer::getChildIndex()` to translate a logical branch + * number to a sparse array index (number of occupied branches below position + * `i`), and by `SHAMapInnerNode::getBranchCount()`. Both are on hot traversal + * paths, so the implementation dispatches to the fastest available intrinsic: + * `std::popcount` (C++20), `__builtin_popcount` (GCC/Clang), or a + * compile-time-generated 256-entry lookup table as a portable fallback. + * + * @param a 16-bit value whose set bits are to be counted. + * @return Number of bits set in `a`, in the range [0, 16]. + */ [[nodiscard]] inline int popcnt16(std::uint16_t a) { diff --git a/include/xrpl/tx/ApplyContext.h b/include/xrpl/tx/ApplyContext.h index 910ec6be42..bb37375e46 100644 --- a/include/xrpl/tx/ApplyContext.h +++ b/include/xrpl/tx/ApplyContext.h @@ -10,10 +10,47 @@ namespace xrpl { -/** State information when applying a tx. */ +/** Central context object for the transaction-application pipeline. + * + * `ApplyContext` is created at the boundary between the *preclaim* phase + * (read-only authorization and fee validation) and the *apply* phase (actual + * ledger state mutation). It lives for the duration of apply and is passed + * by reference to every `Transactor` implementation, giving each handler a + * uniform handle to the sandboxed mutable view, the validated transaction, + * and the fee information. + * + * The sandboxed view is stored as `std::optional` layered on + * top of `base_`. Rollback is implemented by discarding and re-emplacing the + * optional rather than walking an undo log — see `discard()`. Mutations are + * not committed to `base_` until `apply()` is called explicitly. + * + * @note The const-qualified public members (`tx`, `preclaimResult`, + * `baseFee`, `journal`) are immutable for the lifetime of an apply + * cycle. All mutable state is confined to the private `view_`, + * `flags_`, and `parentBatchId_` members. + */ class ApplyContext { public: + /** Construct for a batch-inner transaction. + * + * Use this constructor when the transaction executes inside a `ttBATCH` + * envelope. The `parentBatchId` is forwarded to `ApplyViewImpl::apply()` + * so the generated `TxMeta` records the parent-child relationship. + * Asserts that `parentBatchId` is set if and only if `TapBatch` is + * active in `flags`. + * + * @param registry Service registry providing shared engine services. + * @param base The underlying open ledger view that accumulates + * committed state. Never modified until `apply()` is called. + * @param parentBatchId The `uint256` ID of the enclosing batch + * transaction. Must be non-null when `TapBatch` is set in `flags`. + * @param tx The fully-validated transaction to apply. + * @param preclaimResult The `TER` code produced by the preclaim phase. + * @param baseFee The fee determined before applying, in drops. + * @param flags Apply-phase control flags (e.g., `TapDryRun`). + * @param journal Logging sink; defaults to the null sink. + */ explicit ApplyContext( ServiceRegistry& registry, OpenView& base, @@ -24,6 +61,21 @@ public: ApplyFlags flags, beast::Journal journal = beast::Journal{beast::Journal::getNullSink()}); + /** Construct for a standalone (non-batch) transaction. + * + * Delegates to the full constructor with `std::nullopt` for + * `parentBatchId`. Asserts that `TapBatch` is not set in `flags` — + * batch-inner transactions must use the constructor that accepts a + * `parentBatchId`. + * + * @param registry Service registry providing shared engine services. + * @param base The underlying open ledger view. + * @param tx The fully-validated transaction to apply. + * @param preclaimResult The `TER` code produced by the preclaim phase. + * @param baseFee The fee determined before applying, in drops. + * @param flags Apply-phase control flags. Must not include `TapBatch`. + * @param journal Logging sink; defaults to the null sink. + */ explicit ApplyContext( ServiceRegistry& registry, OpenView& base, @@ -37,24 +89,58 @@ public: XRPL_ASSERT((flags & TapBatch) == 0, "Batch apply flag should not be set"); } + /** Service registry providing shared engine services. */ std::reference_wrapper registry; + + /** The transaction being applied. Immutable for the apply lifecycle. */ STTx const& tx; + + /** The `TER` result produced by the preclaim phase. Immutable. */ TER const preclaimResult; + + /** The fee charged for this transaction, in drops. Immutable. */ XRPAmount const baseFee; + + /** Logging sink. Immutable. */ beast::Journal const journal; + /** Access the sandboxed mutable ledger view. + * + * Returns the `ApplyViewImpl` layered on top of `base_`. All + * transactor mutations go through this view; none reach `base_` + * until `apply()` is called. + * + * @return A mutable reference to the sandboxed apply view. + */ ApplyView& view() { return *view_; // NOLINT(bugprone-unchecked-optional-access) view_ emplaced in constructor } + /** Access the sandboxed ledger view (read-only overload). + * + * @return A const reference to the sandboxed apply view. + */ [[nodiscard]] ApplyView const& view() const { return *view_; // NOLINT(bugprone-unchecked-optional-access) view_ emplaced in constructor } + /** Access the sandboxed view as a low-level `RawView`. + * + * Bypasses the higher-level constraint enforcement in `ApplyView` to + * allow direct ledger-entry writes. Use only where `ApplyView`'s guards + * are legitimately too restrictive for the operation at hand. + * + * @return A mutable reference to the underlying `RawView` interface of + * the sandboxed view. + * + * @note Prefer `view()` wherever possible. This accessor exists as an + * escape hatch for engine internals that must write ledger entries + * without the higher-level guards. + */ // VFALCO Unfortunately this is necessary RawView& rawView() @@ -62,13 +148,20 @@ public: return *view_; // NOLINT(bugprone-unchecked-optional-access) view_ emplaced in constructor } + /** Return the apply-phase control flags for this transaction. */ [[nodiscard]] ApplyFlags const& flags() const { return flags_; } - /** Sets the DeliveredAmount field in the metadata */ + /** Record the delivered amount in the transaction metadata. + * + * Sets the `sfDeliveredAmount` field written into `TxMeta` when + * `apply()` is called. Must be called before `apply()` to take effect. + * + * @param amount The amount actually delivered by this transaction. + */ void deliver(STAmount const& amount) { @@ -76,18 +169,52 @@ public: view_->deliver(amount); } - /** Discard changes and start fresh. */ + /** Discard all sandboxed mutations and reset to a clean view. + * + * Destroys the current `ApplyViewImpl` in-place and constructs a fresh + * one on top of `base_`. The base view is never touched, so all + * accumulated ledger changes evaporate without an undo log. Called by + * `Transactor::reset()` to implement `tec*` rollback and by the + * `tapFAIL_HARD` path to suppress even fee deduction. + */ void discard(); - /** Apply the transaction result to the base. */ + /** Commit sandboxed mutations to the base view and produce metadata. + * + * Delegates to `ApplyViewImpl::apply()`, which writes all accumulated + * state changes into `base_` and generates `TxMeta`. After this call + * the `ApplyViewImpl` is consumed and must not be used again. + * + * @param ter The final `TER` result code for the transaction. + * @return The generated `TxMeta` if the transaction is committed to the + * ledger, or `std::nullopt` when `TapDryRun` is active. + */ std::optional apply(TER); - /** Get the number of unapplied changes. */ + /** Return the number of pending (uncommitted) ledger-entry changes. + * + * @return Count of SLE modifications accumulated in the sandboxed view + * since the last `discard()` or construction. + */ std::size_t size(); - /** Visit unapplied changes. */ + /** Iterate over pending ledger-entry changes in the sandboxed view. + * + * Calls `func` once for each modified, inserted, or deleted SLE + * tracked by the sandbox. Used by invariant checkers (via + * `checkInvariants`) and by `tecOVERSIZE` / `tecKILLED` cleanup + * handlers to identify objects that need post-failure removal. + * + * @param func Callback invoked per entry. Parameters: + * - `key` — ledger index of the entry. + * - `isDelete` — true if the entry is being erased. + * - `before` — the SLE state before this transaction (`nullptr` + * for insertions). + * - `after` — the SLE state after this transaction (`nullptr` + * for deletions). + */ void visit( std::function const& before, std::shared_ptr const& after)> const& func); + /** Burn the given XRP fee from the ledger supply. + * + * Forwards to `RawView::rawDestroyXRP()` on the sandboxed view. + * Called by `Transactor::payFee()` as part of fee deduction. + * + * @param fee Amount of XRP, in drops, to remove from the total supply. + */ void destroyXRP(XRPAmount const& fee) { @@ -103,16 +237,40 @@ public: view_->rawDestroyXRP(fee); } - /** Applies all invariant checkers one by one. - - @param result the result generated by processing this transaction. - @param fee the fee charged for this transaction - @return the result code that should be returned for this transaction. + /** Run all registered invariant checkers and return the final result. + * + * Iterates the compile-time `InvariantChecks` tuple. For each checker, + * calls `visitEntry()` on every pending SLE change (via `visit()`), + * then calls `finalize()`. Results are collected into an array before + * being tested with `std::all_of` — never short-circuited — so that + * every failing invariant emits its own fatal log message. + * + * If any checker fails or throws, delegates to `failInvariantCheck()` + * to determine whether to return `tecINVARIANT_FAILED` (first failure, + * fee still charged) or `tefINVARIANT_FAILED` (repeat failure, tx not + * included in ledger at all). + * + * @param result The `TER` produced by `doApply()`; must be + * `tesSUCCESS` or a `tec*` claim code. + * @param fee The fee charged for this transaction, in drops. + * @return The original `result` if all invariants pass; otherwise + * `tecINVARIANT_FAILED` or `tefINVARIANT_FAILED`. */ TER checkInvariants(TER const result, XRPAmount const fee); private: + /** Determine the escalated failure code for a broken invariant. + * + * Returns `tefINVARIANT_FAILED` if `result` is already + * `tecINVARIANT_FAILED` or `tefINVARIANT_FAILED` (the fee-only retry + * path also broke invariants — nothing safe to commit). Returns + * `tecINVARIANT_FAILED` on the first failure so the transaction is + * still included in the ledger with a fee charge. + * + * @param result The current TER before escalation. + * @return The escalated invariant-failure TER. + */ static TER failInvariantCheck(TER const result); diff --git a/include/xrpl/tx/SignerEntries.h b/include/xrpl/tx/SignerEntries.h index 91fc4bd030..d0e7d904d9 100644 --- a/include/xrpl/tx/SignerEntries.h +++ b/include/xrpl/tx/SignerEntries.h @@ -14,24 +14,43 @@ namespace xrpl { // Forward declarations class STObject; -// Support for SignerEntries that is needed by a few Transactors. -// -// SignerEntries is represented as a std::vector. -// There is no direct constructor for SignerEntries. -// -// o A std::vector is a SignerEntries. -// o More commonly, SignerEntries are extracted from an STObject by -// calling SignerEntries::deserialize(). +/** Non-constructible utility scope for the multi-signature co-signer roster. + * + * A signer list is represented as a `std::vector`. + * Entries are produced exclusively via `SignerEntries::deserialize()`, which + * extracts them from an `STObject` (either a transaction or a live ledger + * entry). This class cannot be instantiated; it exists only to co-locate + * the `SignerEntry` type and the `deserialize()` factory under one name. + * + * @see SignerEntry + * @see deserialize + */ class SignerEntries { public: explicit SignerEntries() = delete; + /** A single co-signer record extracted from an `sfSignerEntries` array. + * + * Holds the co-signer's account ID, their vote weight toward the quorum, + * and an optional destination tag (`sfWalletLocator`) that supports + * phantom accounts — signers that may not yet have an on-ledger account + * root. + * + * @note Comparison operators are intentionally defined on `account` alone. + * Sorting and duplicate detection in `SignerListSet` and + * `Transactor::checkMultiSign()` both rely on account-only ordering: + * `std::sort()` uses `operator<=>` to produce the sorted vector that + * enables the O(n) linear merge in `checkMultiSign()`, and + * `std::adjacent_find()` uses `operator==` to detect duplicate + * account IDs (a `temBAD_SIGNER` condition). Including `weight` or + * `tag` in either operator would silently break both checks. + */ struct SignerEntry { - AccountID account; - std::uint16_t weight; - std::optional tag; + AccountID account; /**< The co-signer's account ID. */ + std::uint16_t weight; /**< Vote weight contributed toward the quorum. */ + std::optional tag; /**< Optional `sfWalletLocator` destination tag. */ SignerEntry( AccountID const& inAccount, @@ -41,13 +60,18 @@ public: { } - // For sorting to look for duplicate accounts + /** Three-way comparison by `account` only, enabling `std::sort` and + * the O(n) merge in `Transactor::checkMultiSign()`. + */ friend auto operator<=>(SignerEntry const& lhs, SignerEntry const& rhs) { return lhs.account <=> rhs.account; } + /** Equality test by `account` only, enabling duplicate detection via + * `std::adjacent_find` after sorting. + */ friend bool operator==(SignerEntry const& lhs, SignerEntry const& rhs) { @@ -55,11 +79,33 @@ public: } }; - // Deserialize a SignerEntries array from the network or from the ledger. - // - // obj Contains a SignerEntries field that is an STArray. - // journal For reporting error conditions. - // annotation Source of SignerEntries, like "ledger" or "transaction". + /** Extract and lightly validate the `sfSignerEntries` array from an STObject. + * + * Works against both an `STTx` (during preflight/preclaim) and an `SLE` + * (during `checkMultiSign()` against the on-ledger signer list). Each + * array element must carry the `sfSignerEntry` field name; the function + * extracts `sfAccount`, `sfSignerWeight`, and optionally `sfWalletLocator` + * per entry. No business-logic validation (quorum reachability, duplicate + * detection, self-reference) is performed here — that belongs to + * `SignerListSet::validateQuorumAndSignerEntries()`. + * + * The returned vector is pre-reserved to `STTx::kMAX_MULTI_SIGNERS` to + * avoid reallocation during iteration. Callers typically sort it + * immediately after return to enable O(n) duplicate detection and the + * linear merge in `checkMultiSign()`. + * + * @param obj Any `STObject` that carries an `sfSignerEntries` field — + * a transaction being preflight-checked or a ledger entry being read + * during apply. + * @param journal Journal used to emit `trace`-level diagnostics when a + * malformed entry is encountered. + * @param annotation Short label — typically `"transaction"` or `"ledger"` + * — prepended to journal messages to identify the data source. + * @return On success, a vector of `SignerEntry` values in the order they + * appear in the `sfSignerEntries` array. On failure, a `NotTEC` error + * code (typically `temMALFORMED`) that callers should propagate + * immediately without dereferencing the value. + */ static Expected, NotTEC> deserialize(STObject const& obj, beast::Journal journal, std::string_view annotation); }; diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index bee2e2942c..b7bce4a041 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -1,3 +1,27 @@ +/** + * @file Transactor.h + * + * Base class and context structures for the XRPL transaction-processing + * pipeline. + * + * Every transaction type (Payment, OfferCreate, AMM, NFT, etc.) inherits + * from `Transactor` and participates in a strict three-phase pipeline: + * + * - **preflight** — stateless, no ledger access; validates format, flags, and + * signature syntax via `invokePreflight()`. + * - **preclaim** — read-only `ReadView`; checks sequence, fee balance, and + * signature validity against ledger state. + * - **doApply** — mutable `ApplyView`; applies state changes; only reached + * when preclaim returns `tesSUCCESS`. + * + * Compile-time polymorphism is achieved through name hiding, not virtual + * dispatch: derived classes define static methods (`preflight`, + * `checkExtraFeatures`, `getFlagsMask`, `preflightSigValidated`) that are + * resolved by the `invokePreflight` template at compile time. + * + * @see PreflightContext, PreclaimContext, ApplyContext + */ + #pragma once #include @@ -11,17 +35,43 @@ namespace xrpl { -/** State information when preflighting a tx. */ +/** Immutable context passed to all preflight checks. + * + * Carries everything a stateless preflight validation step needs: the raw + * transaction, the active ledger rules, apply flags, and — for batch inner + * transactions — the hash of the enclosing batch. No ledger view is + * included because preflight must not access ledger state. + * + * Two constructors enforce the batch/non-batch invariant at construction + * time: the batch constructor asserts `TapBatch` is set and records the + * `parentBatchId`; the non-batch constructor asserts `TapBatch` is clear + * and leaves `parentBatchId` empty. + */ struct PreflightContext { public: + /** Service registry providing network ID, hash router, and load fees. */ std::reference_wrapper registry; + /** The transaction being validated. */ STTx const& tx; + /** Active ledger rules (amendments) at the time of validation. */ Rules const rules; + /** Apply flags controlling validation behavior (e.g., `TapDryRun`, `TapBatch`). */ ApplyFlags flags; + /** Hash of the enclosing batch transaction, present only for batch inner transactions. */ std::optional parentBatchId; + /** Journal for diagnostic logging. */ beast::Journal const j; + /** Construct a context for a batch inner transaction. + * + * @param registry Service registry. + * @param tx The inner transaction. + * @param parentBatchId Hash of the outer batch transaction. + * @param rules Active ledger rules. + * @param flags Apply flags; `TapBatch` must be set. + * @param j Journal for logging. + */ PreflightContext( ServiceRegistry& registry, STTx const& tx, @@ -39,6 +89,14 @@ public: XRPL_ASSERT((flags & TapBatch) == TapBatch, "Batch apply flag should be set"); } + /** Construct a context for an ordinary (non-batch) transaction. + * + * @param registry Service registry. + * @param tx The transaction. + * @param rules Active ledger rules. + * @param flags Apply flags; `TapBatch` must NOT be set. + * @param j Journal for logging. + */ PreflightContext( ServiceRegistry& registry, STTx const& tx, @@ -54,18 +112,48 @@ public: operator=(PreflightContext const&) = delete; }; -/** State information when determining if a tx is likely to claim a fee. */ +/** Immutable context passed to all preclaim checks. + * + * Extends `PreflightContext` with a read-only `ReadView` so that preclaim + * can verify account existence, sequence validity, fee sufficiency, and + * signature correctness against the current ledger state. The result of + * the earlier preflight phase is carried forward in `preflightResult` so + * that preclaim helpers can short-circuit when preflight already failed. + * + * The same batch/non-batch constructor duality as `PreflightContext` + * applies: `parentBatchId` presence must match the `TapBatch` flag, + * enforced by assertion in the unified constructor. + */ struct PreclaimContext { public: + /** Service registry providing network ID, hash router, and load fees. */ std::reference_wrapper registry; + /** Read-only view of the ledger against which preclaim checks are evaluated. */ ReadView const& view; + /** The `NotTEC` code returned by the earlier preflight phase. */ TER preflightResult; + /** Apply flags (e.g., `TapDryRun`, `TapBatch`, `TapUnlimited`). */ ApplyFlags flags; + /** The transaction being evaluated. */ STTx const& tx; + /** Hash of the enclosing batch transaction; set iff `TapBatch` is active. */ std::optional const parentBatchId; + /** Journal for diagnostic logging. */ beast::Journal const j; + /** Construct for a batch inner transaction (or ordinary tx with explicit batch ID). + * + * Asserts that `parentBatchId.has_value() == ((flags & TapBatch) == TapBatch)`. + * + * @param registry Service registry. + * @param view Read-only ledger view. + * @param preflightResult Result from the preflight phase. + * @param tx The transaction. + * @param flags Apply flags. + * @param parentBatchId Hash of the outer batch, or `std::nullopt`. + * @param j Journal for logging. + */ PreclaimContext( ServiceRegistry& registry, ReadView const& view, @@ -87,6 +175,15 @@ public: "Parent Batch ID should be set if batch apply flag is set"); } + /** Construct for an ordinary (non-batch) transaction. + * + * @param registry Service registry. + * @param view Read-only ledger view. + * @param preflightResult Result from the preflight phase. + * @param tx The transaction. + * @param flags Apply flags; `TapBatch` must NOT be set. + * @param j Journal for logging. + */ PreclaimContext( ServiceRegistry& registry, ReadView const& view, @@ -108,15 +205,48 @@ struct PreflightResult; // Needed for preflight specialization class Change; +/** Base class for all XRPL transaction processors. + * + * Implements the three-phase transaction pipeline: preflight (stateless + * validation), preclaim (read-only ledger checks), and doApply (mutable + * ledger application). Every concrete transaction type (Payment, + * OfferCreate, AMMCreate, etc.) inherits from this class. + * + * Polymorphism in the preflight phase is achieved through compile-time + * name hiding rather than virtual dispatch. Derived classes define + * static methods — `preflight`, `preclaim`, `getFlagsMask`, + * `checkExtraFeatures`, `preflightSigValidated` — that are resolved by + * `invokePreflight()` at the call site. See the comment block on + * `invokePreflight` for the rules on what derived classes must and must + * not define. + * + * The single virtual entry point for state mutation is `doApply()`. + * `operator()()` is the top-level dispatch called by the apply loop; + * it drives all three phases, handles fee claiming on failure, runs + * invariant checks, and manages `tapDRY_RUN` simulation semantics. + * + * @note Instances are not copyable. One transactor object is created + * per transaction application. + */ class Transactor { protected: + /** Apply context holding the sandboxed ledger view and transaction. */ ApplyContext& ctx_; + /** Wrapped journal sink that prepends the transaction ID to each log line. */ beast::WrappedSink sink_; + /** Journal backed by `sink_`; use this for all logging inside transactors. */ beast::Journal const j_; + /** The account that submitted the transaction (`sfAccount`). */ AccountID const account_; - XRPAmount preFeeBalance_{}; // Balance before fees. + /** Account balance captured immediately before fee deduction in `apply()`. + * + * Reserve checks in `doApply` must use this value rather than the + * post-fee balance to allow accounts to dip into their reserve to pay + * the fee without violating the reserve requirement for new objects. + */ + XRPAmount preFeeBalance_{}; public: virtual ~Transactor() = default; @@ -124,18 +254,56 @@ public: Transactor& operator=(Transactor const&) = delete; + /** Controls how `TxConsequences` are produced for the transaction queue. + * + * - `Normal` — standard fee/sequence consequences (most transactors). + * - `Blocker` — signals that applying this transaction may prevent + * subsequent queued transactions from the same account from + * claiming fees (e.g., `SetRegularKey`, `AccountDelete`). + * - `Custom` — the transactor implements `makeTxConsequences()` for + * type-specific cost modeling (e.g., `Payment`, `OfferCreate`). + * + * Each derived class must declare: + * @code + * static constexpr ConsequencesFactoryType ConsequencesFactory{...}; + * @endcode + * The correct factory is selected at compile time in `applySteps.cpp` + * via C++20 `requires` constraints. + */ enum class ConsequencesFactoryType { Normal, Blocker, Custom }; - /** Process the transaction. */ + /** Execute the full transaction pipeline for this transactor instance. + * + * Called by the apply loop after preclaim succeeds. Runs: + * 1. RAII numeric-rule guards (`NumberSO`, `CurrentTransactionRulesGuard`). + * 2. Debug-mode serialization round-trip check. + * 3. Optional debug trap (`trapTransaction`). + * 4. `apply()` if preclaim returned `tesSUCCESS`; otherwise returns the + * preclaim error directly. + * 5. `tecOVERSIZE` roll-back: if metadata grew too large, discards all + * mutations, re-deducts fee only, and removes unfunded offers found + * during the failed apply. + * 6. `tapFAIL_HARD`: on a `tec*` result, discards everything including + * the fee. + * 7. Invariant checks via `checkInvariants`; a failing invariant triggers + * a second reset and fee-only commit. + * 8. Forces `applied = false` when `tapDRY_RUN` is set. + * + * @return `{result, applied, metadata}`. `applied` is false when the + * transaction produces no ledger changes (dry-run, `tef*`, `tem*`, + * or invariant escalation to `tefINVARIANT_FAILED`). + */ ApplyResult operator()(); + /** Return the mutable apply view for this transaction. */ ApplyView& view() { return ctx_.view(); } + /** Return the read-only apply view for this transaction. */ [[nodiscard]] ApplyView const& view() const { @@ -156,77 +324,182 @@ public: [[nodiscard]] TER checkInvariants(TER result, XRPAmount fee); - ///////////////////////////////////////////////////// - /* - These static functions are called from invoke_preclaim - using name hiding to accomplish compile-time polymorphism, - so derived classes can override for different or extra - functionality. Use with care, as these are not really - virtual and so don't have the compiler-time protection that - comes with it. - */ + // ---- Preclaim-phase static helpers (overridable via name hiding) -------- + // + // These static functions are called from the preclaim dispatch in + // applySteps.cpp using name hiding to accomplish compile-time + // polymorphism. Derived classes can shadow them to add or replace + // validation logic. They are NOT virtual; the compiler provides no + // protection against incorrect overrides. + /** Verify the transaction's sequence number or ticket against the ledger. + * + * Returns `terNO_ACCOUNT` if the source account does not exist, + * `terPRE_SEQ` / `tefPAST_SEQ` for sequence-number mismatches, and + * `terPRE_TICKET` / `tefNO_TICKET` for ticket-based transactions. + * + * @param view Read-only ledger view. + * @param tx The transaction. + * @param j Journal for trace logging. + * @return `tesSUCCESS` if the sequence/ticket is consumable. + */ static NotTEC checkSeqProxy(ReadView const& view, STTx const& tx, beast::Journal j); + /** Verify `sfAccountTxnID`, `sfLastLedgerSequence`, and duplicate detection. + * + * Returns `tefWRONG_PRIOR` if `sfAccountTxnID` does not match the + * account's last transaction hash, `tefMAX_LEDGER` if the current ledger + * sequence exceeds `sfLastLedgerSequence`, and `tefALREADY` if the + * transaction is already in the ledger. + * + * @param ctx Preclaim context. + * @return `tesSUCCESS` or a `tef*` / `ter*` error. + */ static NotTEC checkPriorTxAndLastLedger(PreclaimContext const& ctx); + /** Verify that the fee attached to the transaction is sufficient. + * + * For open-ledger transactions, the fee must meet the load-scaled + * minimum returned by `minimumFee()`. Also checks that the fee payer's + * account exists and has sufficient balance. + * + * @param ctx Preclaim context. + * @param baseFee Unscaled base fee computed by `calculateBaseFee()`. + * @return `tesSUCCESS`, `telINSUF_FEE_P`, `tecINSUFF_FEE`, + * `terINSUF_FEE_B`, or `terNO_ACCOUNT`. + */ static TER checkFee(PreclaimContext const& ctx, XRPAmount baseFee); + /** Verify the cryptographic signature for an ordinary transaction. + * + * Dispatches to `checkMultiSign()` when `sfSigners` is present, or + * `checkSingleSign()` otherwise. Skips the check for batch inner + * transactions (authorized by the outer batch) and dry-run simulations + * without a signing key. Rejects pseudo-account signers when + * `featureLendingProtocol` is active. + * + * @param ctx Preclaim context. + * @return `tesSUCCESS` or a `tef*` error code. + */ static NotTEC checkSign(PreclaimContext const& ctx); + /** Verify the `sfBatchSigners` array for an outer batch transaction. + * + * Iterates the batch signers, dispatching to `checkMultiSign()` or + * `checkSingleSign()` as appropriate. Allows a signer for an + * account that does not yet exist in the ledger, provided the signing + * key matches the account's master key (used for fund-on-creation inner + * transactions). + * + * @param ctx Preclaim context for the outer batch transaction. + * @return `tesSUCCESS` or a `tef*` error code. + */ static NotTEC checkBatchSign(PreclaimContext const& ctx); - // Returns the fee in fee units, not scaled for load. + /** Compute the base transaction fee in drops, unscaled for load. + * + * Base fee = ledger's configured base fee + one extra base fee per + * multisignature in `sfSigners`. Does not account for server load; + * use `minimumFee()` for the load-adjusted value. + * + * @param view Read-only ledger view (supplies `fees().base`). + * @param tx The transaction. + * @return Fee in drops (XRPAmount). + */ static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); - /* Do NOT define an invokePreflight function in a derived class. - Instead, define: - - // Optional if the transaction is gated on an amendment that - // isn't specified in transactions.macro - static bool - checkExtraFeatures(PreflightContext const& ctx); - - // Optional if the transaction uses any flags other than tfUniversal - static std::uint32_t - getFlagsMask(PreflightContext const& ctx); - - // Required, even if it just returns tesSUCCESS. - static NotTEC - preflight(PreflightContext const& ctx); - - // Optional, rarely needed, if the transaction does any expensive - // checks after the signature is verified. - static NotTEC preflightSigValidated(PreflightContext const& ctx); - - * Do not try to call preflight1 or preflight2 directly. - * Do not check whether relevant amendments are enabled in preflight. - Instead, define checkExtraFeatures. - * Do not check flags in preflight. Instead, define getFlagsMask. - */ + /** Compile-time preflight dispatch for transaction type `T`. + * + * The canonical entry point for preflight validation. Executes the + * following steps in order, returning on the first non-`tesSUCCESS`: + * + * 1. Amendment gate: returns `temDISABLED` if the transaction's + * required amendment is not active. + * 2. `T::checkExtraFeatures(ctx)` — additional amendment gates defined + * by the derived class (return `temDISABLED` on failure). + * 3. `preflight1(ctx, T::getFlagsMask(ctx))` — validates account field, + * fee field, signing key format, network ID, flags, and + * ticket/AccountTxnID exclusivity. + * 4. `T::preflight(ctx)` — transaction-specific field validation. + * 5. `preflight2(ctx)` — cryptographic signature check via hash-router + * cache. Skipped for batch inner transactions. + * 6. `T::preflightSigValidated(ctx)` — optional post-signature checks + * (e.g., expensive crypto conditions). + * + * @note Do NOT define `invokePreflight` in a derived class. Instead, + * define any combination of the static methods above. Do NOT call + * `preflight1` or `preflight2` directly; they are called in the + * correct order by this template. Do NOT gate on amendments in + * `preflight`; use `checkExtraFeatures` for that. Do NOT validate + * flags in `preflight`; define `getFlagsMask` instead. + * + * @note The explicit specialization `invokePreflight` is + * defined in `Change.cpp` and uses entirely different logic because + * `Change` is a pseudo-transaction with no real sender. + * + * @tparam T The concrete transactor type. + * @param ctx Preflight context. + * @return `tesSUCCESS` or a `tem*` / `tel*` error. + */ template static NotTEC invokePreflight(PreflightContext const& ctx); + /** Base-class preclaim hook; most transactors do not need to override this. + * + * The sequence/fee/sign checks are called directly by the preclaim + * dispatch in `applySteps.cpp` before this method. Override only to + * add extra read-only ledger checks that cannot be expressed as field + * validation in `preflight`. + * + * @param ctx Preclaim context. + * @return `tesSUCCESS` (base implementation). + */ static TER preclaim(PreclaimContext const& ctx) { - // Most transactors do nothing - // after checkSeq/Fee/Sign. return tesSUCCESS; } + /** Verify delegate permissions if `sfDelegate` is present. + * + * If the transaction carries an `sfDelegate` field, reads the + * `DelegateObject` at `keylet::delegate(account, delegate)` and + * verifies that its permission set covers this transaction type. + * Returns `terNO_DELEGATE_PERMISSION` if the object is missing or the + * permission is not granted. + * + * Called as a static method during preclaim so the ledger check + * happens before any mutation. + * + * @param view Read-only ledger view. + * @param tx The transaction (may contain `sfDelegate`). + * @return `tesSUCCESS` or `terNO_DELEGATE_PERMISSION`. + */ static NotTEC checkPermission(ReadView const& view, STTx const& tx); - ///////////////////////////////////////////////////// + // ------------------------------------------------------------------------- - // Interface used by AccountDelete + /** Remove a single Ticket SLE and adjust the owner's ticket count and reserve. + * + * Used by `AccountDelete` (via a static interface) and by + * `consumeSeqProxy` when a ticket-based transaction is applied. + * Removes the ticket from the owner directory, decrements `sfTicketCount` + * on the account root, adjusts the owner reserve count, and erases the + * ticket SLE. + * + * @param view Mutable ledger view. + * @param account Owner of the ticket. + * @param ticketIndex Ledger index of the Ticket SLE. + * @param j Journal for fatal-error logging. + * @return `tesSUCCESS` or `tefBAD_LEDGER` if the ledger is corrupt. + */ static TER ticketDelete( ApplyView& view, @@ -235,14 +508,50 @@ public: beast::Journal j); protected: + /** Run the sequence/fee/state-mutation steps for a validated transaction. + * + * Called by `operator()()` when preclaim returned `tesSUCCESS`. + * Snapshots `preFeeBalance_`, advances the sequence (or consumes the + * ticket), deducts the fee, updates `sfAccountTxnID`, then calls + * `doApply()`. + * + * @return The TER returned by `doApply()`, or a `tef*` code if the + * sequence/fee bookkeeping fails (indicates ledger corruption). + */ TER apply(); + /** Construct a transactor bound to the given apply context. + * + * Initialises `account_` from `ctx.tx[sfAccount]` and sets up the + * transaction-ID-prefixed journal sink. + */ explicit Transactor(ApplyContext& ctx); + /** Perform any pre-apply computation that should not repeat per-ledger. + * + * Called at the start of `apply()` before `consumeSeqProxy` and + * `payFee`. The base implementation asserts that `account_` is + * non-zero. Derived classes may cache expensive lookups here. + */ virtual void preCompute(); + /** Apply the transaction's state changes to the mutable ledger view. + * + * The sole virtual method in the pipeline. Only called when all + * preflight and preclaim checks have passed and the fee/sequence have + * been consumed. + * + * Implementations must return `tesSUCCESS` for a full commit. + * Returning a `tec*` code causes `operator()()` to roll back all + * mutations via `reset()` and re-apply the fee only. The tec rollback + * is automatic — there is no need to order mutations defensively or + * undo partial changes before returning `tec*`. + * + * @return `tesSUCCESS` or a `tec*` error. Must not return `tem*`, + * `tef*`, or `ter*` codes (those belong in preflight/preclaim). + */ virtual TER doApply() = 0; @@ -292,22 +601,56 @@ protected: ReadView const& view, beast::Journal const& j) = 0; - /** Compute the minimum fee required to process a transaction - with a given baseFee based on the current server load. - - @param registry The service registry. - @param baseFee The base fee of a candidate transaction - @see xrpl::calculateBaseFee - @param fees Fee settings from the current ledger - @param flags Transaction processing fees + /** Compute the load-scaled minimum fee required to relay this transaction. + * + * Scales `baseFee` using the node's current `LoadFeeTrack`. The + * `TapUnlimited` flag suppresses load scaling (used for locally-submitted + * or admin transactions). + * + * @param registry Service registry (provides `getFeeTrack()`). + * @param baseFee Unscaled base fee from `calculateBaseFee()`. + * @param fees Fee schedule from the current ledger. + * @param flags Apply flags; `TapUnlimited` disables load scaling. + * @return Minimum fee in drops that the network will accept. */ static XRPAmount minimumFee(ServiceRegistry& registry, XRPAmount baseFee, Fees const& fees, ApplyFlags flags); - // Returns the fee in fee units, not scaled for load. + /** Return the owner-reserve increment as a fee, in drops. + * + * Used by transactions that create a ledger object and wish to charge + * one full reserve increment as the transaction fee (e.g., + * `AccountDelete`, `AMMCreate`, `LoanBrokerSet`). + * Asserts that the reserve increment is at least 100× the base fee, + * ensuring the anti-spam reserve is meaningful. + * + * @param view Read-only ledger view (supplies `fees().increment`). + * @param tx The transaction (unused; present for uniformity). + * @return `fees().increment` in drops. + */ static XRPAmount calculateOwnerReserveFee(ReadView const& view, STTx const& tx); + /** Low-level signature check used by both the preclaim and batch paths. + * + * Selects between `checkMultiSign` and `checkSingleSign` based on + * transaction contents. Handles the special cases for batch inner + * transactions (no signature required), dry-run simulation (no key or + * signers is valid), and pseudo-account rejection under + * `featureLendingProtocol`. + * + * The public `checkSign(PreclaimContext const&)` overload is a thin + * wrapper around this one. + * + * @param view Read-only ledger view. + * @param flags Apply flags. + * @param parentBatchId Set for batch inner transactions; suppresses sig check. + * @param idAccount The account whose key must authorize the transaction. + * @param sigObject The STObject containing `sfSigningPubKey` / + * `sfSigners` (usually `ctx.tx`). + * @param j Journal for trace logging. + * @return `tesSUCCESS` or a `tef*` error. + */ static NotTEC checkSign( ReadView const& view, @@ -317,25 +660,85 @@ protected: STObject const& sigObject, beast::Journal const j); - // Base class always returns true + /** Amendment gate hook — override to gate the transaction on amendments. + * + * Called by `invokePreflight` before `preflight1`. The base + * implementation always returns `true` (no extra gating). Derived + * classes that depend on amendments not listed in `transactions.macro` + * should override this method; return `false` to produce `temDISABLED`. + * + * @param ctx Preflight context. + * @return `true` if the transaction is permitted; `false` to disable it. + */ static bool checkExtraFeatures(PreflightContext const& ctx); - // Base class always returns tfUniversalMask + /** Flag-mask hook — override to declare valid flags for this transaction. + * + * The returned mask is passed to `preflight0` to reject unknown flag bits. + * The base implementation returns `tfUniversalMask`. Derived classes + * should override this to OR in their transaction-specific flag bits. + * + * @param ctx Preflight context. + * @return Bitmask of all valid flag bits for this transaction type. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); - // Base class always returns tesSUCCESS + /** Post-signature preflight hook — override for expensive post-sig checks. + * + * Called by `invokePreflight` after `preflight2` (signature + * verification). The base implementation returns `tesSUCCESS`. + * Derived classes that need to perform expensive checks that can only + * run after the signature is verified (e.g., crypto-condition validation + * in `EscrowFinish`) should override this. + * + * @param ctx Preflight context. + * @return `tesSUCCESS` or a `tem*` error. + */ static NotTEC preflightSigValidated(PreflightContext const& ctx); + /** Validate an optional blob field's length. + * + * Returns `false` if the slice is present but empty or exceeds + * `maxLength`; returns `true` if absent or within bounds. + * + * @param slice Optional blob (e.g., from `tx[~sfURI]`). + * @param maxLength Maximum permitted byte length. + * @return `true` if the length is valid. + */ static bool validDataLength(std::optional const& slice, std::size_t maxLength); + /** Validate that an optional numeric field is within `[min, max]`. + * + * An absent optional (`std::nullopt`) is treated as valid — only + * present values are range-checked. This reflects the convention that + * optional fields are legal to omit. + * + * @tparam T Numeric type (must support `<=` comparison). + * @param value Optional field value. + * @param max Inclusive upper bound. + * @param min Inclusive lower bound (default-constructed, usually 0). + * @return `true` if absent or within `[min, max]`. + */ template static bool validNumericRange(std::optional value, T max, T min = T{}); + /** Validate an optional strong-unit numeric field within `[min, max]`. + * + * Overload for `unit::ValueUnit` bounds to maintain type + * safety across unit systems. Delegates to the plain-value overload. + * + * @tparam T Underlying numeric type. + * @tparam Unit Unit tag. + * @param value Optional field value (raw numeric). + * @param max Inclusive upper bound (unit-typed). + * @param min Inclusive lower bound (unit-typed, default zero). + * @return `true` if absent or within `[min, max]`. + */ template static bool validNumericRange( @@ -343,12 +746,30 @@ protected: unit::ValueUnit max, unit::ValueUnit min = unit::ValueUnit{}); - /// Minimum will usually be zero. + /** Validate that an optional numeric field is at least `min`. + * + * An absent optional is treated as valid. + * + * @tparam T Numeric type. + * @param value Optional field value. + * @param min Inclusive lower bound (default-constructed, usually 0). + * @return `true` if absent or `>= min`. + */ template static bool validNumericMinimum(std::optional value, T min = T{}); - /// Minimum will usually be zero. + /** Validate an optional strong-unit numeric field against a minimum. + * + * Overload for `unit::ValueUnit` bounds. Delegates to the + * plain-value overload. + * + * @tparam T Underlying numeric type. + * @tparam Unit Unit tag. + * @param value Optional field value. + * @param min Inclusive lower bound (unit-typed, default zero). + * @return `true` if absent or `>= min`. + */ template static bool validNumericMinimum( @@ -356,13 +777,56 @@ protected: unit::ValueUnit min = unit::ValueUnit{}); private: + /** Roll back all doApply mutations and re-apply fee deduction only. + * + * Calls `ctx_.discard()` to discard all ledger changes, then + * re-deducts the fee from the fee payer's balance (clamped to the + * available balance), and re-consumes the sequence/ticket. Used for + * fee-claiming `tec*` results and after invariant failures. + * + * @param fee Requested fee in drops; clamped to available balance. + * @return `{tesSUCCESS, actualFee}` on success, or + * `{tefINTERNAL, 0}` if the account SLE is missing (ledger + * corruption). + */ std::pair reset(XRPAmount fee); + /** Advance `sfSequence` or consume the Ticket for this transaction. + * + * For sequence-based transactions, increments `sfSequence` by one. + * For ticket-based transactions, delegates to `ticketDelete`. + * + * @param sleAccount Mutable SLE for the submitting account. + * @return `tesSUCCESS` or `tefBAD_LEDGER` if the ticket is missing. + */ TER consumeSeqProxy(SLE::pointer const& sleAccount); + + /** Deduct the transaction fee from the fee payer's balance. + * + * Reads `sfFee` from the transaction and subtracts it from the fee + * payer's `sfBalance`. The caller is responsible for calling + * `view().update(sle)` to commit the change. + * + * @return `tesSUCCESS` or `tefINTERNAL` if the payer account is absent. + */ TER payFee(); + + /** Verify a single-signature transaction against the account root. + * + * Checks, in priority order: regular key → enabled master key → + * disabled master key (`tefMASTER_DISABLED`) → unknown key + * (`tefBAD_AUTH`). + * + * @param view Read-only ledger view. + * @param idSigner AccountID derived from the signing public key. + * @param idAccount AccountID from `sfAccount` (the authorizing account). + * @param sleAccount AccountRoot SLE for `idAccount`. + * @param j Journal for trace logging. + * @return `tesSUCCESS`, `tefMASTER_DISABLED`, or `tefBAD_AUTH`. + */ static NotTEC checkSingleSign( ReadView const& view, @@ -370,6 +834,24 @@ private: AccountID const& idAccount, std::shared_ptr sleAccount, beast::Journal const j); + + /** Verify a multi-signature against the account's SignerList. + * + * Performs an O(n) linear merge of the sorted `sfSigners` array from + * the transaction against the sorted `SignerEntry` list from the + * account's signer list SLE. Every signer in the transaction must + * appear in the account's signer list and pass key verification. + * Returns `tefBAD_QUORUM` if the accumulated weight is below + * `sfSignerQuorum`. + * + * @param view Read-only ledger view. + * @param flags Apply flags (used for dry-run simulation handling). + * @param id The account whose signer list governs authorization. + * @param sigObject The STObject containing `sfSigners`. + * @param j Journal for trace logging. + * @return `tesSUCCESS`, `tefNOT_MULTI_SIGNING`, `tefBAD_SIGNATURE`, + * `tefMASTER_DISABLED`, or `tefBAD_QUORUM`. + */ static NotTEC checkMultiSign( ReadView const& view, @@ -378,23 +860,51 @@ private: STObject const& sigObject, beast::Journal const j); + /** Named breakpoint for replaying specific transactions under a debugger. + * + * Does nothing except log at debug level. Set a breakpoint here to + * pause execution when a specific transaction (identified by its hash + * in the service registry's trap configuration) is being applied. + */ void trapTransaction(uint256) const; - /** Performs early sanity checks on the account and fee fields. - - (And passes flagMask to preflight0) - - Do not try to call preflight1 from preflight() in derived classes. See - the description of invokePreflight for details. - */ + /** Early sanity checks on the account field, fee field, and flags. + * + * Called as step 3 of `invokePreflight` (after + * `checkExtraFeatures`, before `T::preflight`). Validates: + * - `sfDelegate` presence (requires `featurePermissionDelegationV1_1`) + * - `preflight0` (network ID, txid, flags via `flagMask`) + * - `sfAccount` is non-zero + * - `sfFee` is native XRP and non-negative + * - signing key format + * - ticket / AccountTxnID mutual exclusivity + * - `tfInnerBatchTxn` requires `featureBatch` + * + * @note Do not call this from `preflight()` in derived classes. It is + * invoked automatically by `invokePreflight`. + * + * @param ctx Preflight context. + * @param flagMask Bitmask of valid flags from `T::getFlagsMask()`. + * @return `tesSUCCESS` or a `tem*` / `tel*` error. + */ static NotTEC preflight1(PreflightContext const& ctx, std::uint32_t flagMask); - /** Checks whether the signature appears valid - - Do not try to call preflight2 from preflight() in derived classes. See - the description of invokePreflight for details. - */ + /** Validate the cryptographic signature via the hash-router cache. + * + * Called as step 5 of `invokePreflight` (after `T::preflight`, + * before `T::preflightSigValidated`). Skips the check entirely for + * batch inner transactions (`tfInnerBatchTxn` + `featureBatch`) since + * they are authorized by the outer batch's signature. For simulation + * (`TapDryRun`), validates key/signer consistency but skips + * cryptographic verification. + * + * @note Do not call this from `preflight()` in derived classes. It is + * invoked automatically by `invokePreflight`. + * + * @param ctx Preflight context. + * @return `tesSUCCESS` or `temINVALID`. + */ static NotTEC preflight2(PreflightContext const& ctx); @@ -420,28 +930,65 @@ Transactor::checkExtraFeatures(PreflightContext const& ctx) return true; } -/** Performs early sanity checks on the txid and flags */ +/** Early sanity checks on the transaction ID, network ID, and flag bits. + * + * The very first check in the preflight pipeline, called from `preflight1`. + * Validates: + * - Pseudo-transactions may not carry `tfInnerBatchTxn`. + * - `sfNetworkID` presence/absence rules: legacy networks (ID ≤ 1024) must + * not include `sfNetworkID`; newer networks must include it and it must + * match the local node. + * - Transaction ID must not be all-zeros. + * - No flag bits outside `flagMask` may be set. + * + * @param ctx Preflight context. + * @param flagMask Bitmask of valid flags for this transaction type. + * @return `tesSUCCESS` or a `tel*` / `tem*` error. + */ NotTEC preflight0(PreflightContext const& ctx, std::uint32_t flagMask); namespace detail { -/** Checks the validity of the transactor signing key. +/** Validate the format of the signing public key in a transaction or signer. * - * Normally called from preflight1 with ctx.tx. + * Returns `temBAD_SIGNATURE` if the `sfSigningPubKey` field is non-empty + * but not a recognized key type (secp256k1 or Ed25519). An empty key is + * valid (indicates multi-signing or batch inner transaction). + * + * Called from `preflight1` with the transaction object. + * + * @param sigObject The STObject containing `sfSigningPubKey`. + * @param j Journal for debug logging. + * @return `tesSUCCESS` or `temBAD_SIGNATURE`. */ NotTEC preflightCheckSigningKey(STObject const& sigObject, beast::Journal j); -/** Checks the special signing key state needed for simulation +/** Validate signing-key state for dry-run simulation transactions. * - * Normally called from preflight2 with ctx.tx. + * Called from `preflight2` when `TapDryRun` is set. A simulation + * transaction is valid if it has neither a signature nor a multi-signer + * list, or if it uses multi-signers with empty individual signatures. + * Returns `std::nullopt` when `TapDryRun` is not set (the caller should + * proceed to normal signature verification). + * + * @param flags Apply flags; must have `TapDryRun` set to take effect. + * @param sigObject The transaction's STObject. + * @param j Journal for debug logging. + * @return `tesSUCCESS` or `temINVALID` if the simulation keys are + * inconsistent; `std::nullopt` if not in simulation mode. */ std::optional preflightCheckSimulateKeys(ApplyFlags flags, STObject const& sigObject, beast::Journal j); } // namespace detail -// Defined in Change.cpp +/** Explicit preflight specialization for `Change` pseudo-transactions. + * + * `Change` is a validator-generated pseudo-transaction with no real sender; + * its preflight logic is entirely different from normal transactions. + * Defined in `Change.cpp`. + */ template <> NotTEC Transactor::invokePreflight(PreflightContext const& ctx); diff --git a/include/xrpl/tx/apply.h b/include/xrpl/tx/apply.h index 49b30fea02..06bc05e705 100644 --- a/include/xrpl/tx/apply.h +++ b/include/xrpl/tx/apply.h @@ -13,87 +13,100 @@ class HashRouter; class ServiceRegistry; /** Describes the pre-processing validity of a transaction. - - @see checkValidity, forceValidity -*/ + * + * The three levels form a strict hierarchy: `SigBad < SigGoodOnly < Valid`. + * Local checks are only worth performing when the signature is good, so + * `SigGoodOnly` implies the signature passed but local checks failed. + * This hierarchy maps directly to P2P relay semantics: `SigBad` transactions + * are not forwarded; `SigGoodOnly` transactions are relayed but not applied; + * `Valid` transactions are both relayed and applied. + * + * @see checkValidity, forceValidity + */ enum class Validity { - /// Signature is bad. Didn't do local checks. + /// Signature is invalid. Local checks were not attempted. SigBad, - /// Signature is good, but local checks fail. + /// Signature is valid, but local checks failed. SigGoodOnly, - /// Signature and local checks are good / passed. + /// Signature is valid and local checks passed. Valid }; -/** Checks transaction signature and local checks. - - @return A `Validity` enum representing how valid the - `STTx` is and, if not `Valid`, a reason string. - - @note Results are cached internally, so tests will not be - repeated over repeated calls, unless cache expires. - - @return `std::pair`, where `.first` is the status, and - `.second` is the reason if appropriate. - - @see Validity -*/ +/** Check a transaction's cryptographic signature and local well-formedness. + * + * Results are cached in the `HashRouter` using four private flag bits + * (`PRIVATE1`–`PRIVATE4`). Subsequent calls for the same transaction ID + * return immediately from the cache rather than re-verifying the signature. + * The cache inherits its TTL from the `HashRouter`'s aged map. + * + * Batch inner transactions (flagged `tfInnerBatchTxn`) follow a separate + * code path: they must have no signature fields, and after `fixBatchInnerSigs` + * activates they are permanently treated as never-valid to prevent erroneous + * `SF_SIGGOOD` cache entries on unsigned objects. + * + * @param router The hash router used to cache validity flags. + * @param tx The transaction to check. + * @param rules The current ledger rules (used for amendment-gated logic). + * @return A pair whose `.first` is the `Validity` status and whose `.second` + * is a human-readable reason string when the transaction is not `Valid` + * (empty on success). + * + * @see Validity, forceValidity + */ std::pair checkValidity(HashRouter& router, STTx const& tx, Rules const& rules); -/** Sets the validity of a given transaction in the cache. - - @warning Use with extreme care. - - @note Can only raise the validity to a more valid state, - and can not override anything cached bad. - - @see checkValidity, Validity -*/ +/** Assert a specific validity level for a transaction in the hash-router cache. + * + * Uses a deliberate `[[fallthrough]]` switch to enforce monotonicity: setting + * `Valid` also sets the `SigGoodOnly` flag, because local checks cannot pass + * without a valid signature. This can only raise the cached state — it never + * marks a transaction as `SigBad`, so calling with `SigBad` is a no-op. + * + * The primary use case is for locally-constructed transactions that were never + * signed by a remote peer; the transaction queue can mark them pre-verified to + * avoid redundant signature checks on re-application. + * + * @param router The hash router that holds the cached flags. + * @param txid The transaction ID whose cached validity to update. + * @param validity The minimum validity level to assert. Passing `SigBad` + * has no effect. + * + * @warning Calling this bypasses real cryptographic verification. Only use + * when you have an out-of-band guarantee that the transaction is valid + * (e.g., a transaction you constructed locally and submitted yourself). + * + * @see checkValidity, Validity + */ void forceValidity(HashRouter& router, uint256 const& txid, Validity validity); -/** Apply a transaction to an `OpenView`. - - This function is the canonical way to apply a transaction - to a ledger. It rolls the validation and application - steps into one function. To do the steps manually, the - correct calling order is: - @code{.cpp} - preflight -> preclaim -> doApply - @endcode - The result of one function must be passed to the next. - The `preflight` result can be safely cached and reused - asynchronously, but `preclaim` and `doApply` must be called - in the same thread and with the same view. - - @note Does not throw. - - For open ledgers, the `Transactor` will catch exceptions - and return `tefEXCEPTION`. For closed ledgers, the - `Transactor` will attempt to only charge a fee, - and return `tecFAILED_PROCESSING`. - - If the `Transactor` gets an exception while trying - to charge the fee, it will be caught and - turned into `tefEXCEPTION`. - - For network health, a `Transactor` makes its - best effort to at least charge a fee if the - ledger is closed. - - @param app The current running `Application`. - @param view The open ledger that the transaction - will attempt to be applied to. - @param tx The transaction to be checked. - @param flags `ApplyFlags` describing processing options. - @param journal A journal. - - @see preflight, preclaim, doApply - - @return A pair with the `TER` and a `bool` indicating - whether or not the transaction was applied. -*/ +/** Apply a transaction to an `OpenView`, running all three pipeline stages. + * + * Convenience wrapper that composes `preflight → preclaim → doApply` into a + * single call. The `preflight` result can be safely cached and reused across + * threads, but `preclaim` and `doApply` must run on the same thread and with + * the same view. + * + * This function does not throw. Exceptions inside a `Transactor` are caught + * and converted to `tefEXCEPTION`. For closed ledgers, if full application + * fails the `Transactor` will attempt a best-effort fee deduction and return + * `tecFAILED_PROCESSING`; if even the fee-deduction path throws, that + * exception is also caught and returned as `tefEXCEPTION`. This best-effort + * fee guarantee prevents fee-free spam vectors during consensus. + * + * @param registry The service registry providing transactor implementations. + * @param view The open ledger to which the transaction will be applied. + * @param tx The transaction to apply. + * @param flags `ApplyFlags` controlling processing options (e.g., `tapRETRY`, + * `tapDRY_RUN`). + * @param journal Logging sink. + * @return An `ApplyResult` whose `.ter` is the transaction result code and + * whose `.applied` is `true` if the transaction's mutations were committed + * to `view`. + * + * @see preflight, preclaim, doApply, applyTransaction + */ ApplyResult apply( ServiceRegistry& registry, @@ -102,26 +115,58 @@ apply( ApplyFlags flags, beast::Journal journal); -/** Enum class for return value from `applyTransaction` - - @see applyTransaction -*/ +/** Outcome classification returned by `applyTransaction`. + * + * Wraps the raw `TER` code from `apply()` into the three-way decision the + * transaction queue needs: commit, evict, or hold for a retry pass. + * + * @see applyTransaction + */ enum class ApplyTransactionResult { - /// Applied to this ledger + /// Transaction was applied and its mutations committed to the ledger view. Success, - /// Should not be retried in this ledger + /// Terminal failure — do not retry in this ledger. + /// Covers `tef*` (internal failures), `tem*` (malformed), and `tel*` + /// (local-node rejections). Fail, - /// Should be retried in this ledger + /// Soft failure — the transaction may succeed in a later pass or ledger. + /// Covers all other non-applied results (e.g., `ter*`, `tec*` with + /// `tapRETRY`). Retry }; -/** Transaction application helper - - Provides more detailed logging and decodes the - correct behavior based on the `TER` type - - @see ApplyTransactionResult -*/ +/** Apply a transaction and classify the outcome for the transaction queue. + * + * Calls `apply()` and maps its `TER` result to `ApplyTransactionResult`: + * - `tefFailure`, `temMalformed`, `telLocal` → `Fail` (evict, no retry) + * - Any other non-applied result → `Retry` (hold for later) + * - Applied result → `Success` + * + * When `retryAssured` is `true`, `tapRETRY` is added to `flags` before + * calling `apply()`. With `tapRETRY` set, `tec` results are treated as soft + * failures rather than hard fee-claims; this affects `preclaim`'s + * `likelyToClaimFee` signal and determines whether the transaction is safe + * to relay without first applying it to the open ledger. + * + * For `ttBATCH` transactions that succeed, inner transactions are applied in + * a nested `OpenView` sandbox. Inner-transaction changes are committed to the + * main view only if the batch as a whole succeeds under its execution policy + * (`tfAllOrNothing`, `tfUntilFailure`, `tfOnlyOne`, `tfIndependent`). + * + * Exceptions from `apply()` are caught and returned as `Fail`. + * + * @param registry The service registry providing transactor implementations. + * @param view The open ledger to which the transaction will be applied. + * @param tx The transaction to apply. + * @param retryAssured If `true`, adds `tapRETRY` to `flags` so that `tec` + * results are treated as retryable soft failures rather than fee claims. + * @param flags Base `ApplyFlags` controlling processing options. + * @param journal Logging sink. + * @return An `ApplyTransactionResult` indicating success, terminal failure, + * or retryable failure. + * + * @see apply, ApplyTransactionResult + */ ApplyTransactionResult applyTransaction( ServiceRegistry& registry, diff --git a/include/xrpl/tx/applySteps.h b/include/xrpl/tx/applySteps.h index 897cf500d6..1f0447ae8e 100644 --- a/include/xrpl/tx/applySteps.h +++ b/include/xrpl/tx/applySteps.h @@ -1,3 +1,14 @@ +/** @file + * Public interface for the XRPL transaction application pipeline. + * + * Defines the structured three-stage sequence `preflight → preclaim → doApply` + * that every transaction traverses before being committed to an open ledger. + * The stages are exposed as separate functions so the Transaction Queue (TxQ) + * can cache `PreflightResult` across ledger boundaries and defer + * `preclaim`/`doApply` until an application slot is available. + * + * @see apply.h for a single-call wrapper that composes all three stages. + */ #pragma once #include @@ -9,6 +20,14 @@ class ServiceRegistry; class STTx; class TxQ; +/** Outcome of a complete transaction application attempt. + * + * Returned by `doApply()` and by the single-call `apply()` wrapper. + * `applied` is true only when the transaction was committed to the ledger + * (either `tesSUCCESS` or a fee-claiming `tec*` without `tapRETRY`). + * `metadata` is populated when `applied` is true and the caller requested + * metadata generation. + */ struct ApplyResult { TER ter; @@ -21,8 +40,25 @@ struct ApplyResult } }; -/** Return true if the transaction can claim a fee (tec), - and the `ApplyFlags` do not allow soft failures. +/** Return true when a `tec` result will definitely charge a fee. + * + * A `tec` transaction normally charges its fee and is included in the ledger + * with all `doApply` mutations rolled back. However, when `tapRETRY` is set + * the TxQ is treating the transaction as a soft failure that may succeed after + * other queued transactions settle — in that mode the fee must not yet be + * charged because the transaction is not actually being applied. + * + * This predicate is the authoritative definition of "this transaction will + * cost the submitter money right now." It is evaluated in the + * `PreclaimResult` constructor to populate `likelyToClaimFee`, and again + * in `Transactor::operator()()` to decide whether to call `reset()` (which + * discards all `doApply` mutations and re-applies the fee only). + * + * @param ter The `TER` result from preclaim or doApply. + * @param flags The `ApplyFlags` governing this application attempt. + * @return True when `ter` is a `tec*` code and `tapRETRY` is absent, + * meaning the fee will be charged and the transaction included in the + * ledger. */ inline bool isTecClaimHardFail(TER ter, ApplyFlags flags) @@ -30,100 +66,169 @@ isTecClaimHardFail(TER ter, ApplyFlags flags) return isTecClaim(ter) && ((flags & TapRetry) == 0u); } -/** Class describing the consequences to the account - of applying a transaction if the transaction consumes - the maximum XRP allowed. -*/ +/** Worst-case XRP cost and queue-ordering impact of a transaction. + * + * The Transaction Queue (TxQ) evaluates this object — produced during + * `preflight` — to decide whether queued follow-on transactions from the + * same account remain viable *before* the transaction is applied to a + * ledger. It answers: how much XRP can this transaction consume in the + * worst case, and does it invalidate subsequent queue entries? + * + * The five constructors are deliberately distinct variants rather than a + * single struct with defaulted fields, so that each variant enforces its own + * invariant: the `NotTEC` constructor zeros everything to ensure no cost + * estimate leaks from a rejected transaction; the `Category` constructor + * flips `isBlocker_`; and the `XRPAmount`/`uint32_t` constructors extend the + * normal case for transactions with non-standard spending or sequence use. + * + * @note `potentialSpend_` does not include the fee; it represents additional + * XRP that may leave the account (e.g., the `sfSendMax` of a Payment). + */ class TxConsequences { public: - /// Describes how the transaction affects subsequent - /// transactions + /** Categorises the impact a transaction has on subsequent queue entries. */ enum class Category { - /// Moves currency around, creates offers, etc. + /** Standard transaction: moves currency, creates offers, etc. + * Subsequent transactions from the same account may still queue. */ Normal = 0, - /// Affects the ability of subsequent transactions - /// to claim a fee. Eg. `SetRegularKey` + /** Key-management operation whose execution may invalidate the + * signatures on transactions already in the queue. + * Examples: `SetRegularKey` (key removal), `AccountDelete`, + * `SignerListSet`. TxQ enforces that a blocker cannot coexist + * with other queued transactions from the same account. */ Blocker }; private: - /// Describes how the transaction affects subsequent - /// transactions bool isBlocker_; - /// Transaction fee XRPAmount fee_; - /// Does NOT include the fee. + /// Additional XRP the transaction may spend beyond the fee. XRPAmount potentialSpend_; - /// SeqProxy of transaction. SeqProxy seqProx_; - /// Number of sequences consumed. std::uint32_t sequencesConsumed_; public: - // Constructor if preflight returns a value other than tesSUCCESS. - // Asserts if tesSUCCESS is passed. + /** Construct a zeroed-out consequences for a failed preflight. + * + * All cost fields are set to zero so that no XRP estimate leaks from + * a rejected transaction into queue accounting. + * + * @param pfResult A non-success `NotTEC` code from preflight. + * Asserts if `tesSUCCESS` is passed. + */ explicit TxConsequences(NotTEC pfResult); - /// Constructor if the STTx has no notable consequences for the TxQ. + /** Construct consequences for a transaction with no special queue impact. + * + * Fee and sequence are read from the transaction. `potentialSpend_` is + * zero (fee only) and `sequencesConsumed_` is 1 for a sequence-based + * transaction or 0 for a ticket. + * + * @param tx The transaction to summarise. + */ explicit TxConsequences(STTx const& tx); - /// Constructor for a blocker. + /** Construct consequences for a blocker transaction. + * + * Sets `isBlocker_` when `category == Category::Blocker`, preventing + * the TxQ from accepting additional sequence-based transactions from the + * same account while this transaction is queued. + * + * @param tx The transaction to summarise. + * @param category `Category::Blocker` to mark as a blocker; + * `Category::Normal` behaves identically to the single-argument + * constructor. + */ TxConsequences(STTx const& tx, Category category); - /// Constructor for an STTx that may consume more XRP than the fee. + /** Construct consequences for a transaction that may spend XRP beyond its fee. + * + * Used by `ConsequencesFactoryType::Custom` transactors such as + * `Payment` (via `sfSendMax`) and `OfferCreate` (via XRP `TakerGets`). + * + * @param tx The transaction to summarise. + * @param potentialSpend The maximum additional XRP (above the fee) + * the transaction might consume. + */ TxConsequences(STTx const& tx, XRPAmount potentialSpend); - /// Constructor for an STTx that consumes more than the usual sequences. + /** Construct consequences for a transaction that burns multiple sequences. + * + * Used by `TicketCreate`, which reserves a range of future sequence slots + * in one transaction. `followingSeq()` accounts for the full range. + * + * @param tx The transaction to summarise. + * @param sequencesConsumed The total number of sequence slots consumed, + * including the transaction's own sequence number. + */ TxConsequences(STTx const& tx, std::uint32_t sequencesConsumed); - /// Copy constructor TxConsequences(TxConsequences const&) = default; - /// Copy assignment operator TxConsequences& operator=(TxConsequences const&) = default; - /// Move constructor TxConsequences(TxConsequences&&) = default; - /// Move assignment operator TxConsequences& operator=(TxConsequences&&) = default; - /// Fee + /** Transaction fee in drops. */ [[nodiscard]] XRPAmount fee() const { return fee_; } - /// Potential Spend + /** Maximum XRP spend beyond the fee, in drops. + * + * Zero for most transactions. Non-zero for `Payment` and `OfferCreate` + * when the transaction carries an XRP spending cap (`sfSendMax` / + * XRP `TakerGets`). + */ [[nodiscard]] XRPAmount const& potentialSpend() const { return potentialSpend_; } - /// SeqProxy + /** Sequence or ticket proxy identifying this transaction's queue slot. */ [[nodiscard]] SeqProxy seqProxy() const { return seqProx_; } - /// Sequences consumed + /** Number of sequence slots consumed by this transaction. + * + * Normally 1 for sequence-based transactions and 0 for ticket-based + * ones. `TicketCreate` returns the number of tickets it creates. + */ [[nodiscard]] std::uint32_t sequencesConsumed() const { return sequencesConsumed_; } - /// Returns true if the transaction is a blocker. + /** Return true if this transaction may invalidate subsequent queue entries. + * + * Blockers are key-management operations (`SetRegularKey`, `AccountDelete`, + * `SignerListSet`) whose execution can change the account's signing + * authority, rendering the cached signatures of queued followers invalid. + */ [[nodiscard]] bool isBlocker() const { return isBlocker_; } - // Return the SeqProxy that would follow this. + /** Return the first `SeqProxy` not consumed by this transaction. + * + * The TxQ uses this to find gaps in the queued sequence range for an + * account. For sequence-based transactions the result is `seqProxy + 1`; + * for tickets it is unchanged (tickets do not occupy sequence order); for + * `TicketCreate` it advances by `sequencesConsumed`. + * + * @return The `SeqProxy` that should immediately follow this transaction. + */ [[nodiscard]] SeqProxy followingSeq() const { @@ -133,32 +238,52 @@ public: } }; -/** Describes the results of the `preflight` check - - @note All members are const to make it more difficult - to "fake" a result without calling `preflight`. - @see preflight, preclaim, doApply, apply -*/ +/** Immutable token produced by the `preflight` stage of the pipeline. + * + * Captures every input and output of ledger-agnostic transaction validation + * in a single, copy-constructible object. All fields are `const` to prevent + * callers from fabricating a result without going through `preflight`. + * + * The TxQ caches this object (`MaybeTx::pfResult`) and passes it to + * `preclaim` when an application slot opens. Because ledger rules can change + * between the two calls, `preclaim` compares `result.rules` against the + * current view and automatically re-runs `preflight` when they differ. + * + * @note Copy-assignment is deleted; copy-construction is allowed so the TxQ + * can store results in `std::optional`. + * @see preflight, preclaim, doApply, apply + */ struct PreflightResult { public: - /// From the input - the transaction + /** The transaction that was checked. */ STTx const& tx; - /// From the input - the batch identifier, if part of a batch + /** Batch group identifier, present when this is an inner batch transaction. */ std::optional const parentBatchId; - /// From the input - the rules + /** Amendment rules in effect at the time `preflight` ran. + * `preclaim` compares this against the current ledger's rules to detect + * stale results that must be re-validated. */ Rules const rules; - /// Consequences of the transaction + /** Worst-case XRP cost and queue-ordering impact, valid even when + * `ter != tesSUCCESS`. */ TxConsequences const consequences; - /// From the input - the flags + /** Processing flags supplied to `preflight`. */ ApplyFlags const flags; - /// From the input - the journal + /** Journal for diagnostics. */ beast::Journal const j; - /// Intermediate transaction result + /** Validation result. `NotTEC` — only `tem*`, `tel*`, or `tesSUCCESS`; + * `tec*` codes never appear at the preflight stage. */ NotTEC const ter; - /// Constructor + /** Construct from a preflight context and its computed result. + * + * @tparam Context A preflight context type exposing `tx`, `parentBatchId`, + * `rules`, `flags`, and `j` members. + * @param ctx The context object used during the preflight call. + * @param result A pair of `(NotTEC, TxConsequences)` returned by the + * transactor-specific preflight implementation. + */ template PreflightResult(Context const& ctx, std::pair const& result) : tx(ctx.tx) @@ -172,39 +297,59 @@ public: } PreflightResult(PreflightResult const&) = default; - /// Deleted copy assignment operator + /** Deleted to prevent mutation of a cached preflight result. */ PreflightResult& operator=(PreflightResult const&) = delete; }; -/** Describes the results of the `preclaim` check - - @note All members are const to make it more difficult - to "fake" a result without calling `preclaim`. - @see preflight, preclaim, doApply, apply -*/ +/** Immutable token produced by the `preclaim` stage of the pipeline. + * + * Captures every input and output of ledger-dependent transaction validation. + * All fields are `const` to prevent callers from fabricating a result without + * going through `preclaim`. The object is not cached by the TxQ; a fresh + * `PreclaimResult` is produced each time a transaction is about to be applied. + * + * The `likelyToClaimFee` flag is the primary gate for `doApply`: if false, + * the transaction will not be applied and no fee is charged. + * + * @note Copy-assignment is deleted; copy-construction is allowed so callers + * can store the result in a local before passing it to `doApply`. + * @see preflight, preclaim, doApply, apply + */ struct PreclaimResult { public: - /// From the input - the ledger view + /** The ledger view against which the transaction was checked. + * `doApply` verifies that its view sequence matches this one; a mismatch + * (ledger advanced between preclaim and apply) returns `tefEXCEPTION`. */ ReadView const& view; - /// From the input - the transaction + /** The transaction that was checked. */ STTx const& tx; - /// From the input - the batch identifier, if part of a batch + /** Batch group identifier, present when this is an inner batch transaction. */ std::optional const parentBatchId; - /// From the input - the flags + /** Processing flags supplied to `preclaim`. */ ApplyFlags const flags; - /// From the input - the journal + /** Journal for diagnostics. */ beast::Journal const j; - /// Intermediate transaction result + /** Validation result. Full `TER` range: `tes*`, `tec*`, `ter*`, + * `tef*`, or `tem*`. */ TER const ter; - /// Success flag - whether the transaction is likely to - /// claim a fee + /** True when the transaction will charge a fee and should be applied. + * + * Computed as `isTesSuccess(ter) || isTecClaimHardFail(ter, flags)`. + * When false, `doApply` returns immediately without mutating the ledger. */ bool const likelyToClaimFee{}; - /// Constructor + /** Construct from a preclaim context and its computed `TER`. + * + * @tparam Context A preclaim context type exposing `view`, `tx`, + * `parentBatchId`, `flags`, and `j` members. + * @param ctx The context object used during the preclaim call. + * @param ter The `TER` produced by the transactor-specific preclaim + * implementation. + */ template PreclaimResult(Context const& ctx, TER ter) : view(ctx.view) @@ -218,27 +363,36 @@ public: } PreclaimResult(PreclaimResult const&) = default; - /// Deleted copy assignment operator + /** Deleted to prevent mutation of a preclaim result. */ PreclaimResult& operator=(PreclaimResult const&) = delete; }; -/** Gate a transaction based on static information. - - The transaction is checked against all possible - validity constraints that do not require a ledger. - - @param app The current running `Application`. - @param rules The `Rules` in effect at the time of the check. - @param tx The transaction to be checked. - @param flags `ApplyFlags` describing processing options. - @param j A journal. - - @see PreflightResult, preclaim, doApply, apply - - @return A `PreflightResult` object containing, among - other things, the `TER` code. -*/ +/** Perform ledger-agnostic validation of a transaction (stage 1 of 3). + * + * Validates the transaction against all constraints that can be checked + * without ledger state: field presence and format, fee field sanity, signing + * key structure, flag validity, and any static rules imposed by the active + * amendments. This is the cheapest stage and can be parallelised across + * transactions. + * + * The resulting `PreflightResult` — including its `TxConsequences` — may be + * cached by the TxQ across ledger boundaries. If the ledger's amendment + * rules change before `preclaim` runs, `preclaim` will re-execute `preflight` + * automatically with the updated rules before proceeding. + * + * @param registry The service registry providing transactor implementations. + * @param rules Amendment rules in effect at the time of the check. + * @param tx The transaction to validate. + * @param flags `ApplyFlags` describing processing options (e.g. `tapRETRY`, + * `tapDRY_RUN`). + * @param j Journal for diagnostics. + * @return A `PreflightResult` whose `ter` is `tesSUCCESS` if the transaction + * is well-formed, or a `tem*`/`tel*` code if it is not. The + * `consequences` field is always populated regardless of `ter`. + * + * @see PreflightResult, preclaim, doApply, apply + */ /** @{ */ PreflightResult preflight( @@ -248,6 +402,24 @@ preflight( ApplyFlags flags, beast::Journal j); +/** Perform ledger-agnostic validation of an inner batch transaction (stage 1 of 3). + * + * Identical to the standard overload but associates the result with a + * `parentBatchId`, which is stored in `PreflightResult::parentBatchId` and + * threaded through to `preclaim` and `doApply`. Used when an inner + * transaction belonging to a `Batch` group is validated independently. + * + * @param registry The service registry providing transactor implementations. + * @param rules Amendment rules in effect at the time of the check. + * @param parentBatchId The transaction ID of the enclosing `Batch` transaction. + * @param tx The inner transaction to validate. + * @param flags `ApplyFlags` describing processing options. + * @param j Journal for diagnostics. + * @return A `PreflightResult` for the inner transaction, carrying the batch + * association. + * + * @see PreflightResult, preclaim, doApply, apply + */ PreflightResult preflight( ServiceRegistry& registry, @@ -258,86 +430,99 @@ preflight( beast::Journal j); /** @} */ -/** Gate a transaction based on static ledger information. - - The transaction is checked against all possible - validity constraints that DO require a ledger. - - If preclaim succeeds, then the transaction is very - likely to claim a fee. This will determine if the - transaction is safe to relay without being applied - to the open ledger. - - "Succeeds" in this case is defined as returning a - `tes` or `tec`, since both lead to claiming a fee. - - @pre The transaction has been checked - and validated using `preflight` - - @param preflightResult The result of a previous - call to `preflight` for the transaction. - @param app The current running `Application`. - @param view The open ledger that the transaction - will attempt to be applied to. - - @see PreclaimResult, preflight, doApply, apply - - @return A `PreclaimResult` object containing, among - other things the `TER` code and the base fee value for - this transaction. -*/ +/** Perform ledger-dependent validation of a transaction (stage 2 of 3). + * + * Checks all constraints that require read-only access to the current ledger + * state: account existence, sequence/ticket validity, fee sufficiency, and + * cryptographic signature verification. If `preflightResult.ter` is not + * `tesSUCCESS` this function is a no-op (the pipeline short-circuits). + * + * **Rules-change handling**: if the amendment rules embedded in + * `preflightResult` differ from those in `view` (because the ledger advanced + * since `preflight` ran), `preclaim` automatically re-executes `preflight` + * with the updated rules before proceeding. Callers do not need to detect or + * handle this case. + * + * **Security invariant**: every check up to and including signature + * verification must return a `NotTEC` code (never `tec*`). A `tec` before + * the signature check would charge a fee without authentication. + * + * A `tesSUCCESS` or fee-claiming `tec*` result (without `tapRETRY`) sets + * `PreclaimResult::likelyToClaimFee`, indicating the transaction is safe to + * relay to peers even before it is applied. + * + * @pre `preflightResult` was produced by a successful call to `preflight`. + * @param preflightResult The cached result of a prior `preflight` call. + * @param registry The service registry providing transactor implementations. + * @param view The open ledger the transaction will be applied to. + * @return A `PreclaimResult` with the full `TER` and a + * `likelyToClaimFee` boolean that gates `doApply`. + * + * @see PreclaimResult, preflight, doApply, apply + */ PreclaimResult preclaim(PreflightResult const& preflightResult, ServiceRegistry& registry, OpenView const& view); -/** Compute only the expected base fee for a transaction. - - Base fees are transaction specific, so any calculation - needing them must get the base fee for each transaction. - - No validation is done or implied by this function. - - Caller is responsible for handling any exceptions. - Since none should be thrown, that will usually - mean terminating. - - @param view The current open ledger. - @param tx The transaction to be checked. - - @return The base fee. -*/ +/** Return the minimum fee floor for this specific transaction type. + * + * Dispatches through the `with_txn_type` X-macro to the transaction-type's + * static `calculateBaseFee` override, so transactors that impose non-standard + * fees (e.g., multi-signers add one base fee per signer) return the correct + * floor. The TxQ calls this to compute each transaction's fee level — + * the ratio of its actual fee to the floor — for prioritisation. + * + * No validation of the transaction is performed. + * + * @param view The current open ledger (provides the network base fee). + * @param tx The transaction whose fee floor is required. + * @return The minimum acceptable fee in drops. + * @note Callers are responsible for handling any exceptions; in practice + * none should be thrown and an unhandled exception should terminate. + */ XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); -/** Return the minimum fee that an "ordinary" transaction would pay. - - When computing the FeeLevel for a transaction the TxQ sometimes needs - the know what an "ordinary" or reference transaction would be required - to pay. - - @param view The current open ledger. - @param tx The transaction so the correct multisigner count is used. - - @return The base fee in XRPAmount. -*/ +/** Return the fee floor for a generic "reference" transaction. + * + * Unlike `calculateBaseFee`, this function bypasses transactor-specific + * dispatch and calls `Transactor::calculateBaseFee` directly, returning what + * a plain transaction would pay. The TxQ uses this as the denominator when + * computing fee levels, ensuring a non-zero reference even when a particular + * transaction's own base fee is zero. + * + * @param view The current open ledger. + * @param tx The transaction (used only for multisigner count, not tx type). + * @return The reference base fee in drops. + */ XRPAmount calculateDefaultBaseFee(ReadView const& view, STTx const& tx); -/** Apply a prechecked transaction to an OpenView. - - @pre The transaction has been checked - and validated using `preflight` and `preclaim` - - @param preclaimResult The result of a previous - call to `preclaim` for the transaction. - @param registry The service registry. - @param view The open ledger that the transaction - will attempt to be applied to. - - @see preflight, preclaim, apply - - @return A pair with the `TER` and a `bool` indicating - whether or not the transaction was applied. -*/ +/** Apply a pre-validated transaction to an open ledger (stage 3 of 3). + * + * Only runs if `preclaimResult.likelyToClaimFee` is true; otherwise returns + * the preclaim `TER` immediately with `applied = false`. + * + * Constructs an `ApplyContext` over `view`, instantiates the concrete + * transactor, and invokes `Transactor::operator()()`. All ledger mutations + * are staged in the context and are not committed to `view` until + * `ctx_.apply(result)` is called inside the transactor — meaning an early + * return, exception, or `tec*` result leaves the view in its original state + * (except for the fee and sequence deduction on `tec*` hard-fail paths). + * + * As a defensive check, `doApply` compares the view's ledger sequence against + * the one seen during `preclaim`; if they differ (the ledger advanced in the + * interim), it returns `tefEXCEPTION` without mutating anything. + * + * @pre `preclaimResult` was produced by a successful call to `preclaim` on + * the same transaction and the same open ledger. + * @param preclaimResult The result of a prior `preclaim` call. + * @param registry The service registry providing transactor implementations. + * @param view The mutable open ledger to apply the transaction to. + * @return An `ApplyResult` with the final `TER`, an `applied` flag indicating + * whether the transaction was committed, and optional `TxMeta`. + * + * @see preflight, preclaim, apply + */ ApplyResult doApply(PreclaimResult const& preclaimResult, ServiceRegistry& registry, OpenView& view); diff --git a/include/xrpl/tx/invariants/AMMInvariant.h b/include/xrpl/tx/invariants/AMMInvariant.h index 43d9c5ad0a..c36288364f 100644 --- a/include/xrpl/tx/invariants/AMMInvariant.h +++ b/include/xrpl/tx/invariants/AMMInvariant.h @@ -1,3 +1,7 @@ +/** @file + * Declares the ValidAMM post-transaction invariant checker for AMM ledger state. + */ + #pragma once #include @@ -10,39 +14,231 @@ namespace xrpl { +/** Post-transaction invariant checker that validates AMM ledger state consistency. + * + * Part of the `InvariantChecks` tuple run after every transaction. Detects + * corrupt or impossible AMM state that correct code should never produce. Two + * categories of corruption are guarded against: + * + * 1. **Constant-product violation** — after any deposit or withdrawal the + * geometric mean of pool reserves must satisfy + * `sqrt(amount × amount2) ≥ lptAMMBalance`. + * 2. **Structural invariants** — create must set balances exactly equal to + * `sqrt(amount × amount2)`; bid must burn LP tokens without touching the + * pool; vote must change nothing at all; delete must remove the AMM object; + * DEX operations (Payment, OfferCreate, CheckCash) must not write to the + * AMM object. + * + * Enforcement is gated on the `fixAMMv1_3` amendment. Before the amendment + * activates, violations are logged but the checker still returns `true`, + * preserving backward compatibility. After activation, any violation returns + * `false` and triggers the invariant-failure escalation in `ApplyContext`. + * + * @note Each instance is constructed fresh per transaction by the invariant + * framework; there is no shared mutable state between transactions. + * + * @see InvariantChecks + * @see InvariantChecker_PROTOTYPE + */ class ValidAMM { + /** AMM pseudo-account ID, populated when an `ltAMM` object is modified. */ std::optional ammAccount_; + /** LP token supply recorded from the `ltAMM` object after the transaction. */ std::optional lptAMMBalanceAfter_; + /** LP token supply recorded from the `ltAMM` object before the transaction. */ std::optional lptAMMBalanceBefore_; + /** True if any AMM pool entry (`ltRIPPLE_STATE` with `lsfAMMNode`, or + * `ltACCOUNT_ROOT` with `sfAMMID`) was modified by the transaction. */ bool ammPoolChanged_{false}; public: + /** Controls whether an all-zeros pool state is accepted as valid. + * + * `No` requires all three balances (amount, amount2, LP supply) to be + * strictly positive — used for deposits where a zero pool is never + * legitimate. `Yes` additionally permits the simultaneous all-zeros case + * that occurs when the final withdrawal drains the pool completely. + */ enum class ZeroAllowed : bool { No = false, Yes = true }; ValidAMM() = default; + + /** Record AMM-relevant changes for a single modified ledger entry. + * + * Called by the invariant framework once per modified SLE before + * `finalize` is called. Deletions are ignored entirely. For surviving + * entries the method captures: + * - The AMM pseudo-account ID and post-transaction LP token balance when + * an `ltAMM` object is modified. + * - The pre-transaction LP token balance from the before-snapshot of an + * `ltAMM` object. + * - The `ammPoolChanged_` flag when an AMM pool entry is touched. + * + * @param isDelete True if the entry is being deleted; the call is a no-op + * in that case. + * @param before SLE snapshot from before the transaction, or nullptr for + * newly created entries. + * @param after SLE snapshot from after the transaction, or nullptr for + * deleted entries. + */ void visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + /** Render a verdict on AMM state consistency after all entries are visited. + * + * Short-circuits (returns `true`) for any failure result that is neither + * `tesSUCCESS` nor `tecINCOMPLETE`. The `tecINCOMPLETE` carve-out exists + * because `AMMDelete` may return that code on a partial deletion pass and + * still requires validation. + * + * Dispatches to a per-transaction-type helper: + * - `ttAMM_CREATE` → `finalizeCreate` (exact equality check) + * - `ttAMM_DEPOSIT` → `finalizeDeposit` (geometric-mean ≥ LP supply, + * zero pool disallowed) + * - `ttAMM_WITHDRAW`, `ttAMM_CLAWBACK` → `finalizeWithdraw` + * (geometric-mean ≥ LP supply, all-zeros terminal state allowed) + * - `ttAMM_BID` → `finalizeBid` (pool unchanged, LP supply decreased) + * - `ttAMM_VOTE` → `finalizeVote` (pool and LP supply both unchanged) + * - `ttAMM_DELETE` → `finalizeDelete` (AMM object absent on success) + * - `ttCHECK_CASH`, `ttOFFER_CREATE`, `ttPAYMENT` → `finalizeDEX` + * (AMM object must not have been written) + * + * Whether a detected violation returns `false` or is merely logged depends + * on whether the `fixAMMv1_3` amendment is active in `view`. + * + * @param tx The transaction that was applied. + * @param result The TER result of the transaction. + * @param fee The XRP fee charged (unused by this checker). + * @param view Read-only ledger view used to re-read pool balances. + * @param j Journal for error logging. + * @return `true` if the invariant holds (or is unenforced); `false` if a + * violation is detected and `fixAMMv1_3` is active. + */ bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); private: + /** Verify that a bid left the pool unchanged and burned LP tokens. + * + * @param enforce Whether to return `false` on violation (`fixAMMv1_3`). + * @param j Journal for error logging. + * @return `false` if the pool changed or LP supply did not strictly + * decrease to a positive value and `enforce` is true. + */ [[nodiscard]] bool finalizeBid(bool enforce, beast::Journal const&) const; + + /** Verify that a vote left both the pool and the LP token supply unchanged. + * + * @param enforce Whether to return `false` on violation (`fixAMMv1_3`). + * @param j Journal for error logging. + * @return `false` if either the LP token balance or the pool changed and + * `enforce` is true. + */ [[nodiscard]] bool finalizeVote(bool enforce, beast::Journal const&) const; + + /** Verify that an AMM create produced a valid initial pool state. + * + * Checks that the AMM object was actually created, that all three + * balances are strictly positive, and that the LP token supply equals + * `sqrt(amount × amount2)` exactly (using `ammLPTokens`). + * + * @param tx The `ttAMM_CREATE` transaction. + * @param view Ledger view used to read actual pool balances via + * `ammPoolHolds`. + * @param enforce Whether to return `false` on violation (`fixAMMv1_3`). + * @param j Journal for error logging. + * @return `false` if the AMM object is absent, any balance is non-positive, + * or `sqrt(amount × amount2) ≠ lptAMMBalance` and `enforce` is true. + */ [[nodiscard]] bool finalizeCreate(STTx const&, ReadView const&, bool enforce, beast::Journal const&) const; + + /** Verify that an AMM delete removed the AMM object. + * + * On `tesSUCCESS` the AMM object must be absent. On `tecINCOMPLETE` + * (partial deletion pass) the object must similarly not have been written. + * + * @param enforce Whether to return `false` on violation (`fixAMMv1_3`). + * @param res The TER result of the delete transaction. + * @param j Journal for error logging. + * @return `false` if `ammAccount_` is set (object was modified rather than + * deleted) and `enforce` is true. + */ [[nodiscard]] bool finalizeDelete(bool enforce, TER res, beast::Journal const&) const; + + /** Verify the constant-product invariant after a deposit. + * + * Delegates to `generalInvariant` with `ZeroAllowed::No`; a zero pool + * is never legitimate after a deposit. + * + * @param tx The `ttAMM_DEPOSIT` transaction (provides `sfAsset`/`sfAsset2`). + * @param view Ledger view used to read actual pool balances. + * @param enforce Whether to return `false` on violation (`fixAMMv1_3`). + * @param j Journal for error logging. + * @return `false` if the AMM object was deleted or the constant-product + * invariant is violated and `enforce` is true. + */ [[nodiscard]] bool finalizeDeposit(STTx const&, ReadView const&, bool enforce, beast::Journal const&) const; + + /** Verify the constant-product invariant after a withdrawal or clawback. + * + * If `ammAccount_` is absent, the final withdrawal deleted the AMM — this + * is legitimate and the method returns `true` immediately. Otherwise + * delegates to `generalInvariant` with `ZeroAllowed::Yes`, which permits + * the simultaneous all-zeros terminal state. + * + * @param tx The `ttAMM_WITHDRAW` or `ttAMM_CLAWBACK` transaction. + * @param view Ledger view used to read actual pool balances. + * @param enforce Whether to return `false` on violation (`fixAMMv1_3`). + * @param j Journal for error logging. + * @return `false` if the constant-product invariant is violated and + * `enforce` is true; `true` if the AMM was legitimately deleted. + */ // Includes clawback [[nodiscard]] bool finalizeWithdraw(STTx const&, ReadView const&, bool enforce, beast::Journal const&) const; + + /** Verify that a DEX operation (Payment, OfferCreate, CheckCash) did not + * write to the AMM object. + * + * DEX transactions may route liquidity through AMM pools but must never + * mutate the `ltAMM` ledger object itself. + * + * @param enforce Whether to return `false` on violation (`fixAMMv1_3`). + * @param j Journal for error logging. + * @return `false` if `ammAccount_` is set (the AMM object was written) + * and `enforce` is true. + */ [[nodiscard]] bool finalizeDEX(bool enforce, beast::Journal const&) const; + + /** Check the constant-product invariant for deposit and withdrawal paths. + * + * Re-reads actual pool balances from `view` via `ammPoolHolds`, then + * verifies: + * - All balances satisfy the `zeroAllowed` constraint (strictly positive, + * or simultaneously all-zeros when `ZeroAllowed::Yes`). + * - `sqrt(amount × amount2) ≥ lptAMMBalanceAfter_` (strong check), or + * within a relative tolerance of `1e-11` (weak fallback to absorb + * fixed-point rounding). + * + * Logging is unconditional on failure: the error line always includes the + * transaction hash, individual pool amounts, geometric mean, LP token + * balance, and relative deviation. The `enforce` flag is NOT consulted + * here — callers decide whether to propagate `false`. + * + * @param tx Transaction providing `sfAsset`/`sfAsset2` for pool lookup. + * @param view Ledger view used to read pool balances. + * @param zeroAllowed Whether an all-zeros pool is a valid terminal state. + * @param j Journal for error logging. + * @return `false` if either the balance constraint or the geometric-mean + * bound is violated; `true` otherwise. + */ [[nodiscard]] bool generalInvariant(STTx const&, ReadView const&, ZeroAllowed zeroAllowed, beast::Journal const&) const; diff --git a/include/xrpl/tx/invariants/FreezeInvariant.h b/include/xrpl/tx/invariants/FreezeInvariant.h index 645f444462..516bb774f8 100644 --- a/include/xrpl/tx/invariants/FreezeInvariant.h +++ b/include/xrpl/tx/invariants/FreezeInvariant.h @@ -13,56 +13,208 @@ namespace xrpl { /** - * @brief Invariant: frozen trust line balance change is not allowed. + * @brief Invariant checker that prevents token transfers across frozen trust lines. * - * We iterate all affected trust lines and ensure that they don't have - * unexpected change of balance if they're frozen. + * Registered in the `InvariantChecks` tuple and executed after every transaction, + * including failed ones. Implements the two-phase `InvariantChecker_PROTOTYPE` + * contract: `visitEntry()` collects trust line balance changes, and `finalize()` + * evaluates freeze rules across the full set of collected changes. + * + * A single-pass approach is insufficient because a trust line's freeze state + * alone does not determine whether a transfer is forbidden — the check must be + * performed end-to-end, comparing both sides of the transfer across potentially + * different freeze states. The two-phase design makes this possible. + * + * Enforcement is gated on `featureDeepFreeze`: before the amendment activates, + * violations are logged at `fatal` severity and fire `XRPL_ASSERT` in debug + * builds, but do not invalidate the transaction in release builds. This + * provides early warning without a consensus break, and the single `enforce` + * variable is the only change needed if a fix amendment is introduced. + * + * @note `AMMClawback` transactions hold the `OverrideFreeze` privilege and may + * move funds across individually frozen or deep-frozen trust lines, but not + * when the issuer has set a global freeze. */ class TransfersNotFrozen { + /** + * @brief A single trust line's participation in a balance change. + * + * `balanceChangeSign` is +1 if the balance increased (receiving) or -1 if + * it decreased (sending), from the current issuer's perspective. + */ struct BalanceChange { std::shared_ptr const line; int const balanceChangeSign; }; + /** + * @brief All balance changes for a single issuer's token, split by direction. + * + * When both `senders` and `receivers` are non-empty the transfer is + * holder-to-holder and freeze rules apply. If either is empty the tokens + * are moving to or from the issuer directly, which is always permitted. + */ struct IssuerChanges { std::vector senders; std::vector receivers; }; + /** Balance changes keyed by `Issue` (currency + issuer account). */ using ByIssuer = std::map; ByIssuer balanceChanges_; + /** + * @brief Cache of `ltACCOUNT_ROOT` SLEs observed during `visitEntry()`. + * + * `findIssuer()` checks this cache before falling back to `view.read()`, + * avoiding a redundant ledger lookup in the common case where the issuer + * account was already touched by the transaction. + */ std::map const> possibleIssuers_; public: + /** + * @brief Collect balance changes from a single modified ledger entry. + * + * Skips non-trust-line entries, but caches any `ltACCOUNT_ROOT` entries + * in `possibleIssuers_` for later use by `findIssuer()`. For trust lines, + * records the net balance change under both sides' issuer keys so that + * `finalize()` sees issuer-relative directionality regardless of which + * side of the trust line an account sits on. + * + * @param isDelete True if the entry is being deleted; causes the final + * balance to be treated as zero so that trust-line deletion cannot + * transfer frozen funds to a third party. + * @param before SLE state before the transaction; null for newly-created + * trust lines (balance treated as zero in that case). + * @param after SLE state after the transaction; never null even on delete. + */ void visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + /** + * @brief Validate that no collected balance change violates freeze rules. + * + * Iterates over every issuer in `balanceChanges_` and delegates to + * `validateIssuerChanges()`. Only holder-to-holder transfers (both + * `senders` and `receivers` non-empty) are evaluated for freeze + * violations; issuance and redemption are unconditionally allowed. + * + * Enforcement is controlled by `featureDeepFreeze`: when disabled, + * violations are logged and asserted in debug builds but do not cause the + * method to return `false`. + * + * @param tx The transaction being applied (used for privilege checks and + * log correlation). + * @param ter The transaction result (unused; invariant runs regardless). + * @param fee The fee charged (unused by this checker). + * @param view The post-transaction ledger view, used to look up issuers + * not cached in `possibleIssuers_`. + * @param j Journal for diagnostic logging. + * @return `true` if no frozen-fund movement is detected (or if + * `featureDeepFreeze` is disabled and a violation is found). + * `false` if a violation is found and the amendment is active. + */ bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); private: + /** + * @brief Return true if the entry should be processed for freeze checks. + * + * Caches `ltACCOUNT_ROOT` entries in `possibleIssuers_` as a side effect. + * Returns `true` only for `ltRIPPLE_STATE` (trust line) entries where the + * type has not changed between `before` and `after`. + * + * @param before Pre-transaction SLE; null for newly-created entries. + * @param after Post-transaction SLE; must not be null. + * @return `true` if the entry is a trust line eligible for balance-change + * recording. + */ bool isValidEntry(std::shared_ptr const& before, std::shared_ptr const& after); + /** + * @brief Compute the net balance change for a trust line. + * + * When `before` is null (trust line created mid-transaction, e.g., by a + * payment crossing offers), the pre-existing balance is treated as zero. + * When `isDelete` is true, the final balance is treated as zero so that + * deletion cannot bypass frozen-transfer restrictions. + * + * @param before Pre-transaction SLE; null if the trust line was just created. + * @param after Post-transaction SLE. + * @param isDelete True when the entry is being deleted. + * @return Signed `STAmount` representing `balanceAfter - balanceBefore`. + */ static STAmount calculateBalanceChange( std::shared_ptr const& before, std::shared_ptr const& after, bool isDelete); + /** + * @brief Insert a single `BalanceChange` into `balanceChanges_` under the given issue. + * + * Routes the change to `IssuerChanges::senders` when `balanceChangeSign < 0`, + * or `IssuerChanges::receivers` when positive. + * + * @param issue The currency+issuer key for the change. + * @param change The trust line and direction of the change. + */ void recordBalance(Issue const& issue, BalanceChange change); + /** + * @brief Record a trust line balance change from both sides' issuer perspectives. + * + * Because XRPL trust line balances are stored from the low account's + * perspective, the same physical balance change must be inserted twice — + * once for the high-limit account's issuer (using the raw sign) and once + * for the low-limit account's issuer (with the sign inverted) — so that + * `validateIssuerChanges()` sees consistent directionality from each + * issuer's point of view. + * + * @param after Post-transaction trust line SLE. + * @param balanceChange Net balance change; must be non-zero. + */ void recordBalanceChanges(std::shared_ptr const& after, STAmount const& balanceChange); + /** + * @brief Look up an issuer's `AccountRoot` SLE, using the local cache first. + * + * Checks `possibleIssuers_` (populated during `visitEntry()`) before + * falling back to `view.read()`, so that issuers already modified by the + * transaction do not require an additional ledger lookup. + * + * @param issuerID The account ID to look up. + * @param view The post-transaction read-only ledger view. + * @return The issuer's SLE, or nullptr if not found. + */ std::shared_ptr findIssuer(AccountID const& issuerID, ReadView const& view); + /** + * @brief Validate all balance changes for one issuer's token. + * + * Unconditionally allows issuance (no senders) and redemption (no + * receivers). For holder-to-holder transfers, checks every sender and + * receiver trust line against the issuer's global freeze flag and the + * per-line freeze/deep-freeze flags via `validateFrozenState()`. + * + * @param issuer The issuer's `AccountRoot` SLE; must not be null. + * @param changes All senders and receivers for this issuer's token. + * @param tx The transaction being applied. + * @param j Journal for diagnostic logging. + * @param enforce When `false`, violations are logged but do not cause the + * method to return `false` (pre-`featureDeepFreeze` mode). + * @return `true` if all changes are permitted; `false` on a freeze violation + * when `enforce` is `true`. + */ static bool validateIssuerChanges( std::shared_ptr const& issuer, @@ -71,6 +223,28 @@ private: beast::Journal const& j, bool enforce); + /** + * @brief Check whether a single trust line balance change violates freeze rules. + * + * Evaluates three layered freeze conditions in order: + * 1. **Global freeze** (`lsfGlobalFreeze` on the issuer): freezes all trust + * lines with that issuer; no override is possible. + * 2. **Deep freeze** (`lsfLowDeepFreeze`/`lsfHighDeepFreeze`): blocks all + * transfers regardless of direction; overrideable by `AMMClawback`. + * 3. **Standard freeze** (`lsfLowFreeze`/`lsfHighFreeze`): only blocks + * outgoing transfers (`balanceChangeSign < 0`); overrideable by `AMMClawback`. + * + * @param change The trust line and direction of the balance change. + * @param high `true` if the issuer is the high-limit account on the trust + * line (determines which freeze flag bits to examine). + * @param tx The transaction being applied (for privilege and log checks). + * @param j Journal for diagnostic logging. + * @param enforce When `false`, violations log and assert but return `true` + * (pre-`featureDeepFreeze` behavior). + * @param globalFreeze `true` if the issuer's `lsfGlobalFreeze` flag is set. + * @return `true` if the transfer is permitted; `false` if it violates a + * freeze rule and `enforce` is `true`. + */ static bool validateFrozenState( BalanceChange const& change, diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index bc9608fd56..b402ade0fb 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -1,3 +1,30 @@ +/** @file + * Central registry for the XRPL transaction invariant-checking system. + * + * After every transaction (whether it succeeded or failed), the invariant + * framework scans all modified ledger entries and verifies that the result + * is internally consistent. If any check fails, the transaction is rolled + * back to a fee-only charge (`tecINVARIANT_FAILED`), or excluded from the + * ledger entirely (`tefINVARIANT_FAILED`) if even that minimal commit + * breaks an invariant. + * + * This file declares the core checker classes and aggregates every checker + * — including those from sibling headers such as `FreezeInvariant.h`, + * `NFTInvariant.h`, `AMMInvariant.h`, and `VaultInvariant.h` — into the + * `InvariantChecks` tuple. That tuple is the single source of truth for + * which invariants exist. Dispatch in `ApplyContext::checkInvariantsHelper` + * iterates the tuple at compile time via `std::index_sequence`; no virtual + * calls are involved. + * + * To add a new invariant: declare its class (here or in a new sibling + * header), then append it to `InvariantChecks`. No other registration is + * needed. + * + * @see InvariantChecker_PROTOTYPE for the duck-typed interface every + * checker must satisfy. + * @see InvariantCheckPrivilege.h for the `Privilege` bitmask and + * `hasPrivilege()` used by several checkers. + */ #pragma once #include @@ -100,71 +127,121 @@ public: }; #endif -/** - * @brief Invariant: We should never charge a transaction a negative fee or a - * fee that is larger than what the transaction itself specifies. +/** Invariant: the fee charged must be non-negative, less than the total XRP + * supply, and no greater than the fee the transaction authorized. * - * We can, in some circumstances, charge less. + * Undercharging is permitted (e.g., when an account's balance is clamped by + * `reset()`), but overcharging or a negative fee is always a bug. + * `visitEntry` is a no-op; all logic is in `finalize`. */ class TransactionFeeCheck { public: + /** No-op: fee validation needs no per-entry state. */ void visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + /** Verify the fee is in `[0, sfFee]` and strictly below `INITIAL_XRP`. + * + * @param fee The fee actually deducted from the sending account. + * @return `true` if the fee is valid; `false` with a `fatal` log entry + * on any violation. + */ static bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); + finalize(STTx const&, TER const, XRPAmount const fee, ReadView const&, beast::Journal const&); }; -/** - * @brief Invariant: A transaction must not create XRP and should only destroy - * the XRP fee. +/** Invariant: a transaction must not create XRP; it may only destroy XRP + * equal to the fee charged. * - * We iterate through all account roots, payment channels and escrow entries - * that were modified and calculate the net change in XRP caused by the - * transactions. + * Accumulates the net drop-level change across account roots, payment + * channels, and escrows. Payment channel net is `sfAmount - sfBalance` + * (unclaimed funds). Escrow and pay-channel deletions skip the `after` + * side because those entries' amount fields are not adjusted at deletion + * time — only the pre-deletion value is subtracted. */ class XRPNotCreated { std::int64_t drops_ = 0; public: + /** Accumulate XRP delta for account roots, payment channels, and escrows. + * + * @param isDelete `true` when the entry is being deleted; suppresses the + * `after` contribution for pay-channel and escrow entries. + * @param before Entry state before the transaction; null for new entries. + * @param after Entry state after the transaction; null for deleted entries. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after); + /** Verify net XRP change equals exactly `-fee`. + * + * @return `true` if the net drop delta equals the negative of the fee; + * `false` with a `fatal` log entry if XRP was created or the net + * change does not match the fee. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariant: we cannot remove an account ledger entry +/** Invariant: account roots may only be deleted by transactions that hold + * the correct deletion privilege. * - * We iterate all account roots that were modified, and ensure that any that - * were present before the transaction was applied continue to be present - * afterwards unless they were explicitly deleted by a successful - * AccountDelete transaction. + * Transactions with `MustDeleteAcct` (e.g., `AccountDelete`, `AMMDelete`) + * must delete exactly one account root on success. Transactions with + * `MayDeleteAcct` (e.g., `AMMWithdraw`, `AMMClawback`) may delete at most + * one. All other transactions must delete zero. + * + * @note Privilege semantics are defined in `InvariantCheckPrivilege.h` and + * encoded per-transaction-type in `transactions.macro`. */ class AccountRootsNotDeleted { std::uint32_t accountsDeleted_ = 0; public: + /** Count deleted account roots. + * + * @param isDelete `true` when the entry is being deleted. + * @param before Entry state before modification; null for new entries. + * @param after Unused by this checker. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after); + /** Verify deletion count matches the transaction's privilege. + * + * @return `true` if the deletion count is consistent with the privilege + * held by the transaction; `false` with a `fatal` log entry otherwise. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariant: a deleted account must not have any objects left +/** Invariant: a deleted account must leave no orphaned ledger objects behind. * - * We iterate all deleted account roots, and ensure that there are no - * objects left that are directly accessible with that account's ID. + * For every deleted account root, verifies: the post-deletion balance is + * zero, the owner count is zero, and no directly-keyed objects (trust + * lines, escrows, offers, NFT pages, pay channels, etc.) remain in the + * ledger. For pseudo-accounts (AMM, Vault, etc.), the linked protocol + * object must also be absent. * - * There should only be one deleted account, but that's checked by - * AccountRootsNotDeleted. This invariant will handle multiple deleted account - * roots without a problem. + * The `before` snapshot is used to locate linked objects even when an + * ID field is cleared during deletion; `after` is used only for + * post-deletion balance and owner-count assertions. + * + * @note This checker is amendment-gated: violations are always logged at + * `fatal` level and trigger a debug-build `XRPL_ASSERT`, but + * `finalize` only returns `false` (blocking the transaction) when + * `featureInvariantsV1_1`, `featureSingleAssetVault`, or + * `featureLendingProtocol` is enabled. */ class AccountRootsDeletedClean { @@ -176,35 +253,63 @@ class AccountRootsDeletedClean std::vector, std::shared_ptr>> accountsDeleted_; public: + /** Record each deleted account root's before/after snapshots. + * + * @param isDelete `true` when the entry is being deleted. + * @param before Entry state before deletion; used to locate linked objects. + * @param after Entry state after deletion; used to check balance/owner count. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after); + /** Scan the ledger for objects orphaned by each deleted account. + * + * @return `true` if all deleted accounts are clean; `false` with `fatal` + * log entries for each violation when the gating amendment is active. + */ bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; -/** - * @brief Invariant: An account XRP balance must be in XRP and take a value - * between 0 and INITIAL_XRP drops, inclusive. +/** Invariant: every account root's XRP balance must be a native amount in + * `[0, INITIAL_XRP]` (inclusive) both before and after the transaction. * - * We iterate all account roots modified by the transaction and ensure that - * their XRP balances are reasonable. + * The `bad_` flag is sticky — once set by any visited entry it remains set + * regardless of subsequent entries. */ class XRPBalanceChecks { bool bad_ = false; public: + /** Set `bad_` if any visited account root has an out-of-range balance. + * + * @param before Entry state before modification; null for new entries. + * @param after Entry state after modification; null for deleted entries. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, std::shared_ptr const& before, std::shared_ptr const& after); + /** Report failure if any balance violation was detected. + * + * @return `true` if all visited balances were valid; `false` with a + * `fatal` log entry if any balance was out of range. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariant: corresponding modified ledger entries should match in type - * and added entries should be a valid type. +/** Invariant: modified entries must not change `LedgerEntryType`, and newly + * created entries must be a type recognized by `ledger_entries.macro`. + * + * Catches two distinct corruption scenarios: a modification that mutates + * the type field of an existing object (`typeMismatch_`), and creation of + * an object with an unregistered type tag (`invalidTypeAdded_`). Both + * flags are checked independently in `finalize` so each failure produces + * its own diagnostic. */ class LedgerEntryTypesMatch { @@ -212,89 +317,144 @@ class LedgerEntryTypesMatch bool invalidTypeAdded_ = false; public: + /** Detect type changes on modified entries and unrecognized types on new ones. + * + * @param before Entry state before modification; null for new entries. + * @param after Entry state after modification; null for deleted entries. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, std::shared_ptr const& before, std::shared_ptr const& after); + /** Report any type mismatch or unknown type that was detected. + * + * @return `true` if no type anomaly was seen; `false` with separate + * `fatal` log entries for each distinct violation kind. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariant: Trust lines using XRP are not allowed. +/** Invariant: no trust line (`ltRIPPLE_STATE`) may reference XRP as its asset. * - * We iterate all the trust lines created by this transaction and ensure - * that they are against a valid issuer. + * XRP is natively held in account roots; a trust line denominated in XRP + * has no valid semantics and indicates a bug. Both `sfLowLimit` and + * `sfHighLimit` are checked so the asset comparison does not rely solely + * on the `native()` predicate. */ class NoXRPTrustLines { bool xrpTrustLine_ = false; public: + /** Set the violation flag if any trust line's asset is XRP. + * + * Only inspects the `after` snapshot; pre-existing invalid state + * cannot be introduced by this transaction. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, std::shared_ptr const&, std::shared_ptr const& after); + /** @return `true` if no XRP trust lines were seen; `false` with a + * `fatal` log entry otherwise. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariant: Trust lines with deep freeze flag are not allowed if normal - * freeze flag is not set. +/** Invariant: `lsfLowDeepFreeze`/`lsfHighDeepFreeze` may only be set when + * the corresponding `lsfLowFreeze`/`lsfHighFreeze` flag is also set. * - * We iterate all the trust lines created by this transaction and ensure - * that they don't have deep freeze flag set without normal freeze flag set. + * Deep freeze is a stricter form of freeze; it is undefined behaviour to + * deep-freeze a trust line that is not already frozen. */ class NoDeepFreezeTrustLinesWithoutFreeze { bool deepFreezeWithoutFreeze_ = false; public: + /** Set the violation flag if any trust line has deep freeze without freeze. + * + * Only inspects the `after` snapshot; checks both low and high sides + * of the trust line independently. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, std::shared_ptr const&, std::shared_ptr const& after); + /** @return `true` if no deep-freeze-without-freeze condition was seen; + * `false` with a `fatal` log entry otherwise. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariant: offers should be for non-negative amounts and must not - * be XRP to XRP. +/** Invariant: all offer entries (`ltOFFER`) must have non-negative amounts + * and must not exchange XRP for XRP. * - * Examine all offers modified by the transaction and ensure that there are - * no offers which contain negative amounts or which exchange XRP for XRP. + * Both the `before` and `after` snapshots of each offer are inspected, so + * a modification that corrupts an existing offer is caught alongside a + * newly created bad offer. */ class NoBadOffers { bool bad_ = false; public: + /** Set the violation flag if any offer has negative amounts or is XRP↔XRP. + * + * @param before Entry state before modification; null for new entries. + * @param after Entry state after modification; null for deleted entries. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, std::shared_ptr const& before, std::shared_ptr const& after); + /** @return `true` if no bad offers were seen; `false` with a `fatal` + * log entry otherwise. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariant: an escrow entry must take a value between 0 and - * INITIAL_XRP drops exclusive. +/** Invariant: escrow and MPToken amounts must be strictly positive and + * within protocol bounds. + * + * Despite its name this checker is not limited to escrows. It validates: + * - `ltESCROW` amounts: XRP in `(0, INITIAL_XRP)`, IOU > 0 with a valid + * currency, MPT in `(0, MAX_MP_TOKEN_AMOUNT]`. + * - `ltMPTOKEN_ISSUANCE` `sfOutstandingAmount` and optional `sfLockedAmount`: + * both in `[0, MAX_MP_TOKEN_AMOUNT]`, with `lockedAmount ≤ outstandingAmount`. + * - `ltMPTOKEN` `sfMPTAmount` and optional `sfLockedAmount`: + * both in `[0, MAX_MP_TOKEN_AMOUNT]`. */ class NoZeroEscrow { bool bad_ = false; public: + /** Set the violation flag if any escrow or MPToken entry has an invalid amount. + * + * @param before Entry state before modification; null for new entries. + * @param after Entry state after modification; null for deleted entries. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, std::shared_ptr const& before, std::shared_ptr const& after); + /** @return `true` if all amounts were in range; `false` with a `fatal` + * log entry otherwise. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariant: a new account root must be the consequence of a payment, - * must have the right starting sequence, and the payment - * may not create more than one new account root. +/** Invariant: at most one account root may be created per transaction, it + * must originate from a transaction with the correct creation privilege, + * and it must start with the ledger-mandated sequence number and flags. + * + * Normal accounts start with `sfSequence == view.seq()` (current ledger + * sequence). Pseudo-accounts (AMM, Vault, LoanBroker) start with + * `sfSequence == 0` and must have exactly `lsfDisableMaster | + * lsfDefaultRipple | lsfDepositAuth` set. Creating a pseudo-account + * requires the `CreatePseudoAcct` privilege; creating a normal account + * requires `CreateAcct`. */ class ValidNewAccountRoot { @@ -304,20 +464,31 @@ class ValidNewAccountRoot std::uint32_t flags_ = 0; public: + /** Record creation details for newly added account roots. + * + * @param before Null for newly created entries (creation only). + * @param after The new account root's state. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, std::shared_ptr const& before, std::shared_ptr const& after); + /** Verify creation count, privilege, starting sequence, and flags. + * + * @return `true` if the account was created validly; `false` with a + * `fatal` log entry for each distinct violation. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariant: Token holder's trustline balance cannot be negative after - * Clawback. +/** Invariant: a `ttCLAWBACK` transaction must not modify more than one + * trust line or MPToken entry, and must leave the holder's balance + * non-negative. * - * We iterate all the trust lines affected by this transaction and ensure - * that no more than one trustline is modified, and also holder's balance is - * non-negative. + * On success: at most one trust line and at most one MPToken modified; + * the holder's resulting balance ≥ 0. On failure: neither trust lines + * nor MPTokens may have been modified at all. For all other transaction + * types `finalize` is a no-op. */ class ValidClawback { @@ -325,39 +496,82 @@ class ValidClawback std::uint32_t mptokensChanged_ = 0; public: + /** Count touched trust lines and MPToken entries. + * + * @param before Entry state before modification; counts only when non-null. + * @param after Unused by this checker. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, std::shared_ptr const& before, std::shared_ptr const& after); + /** Validate clawback constraints if the transaction is `ttCLAWBACK`. + * + * @return `true` for non-clawback transactions unconditionally; for + * clawback, `true` only if modification counts and holder balance + * pass all checks; `false` with a `fatal` log entry otherwise. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** - * @brief Invariants: Pseudo-accounts have valid and consistent properties +/** Invariant: pseudo-accounts must maintain their structural invariants + * after every modification. * - * Pseudo-accounts have certain properties, and some of those properties are - * unique to pseudo-accounts. Check that all pseudo-accounts are following the - * rules, and that only pseudo-accounts look like pseudo-accounts. + * Any account root with a pseudo-account discriminator field (e.g., + * `sfAMMID`, `sfVaultID`) or with `sfSequence == 0` is treated as a + * pseudo-account and checked for: + * 1. Exactly one pseudo-account discriminator field present. + * 2. `sfSequence` unchanged from before. + * 3. Flags `lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth` all set. + * 4. `sfRegularKey` absent. * + * All violations are collected into `errors_` and logged individually. + * Deletion events are ignored (covered by `AccountRootsDeletedClean`). + * + * @note Enforcement is gated on `featureSingleAssetVault`: violations log + * and fire a debug `XRPL_ASSERT` unconditionally but only return + * `false` when the amendment is active. */ class ValidPseudoAccounts { std::vector errors_; public: + /** Accumulate errors for any pseudo-account that violates its invariants. + * + * @param isDelete Deletion events are skipped entirely. + * @param before Entry state before modification; used for sequence comparison. + * @param after Current account root state to validate. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after); + /** Log all accumulated errors and fail if the gating amendment is active. + * + * @return `true` if no errors were accumulated or the amendment is not + * yet active; `false` with `fatal` log entries for each violation + * when `featureSingleAssetVault` is enabled. + */ bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; -/** - * @brief Invariants: Some fields are unmodifiable +/** Invariant: certain fields on existing ledger entries must never be + * altered once the entry is created. * - * Check that any fields specified as unmodifiable are not modified when the - * object is modified. Creation and deletion are ignored. + * Creation and deletion are ignored (those events have their own checkers). + * For `ltLOAN_BROKER` and `ltLOAN` entries, a broad set of origination + * fields (`sfInterestRate`, `sfBorrower`, `sfStartDate`, etc.) are + * immutable. For all other entry types, `sfLedgerEntryType` and + * `sfLedgerIndex` are universally immutable. * + * @note Enforcement is gated on `featureLendingProtocol`: violations log + * and fire a debug `XRPL_ASSERT` unconditionally but only return + * `false` when that amendment is active — even for the universally + * immutable fields, because that is when this checker was introduced. */ class NoModifiedUnmodifiableFields { @@ -365,15 +579,43 @@ class NoModifiedUnmodifiableFields std::set> changedEntries_; public: + /** Record every modification (non-delete) for inspection in `finalize`. + * + * @param isDelete Deletion and creation events are skipped. + * @param before Entry state before modification. + * @param after Entry state after modification. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after); + /** Scan all recorded modifications for immutable-field changes. + * + * @return `true` if no immutable field was altered; `false` with a + * `fatal` log entry when a violation is found and + * `featureLendingProtocol` is active. + */ bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; -// additional invariant checks can be declared above and then added to this -// tuple +/** Complete set of invariant checkers run after every transaction. + * + * `ApplyContext::checkInvariantsHelper` unpacks this tuple at compile time + * via `std::index_sequence`, calling `visitEntry` on each element for + * every modified ledger entry and then `finalize` on each element once. + * Results from `finalize` are collected into a `std::array` rather than + * short-circuited with `&&`, so every failing checker produces its own + * `fatal`-level log entry before the combined verdict is returned. + * + * To add a new invariant: declare its class (here or in a sibling header), + * include that header above, and append the class to this list. + * + * @see InvariantChecker_PROTOTYPE for the interface each element must satisfy. + * @see ApplyContext::checkInvariants for the dispatch entry point. + */ using InvariantChecks = std::tuple< TransactionFeeCheck, AccountRootsNotDeleted, @@ -401,13 +643,15 @@ using InvariantChecks = std::tuple< ValidVault, ValidMPTPayment>; -/** - * @brief get a tuple of all invariant checks +/** Construct a default-initialized `InvariantChecks` tuple. * - * @return std::tuple of instances that implement the required invariant check - * methods + * Called by `ApplyContext::checkInvariantsHelper` at the start of each + * invariant-checking pass. Each checker accumulates state via `visitEntry` + * across all modified ledger entries before `finalize` renders its verdict. * - * @see xrpl::InvariantChecker_PROTOTYPE + * @return A value-initialized tuple containing one instance of every + * registered invariant checker, ready for a new pass. + * @see InvariantChecker_PROTOTYPE */ inline InvariantChecks getInvariantChecks() diff --git a/include/xrpl/tx/invariants/InvariantCheckPrivilege.h b/include/xrpl/tx/invariants/InvariantCheckPrivilege.h index e55419dfc1..a2d582145d 100644 --- a/include/xrpl/tx/invariants/InvariantCheckPrivilege.h +++ b/include/xrpl/tx/invariants/InvariantCheckPrivilege.h @@ -1,3 +1,20 @@ +/** @file + * Per-transaction-type privilege bitmasks used by the invariant checker. + * + * Each transaction type published in `transactions.macro` carries a + * `Privilege` bitmask that declares what ledger mutations it is permitted to + * perform. Invariant checkers call `hasPrivilege()` in their `finalize()` + * methods to enforce that no forbidden mutation occurred. + * + * @note The `assert(enforce)` pattern: several invariant checker files + * contain `XRPL_ASSERT(enforce, ...)` where `enforce` is `true` only + * when the relevant amendment is active. This is a deliberate two-layer + * defence strategy. Invariants should never fire in production; the + * assert is aimed at developers — it fires in debug/unit-test builds + * when code violates an invariant without the protecting amendment being + * enabled, catching bugs as early as possible while remaining invisible + * to validators in release builds. + */ #pragma once #include @@ -6,49 +23,114 @@ namespace xrpl { -/* -assert(enforce) - -There are several asserts (or XRPL_ASSERTs) in invariant check files that check -a variable named `enforce` when an invariant fails. At first glance, those -asserts may look incorrect, but they are not. - -Those asserts take advantage of two facts: -1. `asserts` are not (normally) executed in release builds. -2. Invariants should *never* fail, except in tests that specifically modify - the open ledger to break them. - -This makes `assert(enforce)` sort of a second-layer of invariant enforcement -aimed at _developers_. It's designed to fire if a developer writes code that -violates an invariant, and runs it in unit tests or a develop build that _does -not have the relevant amendments enabled_. It's intentionally a pain in the neck -so that bad code gets caught and fixed as early as possible. -*/ - -// Bitwise flags, 86 files, used in macros files +/** Bitmask of operations that a transaction type is permitted to perform. + * + * Each enumerator represents one class of ledger mutation. The `must*` + * variants mean the transaction is *required* to perform that mutation on + * success; the `may*` variants mean it is *permitted* but not required. + * Invariant checkers use this distinction to differentiate structural + * violations (wrong count) from policy violations (wrong type). + * + * Privilege sets for each transaction type are declared in + * `transactions.macro` and composed with `operator|`. Unknown or + * deprecated transaction types carry `NoPriv`. + * + * @see hasPrivilege() + * @see transactions.macro + */ // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum Privilege { - NoPriv = 0x0000, // The transaction can not do any of the enumerated operations - CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. - CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account, - // which implies createAcct - MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object - MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT - // object, but does not have to - OverrideFreeze = 0x0010, // The transaction can override some freeze rules - ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT - CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance - DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance - MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT - // object (except by issuer) - MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT - // object (except by issuer) - MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create. - MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault - MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault - MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer. + /** The transaction may not perform any of the enumerated operations. */ + NoPriv = 0x0000, + + /** The transaction may create a new `ACCOUNT_ROOT` object. */ + CreateAcct = 0x0001, + + /** The transaction may create a pseudo-account `ACCOUNT_ROOT`. + * + * Implies `CreateAcct`; checkers that permit `CreatePseudoAcct` also + * accept `CreateAcct`, but the converse is not true — a transaction + * with only `CreateAcct` must not create a pseudo-account. + */ + CreatePseudoAcct = 0x0002, + + /** The transaction must delete exactly one `ACCOUNT_ROOT` on success. + * + * Example: `ttACCOUNT_DELETE`, `ttAMM_DELETE`. + */ + MustDeleteAcct = 0x0004, + + /** The transaction may delete one `ACCOUNT_ROOT`, but is not required to. + * + * Example: `ttAMM_WITHDRAW`, `ttAMM_CLAWBACK` (when LP-token supply + * reaches zero). + */ + MayDeleteAcct = 0x0008, + + /** The transaction may bypass certain freeze rules. + * + * Example: `ttAMM_CLAWBACK` (clawback against AMM trust lines is + * permitted even under global freeze). + */ + OverrideFreeze = 0x0010, + + /** The transaction may mint or burn an NFT, changing `sfMintedNFTokens` + * or `sfBurnedNFTokens` on an account root. + */ + ChangeNftCounts = 0x0020, + + /** The transaction may create a new MPT issuance object. */ + CreateMptIssuance = 0x0040, + + /** The transaction may destroy an existing MPT issuance object. */ + DestroyMptIssuance = 0x0080, + + /** The transaction must create or delete an MPT holder object + * (non-issuer path). + * + * Example: `ttMPTOKEN_AUTHORIZE` when the holder explicitly opts in or + * out. + */ + MustAuthorizeMpt = 0x0100, + + /** The transaction may create or delete an MPT holder object + * (non-issuer path), but is not required to. + * + * Example: `ttAMM_WITHDRAW`, `ttAMM_CLAWBACK`. + */ + MayAuthorizeMpt = 0x0200, + + /** The transaction may delete an MPT holder object but may not create one. + * + * Example: `ttMPTOKEN_ISSUANCE_DESTROY`. + */ + MayDeleteMpt = 0x0400, + + /** The transaction must modify, create, or delete a vault object. */ + MustModifyVault = 0x0800, + + /** The transaction may modify, create, or delete a vault object, + * but is not required to. + */ + MayModifyVault = 0x1000, + + /** The transaction may create an MPT holder object (non-issuer path). + * + * Example: `ttPAYMENT`, `ttCHECK_CASH`. + */ + MayCreateMpt = 0x2000, }; +/** Compose two `Privilege` bitmasks with bitwise-OR. + * + * Used in `transactions.macro` to declare combined privilege sets such as + * `CreateAcct | MayCreateMpt`. `safeCast` guards against accidental + * out-of-range integer conversions. + * + * @param lhs Left-hand privilege set. + * @param rhs Right-hand privilege set. + * @return A `Privilege` value whose set bits are the union of `lhs` and `rhs`. + */ constexpr Privilege operator|(Privilege lhs, Privilege rhs) { @@ -57,6 +139,24 @@ operator|(Privilege lhs, Privilege rhs) safeCast>(rhs)); } +/** Query whether a transaction type holds a given privilege. + * + * Implemented in `InvariantCheck.cpp` via an X-macro expansion of + * `transactions.macro`: each transaction type's `privileges` bitmask is + * AND-ed against `priv` and returned as a `bool`. Deprecated or unknown + * transaction types return `false` (no privileges). + * + * Called exclusively from invariant checker `finalize()` methods to + * determine whether a ledger mutation that was observed is permitted for + * the transaction type being applied. Failed transactions carry no + * privileges regardless of type — checkers must guard on `isTesSuccess` + * separately when the privilege implies a required mutation. + * + * @param tx The transaction being applied. + * @param priv The privilege bit (or OR-composed set of bits) to test. + * @return `true` if `tx`'s transaction type holds all bits in `priv`. + * @see Privilege + */ bool hasPrivilege(STTx const& tx, Privilege priv); diff --git a/include/xrpl/tx/invariants/LoanBrokerInvariant.h b/include/xrpl/tx/invariants/LoanBrokerInvariant.h index e7d14a638b..72dd064986 100644 --- a/include/xrpl/tx/invariants/LoanBrokerInvariant.h +++ b/include/xrpl/tx/invariants/LoanBrokerInvariant.h @@ -11,45 +11,134 @@ namespace xrpl { -/** - * @brief Invariants: Loan brokers are internally consistent +/** Invariant checker that verifies every touched `LoanBroker` ledger object is + * internally consistent after a transaction. * - * 1. If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most one - * node (the root), which will only hold entries for `RippleState` or - * `MPToken` objects. + * Implements the `InvariantChecker_PROTOTYPE` two-method interface and is + * registered unconditionally in the `InvariantChecks` tuple. It enforces: * + * 1. **Zero-OwnerCount directory structure** — when `sfOwnerCount == 0` the + * broker's owner directory may have at most one root page containing at + * most one entry, and that entry must be an `ltRIPPLE_STATE` or + * `ltMPTOKEN` object. + * 2. **Sequence monotonicity** — `sfLoanSequence` must not decrease between + * the before and after snapshots. + * 3. **Non-negative financials** — `sfDebtTotal` and `sfCoverAvailable` must + * each be ≥ 0. + * 4. **Vault linkage** — `sfVaultID` must reference an existing `ltVAULT`. + * 5. **Cover–balance lower bound** — `sfCoverAvailable` must be ≥ the + * pseudo-account's actual asset balance (via `accountHolds`). + * 6. **Cover–balance upper bound** (when `fixSecurity3_1_3` is active) — + * `sfCoverAvailable` must also be ≤ the pseudo-account balance, except + * during `ttLOAN_BROKER_DELETE` where `sfCoverAvailable` is intentionally + * left un-zeroed at deletion. + * + * Brokers are discovered through three channels during `visitEntry`: direct + * `ltLOAN_BROKER` touches, `ltACCOUNT_ROOT` pseudo-account touches carrying + * `sfLoanBrokerID`, and deferred resolution of modified `ltRIPPLE_STATE` / + * `ltMPTOKEN` entries whose owning accounts may be broker pseudo-accounts. + * The map-keyed design deduplicates brokers reached through multiple channels. + * + * @note No amendment gate is needed: `LoanBroker` objects can only exist when + * the Lending Protocol amendment is active, so an empty `brokers_` map + * on pre-amendment ledgers is the correct fast-path. */ class ValidLoanBroker { - // Not all of these elements will necessarily be populated. Remaining items - // will be looked up as needed. + /** Snapshot pair for one broker implicated by a transaction. + * + * `brokerAfter` is the primary snapshot used for absolute-value checks. + * `brokerBefore` is used only for monotonicity checks that require a + * before/after comparison; it may be `nullptr` for newly-created brokers. + * Either pointer may be `nullptr` if the broker was discovered indirectly + * (e.g., through a modified trust line); in that case `finalize()` reads + * the current SLE from the view. + */ struct BrokerInfo { SLE::const_pointer brokerBefore = nullptr; - // After is used for most of the checks, except - // those that check changed values. SLE::const_pointer brokerAfter = nullptr; }; - // Collect all the LoanBrokers found directly or indirectly through - // pseudo-accounts. Key is the brokerID / index. It will be used to find the - // LoanBroker object if brokerBefore and brokerAfter are nullptr + + /** Brokers implicated by this transaction, keyed by ledger index (broker + * ID). Populated directly from `ltLOAN_BROKER` or `ltACCOUNT_ROOT` + * (`sfLoanBrokerID`) touches in `visitEntry`, and extended in `finalize` + * via trust-line and MPToken endpoint resolution. `std::map::emplace` + * prevents double-entry when the same broker is reached through multiple + * channels. + */ std::map brokers_; - // Collect all the modified trust lines. Their high and low accounts will be - // loaded to look for LoanBroker pseudo-accounts. + + /** Modified trust lines collected during `visitEntry`. In `finalize`, + * both the high and low account of each line are resolved; if either + * carries `sfLoanBrokerID` the broker is added to `brokers_`. + */ std::vector lines_; - // Collect all the modified MPTokens. Their accounts will be loaded to look - // for LoanBroker pseudo-accounts. + + /** Modified MPToken entries collected during `visitEntry`. In `finalize`, + * the owning account of each token is resolved; if it carries + * `sfLoanBrokerID` the broker is added to `brokers_`. + */ std::vector mpts_; + /** Validate the owner directory of a broker whose `sfOwnerCount` is zero. + * + * Checks that `dir` is a single-page directory (no chained pages via + * `sfIndexNext` / `sfIndexPrevious`) containing at most one entry, and + * that the entry — if present — resolves to an `ltRIPPLE_STATE` or + * `ltMPTOKEN` object. Logs a fatal message for each violation found. + * + * @param view Read-only view used to resolve the referenced object. + * @param dir The owner-directory root SLE to inspect. + * @param j Journal for fatal-level diagnostic logging. + * @return `true` if the directory satisfies the zero-owner-count + * structural constraint; `false` otherwise. + */ static bool goodZeroDirectory(ReadView const& view, SLE::const_ref dir, beast::Journal const& j); public: + /** Accumulate ledger entries modified by the transaction for later + * validation. + * + * Records `ltLOAN_BROKER` SLEs directly into `brokers_` with their + * before/after snapshots. Records `ltACCOUNT_ROOT` entries that carry + * `sfLoanBrokerID` (broker pseudo-accounts) as broker stubs so that + * `finalize()` will look them up even if the `ltLOAN_BROKER` SLE was not + * itself modified. Collects `ltRIPPLE_STATE` and `ltMPTOKEN` entries + * into `lines_` and `mpts_` for deferred broker-discovery in `finalize`. + * + * @param isDelete `true` when the entry is being removed from the ledger. + * @param before SLE snapshot before the transaction; `nullptr` if the + * entry was created by this transaction. + * @param after SLE snapshot after the transaction; `nullptr` if the + * entry was deleted by this transaction. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after); + /** Validate all `LoanBroker` objects implicated by the transaction. + * + * First extends `brokers_` by resolving the high/low accounts of every + * collected trust line and the owner account of every collected MPToken, + * adding any broker pseudo-accounts discovered there. Then iterates over + * `brokers_` and for each broker asserts the six invariants described in + * the class documentation. + * + * @param tx The transaction being applied (used to detect + * `ttLOAN_BROKER_DELETE`, which exempts the cover upper-bound check). + * @param ter Result code returned by `doApply` (unused by this checker). + * @param fee Fee charged by the transaction (unused by this checker). + * @param view Read-only post-transaction ledger view used to resolve + * broker SLEs not captured directly during `visitEntry`, vault + * objects, and pseudo-account balances. + * @param j Journal for fatal-level diagnostic logging on failure. + * @return `true` if every implicated broker satisfies all invariants; + * `false` if any invariant is violated (transaction will be rolled + * back and escalated to `tecINVARIANT_FAILED`). + */ bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); + finalize(STTx const& tx, TER const ter, XRPAmount const fee, ReadView const& view, beast::Journal const& j); }; } // namespace xrpl diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index bda9c51653..b71736a05c 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -9,24 +9,85 @@ namespace xrpl { -/** - * @brief Invariants: Loans are internally consistent +/** Invariant checker that verifies every touched `ltLOAN` ledger entry is + * internally consistent after a transaction. * - * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * Implements the two-method invariant-checker interface and is registered + * unconditionally in the `InvariantChecks` tuple. If the Lending Protocol + * amendment is not active, no `ltLOAN` objects can exist, so `visitEntry` + * never collects anything and `finalize` returns `true` immediately — no + * explicit amendment gate is required. * + * The following invariants are enforced on every collected loan: + * 1. **Payment-completion consistency (zero direction)** — if + * `sfPaymentRemaining == 0` then `sfTotalValueOutstanding`, + * `sfPrincipalOutstanding`, and `sfManagementFeeOutstanding` must all + * be zero (loan is fully settled). + * 2. **Payment-completion consistency (non-zero direction)** — if + * `sfPaymentRemaining != 0` then at least one of the outstanding amounts + * must also be non-zero (a zeroed balance cannot coexist with a non-zero + * payment count). + * 3. **Overpayment flag immutability** — `lsfLoanOverpayment` must not + * change during a transaction. + * 4. **Non-negative financial fields** — `sfLoanServiceFee`, + * `sfLatePaymentFee`, `sfClosePaymentFee`, `sfPrincipalOutstanding`, + * `sfTotalValueOutstanding`, and `sfManagementFeeOutstanding` must each + * be ≥ 0. + * 5. **Strictly positive periodic payment** — `sfPeriodicPayment` must be + * > 0; a zero or negative value would produce undefined amortization. */ class ValidLoan { - // Pair is . After is used for most of the checks, except - // those that check changed values. + /** Collected loan entries touched by the transaction. + * + * Each pair holds the (before, after) SLE snapshots. `after` is used + * for all absolute-value checks; `before` is used only for the + * overpayment-flag change detection and may be `nullptr` for newly + * created loans. + */ std::vector> loans_; public: + /** Accumulate `ltLOAN` entries modified by the transaction for later + * validation. + * + * Records an entry only when `after` is non-null and its type is + * `ltLOAN`; deletions (where `after` is null) are intentionally + * ignored because a removed loan has no post-transaction state to + * validate. + * + * @param isDelete `true` when the entry is being removed from the + * ledger (unused — filtering is based on `after` nullness). + * @param before SLE snapshot before the transaction; `nullptr` if + * the entry was created by this transaction. + * @param after SLE snapshot after the transaction; `nullptr` if + * the entry was deleted by this transaction. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after); + /** Validate all `ltLOAN` objects collected during `visitEntry`. + * + * Iterates over the collected (before, after) pairs and enforces the + * five invariants described in the class documentation. Returns `false` + * and emits a `fatal`-level journal message on the first violation + * detected within each loan entry. + * + * The `tx`, `fee`, and `view` parameters are unused; loan consistency + * can be verified entirely from the collected SLE snapshots. + * + * @param tx The transaction being applied (unused by this checker). + * @param ter Result code returned by `doApply` (unused by this checker). + * @param fee Fee charged by the transaction (unused by this checker). + * @param view Read-only post-transaction ledger view (unused by this + * checker). + * @param j Journal for fatal-level diagnostic logging on failure. + * @return `true` if every collected loan satisfies all invariants; + * `false` if any invariant is violated (triggers rollback and + * escalation to `tecINVARIANT_FAILED`). + */ bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); + finalize(STTx const& tx, TER const ter, XRPAmount const fee, ReadView const& view, beast::Journal const& j); }; } // namespace xrpl diff --git a/include/xrpl/tx/invariants/MPTInvariant.h b/include/xrpl/tx/invariants/MPTInvariant.h index 7f405814e7..8cd817756b 100644 --- a/include/xrpl/tx/invariants/MPTInvariant.h +++ b/include/xrpl/tx/invariants/MPTInvariant.h @@ -1,3 +1,23 @@ +/** @file + * Invariant checker declarations for Multi-Purpose Token (MPT) ledger + * consistency. + * + * Declares `ValidMPTIssuance` and `ValidMPTPayment`, which together guard + * the MPT subsystem against state corruption after every transaction + * application. Both classes follow the two-phase invariant contract: + * `visitEntry()` accumulates per-SLE data, and `finalize()` renders a + * pass/fail verdict once all entries have been visited. They are registered + * in the `InvariantChecks` tuple in `InvariantCheck.h` and run + * unconditionally — including on failed transactions — as the last line of + * defence against unexpected ledger mutations. + * + * `ValidMPTIssuance` addresses *object lifecycle* (did the correct set of + * `ltMPTOKEN_ISSUANCE` and `ltMPTOKEN` entries appear or disappear?). + * `ValidMPTPayment` addresses *numeric conservation* (does each issuance's + * `OutstandingAmount` remain equal to the sum of all holder balances?). + * The two concerns are kept in separate classes so each remains small and + * straightforward to audit. + */ #pragma once #include @@ -9,6 +29,38 @@ namespace xrpl { +/** Invariant checker that enforces structural integrity of MPT lifecycle + * objects. + * + * During `visitEntry()` the checker counts `ltMPTOKEN_ISSUANCE` and + * `ltMPTOKEN` entries created and deleted, and flags the pathological case + * where a new `ltMPTOKEN` was auto-created for the issuance's own issuer + * account (always a protocol violation). + * + * During `finalize()` the accumulated counts are validated against the + * transaction's privilege bitmask (see `InvariantCheckPrivilege.h`): + * - `createMPTIssuance` → exactly one `ltMPTOKEN_ISSUANCE` created, none + * deleted. + * - `destroyMPTIssuance` → exactly one deleted, none created. + * - `mustAuthorizeMPT` (holder submission) → exactly one `ltMPTOKEN` + * created or deleted. + * - `mayAuthorizeMPT` (e.g. `ttAMM_WITHDRAW`, `ttAMM_CLAWBACK`) → at most + * one created, at most two deleted (covers both sides of an AMM pool + * dissolution). + * - `mayCreateMPT` → auto-creation only: up to two for `ttAMM_CREATE`, up + * to one for `ttCHECK_CASH`; no deletions. + * - `mayDeleteMPT` → deletions only, no creations. + * - No privilege → all counters must be zero on a successful result. + * + * The issuer-MPToken check uses the `assert(enforce)` soft-rollout pattern: + * the fatal log fires unconditionally, but the hard `false` return is gated + * on `featureSingleAssetVault` or `featureLendingProtocol` being active, + * preserving backward compatibility while surfacing problems to operators + * and debug builds. + * + * @see ValidMPTPayment + * @see InvariantCheckPrivilege.h + */ class ValidMPTIssuance { std::uint32_t mptIssuancesCreated_ = 0; @@ -21,24 +73,89 @@ class ValidMPTIssuance bool mptCreatedByIssuer_ = false; public: + /** Accumulate MPT object counts from a single modified ledger entry. + * + * Updates the four creation/deletion counters for `ltMPTOKEN_ISSUANCE` + * and `ltMPTOKEN` entries. Sets `mptCreatedByIssuer_` when a newly + * created `ltMPTOKEN` belongs to the issuance's own issuer account — + * a condition that is always a protocol violation. + * + * @param isDelete `true` when the entry is being removed from the ledger. + * @param before Snapshot of the SLE before the transaction; null if the + * entry did not exist prior to this transaction. + * @param after Snapshot of the SLE after the transaction; null when + * the entry has been erased. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after); + /** Verify that the transaction's MPT object lifecycle changes were + * authorized by its privilege mask. + * + * Dispatches on the transaction's privilege flags rather than its type, + * keeping the logic independent of the growing set of MPT-capable + * transaction types. With `featureMPTokensV2` enabled, `tecINCOMPLETE` + * results are also subject to full invariant checking because partial + * progress is still valid ledger state. + * + * @param tx The transaction that was applied. + * @param result The `TER` returned by `doApply()` or the post-reset + * result. + * @param fee The fee deducted (unused by this checker). + * @param view Read-only view of the post-apply ledger, used to query + * active amendment rules. + * @param j Journal for fatal-level diagnostics on violation. + * @return `true` if all MPT lifecycle constraints are satisfied. + */ [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; -/** Verify: - * - OutstandingAmount <= MaximumAmount for any MPT - * - OutstandingAmount after = OutstandingAmount before + - * sum (MPT after - MPT before) - this is total MPT credit/debit +/** Invariant checker that enforces conservation of MPT outstanding amounts. + * + * After any successful transaction, each `ltMPTOKEN_ISSUANCE` entry's + * `OutstandingAmount` must equal the sum of all holder `ltMPTOKEN` balances + * (`sfMPTAmount + sfLockedAmount`) for that issuance. The conservation + * equation is: + * + * ``` + * OutstandingAmount[After] == + * OutstandingAmount[Before] + Σ(MPTAmount[After] − MPTAmount[Before]) + * ``` + * + * where locked amounts are included because they remain part of the + * outstanding supply. + * + * Overflow is treated as a first-class failure: any individual amount that + * exceeds `kMAX_MP_TOKEN_AMOUNT`, or any arithmetic wrap-around in the + * accumulated delta, sets `overflow_` and causes `finalize()` to fail + * immediately. Enforcement is amendment-gated on `featureMPTokensV2`. + * + * @note Unlike `ValidMPTIssuance`, `finalize()` is non-`const` because the + * `data_` accumulator may be mutated lazily; `finalize()` is the single + * point at which all data is final. + * + * @see ValidMPTIssuance */ class ValidMPTPayment { + /** Index into `MPTData::outstanding` distinguishing the pre- and + * post-transaction `OutstandingAmount` snapshots. + */ enum class Order { Before = 0, After = 1 }; + + /** Per-issuance accounting data accumulated across all visited SLEs. */ struct MPTData { + /** `OutstandingAmount` before (`[0]`) and after (`[1]`) the + * transaction. + */ std::array outstanding{}; + + /** Net delta across all holder `ltMPTOKEN` entries: + * Σ(`sfMPTAmount` + `sfLockedAmount`)_after − + * Σ(`sfMPTAmount` + `sfLockedAmount`)_before. + */ // sum (MPT after - MPT before) std::int64_t mptAmount{0}; }; @@ -49,9 +166,48 @@ class ValidMPTPayment hash_map data_; public: + /** Accumulate outstanding-amount snapshots and holder-balance deltas for + * a single modified ledger entry. + * + * For `ltMPTOKEN_ISSUANCE` entries, records the before/after + * `sfOutstandingAmount` and checks that the post-apply value does not + * exceed `sfMaximumAmount`. For `ltMPTOKEN` entries, adds or subtracts + * `sfMPTAmount + sfLockedAmount` from the running `mptAmount` delta. + * Sets `overflow_` and returns early if any individual value exceeds + * `kMAX_MP_TOKEN_AMOUNT` or if the addition would wrap a 64-bit integer. + * + * @note The `isDelete` parameter is unused because deletion is already + * signalled by `after == nullptr`; the presence of `before` is + * sufficient to handle the before-state contribution. + * + * @param before Snapshot of the SLE before the transaction; null for + * newly inserted entries. + * @param after Snapshot of the SLE after the transaction; null for + * deleted entries. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, std::shared_ptr const& before, std::shared_ptr const& after); + /** Verify the `OutstandingAmount` conservation invariant for all touched + * MPT issuances. + * + * Iterates over every MPT ID recorded during `visitEntry()` and checks + * that `OutstandingAmount[After] == OutstandingAmount[Before] + mptAmount`. + * Fails immediately if `overflow_` was set during accumulation, or if the + * final delta arithmetic would overflow. Only `tesSUCCESS` results + * trigger the check; the invariant passes silently for failed transactions. + * Hard-fails (returns `false`) only when `featureMPTokensV2` is active; + * before activation, violations are logged at fatal severity but return + * `true`. + * + * @param tx The transaction that was applied (unused by this checker). + * @param result The `TER` result; only `tesSUCCESS` triggers the check. + * @param fee The fee deducted (unused by this checker). + * @param view Read-only view used to query active amendment rules. + * @param j Journal for fatal-level diagnostics on violation. + * @return `true` if outstanding-amount conservation holds for all touched + * MPT issuances. + */ bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; diff --git a/include/xrpl/tx/invariants/NFTInvariant.h b/include/xrpl/tx/invariants/NFTInvariant.h index fa056ecbb0..f5b0fb8e64 100644 --- a/include/xrpl/tx/invariants/NFTInvariant.h +++ b/include/xrpl/tx/invariants/NFTInvariant.h @@ -10,61 +10,156 @@ namespace xrpl { -/** - * @brief Invariant: Validates several invariants for NFToken pages. +/** Invariant: every touched `ltNFTOKEN_PAGE` entry must satisfy the + * structural rules of the NFToken directory after each transaction. * - * The following checks are made: - * - The page is correctly associated with the owner. - * - The page is correctly ordered between the next and previous links. - * - The page contains at least one and no more than 32 NFTokens. - * - The NFTokens on this page do not belong on a lower or higher page. - * - The NFTokens are correctly sorted on the page. - * - Each URI, if present, is not empty. + * NFToken pages use a composite 256-bit key: the high 160 bits are the + * owning account and the low 96 bits are the page's *high limit* — the + * exclusive upper bound on which token IDs (by their own low 96 bits) + * belong on that page. Six structural properties are checked on every + * snapshot (before and after) of each visited `ltNFTOKEN_PAGE`: + * + * 1. **Link ownership and ordering**: `sfPreviousPageMin`/`sfNextPageMin` + * high 160 bits must match the current page's account; low 96 bits + * must be strictly ordered (`prev < current < next`). + * 2. **Size**: 1–`dirMaxTokensPerPage` (32) tokens, unless the page is + * being deleted (empty is permitted only then). + * 3. **Membership**: each token's page-bits must fall in `[loLimit, hiLimit)`. + * 4. **Sort**: tokens within the page must be strictly ascending under + * `nft::compareTokens()`. + * 5. **URI**: an `sfURI` field, if present, must be non-empty. + * + * Two additional cross-snapshot checks are gated on `fixNFTokenPageLinks` + * to avoid penalising pre-amendment history: + * - Deleting the final page (all 96 page-bits set) while + * `sfPreviousPageMin` is still present would orphan the directory. + * - A non-final page silently losing `sfNextPageMin` breaks forward + * traversal of the directory. + * + * Failures are recorded in boolean flags; `finalize` reports them. + * + * @see InvariantChecker_PROTOTYPE for the two-phase interface contract. + * @see NFTokenCountTracking for the paired mint/burn counter checker. */ class ValidNFTokenPage { + /** True if any token's page-bits fall outside `[loLimit, hiLimit)`. */ bool badEntry_ = false; + /** True if any page-link crosses accounts or violates strict ordering. */ bool badLink_ = false; + /** True if tokens within any page are not strictly ascending. */ bool badSort_ = false; + /** True if any token carries an empty `sfURI` field. */ bool badURI_ = false; + /** True if any non-deleted page has zero tokens or more than 32 tokens. */ bool invalidSize_ = false; + /** True if the final page (all 96 page-bits == 1) was deleted while + * `sfPreviousPageMin` was still present. Gated on `fixNFTokenPageLinks`. */ bool deletedFinalPage_ = false; + /** True if a non-final page lost `sfNextPageMin` without being deleted. + * Gated on `fixNFTokenPageLinks`. */ bool deletedLink_ = false; public: + /** Inspect one touched `ltNFTOKEN_PAGE` entry for structural integrity. + * + * Non-NFT-page SLEs are silently ignored. For each snapshot that is + * present the inner check validates links, size, membership, sort, and + * URI. The two amendment-gated transition checks (`deletedFinalPage_`, + * `deletedLink_`) are applied across the `before`→`after` pair. + * + * @param isDelete True if the SLE is being removed from the ledger; + * allows an empty token array on the `before` snapshot. + * @param before Snapshot of the entry before the transaction, or nullptr + * if the entry is being created. + * @param after Snapshot of the entry after the transaction, or nullptr if + * the entry is being deleted. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after); + /** Render a pass/fail verdict for all accumulated NFT page checks. + * + * Reports the first set flag and returns `false` (triggering + * `tecINVARIANT_FAILED`). The `deletedFinalPage_` and `deletedLink_` + * checks are enforced only when `fixNFTokenPageLinks` is active, so + * pre-amendment historical replay is unaffected. + * + * @param tx Unused. + * @param result Unused. + * @param fee Unused. + * @param view The post-transaction ledger view; used to query whether + * `fixNFTokenPageLinks` is active. + * @param j Journal for fatal-level diagnostics on failure. + * @return True if all NFT page invariants pass; false on any violation. + */ [[nodiscard]] bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; + finalize(STTx const& tx, TER const result, XRPAmount const fee, ReadView const& view, beast::Journal const& j) const; }; -/** - * @brief Invariant: Validates counts of NFTokens after all transaction types. +/** Invariant: the global `sfMintedNFTokens` and `sfBurnedNFTokens` totals + * across all touched account roots must change only as the transaction type + * and result code require. * - * The following checks are made: - * - The number of minted or burned NFTokens can only be changed by - * NFTokenMint or NFTokenBurn transactions. - * - A successful NFTokenMint must increase the number of NFTokens. - * - A failed NFTokenMint must not change the number of minted NFTokens. - * - An NFTokenMint transaction cannot change the number of burned NFTokens. - * - A successful NFTokenBurn must increase the number of burned NFTokens. - * - A failed NFTokenBurn must not change the number of burned NFTokens. - * - An NFTokenBurn transaction cannot change the number of minted NFTokens. + * `visitEntry` accumulates pre- and post-transaction values of both fields + * across every `ltACCOUNT_ROOT` touched by the transaction. `finalize` + * then branches on the `ChangeNftCounts` privilege: + * + * - **Without privilege** (all transactions except `ttNFTOKEN_MINT`/`BURN`): + * both totals must be completely unchanged. + * - **`ttNFTOKEN_MINT`** success: minted total must strictly increase; burned + * total must be unchanged. On failure: both totals must be unchanged. + * - **`ttNFTOKEN_BURN`** success: burned total must strictly increase; minted + * total must be unchanged. On failure: both totals must be unchanged. + * + * Tracking global sums rather than per-account deltas is intentional: a + * legitimate mint or burn touches exactly one account's counters, so + * global-sum equality is both necessary and sufficient. Strict inequality + * (`>=` rather than `==`) on success additionally catches counter + * wrap-around and incorrect field rewrites. + * + * @note Failed transactions carry no privileges, so the no-privilege path + * catches counter mutations during a failed mint/burn — a critical + * guard against exploit code that corrupts counters on failure. + * @see InvariantChecker_PROTOTYPE for the two-phase interface contract. + * @see ValidNFTokenPage for the paired page-structure checker. */ class NFTokenCountTracking { + /** Sum of `sfMintedNFTokens` across all touched account roots, before the transaction. */ std::uint32_t beforeMintedTotal_ = 0; + /** Sum of `sfBurnedNFTokens` across all touched account roots, before the transaction. */ std::uint32_t beforeBurnedTotal_ = 0; + /** Sum of `sfMintedNFTokens` across all touched account roots, after the transaction. */ std::uint32_t afterMintedTotal_ = 0; + /** Sum of `sfBurnedNFTokens` across all touched account roots, after the transaction. */ std::uint32_t afterBurnedTotal_ = 0; public: + /** Accumulate `sfMintedNFTokens` and `sfBurnedNFTokens` totals. + * + * Non-account-root SLEs are silently skipped. Absent fields are treated + * as zero via `.value_or(0)`. + * + * @param isDelete Unused. + * @param before Pre-transaction snapshot, or nullptr for new entries. + * @param after Post-transaction snapshot, or nullptr for deleted entries. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after); + /** Validate NFT mint/burn counter invariants for the completed transaction. + * + * @param tx The transaction; used for type and privilege checks. + * @param result The transaction result code; distinguishes success from + * failure for the mint/burn symmetry rules. + * @param fee Unused. + * @param view Unused. + * @param j Journal for fatal-level diagnostics on failure. + * @return True if all count invariants pass; false on any violation. + */ [[nodiscard]] bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; + finalize(STTx const& tx, TER const result, XRPAmount const fee, ReadView const& view, beast::Journal const& j) const; }; } // namespace xrpl diff --git a/include/xrpl/tx/invariants/PermissionedDEXInvariant.h b/include/xrpl/tx/invariants/PermissionedDEXInvariant.h index da187779b2..ffa5c9b37b 100644 --- a/include/xrpl/tx/invariants/PermissionedDEXInvariant.h +++ b/include/xrpl/tx/invariants/PermissionedDEXInvariant.h @@ -1,3 +1,8 @@ +/** @file + * Declares `ValidPermissionedDEX`, the invariant checker that enforces + * domain isolation for the Permissioned DEX feature. + */ + #pragma once #include @@ -8,6 +13,26 @@ namespace xrpl { +/** Invariant checker that enforces the isolation contract of the Permissioned DEX. + * + * Verifies that every successful `ttPAYMENT` or `ttOFFER_CREATE` transaction + * that carries an `sfDomainID` operates exclusively within its declared + * domain: it must not touch order-book directories or offers belonging to a + * different domain, and it must not interact with regular (non-domain) offers. + * Additionally ensures that any hybrid offer produced by an `OfferCreate` is + * structurally well-formed. + * + * The definition of "well-formed" for hybrid offers is amendment-gated: + * before `fixSecurity3_1_3` activates, the checker rejects hybrids with a + * missing `sfDomainID`, missing `sfAdditionalBooks`, or an + * `sfAdditionalBooks` array larger than 1; after the amendment activates, + * the size must be exactly 1 (an empty array is also rejected). + * + * Follows the two-phase `visitEntry` / `finalize` protocol required of all + * invariant checkers registered in `InvariantChecks`. + * + * @see ValidPermissionedDomain + */ class ValidPermissionedDEX { bool regularOffers_ = false; @@ -16,9 +41,48 @@ class ValidPermissionedDEX hash_set domains_; public: + /** Accumulate domain and offer state from a single modified ledger entry. + * + * Called once per modified entry before `finalize`. Inspects only the + * post-transaction (`after`) snapshot; `before` is unused. For each + * `ltDIR_NODE` or `ltOFFER` entry, records any `sfDomainID` encountered + * into `domains_`. Sets `regularOffers_` if an offer lacks `sfDomainID`. + * Sets `badHybrids_` / `badHybridsOld_` if a hybrid offer (`lsfHybrid`) + * is structurally malformed (missing domain, missing `sfAdditionalBooks`, + * or wrong array size). + * + * @param isDelete True if the entry is being deleted (ignored). + * @param before Pre-transaction SLE snapshot (unused by this checker). + * @param after Post-transaction SLE snapshot; may be null for deletions. + */ void visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + /** Evaluate all accumulated state and return whether the invariant holds. + * + * Only active for successful (`isTesSuccess`) `ttPAYMENT` and + * `ttOFFER_CREATE` transactions; all others pass unconditionally. + * Transactions that carry no `sfDomainID` also pass unconditionally — + * unpermissioned transactions are not constrained by this checker. + * + * Checks (in order): + * - For `ttOFFER_CREATE`: no hybrid offer is structurally malformed. + * Which malformation predicate applies depends on whether + * `fixSecurity3_1_3` is active in the current rule set (pre-amendment: + * `badHybridsOld_`; post-amendment: `badHybrids_`). + * - The `ltPERMISSIONED_DOMAIN` referenced by `sfDomainID` exists in + * the ledger — guards against a domain deleted within the same batch. + * - Every domain ID recorded in `domains_` matches the transaction's own + * domain; any foreign domain indicates cross-domain contamination. + * - No regular (non-domain) offers were touched. + * + * @param tx The transaction being applied. + * @param result The TER code returned by `doApply`. + * @param fee The fee charged (unused by this checker). + * @param view Read-only ledger view used to verify domain existence. + * @param j Journal for fatal-level diagnostic logging on failure. + * @return True if the invariant holds; false if a violation was detected. + */ bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; diff --git a/include/xrpl/tx/invariants/PermissionedDomainInvariant.h b/include/xrpl/tx/invariants/PermissionedDomainInvariant.h index 2475ed8f6b..1123e3661a 100644 --- a/include/xrpl/tx/invariants/PermissionedDomainInvariant.h +++ b/include/xrpl/tx/invariants/PermissionedDomainInvariant.h @@ -1,3 +1,8 @@ +/** @file + * Declares `ValidPermissionedDomain`, the invariant checker that enforces the + * structural integrity of `ltPERMISSIONED_DOMAIN` ledger entries. + */ + #pragma once #include @@ -9,31 +14,130 @@ namespace xrpl { -/** - * @brief Invariants: Permissioned Domains must have some rules and - * AcceptedCredentials must have length between 1 and 10 inclusive. +/** Invariant checker that enforces the structural integrity of + * `ltPERMISSIONED_DOMAIN` ledger entries. * - * Since only permissions constitute rules, an empty credentials list - * means that there are no rules and the invariant is violated. + * Every `ltPERMISSIONED_DOMAIN` entry must leave its `sfAcceptedCredentials` + * array in a valid state: non-empty, within the + * `kMAX_PERMISSIONED_DOMAIN_CREDENTIALS_ARRAY_SIZE` (10) cap, free of + * duplicate `(sfIssuer, sfCredentialType)` pairs, and in canonical + * lexicographic sort order as defined by `credentials::makeSorted`. * - * Credentials must be sorted and no duplicates allowed + * These guarantees are foundational: downstream consumers such as + * `credentials::validDomain` iterate `sfAcceptedCredentials` without + * re-validating its structure on every access. * + * Follows the two-phase `visitEntry` / `finalize` protocol required of all + * invariant checkers registered in `InvariantChecks`. `visitEntry` snapshots + * credential-array facts into the `sleStatus_` vector; `finalize` interprets + * those facts in the context of the completed transaction. + * + * The strictness of `finalize` is gated on the `fixPermissionedDomainInvariant` + * amendment. Without the amendment, only a successful `ttPERMISSIONED_DOMAIN_SET` + * is checked. With the amendment, the invariant also asserts that failed + * transactions leave all domain entries untouched, that no transaction affects + * more than one domain entry, that `ttPERMISSIONED_DOMAIN_DELETE` deletes + * exactly one entry, and that no unauthorized transaction type touches a + * domain entry at all. + * + * @see ValidPermissionedDEX + * @see InvariantChecker_PROTOTYPE for the duck-typed interface. */ class ValidPermissionedDomain { + /** Snapshot of credential-array facts for a single `ltPERMISSIONED_DOMAIN` + * entry, recorded by `visitEntry` and consumed by `finalize`. + * + * All fields are derived from the post-transaction (`after`) SLE state. + * `isSorted` and `isUnique` are pre-computed in `visitEntry` so + * `finalize` can evaluate them without re-reading the SLE through + * `ReadView`. The sort check short-circuits on the first out-of-order + * pair (O(n)); `isUnique` relies on `credentials::makeSorted` returning + * an empty set when any duplicate pair is present. + */ struct SleStatus { + /** Raw count of entries in `sfAcceptedCredentials`. + * Zero means no rules exist (always invalid); greater than + * `kMAX_PERMISSIONED_DOMAIN_CREDENTIALS_ARRAY_SIZE` means the array + * is oversized. + */ std::size_t credentialsSize{0}; + + /** True when the array's iteration order matches the canonical order + * produced by `credentials::makeSorted`. Only meaningful when + * `isUnique` is also true; duplicates make the sorted comparison + * meaningless. + */ bool isSorted = false; + + /** True when `credentials::makeSorted` returned a non-empty result, + * indicating no duplicate `(sfIssuer, sfCredentialType)` pairs exist. + */ bool isUnique = false; + + /** Propagated from the `isDel` argument of `visitEntry`; allows + * `finalize` to distinguish a creation/modification from a deletion + * without re-querying the view. + */ bool isDelete = false; }; + + /** Accumulated observations, one per `ltPERMISSIONED_DOMAIN` entry + * touched by the current transaction. + */ std::vector sleStatus_; public: + /** Record credential-array facts for a single modified `ltPERMISSIONED_DOMAIN` + * entry. + * + * Non-domain entries are ignored immediately. Only the post-transaction + * `after` state is examined; the pre-transaction state is irrelevant to + * whether the resulting ledger is structurally valid. Deleted entries + * (where `after` is null) are recorded as deletions with zero credential + * count — `finalize` uses `isDelete` to distinguish them. + * + * @param isDel True when the entry is being deleted by this transaction. + * @param before The ledger entry state before the transaction; may be null + * for newly created entries. Unused by this checker. + * @param after The ledger entry state after the transaction; null for + * deleted entries, in which case this call is a no-op. + */ void visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + /** Apply the invariant policy using the entry facts collected by `visitEntry`. + * + * Behavior depends on whether `fixPermissionedDomainInvariant` is active: + * + * **Without the amendment (legacy path):** only validates after a + * successful `ttPERMISSIONED_DOMAIN_SET` that touched at least one domain + * entry. All other transaction types and all failed transactions pass + * unconditionally. + * + * **With the amendment (strict path):** + * - A failed transaction must not have mutated any domain entry + * (`sleStatus_` must be empty). + * - At most one domain entry may be affected per transaction. + * - `ttPERMISSIONED_DOMAIN_SET`: must have modified (not deleted) exactly + * one entry whose `sfAcceptedCredentials` array satisfies all four + * constraints (non-empty, within cap, unique, sorted). + * - `ttPERMISSIONED_DOMAIN_DELETE`: must have deleted exactly one entry + * and must not have modified it. + * - Any other transaction type: must not have affected any domain entry. + * + * All failures are logged at `fatal` severity before returning `false`, + * which causes `ApplyContext` to roll back the transaction. + * + * @param tx The transaction being finalized. + * @param result The `TER` code produced by `doApply`. + * @param fee The fee charged (unused by this checker). + * @param view Post-transaction read view; used to query whether + * `fixPermissionedDomainInvariant` is active. + * @param j Journal for `fatal`-level diagnostic logging on failure. + * @return `true` if the invariant holds; `false` to veto the transaction. + */ bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 7ca67f546d..9b87eb6b23 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -1,3 +1,12 @@ +/** @file + * Post-transaction invariant checker for the SingleAssetVault feature. + * + * Declares `ValidVault`, the dedicated guardian for vault ledger objects + * (`ltVAULT`, the share `ltMPTOKEN_ISSUANCE`, depositor `ltMPTOKEN` entries, + * vault pseudo-account `ltACCOUNT_ROOT`, and associated trust lines). It is + * registered as position 24 in the `InvariantChecks` tuple and runs after + * every transaction before ledger state is committed. + */ #pragma once #include @@ -14,29 +23,38 @@ namespace xrpl { -/* - * @brief Invariants: Vault object and MPTokenIssuance for vault shares +/** Post-transaction consistency guard for Single Asset Vaults. * - * - vault deleted and vault created is empty - * - vault created must be linked to pseudo-account for shares and assets - * - vault must have MPTokenIssuance for shares - * - vault without shares outstanding must have no shares - * - loss unrealized does not exceed the difference between assets total and - * assets available - * - assets available do not exceed assets total - * - vault deposit increases assets and share issuance, and adds to: - * total assets, assets available, shares outstanding - * - vault withdrawal and clawback reduce assets and share issuance, and - * subtracts from: total assets, assets available, shares outstanding - * - vault set must not alter the vault assets or shares balance - * - no vault transaction can change loss unrealized (it's updated by loan - * transactions) + * Implements the two-phase invariant checker interface required by + * `InvariantChecks`. `visitEntry` accumulates per-entry balance deltas and + * snapshots vault/MPT state; `finalize` evaluates per-transaction invariants + * against those snapshots. * + * All meaningful work is skipped via an early-exit when no vault entry was + * touched, making this checker essentially free for the vast majority of + * transactions. `Number` (high-precision rational) is used throughout for + * asset amounts because IOU assets require fractional precision beyond what + * 64-bit integers can represent losslessly. + * + * Enforcement is gated on `featureSingleAssetVault`: violations are always + * logged at `fatal` level and fire a debug-build `XRPL_ASSERT`, but + * `finalize` only returns `false` (blocking the transaction) once the + * amendment is live on the network. + * + * @see InvariantChecker_PROTOTYPE for the duck-typed interface contract. + * @see InvariantChecks in `InvariantCheck.h` for checker registration. */ class ValidVault { Number static constexpr kZERO{}; + /** Immutable snapshot of a single `ltVAULT` ledger entry. + * + * Captures every field that invariant checks compare across before/after + * states. Constructed exclusively via `Vault::make`; not an aggregate so + * that factory validation (the `XRPL_ASSERT` on entry type) is always + * enforced. + */ struct Vault final { uint256 key = beast::kZERO; @@ -49,25 +67,72 @@ class ValidVault Number assetsMaximum = 0; Number lossUnrealized = 0; + /** Construct a `Vault` snapshot from a live `ltVAULT` ledger entry. + * + * @param from A ledger entry whose type must be `ltVAULT`. + * @return A fully-populated `Vault` value object. + */ Vault static make(SLE const&); }; + /** Immutable snapshot of a single `ltMPTOKEN_ISSUANCE` ledger entry. + * + * Captures identity (`MPTIssue`), current outstanding amount, and + * effective maximum so `finalize` can check share-ceiling constraints. + * Because a modified `ltMPTOKEN_ISSUANCE` may belong to the vault or to + * an entirely unrelated MPT issuance, these are recorded lazily in + * `visitEntry` and resolved against `shareMPTID` in `finalize`. + */ struct Shares final { MPTIssue share; std::uint64_t sharesTotal = 0; std::uint64_t sharesMaximum = 0; + /** Construct a `Shares` snapshot from a live `ltMPTOKEN_ISSUANCE` entry. + * + * When `sfMaximumAmount` is absent the effective maximum defaults to + * `kMAX_MP_TOKEN_AMOUNT`. + * + * @param from A ledger entry whose type must be `ltMPTOKEN_ISSUANCE`. + * @return A fully-populated `Shares` value object. + */ Shares static make(SLE const&); }; public: + /** Signed balance delta for a single ledger entry, with precision metadata. + * + * Pairs a `Number` delta with an optional scale (exponent) so that + * comparisons in `finalize` can round all operands to a common precision + * before testing equality. The scale is `std::nullopt` until the entry + * type is identified in `visitEntry`; a present scale of `0` means the + * value is an integer (XRP drops or MPT integer amount). + * + * Sign convention (set in `visitEntry`): positive delta means assets or + * shares *flowed into* the associated account from the vault's perspective. + * For `ltMPTOKEN_ISSUANCE` the sign is `+1` (outstanding amount increases + * as shares are minted). For account roots, trust lines, and `ltMPTOKEN` + * the sign is `-1` (a balance decrease means assets left the holder). + */ struct DeltaInfo final { Number delta = kNUM_ZERO; std::optional scale; - // Compute the delta between two Numbers, taking the coarsest scale + /** Construct a `DeltaInfo` representing the change from `before` to `after`. + * + * Sets `scale` to the coarser (larger) of the two values' asset-specific + * scales so that subsequent rounding via `roundToAsset` uses a precision + * no finer than either operand. + * + * @param before Pre-transaction numeric value. + * @param after Post-transaction numeric value. + * @param asset Asset type used to determine the appropriate scale for + * each value via `xrpl::scale()`. + * @return `DeltaInfo` where `delta = after - before` and `scale` is the + * maximum of the two operand scales. + */ [[nodiscard]] static DeltaInfo makeDelta(Number const& before, Number const& after, Asset const& asset); }; @@ -80,13 +145,72 @@ private: std::unordered_map deltas_; public: + /** Phase-1 entry visitor: accumulate per-key balance deltas and snapshots. + * + * Called once per modified ledger entry during transaction processing. + * For `ltVAULT` entries, pushes a `Vault` snapshot into `beforeVault_` + * and/or `afterVault_`. For `ltMPTOKEN_ISSUANCE` entries, pushes a + * `Shares` snapshot (vault vs. unrelated issuance is resolved later in + * `finalize`). For balance-carrying entries (`ltMPTOKEN_ISSUANCE`, + * `ltMPTOKEN`, `ltACCOUNT_ROOT`, `ltRIPPLE_STATE`), accumulates a signed + * `DeltaInfo` into `deltas_` keyed by ledger-entry key. + * + * A delta entry is recorded even when the net balance change is zero + * (e.g., a fee exactly offsets an incoming transfer) to avoid treating a + * coincidental zero as "no activity". + * + * @param isDelete `true` when the entry is being deleted; the `after` + * snapshot is not pushed for deleted entries. + * @param before Pre-transaction SLE, or `nullptr` for newly created entries. + * @param after Post-transaction SLE; must be non-null. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after); + /** Phase-2 invariant evaluation: verify vault consistency after transaction. + * + * Called once after all `visitEntry` calls complete. Returns `true` if + * all relevant invariants pass (or the transaction did not touch any vault + * objects), `false` if a violation is detected and `featureSingleAssetVault` + * is active. + * + * Short-circuits immediately on non-`tesSUCCESS` results. Per-transaction + * invariants checked include: + * - At most one vault is created or modified per transaction. + * - Immutable fields (`sfAsset`, `sfAccount`, `sfShareMPTID`) never change. + * - `assetsAvailable` ≤ `assetsTotal` ≥ 0; `assetsMaximum` ≥ 0. + * - `lossUnrealized` ≤ `assetsTotal − assetsAvailable`. + * - `lossUnrealized` changes only for `ttLOAN_MANAGE` / `ttLOAN_PAY`. + * - Asset and share conservation per transaction type (deposit, withdraw, + * clawback, set, create, delete). + * - Deletion co-deletes the share issuance and requires zero assets/shares. + * - Creation produces an empty vault whose pseudo-account back-links via + * `sfVaultID`. + * - Only transactions with `MustModifyVault` or `MayModifyVault` privilege + * may touch vault state at all. + * + * @param tx The applied transaction. + * @param ret Final TER result of the transaction. + * @param fee Transaction fee in drops (compensated in XRP-vault delta checks). + * @param view Current ledger view for read-only fallback lookups. + * @param j Journal for `fatal`-level invariant-failure diagnostics. + * @return `true` if invariants pass or are not applicable; `false` on violation. + */ bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); + finalize(STTx const& tx, TER const ret, XRPAmount const fee, ReadView const& view, beast::Journal const& j); - // Compute the coarsest scale required to represent all numbers + /** Return the coarsest (largest) scale across a set of `DeltaInfo` values. + * + * Invariant comparisons mix values computed at different precisions + * (e.g., XRP drops vs. IOU mantissa-exponent pairs). Rounding all operands + * to the coarsest scale prevents spurious equality failures caused by + * sub-precision differences. + * + * @param numbers Collection of `DeltaInfo` values, each carrying an + * optional scale. An absent scale indicates the value has not yet been + * assigned a precision context (should not occur in well-formed inputs). + * @return The maximum scale value found, or `0` if `numbers` is empty. + */ [[nodiscard]] static std::int32_t computeCoarsestScale(std::vector const& numbers); }; diff --git a/include/xrpl/tx/paths/AMMLiquidity.h b/include/xrpl/tx/paths/AMMLiquidity.h index 3211d0caa7..e18cb44a99 100644 --- a/include/xrpl/tx/paths/AMMLiquidity.h +++ b/include/xrpl/tx/paths/AMMLiquidity.h @@ -1,3 +1,15 @@ +/** @file + * Declares `AMMLiquidity`, the adapter that exposes an on-ledger Automated + * Market Maker pool as a sequence of synthetic offers to the XRPL payment + * engine's `BookStep` traversal layer. + * + * `AMMLiquidity` produces `AMMOffer` objects — virtual offers sized + * from live pool state — so that `BookStep` can consume AMM liquidity + * identically to CLOB (Central Limit Order Book) offers during path execution. + * Two sizing strategies are used depending on whether the payment traverses + * a single path or multiple paths. + */ + #pragma once #include @@ -13,35 +25,77 @@ namespace xrpl { template class AMMOffer; -/** AMMLiquidity class provides AMM offers to BookStep class. - * The offers are generated in two ways. If there are multiple - * paths specified to the payment transaction then the offers - * are generated based on the Fibonacci sequence with - * a limited number of payment engine iterations consuming AMM offers. - * These offers behave the same way as CLOB offers in that if - * there is a limiting step, then the offers are adjusted - * based on their quality. - * If there is only one path specified in the payment transaction - * then the offers are generated based on the competing CLOB offer - * quality. In this case the offer's size is set in such a way - * that the new AMM's pool spot price quality is equal to the CLOB's - * offer quality. +/** Adapts an on-ledger AMM pool to the payment engine's offer-based interface. + * + * `BookStep` iterates over discrete offers sorted by quality. `AMMLiquidity` + * bridges the continuous-liquidity AMM model into that interface by generating + * synthetic `AMMOffer` objects on demand from live pool state. + * + * Two sizing strategies are selected at construction time via `AMMContext`: + * - **Multi-path** (`ammContext_.multiPath()` true): `generateFibSeqOffer()` + * emits exponentially growing offers keyed to the iteration count, so each + * path strand gets a modest initial slice and larger slices only after + * prior iterations have established price quality. + * - **Single-path**: `changeSpotPriceQuality()` computes the exact swap that + * moves the pool's spot price to the competing CLOB offer's quality level, + * maximising value extraction in a single pass. + * + * @tparam TIn Amount type for the pool's input asset (`IOUAmount`, + * `XRPAmount`, or `MPTAmount`). + * @tparam TOut Amount type for the pool's output asset (`IOUAmount`, + * `XRPAmount`, or `MPTAmount`). + * + * @note Not copyable. `AMMLiquidity` holds a mutable reference to `AMMContext` + * (shared state) and an immutable snapshot of pool balances captured at + * construction; a copy would alias that state without representing a + * coherent point in time. `BookStep` stores instances via + * `std::optional::emplace()` to avoid copies. + * @note Explicitly instantiated for all eight valid `(TIn, TOut)` pairs in + * `AMMLiquidity.cpp`; do not add implicit instantiations elsewhere. */ template class AMMLiquidity { private: + /** Base fraction of `initialBalances_.in` used for the first Fibonacci + * offer (iteration 0). Equals 5/20000 = 0.025% of the initial input + * balance, keeping the opening offer small relative to the pool. + */ inline static Number const kINITIAL_FIB_SEQ_PCT = Number(5) / 20000; AMMContext& ammContext_; AccountID const ammAccountID_; std::uint32_t const tradingFee_; Asset const assetIn_; Asset const assetOut_; - // Initial AMM pool balances + /** Pool balances captured at construction time. + * + * Used as the scaling base for Fibonacci offer sizes in + * `generateFibSeqOffer()`. Keeping these fixed across iterations ensures + * offer sizes are deterministic given the same starting state; using live + * balances would create feedback loops where earlier iterations change + * the sizes of later ones unpredictably. + */ TAmounts const initialBalances_; beast::Journal const j_; public: + /** Construct an `AMMLiquidity` for the given AMM account. + * + * Immediately fetches pool balances from `view` and stores them in + * `initialBalances_` for use as a Fibonacci scaling base. + * + * @param view Read-only ledger view used to fetch initial + * pool balances. + * @param ammAccountID On-ledger account ID of the AMM pool. + * @param tradingFee AMM trading fee in basis points (0–1000). + * @param in Input-side asset of the pool. + * @param out Output-side asset of the pool. + * @param ammContext Shared context tracking iteration count and + * multi-path state; must outlive this object. + * @param j Journal for diagnostic logging. + * @throws std::runtime_error if either pool balance is negative, which + * indicates ledger corruption. + */ AMMLiquidity( ReadView const& view, AccountID const& ammAccountID, @@ -55,10 +109,33 @@ public: AMMLiquidity& operator=(AMMLiquidity const&) = delete; - /** Generate AMM offer. Returns nullopt if clobQuality is provided - * and it is better than AMM offer quality. Otherwise returns AMM offer. - * If clobQuality is provided then AMM offer size is set based on the - * quality. + /** Generate a synthetic AMM offer for the current payment engine iteration. + * + * Returns `std::nullopt` without generating an offer when: + * - `AMMContext::maxItersReached()` is true (30-iteration cap exhausted), + * - Either pool balance is zero (frozen account), + * - The pool's current spot-price quality is less than or within 1e-7 of + * `clobQuality` (AMM cannot profitably compete), or + * - The chosen sizing strategy produces an offer of zero size or overflow. + * + * Strategy selection: + * - **Multi-path**: delegates to `generateFibSeqOffer()`, then discards + * the result if its quality is below `clobQuality`. + * - **Single-path, no CLOB**: delegates to `maxOffer()`; `BookStep` will + * trim the offer to the actual delivery limit. + * - **Single-path, with CLOB**: uses `changeSpotPriceQuality()` to size + * the offer so that full consumption moves the spot price to exactly + * `clobQuality`. Falls back to `maxOffer()` under `fixAMMv1_2` if + * `changeSpotPriceQuality()` returns nothing and `maxOffer()` beats + * `clobQuality`. On `std::overflow_error` (pre-`fixAMMOverflowOffer`) + * falls back to `maxOffer()` rather than propagating. + * + * @param view Current read-only ledger view; used to fetch live + * pool balances and check active amendments. + * @param clobQuality Quality of the best competing CLOB offer, or + * `std::nullopt` if no CLOB offer is available on this strand. + * @return A synthetic `AMMOffer` priced from live pool state, or + * `std::nullopt` if the AMM has no profitable offer to contribute. */ [[nodiscard]] std::optional> getOffer(ReadView const& view, std::optional const& clobQuality) const; @@ -100,29 +177,51 @@ public: } private: - /** Fetches current AMM balances. + /** Fetch live pool balances from the ledger. + * + * @param view Read-only ledger view. + * @return Balances as a `TAmounts` pair. + * @throws std::runtime_error if either balance is negative, indicating + * ledger corruption (the AMM invariant checker guarantees non-negative + * balances under normal operation). */ [[nodiscard]] TAmounts fetchBalances(ReadView const& view) const; - /** Generate AMM offers with the offer size based on Fibonacci sequence. - * The sequence corresponds to the payment engine iterations with AMM - * liquidity. Iterations that don't consume AMM offers don't count. - * The number of iterations with AMM offers is limited. - * If the generated offer exceeds the pool balance then the function - * throws overflow exception. + /** Compute the offer size for one multi-path engine iteration using the + * Fibonacci sequence. + * + * The base offer (`curIters == 0`) is `kINITIAL_FIB_SEQ_PCT × initialBalances_.in` + * (0.025% of the input balance at construction). For subsequent iterations + * the output amount is scaled by `kFIB[curIters - 1]`, a hard-coded + * 30-entry Fibonacci table matching `AMMContext::kMAX_ITERATIONS`. + * Scaling against `initialBalances_` (not live balances) keeps offer + * sizes deterministic across iterations. + * + * @param balances Current live pool balances, used to derive the input + * amount via `swapAssetOut()` and to guard against overflow. + * @return Synthetic `TAmounts` representing the offer's `{in, out}` pair. + * @throws std::overflow_error if the computed output equals or exceeds + * `balances.out`; the caller (`getOffer`) catches this and falls back + * to `std::nullopt` or `maxOffer()` depending on the active amendments. */ [[nodiscard]] TAmounts generateFibSeqOffer(TAmounts const& balances) const; - /** Generate max offer. - * If `fixAMMOverflowOffer` is active, the offer is generated as: - * takerGets = 99% * balances.out takerPays = swapOut(takerGets). - * Return nullopt if takerGets is 0 or takerGets == balances.out. + /** Construct the largest safe synthetic offer against the pool. * - * If `fixAMMOverflowOffer` is not active, the offer is generated as: - * takerPays = max input amount; - * takerGets = swapIn(takerPays). + * Behaviour depends on the `fixAMMOverflowOffer` amendment: + * - **Active**: `takerGets = 99% × balances.out` (rounded down); + * `takerPays = swapAssetOut(takerGets)`. Returns `std::nullopt` if the + * 99% cap rounds to zero or equals `balances.out` (degenerate pool). + * - **Inactive** (legacy): `takerPays = maxAmount()` (protocol + * ceiling); `takerGets = swapAssetIn(takerPays)`. This path could + * overflow on large pools — the bug that motivated the amendment. + * + * @param balances Current live pool balances. + * @param rules Active amendment rules, used to gate `fixAMMOverflowOffer`. + * @return The maximum-size `AMMOffer`, or `std::nullopt` if the pool is + * too small to produce a valid offer under the fixed path. */ [[nodiscard]] std::optional> maxOffer(TAmounts const& balances, Rules const& rules) const; diff --git a/src/libxrpl/basics/Archive.cpp b/src/libxrpl/basics/Archive.cpp index bba144ed04..98ad838400 100644 --- a/src/libxrpl/basics/Archive.cpp +++ b/src/libxrpl/basics/Archive.cpp @@ -1,3 +1,8 @@ +/** @file + * Implements `extractTarLz4`, the decompression primitive used when + * bootstrapping an XRPL node from a pre-built ledger database snapshot. + */ + #include #include @@ -14,6 +19,37 @@ namespace xrpl { +/** Decompress and extract a `.tar.lz4` archive into a destination directory. + * + * Uses the libarchive two-handle idiom: a read handle (`ar`) for + * decompression and TAR parsing, and a disk-write handle (`aw`) for + * filesystem output. Both handles are managed by RAII `unique_ptr` with + * custom deleters so they are released on any exit path, including throws. + * + * The reader is narrowed explicitly to TAR + LZ4 rather than using + * auto-detect, so passing a different archive format fails early with a + * clear error. The writer restores timestamps, permissions, ACLs, and file + * flags (`ARCHIVE_EXTRACT_TIME | ARCHIVE_EXTRACT_PERM | ARCHIVE_EXTRACT_ACL + * | ARCHIVE_EXTRACT_FFLAGS`) and resolves user/group names via the local + * system's user database. + * + * Every stored entry pathname is prefixed with `dst` before writing, so all + * output lands under the caller-supplied destination regardless of the paths + * recorded in the archive. + * + * @param src Path to the `.tar.lz4` source file; must be a regular file. + * @param dst Destination directory under which all entries are extracted. + * @throws std::runtime_error if `src` is not a regular file, if any + * libarchive call fails, or if a filesystem write error occurs. On + * error mid-extraction the destination is left in a partial state; + * callers that require atomicity must clean up themselves. + * @note The pathname rewrite prepends `dst` but does not strip `..` + * components from stored entry names. Archives from untrusted sources + * could use paths such as `../../etc/cron.d/evil` to escape `dst`. + * This is acceptable for the trusted first-party snapshots this + * function was designed for, but callers should be aware of the + * limitation before using it against untrusted input. + */ void extractTarLz4(boost::filesystem::path const& src, boost::filesystem::path const& dst) { @@ -31,7 +67,7 @@ extractTarLz4(boost::filesystem::path const& src, boost::filesystem::path const& if (archive_read_support_filter_lz4(ar.get()) < ARCHIVE_OK) Throw(archive_error_string(ar.get())); - // Examples suggest this block size + // 10 240-byte block size matches libarchive's own example code. if (archive_read_open_filename(ar.get(), src.string().c_str(), 10240) < ARCHIVE_OK) { Throw(archive_error_string(ar.get())); diff --git a/src/libxrpl/basics/BasicConfig.cpp b/src/libxrpl/basics/BasicConfig.cpp index c1997eb713..75f991104f 100644 --- a/src/libxrpl/basics/BasicConfig.cpp +++ b/src/libxrpl/basics/BasicConfig.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements `Section` and `BasicConfig`, the mid-layer of the XRPL + * configuration subsystem. `Section` parses raw INI lines into structured + * key-value maps while preserving ordering and handling comment syntax. + * `BasicConfig` owns a named map of `Section` objects and exposes the query + * and mutation interface consumed by module-specific config readers. + */ + #include #include @@ -14,20 +22,64 @@ namespace xrpl { +/** Construct a Section with the given name and empty storage. */ Section::Section(std::string name) : name_(std::move(name)) { } +/** Store or replace a key/value pair in the lookup map. + * + * Uses `insert_or_assign` so repeated calls always take the latest value + * without error — this is the path taken by both `append()` (file load) + * and `BasicConfig::overwrite()` (command-line injection). + * + * @param key Setting name. Must be a valid identifier; enforcement is the + * caller's responsibility. + * @param value Setting value, after any comment stripping performed by + * `append()`. + */ void Section::set(std::string const& key, std::string const& value) { lookup_.insert_or_assign(key, value); } +/** Parse and ingest a batch of raw INI lines into this section. + * + * Each line is processed in two passes: + * + * 1. **Comment stripping** — an inline `removeComment` lambda scans for `#`. + * A backslash-escaped `\#` has the backslash removed and scanning + * resumes; a bare `#` truncates the string at that point (trailing + * whitespace is also stripped) and sets `hadTrailingComments_`. + * A leading `#` zeroes the entire line (whole-line comment). + * Empty lines after stripping are skipped. + * + * 2. **Key-value matching** — the cleaned line is tested against the regex + * `^(\s*)([a-zA-Z][_a-zA-Z0-9]*)(\s*)=(\s*)(.*\S+)(\s*)`. + * Keys must start with a letter and consist only of alphanumerics and + * underscores; values must contain at least one non-whitespace character. + * Matching lines are stored in `lookup_` via `set()`. Non-matching lines + * are pushed to `values_` — this is intentional: many config sections + * contain positional entries (peer IPs, validator public keys, file paths) + * that are not key-value pairs. + * + * Every non-empty post-strip line (whether key-value or positional) is also + * pushed to `lines_` to preserve the full input record in order. + * + * The `kRE1` regex object is `static const` and compiled exactly once + * per process lifetime; `boost::regex_constants::optimize` is passed to + * request DFA construction. + * + * @param lines Raw, unprocessed text lines from the INI tokenizer. + * @note This method is not re-entrant on the same `Section` object, but + * multiple calls are safe — subsequent calls accumulate into the + * existing storage. + */ void Section::append(std::vector const& lines) { - // '=' + // Regex for '=' — compiled once for the process lifetime. static boost::regex const kRE1( "^" // start of line "(?:\\s*)" // whitespace (optional) @@ -50,21 +102,20 @@ Section::append(std::vector const& lines) { if (comment == 0) { - // entire value is a comment. In most cases, this - // would have already been handled by the file reader + // Whole-line comment; in most cases the file reader has + // already filtered these, but handle defensively. val = ""; break; } if (val.at(comment - 1) == '\\') { - // we have an escaped comment char. Erase the escape char - // and keep looking + // Escaped '#': remove the backslash and keep scanning + // from the same position (the '#' shifts left by one). val.erase(comment - 1, 1); } else { - // this must be a real comment. Extract the value - // as a substring and stop looking. + // Unescaped '#': truncate here and report truncation. val = trimWhitespace(val.substr(0, comment)); removedTrailing = true; break; @@ -95,12 +146,30 @@ Section::append(std::vector const& lines) } } +/** Returns `true` if a key with the given name exists in the lookup map. + * + * @param name The setting key to look up. + * @return `true` if the key was present in a `key=value` line seen by + * `append()` or injected via `set()`. + */ bool Section::exists(std::string const& name) const { return lookup_.contains(name); } +/** Serialize all key-value pairs in the section as `key=value\n` lines. + * + * Only entries in `lookup_` are emitted; positional `values_` entries and + * the original line ordering from `lines_` are not preserved. Inline + * comments stripped during `append()` are also lost. This means a + * round-trip through `operator<<` is lossy when `hadTrailingComments()` is + * true. + * + * @param os Output stream to write to. + * @param section The `Section` whose key-value pairs are serialized. + * @return The same output stream. + */ std::ostream& operator<<(std::ostream& os, Section const& section) { @@ -111,18 +180,44 @@ operator<<(std::ostream& os, Section const& section) //------------------------------------------------------------------------------ +/** Returns `true` if a section with the given name exists. + * + * @param name Section name to look up (case-sensitive). + * @return `true` if the section was created during `build()`, via the mutable + * `section()` overload, or via `overwrite()`. + */ bool BasicConfig::exists(std::string const& name) const { return map_.contains(name); } +/** Return or create the section with the given name (mutable overload). + * + * Uses `map_.emplace` to auto-create the section on first access, making + * this overload appropriate for mutation paths such as `overwrite()`. + * + * @param name Section name (case-sensitive). + * @return Reference to the existing or newly created `Section`. + */ Section& BasicConfig::section(std::string const& name) { return map_.emplace(name, name).first->second; } +/** Return the section with the given name (const overload). + * + * Implements the null-object pattern: when the requested section does not + * exist, a reference to an internal `static Section const kNONE("")` is + * returned instead of throwing or returning a pointer. This allows the + * common call pattern `config["missing"].get("key")` to safely yield + * `std::nullopt` without null checks by the caller. + * + * @param name Section name (case-sensitive). + * @return Reference to the matching section, or to the empty sentinel if + * no such section exists. + */ Section const& BasicConfig::section(std::string const& name) const { @@ -133,6 +228,18 @@ BasicConfig::section(std::string const& name) const return iter->second; } +/** Inject or replace a single key-value pair, bypassing INI parsing. + * + * Creates the target section if it does not yet exist, then calls + * `Section::set()` directly — skipping the comment-stripping and regex + * machinery of `Section::append()`. This is the intended path for + * command-line argument injection, where CLI-supplied values must + * unconditionally override file-based config regardless of format. + * + * @param section Name of the section to write into (created on demand). + * @param key Setting name. + * @param value Setting value; stored verbatim, no comment processing. + */ void BasicConfig::overwrite(std::string const& section, std::string const& key, std::string const& value) { @@ -141,6 +248,14 @@ BasicConfig::overwrite(std::string const& section, std::string const& key, std:: result.first->second.set(key, value); } +/** Replace an existing section with a fresh empty one, discarding all content. + * + * The `deprecated` prefix signals that wholesale erasure of a section is a + * design smell; callers of this method are candidates for refactoring. If + * the section does not exist this is a no-op. + * + * @param section Name of the section to clear. + */ void BasicConfig::deprecatedClearSection(std::string const& section) { @@ -149,18 +264,54 @@ BasicConfig::deprecatedClearSection(std::string const& section) i->second = Section(section); } +/** Inject a raw (non-key-value) string into a section's first line slot. + * + * Legacy sections hold a single bare value — e.g. a database path or a + * simple flag — rather than key-value pairs. This setter creates the + * section on demand and writes the value via `Section::legacy(string)`, + * which sets or overwrites `lines_[0]`. It is backward-compatibility + * scaffolding and not a general-purpose storage mechanism. + * + * @param section Name of the section to write into (created on demand). + * @param value The raw string to store as the section's sole line. + */ void BasicConfig::legacy(std::string const& section, std::string value) { map_.emplace(section, section).first->second.legacy(std::move(value)); } +/** Retrieve the legacy (single-line) value of a section. + * + * Delegates to `Section::legacy()`, which throws `std::runtime_error` if + * the section has more than one line — the invariant being that a legacy + * section contains exactly one bare value. + * + * @param sectionName Name of the section to query. + * @return The single raw line stored in the section, or an empty string if + * the section does not exist or has no lines. + * @throws std::runtime_error if the section contains more than one line. + */ std::string BasicConfig::legacy(std::string const& sectionName) const { return section(sectionName).legacy(); } +/** Populate `map_` from a tokenized INI file structure. + * + * Iterates the `IniFileSections` map produced by `parseIniFile()` and, for + * each entry, emplaces a corresponding `Section` and calls + * `Section::append()` with the raw line vector. This is the sole path by + * which file-sourced configuration enters `BasicConfig`. + * + * Declared `protected` so that only the `Config` subclass (which manages the + * load sequence) may call it; external callers interact only through the + * query and mutation interface. + * + * @param ifs Tokenized INI data mapping section names to their raw lines, + * as returned by `parseIniFile()`. + */ void BasicConfig::build(IniFileSections const& ifs) { @@ -172,6 +323,17 @@ BasicConfig::build(IniFileSections const& ifs) } } +/** Serialize the entire configuration as INI-style text. + * + * For each section, emits a `[section_name]` header followed by the + * `key=value\n` output of `operator<<(ostream&, Section const&)`. + * Positional `values_` entries and inline comments are not emitted, so the + * output is lossy for sections that used those features. + * + * @param ss Output stream to write to. + * @param c The `BasicConfig` to serialize. + * @return The same output stream. + */ std::ostream& operator<<(std::ostream& ss, BasicConfig const& c) { diff --git a/src/libxrpl/basics/CountedObject.cpp b/src/libxrpl/basics/CountedObject.cpp index 1bf88687f8..bc16c879b2 100644 --- a/src/libxrpl/basics/CountedObject.cpp +++ b/src/libxrpl/basics/CountedObject.cpp @@ -1,9 +1,24 @@ +/** @file + * Implementation of the CountedObjects singleton registry. + * + * CountedObjects maintains a lock-free linked list of Counter nodes — one + * per tracked type — whose live-instance counts are updated atomically by + * the CountedObject CRTP mixin. getCounts() is the only public query + * path; it is called exclusively from the get_counts admin RPC command and + * from test code, never from any ledger-critical path. + */ + #include #include namespace xrpl { +/** Return the process-wide singleton, creating it on first call. + * + * Uses the Meyer's singleton idiom: the function-local static is + * initialised exactly once in a thread-safe manner under C++11 and later. + */ CountedObjects& CountedObjects::getInstance() noexcept { @@ -12,10 +27,28 @@ CountedObjects::getInstance() noexcept return kINSTANCE; } +/** Initialise the registry with an empty counter list. */ CountedObjects::CountedObjects() noexcept : count_(0), head_(nullptr) { } +/** Collect a diagnostic snapshot of live object counts. + * + * Traverses the lock-free linked list of Counter nodes. Because Counter + * nodes are never removed (they are static objects with program lifetime), + * the traversal cannot encounter a dangling pointer, but it may miss a + * Counter that was prepended after the walk started. This is acceptable + * for a purely diagnostic facility. + * + * The result is sorted alphabetically by type name so that successive + * snapshots can be diffed easily. + * + * @param minimumThreshold Suppress entries whose live count is strictly + * below this value. Pass 0 for an exhaustive view; the get_counts + * RPC handler defaults to 10 to reduce noise. + * @return A sorted list of (type-name, live-count) pairs for all tracked + * types whose current count meets the threshold. + */ CountedObjects::List CountedObjects::getCounts(int minimumThreshold) const { diff --git a/src/libxrpl/basics/FileUtilities.cpp b/src/libxrpl/basics/FileUtilities.cpp index 1a6e604724..7fad91d702 100644 --- a/src/libxrpl/basics/FileUtilities.cpp +++ b/src/libxrpl/basics/FileUtilities.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements whole-file I/O utilities with uniform error-code reporting. + * + * Provides `getFileContents` and `writeFileContents` as a single, consistent + * layer over `std::ifstream`/`std::ofstream` so that callers (Config, ValidatorList, + * WorkFile) do not each need to replicate error-handling boilerplate. All errors are + * communicated through `boost::system::error_code`; neither function throws. + */ #include #include diff --git a/src/libxrpl/basics/Log.cpp b/src/libxrpl/basics/Log.cpp index 43f9ee4b67..e71e52ea3e 100644 --- a/src/libxrpl/basics/Log.cpp +++ b/src/libxrpl/basics/Log.cpp @@ -1,3 +1,11 @@ +/** @file + * Concrete implementation of the XRPL logging infrastructure. + * + * Bridges the `beast::Journal` front-end abstraction with actual I/O: + * an append-mode log file and `stderr`. Three collaborating classes are + * defined here — `Logs::Sink`, `Logs::File`, and the `Logs` coordinator — + * plus the file-local `DebugSink` that backs the global `debugLog()` journal. + */ #include #include @@ -21,11 +29,29 @@ namespace xrpl { +/** Construct a Sink for the named partition. + * + * @param partition Human-readable channel label (e.g. "Application"). + * @param thresh Initial severity threshold; messages below this level + * are discarded before formatting. + * @param logs Owning `Logs` coordinator. The reference must remain + * valid for the lifetime of this object, which is guaranteed because + * `Sink` instances are owned by `Logs::sinks_`. + */ Logs::Sink::Sink(std::string partition, beast::Severity thresh, Logs& logs) : beast::Journal::Sink(thresh, false), logs_(logs), partition_(std::move(partition)) { } +/** Write a message if it meets the per-sink severity threshold. + * + * The threshold gate is applied here, close to where the formatted string + * was constructed, so callers that bypassed `beast::Journal::Stream`'s own + * active-check do not pay formatting cost for suppressed messages. + * + * @param level Severity of the message. + * @param text Pre-formatted message text. + */ void Logs::Sink::write(beast::Severity level, std::string const& text) { @@ -35,6 +61,14 @@ Logs::Sink::write(beast::Severity level, std::string const& text) logs_.write(level, partition_, text, console()); } +/** Write a message unconditionally, bypassing the severity threshold. + * + * Used for administrative override messages that must appear in the log + * regardless of the current verbosity configuration. + * + * @param level Severity attached to the message (used for formatting only). + * @param text Pre-formatted message text. + */ void Logs::Sink::writeAlways(beast::Severity level, std::string const& text) { @@ -108,16 +142,38 @@ Logs::File::writeln(char const* text) //------------------------------------------------------------------------------ -Logs::Logs(beast::Severity thresh) : thresh_(thresh) // default severity +/** Construct the logging coordinator with a global severity threshold. + * + * @param thresh Initial threshold applied to every partition. Individual + * partitions inherit this value and can be overridden later via + * `threshold(Severity)`. + */ +Logs::Logs(beast::Severity thresh) : thresh_(thresh) { } +/** Open a log file for append-mode writing. + * + * @param pathToLogFile Filesystem path of the log file. Created if it + * does not exist; existing content is preserved. + * @return `true` if the file was successfully opened. + */ bool Logs::open(boost::filesystem::path const& pathToLogFile) { return file_.open(pathToLogFile); } +/** Return the sink for the named partition, creating it if necessary. + * + * The `sinks_` map is keyed with case-insensitive comparison, so + * `"Application"` and `"application"` resolve to the same sink. + * `emplace()` is a no-op when the key already exists, so there is no + * risk of double-creating a sink under concurrent first-access. + * + * @param name Partition label. + * @return Reference to the sink; valid for the lifetime of this `Logs`. + */ beast::Journal::Sink& Logs::get(std::string const& name) { @@ -126,24 +182,50 @@ Logs::get(std::string const& name) return *result.first->second; } +/** Convenience overload; equivalent to `get(name)`. + * + * @param name Partition label. + * @return Reference to the sink for that partition. + */ beast::Journal::Sink& Logs::operator[](std::string const& name) { return get(name); } +/** Create a `beast::Journal` that writes to the named partition. + * + * The returned journal is lightweight and copyable; subsystems typically + * store one as a member variable. + * + * @param name Partition label (case-insensitive). + * @return A `beast::Journal` backed by the partition's sink. + */ beast::Journal Logs::journal(std::string const& name) { return beast::Journal(get(name)); } +/** Return the current global severity threshold. + * + * @return The threshold that was most recently set via `threshold(Severity)`, + * or the value passed to the constructor if it was never changed. + */ beast::Severity Logs::threshold() const { return thresh_; } +/** Set the global severity threshold and propagate it to all existing sinks. + * + * This is the mechanism behind the `logLevel` admin command on a running + * node: a single call fans out to every registered partition. Sinks + * created after this call inherit the new threshold via `get()`. + * + * @param thresh New minimum severity; messages below this level are dropped. + */ void Logs::threshold(beast::Severity thresh) { @@ -153,6 +235,14 @@ Logs::threshold(beast::Severity thresh) sink.second->threshold(thresh); } +/** Snapshot the current severity level of every registered partition. + * + * Takes the mutex even though the method is `const`, because it reads the + * live sink map that is mutated by `get()` and `threshold()`. + * + * @return A vector of `{partitionName, severityString}` pairs, one per + * registered sink, in map iteration order. + */ std::vector> Logs::partitionSeverities() const { @@ -164,6 +254,17 @@ Logs::partitionSeverities() const return list; } +/** Format and emit a log message to the file and optionally to stderr. + * + * Formatting (timestamp, partition label, severity tag, length cap, and + * credential scrubbing) is performed before the mutex is taken so that + * the lock is held only for the actual I/O operations. + * + * @param level Severity of the message. + * @param partition Partition label to include in the formatted output. + * @param text Raw message text. + * @param console Reserved for future console output; currently unused. + */ void Logs::write( beast::Severity level, @@ -182,6 +283,16 @@ Logs::write( // out_.write_console(s); } +/** Close and reopen the log file to interoperate with log-rotation tools. + * + * When an external tool (e.g. `logrotate(8)`) renames the active log file, + * a SIGHUP handler can call this method. The file descriptor is released + * and then reopened at the original path, picking up the freshly created + * file. + * + * @return A human-readable status string suitable for returning in an admin + * RPC response. + */ std::string Logs::rotate() { @@ -192,12 +303,27 @@ Logs::rotate() return "The log file could not be closed and reopened."; } +/** Factory method for creating partition sinks; virtual for testability. + * + * Test harnesses may subclass `Logs` and override this method to inject + * mock sinks without touching file I/O. + * + * @param name Partition label. + * @param threshold Initial severity threshold for the new sink. + * @return Owning pointer to the new sink. + */ std::unique_ptr Logs::makeSink(std::string const& name, beast::Severity threshold) { return std::make_unique(name, threshold, *this); } +/** Convert a `beast::Severity` value to a human-readable string. + * + * @param s The severity to convert. + * @return One of `"Trace"`, `"Debug"`, `"Info"`, `"Warning"`, `"Error"`, + * `"Fatal"`. Fires `UNREACHABLE` in debug builds for unrecognised values. + */ std::string Logs::toString(beast::Severity s) { @@ -223,6 +349,16 @@ Logs::toString(beast::Severity s) } } +/** Parse a severity level from a string, case-insensitively. + * + * Accepts several alias spellings to be forgiving of operator input from + * config files or admin commands: `"warn"`, `"warnings"`, `"information"`, + * `"errors"`, `"fatals"`. + * + * @param s The string to parse. + * @return The corresponding `beast::Severity`, or `std::nullopt` if the + * string does not match any known level. + */ std::optional Logs::fromString(std::string const& s) { @@ -247,6 +383,32 @@ Logs::fromString(std::string const& s) return std::nullopt; } +/** Assemble a log line and scrub sensitive credential fields. + * + * Produces a line of the form: + * @code + * 2024-01-15T12:34:56Z Application:NFO some message text + * @endcode + * + * After assembly, two post-processing steps are applied: + * 1. **Length cap** — the total line is truncated to `kMAXIMUM_MESSAGE_CHARACTERS` + * (12 KB) and suffixed with `"..."` to indicate truncation. + * 2. **Credential scrubbing** — the value following any of the JSON keys + * `"seed"`, `"seed_hex"`, `"secret"`, `"master_key"`, `"master_seed"`, + * `"master_seed_hex"`, or `"passphrase"` is replaced with asterisks. + * These are the exact field names used in XRPL's wallet and key-generation + * RPC calls (`wallet_propose`, `sign`, etc.), preventing accidental + * credential exposure if an RPC request body is logged verbatim. + * + * @param output Destination string; any prior content is overwritten. + * @param message Raw message text to append after the header. + * @param severity Determines the 3-letter tag (`TRC`, `DBG`, `NFO`, + * `WRN`, `ERR`, `FTL`). + * @param partition Partition label; omitted from the header when empty. + * @note This method is static and does not take the mutex; callers are + * responsible for calling it before acquiring the lock if they need + * to minimise lock contention. + */ void Logs::format( std::string& output, @@ -292,20 +454,15 @@ Logs::format( output += message; - // Limit the maximum length of the output if (output.size() > kMAXIMUM_MESSAGE_CHARACTERS) { output.resize(kMAXIMUM_MESSAGE_CHARACTERS - 3); output += "..."; } - // Attempt to prevent sensitive information from appearing in log files by - // redacting it with asterisks. auto scrubber = [&output](char const* token) { auto first = output.find(token); - // If we have found the specified token, then attempt to isolate the - // sensitive data (it's enclosed by double quotes) and mask it off: if (first != std::string::npos) { first = output.find('\"', first + std::strlen(token)); @@ -333,6 +490,16 @@ Logs::format( //------------------------------------------------------------------------------ +/** Thread-safe holder for the process-wide debug journal sink. + * + * Defaults to the null sink so that `debugLog()` is always safe to call + * even when no debug sink has been installed. `set()` atomically swaps in + * a new sink and returns ownership of the previous one, enabling clean + * teardown in tests via `setDebugLogSink(nullptr)`. + * + * @note Non-copyable and non-movable; intended for use as a function-local + * static only. + */ class DebugSink { private: @@ -353,6 +520,13 @@ public: DebugSink& operator=(DebugSink&&) = delete; + /** Atomically replace the active sink and return the previous one. + * + * Passing `nullptr` resets the debug journal to the null sink. + * + * @param sink Owning pointer to the new sink, or `nullptr` to reset. + * @return Owning pointer to the sink that was active before this call. + */ std::unique_ptr set(std::unique_ptr sink) { @@ -373,6 +547,10 @@ public: return sink; } + /** Return a reference to the currently active sink. + * + * @return The installed sink, or the null sink if none was installed. + */ beast::Journal::Sink& get() { @@ -381,6 +559,7 @@ public: } }; +/** Return the process-wide `DebugSink` singleton. */ static DebugSink& debugSink() { diff --git a/src/libxrpl/basics/MallocTrim.cpp b/src/libxrpl/basics/MallocTrim.cpp index 6fb9ab611b..1c061931fd 100644 --- a/src/libxrpl/basics/MallocTrim.cpp +++ b/src/libxrpl/basics/MallocTrim.cpp @@ -1,3 +1,20 @@ +/** @file + * Implements `mallocTrim()`, which requests glibc to return fragmented free + * heap pages to the OS via `::malloc_trim(0)`. + * + * The entire implementation is compiled only on Linux/glibc + * (`__GLIBC__ && BOOST_OS_LINUX`). On all other platforms `mallocTrim()` + * is a documented no-op that returns a `MallocTrimReport` with + * `supported = false`. A compile-time `#error` guards against Linux/glibc + * builds that somehow lack `RUSAGE_THREAD`, enforcing the invariant that + * thread-scoped page-fault measurement is always available when the active + * code path is compiled. + * + * The public entry point is called from `Application::doSweep()` after + * cache evictions and other operations that free significant amounts of + * memory, ensuring that the OS RSS reflects actual live heap usage rather + * than ptmalloc arena fragmentation. + */ #include #include @@ -28,6 +45,15 @@ namespace { +/** Capture a per-thread resource-usage snapshot via `RUSAGE_THREAD`. + * + * Wraps `::getrusage(RUSAGE_THREAD, &ru)` so callers can gate page-fault + * delta computation on success without propagating the raw return code. + * + * @param ru Output parameter filled with the current thread's `rusage` data + * on success; left in an indeterminate state on failure. + * @return `true` if `getrusage` succeeded, `false` otherwise. + */ bool getRusageThread(struct rusage& ru) { @@ -45,23 +71,49 @@ namespace detail { #if defined(__GLIBC__) && BOOST_OS_LINUX +/** Invoke `::malloc_trim` with the given arena-padding hint. + * + * Requests that glibc release all free heap pages whose addresses lie above + * the current break minus `padBytes`. Passing `0` asks for maximum + * reclamation. The return value mirrors `::malloc_trim`: `1` if memory was + * actually returned to the OS, `0` if the call succeeded but had nothing to + * release. + * + * @param padBytes Bytes of free space to retain in the arena after trimming. + * @return `1` if memory was released to the OS, `0` if nothing was released. + */ inline int mallocTrimWithPad(std::size_t padBytes) { return ::malloc_trim(padBytes); } +/** Parse the RSS field from a `/proc/self/statm` snapshot and convert to KB. + * + * `/proc/self/statm` exposes seven space-delimited fields (all in pages): + * `size resident shared text lib data dt`. This function extracts the second + * field (`resident`) and multiplies by the system page size to produce a KB + * value. Parsing is done with `std::istringstream`; any extraction failure + * (empty input, non-numeric data, or fewer than two fields) sets `failbit` + * and causes an early return of `-1`. A non-positive page size from + * `sysconf(_SC_PAGESIZE)` also yields `-1`. + * + * @param statm Raw content of `/proc/self/statm`, e.g. `"25365 1000 2377 0 0 5623 0"`. + * @return Resident set size in kilobytes, or `-1` if parsing or page-size + * lookup failed. + * + * @note The sentinel value `-1` is propagated through `MallocTrimReport` + * fields; `MallocTrimReport::deltaKB()` returns `0` rather than a + * misleading negative number when either RSS reading carries this sentinel. + */ long parseStatmRSSkB(std::string const& statm) { - // /proc/self/statm format: size resident shared text lib data dt - // We want the second field (resident) which is in pages std::istringstream iss(statm); long size = 0, resident = 0; if (!(iss >> size >> resident)) return -1; - // Convert pages to KB long const pageSize = ::sysconf(_SC_PAGESIZE); if (pageSize <= 0) return -1; @@ -97,7 +149,6 @@ mallocTrim(std::string_view tag, beast::Journal journal) if (!ifs.is_open()) return {}; - // /proc files are often not seekable; read as a stream. std::ostringstream oss; oss << ifs.rdbuf(); return oss.str(); @@ -124,7 +175,6 @@ mallocTrim(std::string_view tag, beast::Journal journal) auto const statmAfter = readFile(statmPath); long const rssAfterKB = detail::parseStatmRSSkB(statmAfter); - // Populate report fields report.rssBeforeKB = rssBeforeKB; report.rssAfterKB = rssAfterKB; report.durationUs = std::chrono::duration_cast(t1 - t0); diff --git a/src/libxrpl/basics/Number.cpp b/src/libxrpl/basics/Number.cpp index 08ead182bf..4b94d403fc 100644 --- a/src/libxrpl/basics/Number.cpp +++ b/src/libxrpl/basics/Number.cpp @@ -1,3 +1,19 @@ +/** @file + * Complete arithmetic implementation of `xrpl::Number`, the XRPL ledger's + * custom fixed-precision decimal floating-point type. + * + * `Number` was introduced to replace ad-hoc arithmetic embedded in `STAmount` + * and related types, giving the ledger a single, auditable numeric type that + * supports all asset classes — IOU amounts, XRP drops, and MPT amounts — with + * correct, amendment-controlled precision. + * + * Two overriding design constraints dominate the implementation: + * - **Exact decimal rounding** to satisfy on-ledger determinism requirements. + * - **Dual mantissa precision** selected at runtime (via thread-local state) + * based on which amendments are active: 16 significant digits (small range) + * for legacy IOU arithmetic, and 19 significant digits (large range) for + * full `int64_t` integer fidelity required by XRP drops and MPT amounts. + */ #include #include @@ -27,7 +43,19 @@ using int128_t = __int128_t; namespace xrpl { +/** Thread-local rounding mode; defaults to `ToNearest` (round-half-to-even). + * + * Each worker thread owns an independent copy so that amendment-gate changes + * at transaction start do not race with arithmetic already in progress. + */ thread_local Number::RoundingMode Number::mode = Number::RoundingMode::ToNearest; + +/** Thread-local active mantissa range; defaults to `kLARGE_RANGE`. + * + * Stored as a `reference_wrapper` so that switching ranges is a cheap pointer + * swap and so the range constants themselves cannot be mutated accidentally. + * Changed via `setMantissaScale()` at the start of each transaction context. + */ thread_local std::reference_wrapper Number::kRANGE = kLARGE_RANGE; Number::RoundingMode @@ -57,20 +85,33 @@ Number::setMantissaScale(MantissaRange::MantissaScale scale) kRANGE = scale == MantissaRange::MantissaScale::Small ? kSMALL_RANGE : kLARGE_RANGE; } -// Guard - -// The Guard class is used to temporarily add extra digits of -// precision to an operation. This enables the final result -// to be correctly rounded to the internal precision of Number. - +/** Concept restricting template parameters to unsigned 64-bit or 128-bit integer + * types used as mantissa carriers throughout the arithmetic implementation. + */ template concept UnsignedMantissa = std::is_unsigned_v || std::is_same_v; +/** Accumulates extra decimal digits shed during normalization so the final + * result can be correctly rounded without accumulated truncation error. + * + * Guard stores up to 16 decimal guard digits packed as 4-bit BCD nibbles in a + * single `uint64_t`. A one-bit sticky flag (`xbit_`) records whether any + * non-zero digit was ever shifted off the bottom of the guard register; this + * is used by `round()` to break ties correctly for `ToNearest` mode. + * + * The usual protocol is `push(d)` when a digit falls out of the main + * mantissa, then `doRoundUp` or `doRoundDown` once to apply the rounding + * decision. For subtraction, `pop()` reclaims the most-significant guard + * digit back into the mantissa. + */ class Number::Guard { - std::uint64_t digits_{0}; // 16 decimal guard digits - std::uint8_t xbit_ : 1 {0}; // has a non-zero digit been shifted off the end - std::uint8_t sbit_ : 1 {0}; // the sign of the guard digits + /** Packed BCD: 16 digits × 4 bits, most-significant digit at bit 63. */ + std::uint64_t digits_{0}; + /** Sticky bit: set when any non-zero digit has been shifted off the bottom. */ + std::uint8_t xbit_ : 1 {0}; + /** Sign of the operand that owns these guard digits. */ + std::uint8_t sbit_ : 1 {0}; public: explicit Guard() = default; @@ -83,22 +124,56 @@ public: [[nodiscard]] bool isNegative() const noexcept; - // add a digit + /** Push one decimal digit into the top of the guard register. + * + * Any digit previously at the bottom is OR-ed into `xbit_` before it + * is lost. Only the lowest 4 bits of `d` are used. + * + * @tparam T An unsigned or `uint128_t` type. + * @param d The digit to store (0–9; higher bits are masked off). + */ template void push(T d) noexcept; - // recover a digit + /** Recover the most-significant guard digit, shifting the rest down. + * + * Used during subtraction to reclaim precision lost when aligning + * exponents. + * + * @return The top digit (0–9). + */ unsigned pop() noexcept; - // Indicate round direction: 1 is up, -1 is down, 0 is even - // This enables the client to round towards nearest, and on - // tie, round towards even. + /** Determine whether the guard digits are above, below, or exactly half. + * + * Interprets the current thread-local rounding mode and the packed guard + * value to produce a rounding direction consistent with IEEE 754. + * + * @return 1 if the guard value is more than half (round up), + * -1 if the guard value is less than half (round down), + * 0 if the guard value is exactly half (tie — round to even). + */ [[nodiscard]] int round() const noexcept; - // Modify the result to the correctly rounded value + /** Apply upward rounding to a normalized `(negative, mantissa, exponent)` triple. + * + * Increments the mantissa when `round()` indicates an upward adjustment + * (including ties rounded to even). If the increment pushes the mantissa + * above `maxMantissa` or `kMAX_REP`, divides by 10 and increments the + * exponent to preserve normalization. + * + * @tparam T Unsigned mantissa carrier type. + * @param negative Sign flag; adjusted if the result collapses to zero. + * @param mantissa Mantissa to round; updated in place. + * @param exponent Exponent; updated in place. + * @param minMantissa Lower bound of the active normalization range. + * @param maxMantissa Upper bound of the active normalization range. + * @param location Diagnostic string embedded in the `overflow_error` message. + * @throws std::overflow_error if the rounded exponent exceeds `kMAX_EXPONENT`. + */ template void doRoundUp( @@ -109,19 +184,48 @@ public: internalrep const& maxMantissa, std::string location); - // Modify the result to the correctly rounded value + /** Apply downward rounding to a normalized `(negative, mantissa, exponent)` triple. + * + * Decrements the mantissa when `round()` indicates a downward adjustment. + * If the decrement drops the mantissa below `minMantissa`, multiplies by + * 10 and decrements the exponent to restore normalization. + * + * @tparam T Unsigned mantissa carrier type. + * @param negative Sign flag; adjusted if the result collapses to zero. + * @param mantissa Mantissa to round; updated in place. + * @param exponent Exponent; updated in place. + * @param minMantissa Lower bound of the active normalization range. + */ template void doRoundDown(bool& negative, T& mantissa, int& exponent, internalrep const& minMantissa); - // Modify the result to the correctly rounded value + /** Round a scaled integer `rep` value and apply its sign. + * + * Used by `Number::operator rep()` after the mantissa has been fully + * scaled to an integer. Applies the sign stored in `sbit_` after + * rounding. + * + * @param drops The integer value to round; updated in place. + * @param location Diagnostic string embedded in the `overflow_error` message. + * @throws std::overflow_error if rounding would push `drops` above `kMAX_REP` + * (theoretically impossible given correct normalization, but guarded + * defensively). + */ void doRound(rep& drops, std::string location) const; private: + /** Core push implementation; operates on a plain `unsigned`. */ void doPush(unsigned d) noexcept; + /** Restore normalization after a rounding step changes the mantissa. + * + * If rounding caused `mantissa` to drop below `minMantissa`, multiplies + * by 10 and decrements the exponent. If the exponent underflows + * `kMIN_EXPONENT` the triple is reset to canonical zero. + */ template void bringIntoRange(bool& negative, T& mantissa, int& exponent, internalrep const& minMantissa); @@ -168,10 +272,6 @@ Number::Guard::pop() noexcept return d; } -// Returns: -// -1 if Guard is less than half -// 0 if Guard is exactly half -// 1 if Guard is greater than half int Number::Guard::round() const noexcept { @@ -217,8 +317,6 @@ Number::Guard::bringIntoRange( int& exponent, internalrep const& minMantissa) { - // Bring mantissa back into the minMantissa / maxMantissa range AFTER - // rounding if (mantissa < minMantissa) { mantissa *= 10; @@ -282,7 +380,6 @@ Number::Guard::doRoundDown( bringIntoRange(negative, mantissa, exponent, minMantissa); } -// Modify the result to the correctly rounded value void Number::Guard::doRound(rep& drops, std::string location) const { @@ -308,10 +405,15 @@ Number::Guard::doRound(rep& drops, std::string location) const // Number -// Safely convert rep (int64) mantissa to internalrep (uint64). If the rep is -// negative, returns the positive value. This takes a little extra work because -// converting std::numeric_limits::min() flirts with UB, and can -// vary across compilers. +/** Convert a signed `int64_t` mantissa to its unsigned absolute value. + * + * Converting `INT64_MIN` via negation of `int64_t` is undefined behavior in + * C++; this function handles that edge case safely by routing through + * `int128_t` for the one value that overflows the positive `int64_t` range. + * + * @param mantissa Signed external mantissa, possibly negative. + * @return Unsigned magnitude; always fits in `uint64_t`. + */ Number::internalrep Number::externalToInternal(rep mantissa) { @@ -331,6 +433,12 @@ Number::externalToInternal(rep mantissa) return static_cast(-temp); } +/** Return the value 1 normalized for the small mantissa range (10^15 scale). + * + * Constructs 1.0 without normalization overhead so it can be a `constexpr`. + * The mantissa is `kSMALL_RANGE.min` (10^15) and the exponent is + * `−kSMALL_RANGE.log` (−15), giving exactly 1.0 × 10^0. + */ constexpr Number Number::oneSmall() { @@ -339,6 +447,12 @@ Number::oneSmall() constexpr Number kONE_SML = Number::oneSmall(); +/** Return the value 1 normalized for the large mantissa range (10^18 scale). + * + * Constructs 1.0 without normalization overhead so it can be a `constexpr`. + * The mantissa is `kLARGE_RANGE.min` (10^18) and the exponent is + * `−kLARGE_RANGE.log` (−18), giving exactly 1.0 × 10^0. + */ constexpr Number Number::oneLarge() { @@ -347,6 +461,11 @@ Number::oneLarge() constexpr Number kONE_LRG = Number::oneLarge(); +/** Return the value 1 in the currently active mantissa range. + * + * Dispatches to the pre-computed `kONE_SML` or `kONE_LRG` constant based on + * the thread-local `kRANGE` setting, avoiding a full normalization pass. + */ Number Number::one() { @@ -356,8 +475,37 @@ Number::one() return kONE_LRG; } -// Use the member names in this static function for now so the diff is cleaner // TODO: Rename the function parameters to get rid of the "_" suffix + +/** Bring a `(negative, mantissa, exponent)` triple into canonical normalized form. + * + * This is the shared implementation invoked by all three explicit + * specializations of `Number::normalize`. The algorithm: + * 1. A zero mantissa is mapped to canonical zero and returns early. + * 2. While `mantissa < minMantissa` and `exponent > kMIN_EXPONENT`, the + * mantissa is multiplied by 10 and the exponent decremented. + * 3. While `mantissa > maxMantissa` or `mantissa > kMAX_REP`, the last + * digit is pushed into a `Guard` and the mantissa is divided by 10. + * 4. An extra division handles the case where the intermediate value exceeds + * `INT64_MAX` but the final stored value must not (relevant for the large + * range, e.g. 9.9 × 10^18 > INT64_MAX). + * 5. `Guard::doRoundUp` is called to apply correctly-rounded rounding to the + * result. + * + * @tparam T Mantissa type: `uint128_t`, `unsigned long long`, or + * `unsigned long`. The 128-bit type is needed for `operator*=`, which + * computes a product that temporarily requires wider storage. + * @param negative Sign flag; updated to canonical zero sign when result is 0. + * @param mantissa The raw mantissa to normalize; updated in place. + * @param exponent The raw exponent; updated in place. + * @param minMantissa Lower bound of the active normalization range. + * @param maxMantissa Upper bound of the active normalization range. + * @throws std::overflow_error if normalization would push `exponent` above + * `kMAX_EXPONENT`. + * @note The function is declared a `friend` of `Number` so it can access the + * `kMIN_EXPONENT`, `kMAX_EXPONENT`, and `kMAX_REP` constants as well as + * the `Guard` inner class. + */ template void doNormalize( @@ -484,9 +632,20 @@ Number::normalize() normalize(negative_, mantissa_, exponent_, range.min, range.max); } -// Copy the number, but set a new exponent. Because the mantissa doesn't change, -// the result will be "mostly" normalized, but the exponent could go out of -// range. +/** Return a copy of this `Number` with its exponent shifted by `exponentDelta`. + * + * The mantissa is unchanged, so the returned value is mathematically equal to + * `*this × 10^exponentDelta`. Normalization is not re-run; the mantissa + * remains in its existing range as long as the new exponent is valid. Used + * internally by `root` and `root2` to undo the pre-scaling step without the + * cost of a full normalization pass. + * + * @param exponentDelta Signed amount to add to the current exponent. + * @return Adjusted `Number`, or zero if `exponent_ + exponentDelta` underflows + * `kMIN_EXPONENT`. + * @throws std::overflow_error if `exponent_ + exponentDelta >= kMAX_EXPONENT`. + * @pre `*this` must satisfy `isnormal()`. + */ Number Number::shiftExponent(int exponentDelta) const { @@ -503,6 +662,18 @@ Number::shiftExponent(int exponentDelta) const return result; } +/** Add `y` to `*this`, rounding the result to the active mantissa range. + * + * Operands are aligned to a common exponent by dividing the smaller-exponent + * operand by 10 repeatedly, accumulating shed digits in a `Guard`. Same-sign + * operands are added via `uint128_t` (avoiding overflow on large mantissas) and + * rounded up. Opposite-sign operands are subtracted and guard digits are + * reclaimed via `pop()` before rounding down. + * + * @param y Value to add. + * @return Reference to `*this` after the operation. + * @throws std::overflow_error if the result's exponent exceeds `kMAX_EXPONENT`. + */ Number& Number::operator+=(Number const& y) { @@ -602,34 +773,43 @@ Number::operator+=(Number const& y) return *this; } -// Optimization equivalent to: -// auto r = static_cast(u % 10); -// u /= 10; -// return r; -// Derived from Hacker's Delight Second Edition Chapter 10 -// by Henry S. Warren, Jr. +/** Divide a `uint128_t` by 10 in place and return the remainder. + * + * Equivalent to `r = u % 10; u /= 10; return r;` but avoids the native + * 128-bit modulo instruction, which is expensive on common architectures. + * The algorithm approximates `u / 10` using bit-shifts and a correction + * step derived from Hacker's Delight (2nd ed., §10) by Henry S. Warren Jr. + * + * @param u Value to divide; updated in place to `u / 10`. + * @return The remainder `u % 10` (0–9) before the division. + */ static inline unsigned divu10(uint128_t& u) { - // q = u * 0.75 auto q = (u >> 1) + (u >> 2); - // iterate towards q = u * 0.8 q += q >> 4; q += q >> 8; q += q >> 16; q += q >> 32; q += q >> 64; - // q /= 8 approximately == u / 10 q >>= 3; - // r = u - q * 10 approximately == u % 10 auto r = static_cast(u - ((q << 3) + (q << 1))); - // correction c is 1 if r >= 10 else 0 auto c = (r + 6) >> 4; u = q + c; r -= c * 10; return r; } +/** Multiply `*this` by `y`, rounding the result to the active mantissa range. + * + * The two mantissas are multiplied as `uint128_t` to avoid overflow, the + * exponents are summed, and the 128-bit product is trimmed into range using + * `divu10` — which is cheaper than the native 128-bit modulo instruction. + * + * @param y Multiplier. + * @return Reference to `*this` after the operation. + * @throws std::overflow_error if the product's exponent exceeds `kMAX_EXPONENT`. + */ Number& Number::operator*=(Number const& y) { @@ -693,6 +873,21 @@ Number::operator*=(Number const& y) return *this; } +/** Divide `*this` by `y`, rounding the result to the active mantissa range. + * + * The numerator is pre-scaled by a power of 10 before integer division to + * preserve precision: 10^17 for the small range, 10^19 for the large range. + * For the large range an additional 1000× correction term is computed from + * the division remainder (because the combined scale factor would overflow + * `uint128_t`) and folded in before normalization. + * + * @param y Divisor. + * @return Reference to `*this` after the operation. + * @throws std::overflow_error if `y` is zero. + * @note The pre-scale factor for the large range is 10^19 rather than the + * 10^17 used for the small range; a comment in the source tracks an open + * question about whether a larger factor could be used safely. + */ Number& Number::operator/=(Number const& y) { @@ -788,6 +983,17 @@ Number::operator/=(Number const& y) return *this; } +/** Convert this `Number` to a signed 64-bit integer, rounding to the active mode. + * + * Scales the mantissa to an integer by repeatedly dividing (when the exponent + * is negative, pushing each dropped digit into a `Guard`) or multiplying + * (when the exponent is positive, checking for overflow), then calls + * `Guard::doRound` to apply the thread-local rounding mode and sign. This + * is the primary way XRP drop values are materialized from `Number` arithmetic. + * + * @return The rounded integer value of this `Number`. + * @throws std::overflow_error if scaling overflows `int64_t`. + */ Number:: operator rep() const { @@ -817,6 +1023,17 @@ operator rep() const return drops; } +/** Remove the fractional part of this `Number` without rounding. + * + * Repeatedly divides the internal mantissa by 10 and increments the exponent + * until `exponent_ >= 0`, then renormalizes. Unlike `operator rep()`, this + * does not apply any rounding mode — the result is always truncated toward zero. + * + * @return A `Number` whose value is the integer part of `*this`. + * @note The `noexcept` guarantee holds because the resulting exponent is never + * positive (the loop terminates at 0) and normalization cannot throw when + * the exponent is non-positive. + */ Number Number::truncate() const noexcept { @@ -835,6 +1052,18 @@ Number::truncate() const noexcept return ret; } +/** Format a `Number` as a human-readable decimal string. + * + * For values whose exponent falls outside a window centered on the active + * `mantissaLog()`, scientific notation is used (`Me+E`). Otherwise, the + * function constructs a zero-padded string of the integer mantissa, locates + * the decimal point via pointer arithmetic, and strips leading and trailing + * zeros to produce a compact decimal representation. + * + * @param amount The value to format. + * @return Human-readable decimal string, e.g. `"1.5"`, `"-0.001"`, + * `"1e+20"`, or `"0"` for the zero value. + */ std::string to_string(Number const& amount) { @@ -911,7 +1140,6 @@ to_string(Number const& amount) if (negative) ret.append(1, '-'); - // Assemble the output: if (preFrom == preTo) { ret.append(1, '0'); @@ -930,9 +1158,15 @@ to_string(Number const& amount) return ret; } -// Returns f^n -// Uses a log_2(n) number of multiplications - +/** Raise `f` to the non-negative integer power `n`. + * + * Uses binary (fast) exponentiation, requiring O(log₂ n) multiplications. + * Returns `Number::one()` for `n == 0` and `f` itself for `n == 1`. + * + * @param f Base value. + * @param n Non-negative integer exponent. + * @return f^n, rounded to the active mantissa range. + */ Number power(Number const& f, unsigned n) { @@ -947,15 +1181,28 @@ power(Number const& f, unsigned n) return r; } -// Returns f^(1/d) -// Uses Newton–Raphson iterations until the result stops changing -// to find the non-negative root of the polynomial g(x) = x^d - f - -// This function, and power(Number f, unsigned n, unsigned d) -// treat corner cases such as 0 roots as advised by Annex F of -// the C standard, which itself is consistent with the IEEE -// floating point standards. - +/** Compute the `d`-th root of `f` (i.e. f^(1/d)) via Newton–Raphson iteration. + * + * Finds the non-negative root of g(x) = x^d − f. To ensure rapid convergence + * regardless of scale, `f` is first shifted so that its exponent is a multiple + * of `d` and the value lies in (0, 1). A quadratic least-squares curve fit + * provides the initial guess; the iteration `r ← ((d−1)r + f/r^(d−1)) / d` + * continues until the result is stable or oscillates between two values (a + * cycle-of-2 detector prevents infinite loops near the rounding boundary). + * The exponent shift is reversed with `shiftExponent` before returning. + * + * Corner cases follow IEEE 754 Annex F / C standard Annex F conventions: + * - `d == 0`, `f == ±one`: returns `one`. + * - `d == 0`, `abs(f) < one`: returns zero. + * - `d == 0`, `abs(f) > one`: throws (infinity). + * - Even `d` with negative `f`: throws (NaN). + * - `f == zero`: returns zero. + * + * @param f Radicand. May be negative only when `d` is odd. + * @param d Root degree. + * @return f^(1/d), rounded to the active mantissa range. + * @throws std::overflow_error for semantically infinite or NaN results. + */ Number root(Number f, unsigned d) { @@ -977,7 +1224,6 @@ root(Number f, unsigned d) if (f == kZERO) return f; - // Scale f into the range (0, 1) such that f's exponent is a multiple of d auto e = f.exponent_ + Number::mantissaLog() + 1; auto const di = static_cast(d); auto ex = [e = e, di = di]() // Euclidean remainder of e/d @@ -999,7 +1245,6 @@ root(Number f, unsigned d) f = -f; } - // Quadratic least squares curve fit of f^(1/d) in the range [0, 1] auto const D = (((6 * di + 11) * di + 6) * di) + 1; // NOLINT(readability-identifier-naming) auto const a0 = 3 * di * ((2 * di - 3) * di + 1); auto const a1 = 24 * di * (2 * di - 1); @@ -1011,8 +1256,6 @@ root(Number f, unsigned d) r = -r; } - // Newton–Raphson iteration of f^(1/d) with initial guess r - // halt when r stops changing, checking for bouncing on the last iteration Number rm1{}; Number rm2{}; do @@ -1022,12 +1265,22 @@ root(Number f, unsigned d) r = (Number(d - 1) * r + f / power(r, d - 1)) / Number(d); } while (r != rm1 && r != rm2); - // return r * 10^(e/d) to reverse scaling auto const result = r.shiftExponent(e / di); XRPL_ASSERT_PARTS(result.isnormal(), "xrpl::root(Number, unsigned)", "result is normalized"); return result; } +/** Compute the square root of `f` using a specialised Newton–Raphson loop. + * + * Functionally equivalent to `root(f, 2)` but uses hardcoded coefficients + * (D=105, a0=18, a1=144, a2=−60) for the initial quadratic guess and the + * simplified iteration `r ← (r + f/r) / 2`, making it faster than the + * general `root` for the common `d == 2` case. + * + * @param f Non-negative radicand. + * @return √f, rounded to the active mantissa range. + * @throws std::overflow_error if `f` is negative (NaN semantics). + */ Number root2(Number f) { @@ -1041,22 +1294,18 @@ root2(Number f) if (f == kZERO) return f; - // Scale f into the range (0, 1) such that f's exponent is a multiple of d auto e = f.exponent_ + Number::mantissaLog() + 1; if (e % 2 != 0) ++e; f = f.shiftExponent(-e); // f /= 10^e; XRPL_ASSERT_PARTS(f.isnormal(), "xrpl::root2(Number)", "f is normalized"); - // Quadratic least squares curve fit of f^(1/d) in the range [0, 1] auto const D = 105; // NOLINT(readability-identifier-naming) auto const a0 = 18; auto const a1 = 144; auto const a2 = -60; Number r = ((Number{a2} * f + Number{a1}) * f + Number{a0}) / Number{D}; - // Newton–Raphson iteration of f^(1/2) with initial guess r - // halt when r stops changing, checking for bouncing on the last iteration Number rm1{}; Number rm2{}; do @@ -1066,15 +1315,28 @@ root2(Number f) r = (r + f / r) / Number(2); } while (r != rm1 && r != rm2); - // return r * 10^(e/2) to reverse scaling auto const result = r.shiftExponent(e / 2); XRPL_ASSERT_PARTS(result.isnormal(), "xrpl::root2(Number)", "result is normalized"); return result; } -// Returns f^(n/d) - +/** Raise `f` to the rational power `n/d`. + * + * Reduces the fraction `n/d` by their GCD, then computes `root(power(f, n), d)`. + * Corner cases follow IEEE 754 Annex F / C standard Annex F conventions: + * - `f == one`: returns `one`. + * - `n == 0` (after GCD reduction): returns `one`. + * - `d == 0` after GCD reduction: infinite or NaN depending on `abs(f)`. + * - Odd numerator, even denominator, negative `f`: throws (NaN). + * - Both `n` and `d` zero (`gcd == 0`): throws (NaN). + * + * @param f Base value. + * @param n Numerator of the exponent. + * @param d Denominator of the exponent. + * @return f^(n/d), rounded to the active mantissa range. + * @throws std::overflow_error for semantically infinite or NaN results. + */ Number power(Number const& f, unsigned n, unsigned d) { diff --git a/src/libxrpl/basics/ResolverAsio.cpp b/src/libxrpl/basics/ResolverAsio.cpp index 4a5ceb3d8d..5d0cb9f832 100644 --- a/src/libxrpl/basics/ResolverAsio.cpp +++ b/src/libxrpl/basics/ResolverAsio.cpp @@ -1,3 +1,15 @@ +/** @file + * Concrete asynchronous DNS resolver built on Boost.Asio. + * + * Provides `ResolverAsioImpl`, the production implementation of the + * `ResolverAsio` / `Resolver` interface used by the peer-discovery and + * overlay subsystems to translate string-form peer addresses (e.g. + * `r.ripple.com:51235`) into `beast::IP::Endpoint` objects. + * + * Also anchors the vtable for `Resolver` by defining its pure-virtual + * destructor in this translation unit. + */ + #include #include @@ -33,10 +45,24 @@ namespace xrpl { -/** Mix-in to track when all pending I/O is complete. - Derived classes must be callable with this signature: - void asyncHandlersComplete() -*/ +/** CRTP mix-in that tracks outstanding asynchronous handler completions. + * + * Maintains an atomic counter, `pending_`, that is incremented for each + * live async operation and decremented when it finishes. When the counter + * reaches zero the mix-in calls `Derived::asyncHandlersComplete()`, signalling + * that the object can safely be torn down. + * + * Two increment/decrement mechanisms are provided: + * - `CompletionCounter` — RAII guard; bind one into every Asio handler. + * - `addReference` / `removeReference` — manual pair used for the object's + * "lifetime reference" held between `start()` and `doStop()`. + * + * @tparam Derived The owning class, which must expose + * `void asyncHandlersComplete()`. + * + * @note The destructor asserts `pending_ == 0`, so destroying the object + * with any live handlers is a hard failure. + */ template class AsyncObject { @@ -47,27 +73,50 @@ class AsyncObject public: ~AsyncObject() { - // Destroying the object with I/O pending? Not a clean exit! XRPL_ASSERT(pending_.load() == 0, "xrpl::AsyncObject::~AsyncObject : nothing pending"); } - /** RAII container that maintains the count of pending I/O. - Bind this into the argument list of every handler passed - to an initiating function. - */ + /** RAII guard that keeps the pending-handler count non-zero for its lifetime. + * + * Increment `pending_` on construction (including copy construction, which + * is what Asio does when it copies a bound handler into its internal queue) + * and decrement it on destruction, calling `asyncHandlersComplete()` on + * the owning `Derived` object when the count reaches zero. + * + * Bind one of these into the argument list of every handler passed to an + * Asio initiating function so that the counter accurately reflects the + * number of handlers that have not yet returned. + * + * @note Copy-assignment is deleted; a counter should only be moved through + * copy-construction by Asio's internal machinery. + */ class CompletionCounter { public: + /** Construct and increment the owner's pending count. + * + * @param owner The `Derived` instance whose counter to increment. + * Must not be null and must outlive this counter. + */ explicit CompletionCounter(Derived* owner) : owner_(owner) { ++owner_->pending_; } + /** Copy-construct, incrementing the shared pending count. + * + * Asio copies bound handlers when posting them to an executor; + * this constructor ensures each copy independently holds a reference. + */ CompletionCounter(CompletionCounter const& other) : owner_(other.owner_) { ++owner_->pending_; } + /** Destroy and decrement the pending count. + * + * Calls `Derived::asyncHandlersComplete()` when the count reaches zero. + */ ~CompletionCounter() { if (--owner_->pending_ == 0) @@ -81,12 +130,23 @@ public: Derived* owner_; }; + /** Increment the pending count without an RAII guard. + * + * Must be paired with a matching `removeReference()` call. Used to + * hold a "lifetime reference" while the resolver is running but has no + * active Asio handlers. + */ void addReference() { ++pending_; } + /** Decrement the pending count and fire `asyncHandlersComplete()` if zero. + * + * Pair with `addReference()`. Dropping the last reference triggers the + * same completion callback that `CompletionCounter` uses. + */ void removeReference() { @@ -95,36 +155,81 @@ public: } private: - // The number of handlers pending. + /** Number of live handlers plus any manually held references. */ std::atomic pending_; friend Derived; }; +/** Concrete implementation of `ResolverAsio` using Boost.Asio's `tcp::resolver`. + * + * Resolves hostname batches serially — one DNS query at a time — to avoid + * exhausting file descriptors or hitting resolver rate limits. All mutable + * I/O-path state is accessed exclusively on a `boost::asio::strand`, so no + * explicit locking is needed there. A `std::mutex` / `std::condition_variable` + * pair is used only for the synchronous `stop()` call. + * + * Lifecycle: the object starts in a *stopped* state. Call `start()` before + * submitting any resolution requests, and call `stop()` (or `stopAsync()`) to + * drain in-flight work before destruction. The destructor asserts that the + * work queue is empty and the stopped flag is set. + * + * @see AsyncObject for the pending-handler reference-counting scheme. + */ class ResolverAsioImpl : public ResolverAsio, public AsyncObject { public: + /** Parsed result of a `host:port` string. */ using HostAndPort = std::pair; + /** Journal used for debug/error logging throughout this instance. */ beast::Journal journal; + /** The `io_context` on which all async operations are dispatched. */ boost::asio::io_context& io_context; + + /** Strand that serialises all access to the work queue and resolver state. */ boost::asio::strand strand; + + /** Underlying Asio TCP resolver. */ boost::asio::ip::tcp::resolver resolver; + /** Condition variable used by `stop()` to wait for all handlers to finish. */ std::condition_variable cv; + + /** Mutex that guards `asyncHandlersCompleted`. */ std::mutex mut; + + /** Set to `true` (under `mut`) when `AsyncObject::pending_` reaches zero. */ bool asyncHandlersCompleted{true}; + /** Set to `true` atomically by `stopAsync()`; prevents new work from being enqueued. */ std::atomic stop_called; + + /** `true` when the resolver is not running (initial state and after `doStop()`). */ std::atomic stopped; - // Represents a unit of work for the resolver to do + /** A batch of hostnames to resolve, plus the callback to invoke per result. + * + * Names are stored in **reverse** order so that serving the next entry is + * a `pop_back()` — O(1) without element shifting. + */ struct Work { + /** Hostnames remaining to resolve, stored in reverse submission order. */ std::vector names; + + /** Callback invoked with each resolved hostname and its endpoints. */ HandlerType handler; + /** Construct a `Work` item from a sequence of names and a handler. + * + * Names are reverse-copied so that `names.back()` yields the first + * submitted name and `pop_back()` serves them in FIFO order. + * + * @param inNames Input range of hostnames to resolve. + * @param handler Callback to invoke for each resolved name. + */ template Work(StringSequence const& inNames, HandlerType handler) : handler(std::move(handler)) { @@ -134,8 +239,14 @@ public: } }; + /** Queue of pending resolution batches, drained serially by `doWork()`. */ std::deque work; + /** Construct in stopped state; no resolution work is performed until `start()` is called. + * + * @param ioContext The `io_context` for all async I/O. + * @param journal Logging sink for debug and error messages. + */ ResolverAsioImpl(boost::asio::io_context& ioContext, beast::Journal journal) : journal(journal) , io_context(ioContext) @@ -146,14 +257,24 @@ public: { } + /** Destroy the resolver. + * + * Asserts that `stop()` has been called and the work queue is empty; + * destroying the object without stopping first is a programming error. + */ ~ResolverAsioImpl() override { XRPL_ASSERT(work.empty(), "xrpl::ResolverAsioImpl::~ResolverAsioImpl : no pending work"); XRPL_ASSERT(stopped, "xrpl::ResolverAsioImpl::~ResolverAsioImpl : stopped"); } - //------------------------------------------------------------------------- - // AsyncObject + // --- AsyncObject callback --- + + /** Called by `AsyncObject` when the pending-handler count reaches zero. + * + * Sets `asyncHandlersCompleted` under `mut` and notifies `stop()` via + * `cv` so it can unblock and return. + */ void asyncHandlersComplete() { @@ -162,12 +283,19 @@ public: cv.notify_all(); } - //-------------------------------------------------------------------------- - // - // Resolver - // - //-------------------------------------------------------------------------- + // --- Resolver interface --- + /** Transition from stopped to running and acquire the lifetime reference. + * + * Clears the stopped flag and calls `addReference()` to hold a "lifetime + * reference" in `AsyncObject`, preventing `asyncHandlersComplete()` from + * firing prematurely while no Asio handlers are queued. Must be called + * before any `resolve()` requests are submitted. + * + * @note Asserts that the resolver is currently stopped and that no stop + * has been requested — calling `start()` after `stopAsync()` is a + * programming error. + */ void start() override { @@ -184,6 +312,12 @@ public: } } + /** Request an asynchronous stop; idempotent. + * + * Posts `doStop()` to the strand exactly once (subsequent calls are + * no-ops). `doStop()` will clear the work queue, cancel any in-flight + * Asio resolution, and drop the lifetime reference. + */ void stopAsync() override { @@ -198,6 +332,12 @@ public: } } + /** Synchronously stop the resolver and block until all handlers have returned. + * + * Calls `stopAsync()` then waits on `cv` for `asyncHandlersCompleted` to + * become `true`. Returns only after every `CompletionCounter` has been + * destroyed, guaranteeing that no handler is still executing. + */ void stop() override { @@ -210,6 +350,19 @@ public: JLOG(journal.debug()) << "Stopped"; } + /** Enqueue a batch of hostnames for asynchronous resolution. + * + * Serialises onto the strand and calls `doResolve()`, which wraps the + * batch in a `Work` item and triggers `doWork()` to start draining the + * queue one query at a time. + * + * @param names Non-empty list of hostnames (or `host:port` strings) + * to resolve. + * @param handler Callback invoked once per name with its resolved + * `beast::IP::Endpoint` list (empty on error). + * + * @note Must not be called after `stopAsync()` or `stop()`. + */ void resolve(std::vector const& names, HandlerType const& handler) override { @@ -226,8 +379,20 @@ public: &ResolverAsioImpl::doResolve, this, names, handler, CompletionCounter(this)))); } - //------------------------------------------------------------------------- - // Resolver + // --- Strand-serialised implementation helpers --- + + /** Strand handler that performs the actual shutdown sequence. + * + * Clears the work queue, cancels any in-flight Asio resolution (causing + * pending handlers to be called back with `operation_aborted`), and drops + * the lifetime reference acquired in `start()`. Idempotent: the + * `stopped.exchange(true)` guard ensures the body runs only once even if + * `doStop()` is somehow posted twice. + * + * @param The `CompletionCounter` parameter is intentionally unnamed; + * it is passed solely so the pending count stays non-zero until + * this handler returns. + */ void doStop(CompletionCounter) { @@ -242,6 +407,24 @@ public: } } + /** Strand handler called when a single `async_resolve` completes. + * + * Silently discards `operation_aborted` results (these occur when + * `doStop()` cancels the resolver and produce no user-visible callback). + * For any other outcome — success or a genuine error — converts the Asio + * result set into a `std::vector` (empty on error) + * and invokes `handler`. After invoking the handler, posts a new + * `doWork()` call to the strand to process the next queued name. + * + * @param name The original name string that was resolved. + * @param ec Error code from `async_resolve`; non-zero means `results` + * should be ignored. + * @param handler User-supplied callback to invoke with the resolved + * endpoints. + * @param results Asio resolver result set; valid only when `ec` is clear. + * @param Unnamed `CompletionCounter` that keeps the pending count + * non-zero for the duration of this handler. + */ void doFinish( std::string name, @@ -256,8 +439,7 @@ public: std::vector addresses; auto iter = results.begin(); - // If we get an error message back, we don't return any - // results that we may have gotten. + // Any error means we return an empty address list to the handler. if (!ec) { while (iter != results.end()) @@ -275,22 +457,34 @@ public: strand, std::bind(&ResolverAsioImpl::doWork, this, CompletionCounter(this)))); } + /** Parse a `host:port` string into its component parts. + * + * Uses a two-tier strategy to handle both IP addresses and hostnames: + * + * 1. Attempt `beast::IP::Endpoint::fromStringChecked()`, which correctly + * handles IPv6 bracket notation (e.g. `[::1]:6006`) where a raw + * colon-split would produce incorrect results. + * 2. Fall back to a whitespace-trimmed scan that treats both `:` and + * whitespace as the host/port separator, so `"r.ripple.com 51235"` and + * `"r.ripple.com:51235"` are both accepted. + * + * An all-whitespace input returns a pair of empty strings. + * + * @param str The raw name string from the work queue. + * @return A `{host, port}` pair. Either component may be empty if the + * input is malformed. + */ static HostAndPort parseName(std::string const& str) { - // first attempt to parse as an endpoint (IP addr + port). - // If that doesn't succeed, fall back to generic name + port parsing - if (auto const result = beast::IP::Endpoint::fromStringChecked(str)) { return make_pair(result->address().to_string(), std::to_string(result->port())); } - // generic name/port parsing, which doesn't work for - // IPv6 addresses in particular because it considers a colon - // a port separator + // Generic host/port scan — does not handle IPv6 because colons would + // be misinterpreted as port separators; the branch above handles that. - // Attempt to find the first and last non-whitespace auto const findWhitespace = std::bind(&std::isspace, std::placeholders::_1, std::locale()); @@ -299,11 +493,10 @@ public: auto portLast = std::ranges::find_if_not(std::ranges::reverse_view(str), findWhitespace).base(); - // This should only happen for all-whitespace strings + // Only reachable for all-whitespace strings. if (hostFirst >= portLast) return std::make_pair(std::string(), std::string()); - // Attempt to find the first and last valid port separators auto const findPortSeparator = [](char const c) -> bool { if (std::isspace(static_cast(c))) return true; @@ -321,13 +514,24 @@ public: return make_pair(std::string(hostFirst, hostLast), std::string(portFirst, portLast)); } + /** Strand handler that dequeues and issues one DNS resolution. + * + * Pulls the next name from the front batch (using `pop_back()` on the + * reversed `names` vector), parses it with `parseName()`, and calls + * `resolver.async_resolve()`. If `parseName()` returns an empty host the + * name is logged and skipped, and `doWork()` is re-posted immediately so + * the remaining queue is not stalled. If the queue is empty or a stop has + * been requested the method returns without posting further work. + * + * @param Unnamed `CompletionCounter` that keeps the pending count + * non-zero for the duration of this handler. + */ void doWork(CompletionCounter) { if (stop_called) return; - // We don't have any work to do at this time if (work.empty()) return; @@ -366,6 +570,17 @@ public: CompletionCounter(this))); } + /** Strand handler that enqueues a new batch of names and triggers draining. + * + * Wraps `names` and `handler` in a `Work` item (which reverse-copies the + * names for O(1) `pop_back()` dispatch) and appends it to `work`. Then + * posts `doWork()` to begin processing. Does nothing if a stop has been + * requested. + * + * @param names Non-empty list of hostnames to resolve. + * @param handler Callback to invoke per resolved name. + * @param Unnamed `CompletionCounter` holding the pending count. + */ void doResolve(std::vector const& names, HandlerType const& handler, CompletionCounter) { @@ -390,14 +605,27 @@ public: } }; -//----------------------------------------------------------------------------- - +/** Create a `ResolverAsio` backed by `ResolverAsioImpl`. + * + * This is the sole construction path for the concrete resolver. Callers + * receive a `std::unique_ptr` and never see the implementation + * type directly. + * + * @param ioContext The `io_context` on which async operations will run. + * @param journal Logging sink for debug and error messages. + * @return Owning pointer to a newly constructed, stopped resolver. + */ std::unique_ptr ResolverAsio::make(boost::asio::io_context& ioContext, beast::Journal journal) { return std::make_unique(ioContext, journal); } -//----------------------------------------------------------------------------- +/** Anchor the `Resolver` vtable in this translation unit. + * + * The pure-virtual destructor is declared `= 0` in the header; defining it + * here satisfies the ODR requirement and prevents the vtable from being + * emitted in every translation unit that includes `Resolver.h`. + */ Resolver::~Resolver() = default; } // namespace xrpl diff --git a/src/libxrpl/basics/StringUtilities.cpp b/src/libxrpl/basics/StringUtilities.cpp index fa35f7f740..a5f1fa2e1a 100644 --- a/src/libxrpl/basics/StringUtilities.cpp +++ b/src/libxrpl/basics/StringUtilities.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements string and binary-data utilities used across the XRPL daemon. + * + * Provides five free functions in the `xrpl` namespace: `sqlBlobLiteral` + * for SQLite hex-literal encoding, `parseUrl` for URI decomposition, + * `trimWhitespace` for whitespace stripping, `toUInt64` for safe decimal + * conversion, and `isProperlyFormedTomlDomain` for validator-TOML domain + * validation. Both regex objects are `static` locals so they are compiled + * once and reused across all calls. + */ #include #include @@ -19,6 +29,15 @@ namespace xrpl { +/** Encode binary data as an SQLite blob literal of the form `X'AABBCC...'`. + * + * Pre-reserves `(blob.size() * 2) + 3` bytes to hold the leading `X'`, the + * hex body, and the closing `'` without reallocation. + * + * @param blob Binary data to encode. + * @return The input encoded as an SQLite blob literal. + * @see https://sqlite.org/lang_expr.html#literal_values_constants_ + */ std::string sqlBlobLiteral(Blob const& blob) { @@ -33,29 +52,49 @@ sqlBlobLiteral(Blob const& blob) return j; } +/** Decompose a URI into its constituent parts, populating a `ParsedUrl`. + * + * Only URIs with an `//authority` hier-part are accepted + * (`scheme://[userinfo@]host[:port][/path]`). Opaque URIs such as + * `mailto:user@example.com` are rejected. The extracted scheme is always + * stored in lower-case regardless of the case in `strUrl`. + * + * IPv6 addresses in bracket notation (e.g. `[::1]`) are normalised: the + * brackets are stripped and the address is canonicalised via + * `beast::IP::Endpoint`. Plain hostnames are stored verbatim. + * + * Port validation accepts only values in `[1, 65535]`. Port 0 and ports + * that overflow `uint16_t` (e.g. `65536`, `23498765`) all cause the + * function to return `false`. + * + * The regex is compiled once as a `static` local and reused across all + * calls. The entire match is wrapped in `catch (...)` because + * `boost::regex_match` can throw `std::runtime_error` on pathological + * inputs (e.g. a host component consisting of thousands of colons). + * + * @param pUrl Output struct populated on success; not modified on failure. + * @param strUrl The URI string to parse. + * @return `true` if the URI was well-formed and all fields were extracted + * successfully; `false` otherwise. + * @note `ParsedUrl::operator==` intentionally ignores `username` and + * `password` so two URLs pointing at the same endpoint compare equal + * regardless of embedded credentials. + */ bool parseUrl(ParsedUrl& pUrl, std::string const& strUrl) { - // scheme://username:password@hostname:port/rest static boost::regex const kRE_URL( "(?i)\\`\\s*" - // required scheme "([[:alpha:]][-+.[:alpha:][:digit:]]*?):" - // We choose to support only URIs whose `hier-part` has the form - // `"//" authority path-abempty`. + // Only `//authority` hier-part URIs are accepted. "//" - // optional userinfo "(?:([^:@/]*?)(?::([^@/]*?))?@)?" - // optional host "([[:digit:]:]*[[:digit:]]|\\[[^]]+\\]|[^:/?#]*?)" - // optional port "(?::([[:digit:]]+))?" - // optional path "(/.*)?" "\\s*?\\'"); boost::smatch smMatch; - // Bail if there is no match. try { if (!boost::regex_match(strUrl, smMatch, kRE_URL)) @@ -71,9 +110,6 @@ parseUrl(ParsedUrl& pUrl, std::string const& strUrl) pUrl.username = smMatch[2]; pUrl.password = smMatch[3]; std::string const domain = smMatch[4]; - // We need to use Endpoint to parse the domain to - // strip surrounding brackets from IPv6 addresses, - // e.g. [::1] => ::1. auto const result = beast::IP::Endpoint::fromStringChecked(domain); pUrl.domain = result ? result->address().to_string() : domain; std::string const port = smMatch[5]; @@ -81,8 +117,7 @@ parseUrl(ParsedUrl& pUrl, std::string const& strUrl) { pUrl.port = beast::lexicalCast(port); - // For inputs larger than 2^32-1 (65535), lexicalCast returns 0. - // parseUrl returns false for such inputs. + // lexicalCast returns 0 for values outside [1, 65535]. if (pUrl.port == 0) { return false; @@ -93,6 +128,14 @@ parseUrl(ParsedUrl& pUrl, std::string const& strUrl) return true; } +/** Strip leading and trailing whitespace from a string. + * + * The argument is taken by value so the caller receives a new trimmed copy; + * passing an rvalue avoids the extra copy entirely. + * + * @param str The string to trim. + * @return A copy of `str` with leading and trailing whitespace removed. + */ std::string trimWhitespace(std::string str) { @@ -100,6 +143,16 @@ trimWhitespace(std::string str) return str; } +/** Convert a decimal string to a `uint64_t`, returning `nullopt` on failure. + * + * Unlike a sentinel return value such as `0` or `-1`, `std::nullopt` + * unambiguously distinguishes parse failure from the valid value `"0"`. + * Used primarily in configuration parsing. + * + * @param s Decimal string to convert. + * @return The converted value, or `std::nullopt` if `s` is not a valid + * decimal representation of a `uint64_t`. + */ std::optional toUInt64(std::string const& s) { @@ -109,28 +162,39 @@ toUInt64(std::string const& s) return std::nullopt; } +/** Validate that a domain string is plausibly well-formed for use in an + * XRPL validator TOML file. + * + * Two fast-path length checks (4–128 characters) guard the regex from + * obviously invalid inputs. The `static` regex enforces RFC-like hostname + * structure: each label must be `[a-zA-Z0-9-]{1,63}` with no leading or + * trailing hyphens, and the TLD must be pure alpha with at least two + * characters. The regex is compiled once with + * `boost::regex_constants::optimize` and reused across all calls. + * + * @param domain The domain string to validate. + * @return `true` if the domain passes structural checks; `false` otherwise. + * @note This is not a full RFC 5891 validator. It rejects some valid + * internationalised domain names and does not verify IANA TLD + * registration. Its purpose is to filter obviously malformed inputs + * during TOML parsing, not to definitively resolve domains. + */ bool isProperlyFormedTomlDomain(std::string_view domain) { - // The domain must be between 4 and 128 characters long if (domain.size() < 4 || domain.size() > 128) return false; - // This regular expression should do a decent job of weeding out - // obviously wrong domain names but it isn't perfect. It does not - // really support IDNs. If this turns out to be an issue, a more - // thorough regex can be used or this check can just be removed. static boost::regex const kRE( - "^" // Beginning of line - "(" // Beginning of a segment - "(?!-)" // - must not begin with '-' - "[a-zA-Z0-9-]{1,63}" // - only alphanumeric and '-' - "(? @@ -10,7 +21,11 @@ namespace xrpl { std::atomic UptimeClock::kNOW{0}; // seconds since start std::atomic UptimeClock::kSTOP{false}; // stop update thread -// On xrpld shutdown, cancel and wait for the update thread +/** Signal the background counter thread to stop and block until it exits. + * + * Sets `kSTOP` to `true` then calls `join()`. The thread checks `kSTOP` + * before each sleep, so the wait is at most one sleep cycle (≤ 1 second). + */ UptimeClock::UpdateThread::~UpdateThread() { if (joinable()) @@ -22,7 +37,14 @@ UptimeClock::UpdateThread::~UpdateThread() } } -// Launch the update thread +/** Start the background thread that increments `kNOW` once per second. + * + * Uses `sleep_until` against a fixed `next` timestamp rather than + * `sleep_for` so that scheduling jitter does not accumulate into drift + * over the lifetime of the process. + * + * @return An `UpdateThread` RAII handle that joins on destruction. + */ UptimeClock::UpdateThread UptimeClock::startClock() { @@ -30,7 +52,6 @@ UptimeClock::startClock() using namespace std; using namespace std::chrono; - // Wake up every second and update kNOW auto next = system_clock::now() + 1s; while (!kSTOP) { @@ -41,17 +62,22 @@ UptimeClock::startClock() }}; } -// This actually measures time since first use, instead of since xrpld start. -// However the difference between these two epochs is a small fraction of a -// second and unimportant. - +/** Return the number of seconds elapsed since first use of this clock. + * + * Lazily starts the background counter thread on the first call via a + * function-local `static`; C++11 guarantees this initialisation is + * thread-safe and happens exactly once. Subsequent calls are a single + * atomic load with no kernel transition. + * + * @return A `time_point` whose value is the contents of `kNOW`. + * @note The epoch is the moment of first call rather than true process + * start, but the difference is a negligible fraction of a second. + */ UptimeClock::time_point UptimeClock::now() { - // start the update thread on first use static auto const kINIT = startClock(); - // Return the number of seconds since xrpld start return time_point{duration{kNOW}}; } diff --git a/src/libxrpl/basics/base64.cpp b/src/libxrpl/basics/base64.cpp index 7a649a8c3a..37fd37613b 100644 --- a/src/libxrpl/basics/base64.cpp +++ b/src/libxrpl/basics/base64.cpp @@ -32,6 +32,17 @@ */ +/** @file + * RFC 4648 Base64 codec for the XRPL ledger library. + * + * Derived from René Nyffenegger's public-domain implementation (2004–2008). + * Two API layers are provided: the inner `xrpl::base64` namespace exposes + * buffer-oriented primitives that avoid heap allocation, while the outer + * `xrpl` namespace exposes `base64Encode` / `base64Decode` which manage + * `std::string` memory automatically. All functions are fully re-entrant; + * all mutable state is function-local and the lookup tables are `constexpr`. + */ + #include #include @@ -44,6 +55,11 @@ namespace xrpl { namespace base64 { +/** Return a pointer to the 64-character Base64 alphabet (A–Z, a–z, 0–9, +, /). + * + * The array is stored as a function-local `static constexpr` and its + * lifetime is that of the program, so the returned pointer is always valid. + */ inline char const* getAlphabet() { @@ -52,6 +68,13 @@ getAlphabet() return &kTAB[0]; } +/** Return a pointer to the 256-entry inverse-alphabet lookup table. + * + * For each byte value `b`, `getInverse()[b]` is the 6-bit Base64 value of + * the character (0–63), or -1 if `b` is not a valid Base64 character. + * Using a flat 256-element array makes validation and value extraction a + * single array index — O(1) with no per-character branching. + */ inline signed char const* getInverse() { @@ -76,30 +99,44 @@ getInverse() return &kTAB[0]; } -/// Returns max chars needed to encode a base64 string +/** Compute the exact number of Base64 characters produced by encoding `n` bytes. + * + * The result is `4 * ⌈n / 3⌉`, always a multiple of four due to `=` padding. + * + * @param n Number of raw input bytes. + * @return Exact output size in characters (no null terminator included). + */ std::size_t constexpr encodedSize(std::size_t n) { return 4 * ((n + 2) / 3); } -/// Returns max bytes needed to decode a base64 string +/** Compute an upper-bound buffer size sufficient to hold the decoded output of `n` Base64 characters. + * + * Returns `(n / 4) * 3 + 2`, which is deliberately conservative: the `+2` + * guarantees the caller's pre-allocated buffer is always large enough + * regardless of `=` padding or a partial trailing group. The actual number + * of bytes written is returned by `decode()`, not by this function. + * + * @param n Number of Base64 input characters. + * @return Upper-bound byte count for the decoded output buffer. + */ std::size_t constexpr decodedSize(std::size_t n) { return ((n / 4) * 3) + 2; } -/** Encode a series of octets as a padded, base64 string. - - The resulting string will not be null terminated. - - @par Requires - - The memory pointed to by `out` points to valid memory - of at least `encoded_size(len)` bytes. - - @return The number of characters written to `out`. This - will exclude any null termination. -*/ +/** Encode raw bytes as a padded Base64 string into a caller-supplied buffer. + * + * Processes input three bytes at a time, emitting four Base64 characters per + * group. A one- or two-byte tail is handled with `=` padding so the output + * length is always a multiple of four. The output is not null-terminated. + * + * @param dest Destination buffer; must be at least `encodedSize(len)` bytes. + * @param src Source buffer containing the raw bytes to encode. + * @param len Number of bytes to read from `src`. + * @return Number of Base64 characters written to `dest` (no null terminator). + */ std::size_t encode(void* dest, void const* src, std::size_t len) { @@ -140,17 +177,27 @@ encode(void* dest, void const* src, std::size_t len) return out - static_cast(dest); } -/** Decode a padded base64 string into a series of octets. - - @par Requires - - The memory pointed to by `out` points to valid memory - of at least `decoded_size(len)` bytes. - - @return The number of octets written to `out`, and - the number of characters read from the input string, - expressed as a pair. -*/ +/** Decode a Base64 string into raw bytes in a caller-supplied buffer. + * + * Reads four Base64 characters at a time and reconstructs three output bytes + * per group. Decoding stops at the first `=` padding character, the first + * character that maps to -1 in the inverse table (i.e. not in the Base64 + * alphabet), or when `len` input characters have been consumed — whichever + * comes first. Any partial group of 1–3 valid characters accumulated before + * stopping produces `i - 1` additional output bytes. + * + * @note There is no error return: invalid input silently terminates decoding. + * For example, `decode("not_base64!!")` yields the same output as + * `decode("not")` because `_` is not a valid Base64 character. Callers + * that need to detect partial decodes must compare the returned byte count + * against the expected output size themselves. + * + * @param dest Destination buffer; must be at least `decodedSize(len)` bytes. + * @param src Pointer to the Base64-encoded input characters. + * @param len Maximum number of input characters to consume. + * @return A pair `{bytesWritten, charsConsumed}`: the number of raw bytes + * written to `dest` and the number of input characters read from `src`. + */ std::pair decode(void* dest, char const* src, std::size_t len) { @@ -196,6 +243,17 @@ decode(void* dest, char const* src, std::size_t len) } // namespace base64 +/** Encode raw bytes as a Base64 string. + * + * Pre-allocates the output string to the exact encoded size, fills it via + * the buffer-oriented `base64::encode`, then returns it without any extra + * copy or reallocation. + * + * @param data Pointer to the raw bytes to encode. + * @param len Number of bytes to encode. + * @return Base64-encoded string with `=` padding; length is always a + * multiple of four. + */ std::string base64Encode(std::uint8_t const* data, std::size_t len) { @@ -205,6 +263,18 @@ base64Encode(std::uint8_t const* data, std::size_t len) return dest; } +/** Decode a Base64 string, returning the raw bytes. + * + * Pre-allocates to the conservative upper-bound size from + * `base64::decodedSize`, invokes `base64::decode`, then shrinks the string + * to the actual byte count before returning. Decoding stops silently at + * the first invalid or padding character; no exception is thrown and no + * error status is returned. + * + * @param data Base64-encoded input; need not be null-terminated. + * @return Decoded byte string. If `data` contains invalid Base64 characters, + * only the bytes decoded before the first invalid character are included. + */ std::string base64Decode(std::string_view data) { diff --git a/src/libxrpl/basics/contract.cpp b/src/libxrpl/basics/contract.cpp index a249b82353..58698b5b47 100644 --- a/src/libxrpl/basics/contract.cpp +++ b/src/libxrpl/basics/contract.cpp @@ -1,3 +1,12 @@ +/** @file + * Runtime implementation of the XRPL programming-by-contract facility. + * + * Provides `logThrow` (best-effort diagnostic logging before an exception + * propagates) and `logicError` (fatal termination on a broken invariant). + * The companion header `contract.h` builds `Throw()` and `rethrow()` on + * top of these two functions. + */ + #include #include @@ -9,12 +18,42 @@ namespace xrpl { +/** Emit a warning-level journal entry immediately before an exception propagates. + * + * This is a best-effort diagnostic: `debugLog()` drains to a debug sink that + * may be a null sink when logging has not yet been configured, so the message + * may never be visible. It is not a reliable audit trail — callers that need + * the message preserved should use the exception object itself. + * + * @param title Human-readable label identifying the throw site, typically + * including the exception type and `what()` string. + */ void logThrow(std::string const& title) { JLOG(debugLog().warn()) << title; } +/** Terminate the process after logging a broken invariant. + * + * Called at sites where an "impossible" condition has been detected and + * continuing would risk data corruption or incorrect ledger state. Never + * call this on error paths that are expected to trigger under normal load. + * + * The message is written to both the XRPL journal (fatal level) and + * `std::cerr`. The dual output ensures visibility even if the journal + * subsystem is uninitialised or itself corrupt. + * + * In debug builds `UNREACHABLE` fires an assert, giving debuggers a clean + * crash point. In release builds `std::abort()` on the following line + * provides a guaranteed `SIGABRT` that crash-reporting infrastructure can + * capture. The function body is excluded from LCOV coverage because these + * lines are unreachable in any test that does not instrument process abort. + * + * @param s Message describing the violated invariant. + * @note Marked `noexcept` to signal to callers and the compiler that this + * path never propagates an exception — it only terminates. + */ [[noreturn]] void logicError(std::string const& s) noexcept { diff --git a/src/libxrpl/basics/make_SSLContext.cpp b/src/libxrpl/basics/make_SSLContext.cpp index 89da14333a..ff857208e0 100644 --- a/src/libxrpl/basics/make_SSLContext.cpp +++ b/src/libxrpl/basics/make_SSLContext.cpp @@ -1,3 +1,19 @@ +/** @file + * TLS context factory for the XRP Ledger node. + * + * Provides two flavors of `boost::asio::ssl::context`: an anonymous variant + * used for peer overlay connections (where application-layer node identity + * makes certificate-based authentication redundant) and an authenticated + * variant used for operator-configured RPC/WebSocket endpoints. Both share + * a common `getContext()` base that enforces TLS 1.2+, DH parameters, and a + * hardened AEAD-only cipher list. + * + * @note The TSAN suppression file (`sanitizers/suppressions/tsan.supp`) + * contains an explicit entry for this file, acknowledging a benign + * initialization race on the `static` locals inside `initAnonymous()`. + * The race is benign: all competing initializations produce identical + * results and the statics are idempotent once set. + */ #include #include @@ -43,9 +59,9 @@ namespace openssl::detail { - There should not be any truly secure information (e.g. seeds or private keys) that gets relayed to the server anyways over these RPCs. - @note If you increase the number of bits you need to generate new - default DH parameters and update defaultDH accordingly. - * */ + @note If you increase the number of bits you need to generate new + default DH parameters and update `kDEFAULT_DH` accordingly. + */ int gDefaultRsaKeyBits = 2048; /** The default DH parameters. @@ -86,6 +102,29 @@ static constexpr char const kDEFAULT_DH[] = */ std::string const kDEFAULT_CIPHER_LIST = "TLSv1.2:!CBC:!DSS:!PSK:!eNULL:!aNULL"; +/** Install a process-lifetime ephemeral certificate into an SSL context. + * + * Generates a 2048-bit RSA key pair and a self-signed X.509v3 certificate + * exactly once per process (via `static` locals), then installs both into + * @p context. Subsequent calls reuse the same key and certificate. + * + * The certificate carries no meaningful identity; it exists only to satisfy + * the TLS handshake. Notable details of the generated certificate: + * - `notBefore` is set 25 hours before the current time (midnight-rounded) + * to prevent observers from inferring the server's start time. + * - Serial number is a fresh 128-bit random value on each process start. + * - Extensions: `CA:FALSE`, `keyUsage=digitalSignature`, + * `extendedKeyUsage=serverAuth,clientAuth`. + * + * @param context The SSL context to configure. + * @note All OpenSSL allocation failures call `logicError()`, which is + * non-recoverable. A server that cannot build its TLS context at + * startup has no viable recovery path. + * @note `RSA_up_ref()` is called before `EVP_PKEY_assign_RSA()` because + * `EVP_PKEY_assign_RSA` takes ownership of the key; the extra reference + * prevents the shared `kDEFAULT_RSA` static from being freed when the + * `EVP_PKEY` is eventually released. + */ static void initAnonymous(boost::asio::ssl::context& context) { @@ -114,8 +153,6 @@ initAnonymous(boost::asio::ssl::context& context) if (!pkey) logicError("EVP_PKEY_new failed"); - // We need to up the reference count of here, since we are retaining a - // copy of the key for (potential) reuse. if (RSA_up_ref(kDEFAULT_RSA) != 1) logicError("EVP_PKEY_assign_RSA: incrementing reference count failed"); @@ -131,13 +168,9 @@ initAnonymous(boost::asio::ssl::context& context) if (x509 == nullptr) logicError("X509_new failed"); - // According to the standards (X.509 et al), the value should be one - // less than the actually certificate version we want. Since we want - // version 3, we must use a 2. + // X.509 encodes version as (desired_version - 1); pass 2 for v3. X509_set_version(x509, 2); - // To avoid leaking information about the precise time that the - // server started up, we adjust the validity period: char buf[16] = {0}; auto const ts = std::time(nullptr) - (25 * 60 * 60); @@ -149,10 +182,8 @@ initAnonymous(boost::asio::ssl::context& context) if (ASN1_TIME_set_string_X509(X509_get_notBefore(x509), buf) != 1) logicError("Unable to set certificate validity date"); - // And make it valid for two years X509_gmtime_adj(X509_get_notAfter(x509), 2 * 365 * 24 * 60 * 60); - // Set a serial number if (auto b = BN_new(); b != nullptr) { if (BN_rand(b, 128, BN_RAND_TOP_ANY, BN_RAND_BOTTOM_ANY)) @@ -169,7 +200,6 @@ initAnonymous(boost::asio::ssl::context& context) BN_clear_free(b); } - // Some certificate details { X509V3_CTX ctx; @@ -204,7 +234,6 @@ initAnonymous(boost::asio::ssl::context& context) } } - // And a private key X509_set_pubkey(x509, kDEFAULT_EPHEMERAL_PRIVATE_KEY); if (!X509_sign(x509, kDEFAULT_EPHEMERAL_PRIVATE_KEY, EVP_sha256())) @@ -222,6 +251,31 @@ initAnonymous(boost::asio::ssl::context& context) logicError("SSL_CTX_use_PrivateKey failed"); } +/** Load operator-supplied certificate and key material into an SSL context. + * + * Handles three optional file paths. Each path is skipped if empty. + * When @p chainFile is provided, it is read in a PEM loop: the first + * certificate block becomes the leaf certificate (unless @p certFile was + * already loaded, in which case it is added directly to the chain); all + * subsequent blocks are appended as intermediate CA certificates via + * `SSL_CTX_add_extra_chain_cert`. This supports the common deployment + * pattern of a single file containing the server cert followed by the + * CA chain. + * + * After loading all material, `SSL_CTX_check_private_key` verifies that + * the private key matches the leaf certificate's public key, catching + * misconfiguration before the server accepts any connections. + * + * @param context The SSL context to configure. + * @param keyFile Path to the PEM-encoded private key file. + * @param certFile Path to the PEM-encoded leaf certificate file. + * @param chainFile Path to a PEM file containing one or more certificates + * forming the CA chain (and optionally the leaf if @p certFile is + * empty). + * @note All failures call `logicError()`, which is non-recoverable. + * @note The chain file is opened with `fopen` (known technical debt; + * see `// VFALCO Replace fopen() with RAII` in the source). + */ static void initAuthenticated( boost::asio::ssl::context& context, @@ -318,6 +372,31 @@ initAuthenticated( } } +/** Create a hardened TLS context with protocol and cipher constraints. + * + * Constructs a `boost::asio::ssl::context` using the `sslv23` method + * identifier — a Boost.Asio naming artifact that means "negotiate the best + * mutually supported version" — then immediately disables SSLv2, SSLv3, + * TLS 1.0, TLS 1.1, and compression, leaving only TLS 1.2+. Disabling + * compression mitigates CRIME-class attacks. + * + * The cipher list defaults to `kDEFAULT_CIPHER_LIST` if @p cipherList is + * empty. The `!CBC` exclusion in the default list strips all block-cipher + * suites, leaving only AEAD constructions (GCM in practice), which sidestep + * the BEAST and POODLE attack families. + * + * Hardcoded 2048-bit DH parameters (`kDEFAULT_DH`) are loaded + * unconditionally. TLS 1.2 renegotiation is disabled via + * `SSL_OP_NO_RENEGOTIATION` as a belt-and-suspenders mitigation for + * CVE-2021-3499 on OpenSSL versions prior to 1.1.1k. + * + * @param cipherList OpenSSL cipher list string; pass an empty string to use + * `kDEFAULT_CIPHER_LIST`. + * @return A fully configured `ssl::context` ready for anonymous or + * authenticated certificate installation. + * @note Callers must install certificate material (via `initAnonymous()` or + * `initAuthenticated()`) before the context can be used for a handshake. + */ std::shared_ptr getContext(std::string cipherList) { @@ -337,10 +416,7 @@ getContext(std::string cipherList) c->use_tmp_dh({std::addressof(detail::kDEFAULT_DH), sizeof(kDEFAULT_DH)}); - // Disable all renegotiation support in TLS v1.2. This can help prevent - // exploitation of the bug described in CVE-2021-3499 (for details see - // https://www.openssl.org/news/secadv/20210325.txt) when linking - // against OpenSSL versions prior to 1.1.1k. + // Belt-and-suspenders mitigation for CVE-2021-3499 (OpenSSL < 1.1.1k). SSL_CTX_set_options(c->native_handle(), SSL_OP_NO_RENEGOTIATION); return c; @@ -349,17 +425,53 @@ getContext(std::string cipherList) } // namespace openssl::detail //------------------------------------------------------------------------------ + +/** Create a TLS context for anonymous peer overlay connections. + * + * Builds a hardened TLS 1.2+ context, installs a process-lifetime + * self-signed ephemeral certificate (generated once via `initAnonymous()`), + * and sets `verify_none` — peer identity is established at the application + * layer via cryptographic node identities, so certificate validation is not + * required at the TLS layer. + * + * Used by `OverlayImpl` for all peer-to-peer connections. + * + * @param cipherList OpenSSL cipher list string; pass an empty string to use + * the default AEAD-only TLS 1.2 cipher list. + * @return A configured `ssl::context` ready for overlay use. + * @note The ephemeral certificate and RSA key are shared across all contexts + * created by this function within the same process lifetime. + */ std::shared_ptr makeSslContext(std::string const& cipherList) { auto context = openssl::detail::getContext(cipherList); openssl::detail::initAnonymous(*context); - // VFALCO NOTE, It seems the WebSocket context never has - // set_verify_mode called, for either setting of WEBSOCKET_SECURE context->set_verify_mode(boost::asio::ssl::verify_none); return context; } +/** Create a TLS context for authenticated RPC/WebSocket endpoints. + * + * Builds a hardened TLS 1.2+ context and loads operator-supplied certificate + * and key material from disk via `initAuthenticated()`. Unlike + * `makeSslContext()`, this path does not set `verify_none` and does not + * install an ephemeral certificate — callers are expected to present a real + * certificate chain trusted by connecting clients (browsers, tooling, etc.). + * + * Used by `ServerHandler` for HTTP/WebSocket-facing RPC ports configured + * with `ssl_key`, `ssl_cert`, and/or `ssl_chain` in the config file. + * + * @param keyFile Path to the PEM-encoded private key file; may be empty. + * @param certFile Path to the PEM-encoded leaf certificate; may be empty. + * @param chainFile Path to a PEM file containing the CA chain (and + * optionally the leaf certificate); may be empty. + * @param cipherList OpenSSL cipher list string; pass an empty string to use + * the default AEAD-only TLS 1.2 cipher list. + * @return A configured `ssl::context` ready for authenticated use. + * @note If the loaded private key does not match the certificate, + * `logicError()` is called (non-recoverable). + */ std::shared_ptr makeSslContextAuthed( std::string const& keyFile, diff --git a/src/libxrpl/basics/mulDiv.cpp b/src/libxrpl/basics/mulDiv.cpp index 64d37a35c3..53cd139b4e 100644 --- a/src/libxrpl/basics/mulDiv.cpp +++ b/src/libxrpl/basics/mulDiv.cpp @@ -1,3 +1,18 @@ +/** @file + * Overflow-safe multiply-then-divide for 64-bit unsigned integers. + * + * Naive evaluation of `(value * mul) / div` in 64-bit arithmetic silently + * overflows whenever the intermediate product exceeds `UINT64_MAX` (~1.8×10¹⁹), + * producing a wildly incorrect result with no indication of failure. This is + * a real hazard in XRPL fee scaling, where `value * mul` routinely exceeds + * that bound. + * + * The implementation widens to `boost::multiprecision::uint128_t` before + * multiplying, so any product of two `uint64_t` values fits without overflow + * (max ≈ 3.4×10³⁸). Division is performed on the full-precision 128-bit value, + * preserving exact integer arithmetic. `boost::multiprecision` is used instead + * of the GCC-only `__uint128_t` extension to remain portable across MSVC. + */ #include #include // IWYU pragma: keep @@ -7,6 +22,25 @@ namespace xrpl { +/** Compute `(value * mul) / div` using a 128-bit intermediate to prevent overflow. + * + * The product `value * mul` is held in a 128-bit accumulator before dividing, + * ensuring no precision is lost before the final narrowing back to 64 bits. + * If the quotient still exceeds `kMULDIV_MAX` (i.e. `UINT64_MAX`), the result + * cannot be represented as a `uint64_t` and `std::nullopt` is returned instead + * of truncating silently. + * + * @param value The base value to scale. + * @param mul The numerator of the scaling ratio. + * @param div The denominator of the scaling ratio. Behaviour is undefined + * if `div` is zero. + * @return The exact quotient as a `uint64_t`, or `std::nullopt` if the result + * exceeds `UINT64_MAX`. + * @note Callers that need an exception on overflow (e.g. `LoadFeeTrack`) should + * check the returned optional and throw themselves; this function + * intentionally keeps overflow policy out of the utility. + * @see xrpl::kMULDIV_MAX + */ std::optional mulDiv(std::uint64_t value, std::uint64_t mul, std::uint64_t div) { diff --git a/src/libxrpl/beast/clock/basic_seconds_clock.cpp b/src/libxrpl/beast/clock/basic_seconds_clock.cpp index 886887dd97..8b6ea07c4d 100644 --- a/src/libxrpl/beast/clock/basic_seconds_clock.cpp +++ b/src/libxrpl/beast/clock/basic_seconds_clock.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements `BasicSecondsClock`: a low-cost, second-resolution clock backed + * by a background thread that amortises OS time queries across all callers. + * + * Hot paths (message timestamping, cache expiry, ledger timing) may query the + * time millions of times per second. Rather than routing each query through + * the OS, a single `SecondsClockThread` samples `std::chrono::steady_clock` + * at every second boundary and publishes the result into a lock-free atomic. + * Callers then read that atomic with no arithmetic and no kernel transition. + */ #include #include @@ -12,7 +22,19 @@ namespace beast { namespace { -// Updates the clock +/** Background thread that maintains a second-resolution time point for + * `BasicSecondsClock`. + * + * A single writer thread wakes at each second boundary and stores the + * current `Clock::time_point` into an atomic integer (`tp_`). Callers + * read `tp_` via a single lock-free atomic load — no OS calls, no + * arithmetic. The maximum staleness of any returned value is one second. + * + * Thread safety: `now()` may be called concurrently by any number of + * threads. Construction and destruction are not thread-safe with respect + * to each other, but the singleton pattern in `BasicSecondsClock::now()` + * ensures they never overlap. + */ class SecondsClockThread { using Clock = BasicSecondsClock::Clock; @@ -27,16 +49,36 @@ public: ~SecondsClockThread(); SecondsClockThread(); + /** Return the most recently sampled time point. + * + * Reduces to a single lock-free atomic load; no kernel involvement. + * The returned value may lag the true current time by up to one second. + * + * @return The last time point recorded by the background thread. + */ Clock::time_point now(); private: + /** Background loop: sample the clock, sleep to the next second boundary, + * repeat until `stop_` is set. + */ void run(); }; +// If this fires, reading tp_ from caller threads would silently acquire an +// internal mutex, negating the entire performance rationale of this class. static_assert(std::atomic::is_always_lock_free); +/** Signal the background thread to stop and block until it exits. + * + * Sets `stop_` under the mutex, releases the lock, then notifies the + * condition variable. Releasing before notifying ensures that if the + * sleeping thread happens to time out and re-check the predicate before + * the notification arrives it will still see `stop_ == true` and exit + * without an extra sleep cycle. + */ SecondsClockThread::~SecondsClockThread() { XRPL_ASSERT( @@ -49,6 +91,12 @@ SecondsClockThread::~SecondsClockThread() thread_.join(); } +/** Initialise `tp_` to the current time and start the background thread. + * + * Pre-seeding `tp_` before launching the thread guarantees that any call + * to `now()` between construction and the first loop iteration returns a + * valid, current timestamp rather than a zero-epoch value. + */ SecondsClockThread::SecondsClockThread() : tp_{Clock::now().time_since_epoch().count()} { thread_ = std::thread(&SecondsClockThread::run, this); @@ -60,6 +108,15 @@ SecondsClockThread::now() return Clock::time_point{Clock::duration{tp_.load()}}; } +/** Sample the clock, publish the result, then sleep to the next second + * boundary. + * + * Sleeping to `floor(now) + 1s` (rather than `sleep_for(1s)`) + * anchors wake-ups to wall-clock second boundaries, preventing drift from + * accumulated sleep overhead over long uptimes. The `wait_until` predicate + * checks `stop_`, so a shutdown notification wakes the thread immediately + * without waiting for the next second. + */ void SecondsClockThread::run() { @@ -78,6 +135,18 @@ SecondsClockThread::run() } // unnamed namespace +/** Return a second-resolution approximation of the current steady clock time. + * + * On the first call, constructs the `SecondsClockThread` singleton (C++11 + * guarantees thread-safe initialisation of function-local statics). + * Subsequent calls reduce to a single lock-free atomic load with no OS + * involvement. The returned value may lag the true current time by up to + * one second, which is sufficient for all ledger-timing and cache-expiry + * use cases. + * + * @return A `time_point` whose value was current within the last second. + * @see xrpl::stopwatch() which exposes this clock as the node's `Stopwatch`. + */ BasicSecondsClock::time_point BasicSecondsClock::now() { diff --git a/src/libxrpl/beast/core/CurrentThreadName.cpp b/src/libxrpl/beast/core/CurrentThreadName.cpp index 39daa286c6..224ab046bd 100644 --- a/src/libxrpl/beast/core/CurrentThreadName.cpp +++ b/src/libxrpl/beast/core/CurrentThreadName.cpp @@ -1,32 +1,53 @@ +/** @file + * Cross-platform implementation of thread-naming utilities. + * + * Provides `beast::setCurrentThreadName` and `beast::getCurrentThreadName` + * with OS-specific backends selected at compile time via Boost.Predef macros. + * The canonical thread name is always stored in a `thread_local` string and + * is never queried back from the OS, so `getCurrentThreadName` always returns + * exactly what was passed to `setCurrentThreadName`. + * + * @see include/xrpl/beast/core/CurrentThreadName.h + */ #include #include #include -//------------------------------------------------------------------------------ - #if BOOST_OS_WINDOWS #include #include namespace beast::detail { +/** Notify the Visual Studio debugger of the current thread's name (Windows). + * + * Raises the well-known Microsoft debugger exception `0x406d1388` carrying a + * `THREADNAME_INFO` payload. The Visual Studio debugger intercepts this + * exception and registers the name; all other exception handlers receive + * `EXCEPTION_CONTINUE_EXECUTION` so the raise is invisible to the program. + * + * This body compiles only when `DEBUG && BOOST_COMP_MSVC` are both true; + * in release builds or under non-MSVC compilers the function is a no-op. + * + * @param name The thread name to register with the debugger. Must point to + * a null-terminated string for the lifetime of the `RaiseException` call. + * @note `#pragma pack(push, 8)` ensures `THREADNAME_INFO` has the exact + * layout the debugger expects regardless of the ambient pack setting. + * @see https://docs.microsoft.com/en-us/visualstudio/debugger/how-to-set-a-thread-name-in-native-code + */ inline void setCurrentThreadNameImpl(std::string_view name) { #if DEBUG && BOOST_COMP_MSVC - // This technique is documented by Microsoft and works for all versions - // of Windows and Visual Studio provided that the process is being run - // under the Visual Studio debugger. For more details, see: - // https://docs.microsoft.com/en-us/visualstudio/debugger/how-to-set-a-thread-name-in-native-code - #pragma pack(push, 8) + /** Payload struct for the Visual Studio thread-name debugger exception. */ struct THREADNAME_INFO { - DWORD dwType; - LPCSTR szName; - DWORD dwThreadID; - DWORD dwFlags; + DWORD dwType; /**< Must be 0x1000. */ + LPCSTR szName; /**< Pointer to the null-terminated thread name. */ + DWORD dwThreadID; /**< Thread ID (-1 for the calling thread). */ + DWORD dwFlags; /**< Reserved; must be zero. */ }; #pragma pack(pop) @@ -58,10 +79,18 @@ setCurrentThreadNameImpl(std::string_view name) namespace beast::detail { +/** Notify the OS of the current thread's name (macOS). + * + * Calls the one-argument Darwin variant of `pthread_setname_np`, which + * names only the calling thread (unlike the POSIX two-argument form). + * + * @param name The thread name. The underlying string data must be + * null-terminated; callers always pass either a string literal or a + * `std::string`, satisfying this invariant. + */ inline void setCurrentThreadNameImpl(std::string_view name) { - // The string is assumed to be null terminated pthread_setname_np(name.data()); // NOLINT(bugprone-suspicious-stringview-data-usage) } @@ -76,10 +105,28 @@ setCurrentThreadNameImpl(std::string_view name) namespace beast::detail { +/** Notify the OS of the current thread's name (Linux). + * + * Linux enforces a hard kernel limit of 16 bytes (including the null + * terminator) via `pthread_setname_np`; names longer than 15 characters + * cause `ERANGE` and the name is not set at all. To avoid this silent + * failure, `name` is manually truncated into a stack buffer before the + * `pthread_setname_np` call. + * + * If the build macro `TRUNCATED_THREAD_NAME_LOGS` is defined, a warning is + * emitted to `std::cerr` whenever truncation occurs. + * + * @param name The desired thread name. Names longer than + * `kMAX_THREAD_NAME_LENGTH` (15) characters are silently truncated to + * fit the kernel limit. + * @note The header's template overload of `setCurrentThreadName` catches + * oversized string literals at compile time; this runtime truncation + * handles the `std::string_view` overload where the length is unknown + * until runtime. + */ inline void setCurrentThreadNameImpl(std::string_view name) { - // truncate and set the thread name. char boundedName[kMAX_THREAD_NAME_LENGTH + 1]; auto const boundedSize = name.size() < kMAX_THREAD_NAME_LENGTH ? name.size() : kMAX_THREAD_NAME_LENGTH; @@ -104,6 +151,13 @@ setCurrentThreadNameImpl(std::string_view name) namespace beast { namespace detail { +/** Thread-local storage for the name assigned to the current thread. + * + * Written by `setCurrentThreadName` and read by `getCurrentThreadName`. + * Storing the name here — rather than querying the OS — guarantees that + * `getCurrentThreadName` always returns exactly the string that was passed + * in, with no platform-imposed truncation or encoding changes. + */ thread_local std::string gThreadName; } // namespace detail diff --git a/src/libxrpl/beast/core/SemanticVersion.cpp b/src/libxrpl/beast/core/SemanticVersion.cpp index a99437f8f2..606b895ca9 100644 --- a/src/libxrpl/beast/core/SemanticVersion.cpp +++ b/src/libxrpl/beast/core/SemanticVersion.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements SemanticVersion parsing, printing, and comparison. + * + * Provides a strict, spec-compliant parser for Semantic Versioning 2.0 + * strings. Inputs that deviate from the specification in any way — including + * leading zeroes, embedded whitespace, or trailing characters — are rejected. + * The file-scope helpers below are internal to this translation unit. + * + * @see https://semver.org/ + */ + #include #include @@ -14,6 +25,16 @@ namespace beast { +/** Serialize an identifier list to a dot-separated string. + * + * Joins each element of @p list with a `.` separator, producing the + * canonical SemVer pre-release or build-metadata segment (without the + * leading `-` or `+` sigil). + * + * @param list The identifier list to serialize. May be empty, in which + * case an empty string is returned. + * @return A dot-separated concatenation of all identifiers. + */ std::string printIdentifiers(SemanticVersion::identifier_list const& list) { @@ -29,19 +50,39 @@ printIdentifiers(SemanticVersion::identifier_list const& list) return ret; } +/** Determine whether a pre-release identifier string is purely numeric. + * + * A string is numeric in the SemVer sense if it converts to a non-negative + * integer and has no leading zeroes. The leading-zero check is performed by + * round-tripping: `lexicalCastChecked("01", n)` succeeds with `n = 1`, but + * `std::to_string(1) != "01"` catches the problem without explicit string + * inspection. + * + * @param s The identifier to test. + * @return `true` if @p s represents a non-negative integer with no leading + * zeroes; `false` otherwise. + */ bool isNumeric(std::string const& s) { int n = 0; - // Must be convertible to an integer if (!lexicalCastChecked(n, s)) return false; - // Must not have leading zeroes return std::to_string(n) == s; } +/** Consume a fixed literal prefix from a mutable input string. + * + * If @p input begins with @p what, erases those characters from the front + * of @p input and returns `true`. If the prefix is not present, @p input + * is left unchanged and `false` is returned. + * + * @param what The literal prefix to match and consume. + * @param input The string being parsed; modified in place on success. + * @return `true` if @p what was found at position 0 and consumed. + */ bool chop(std::string const& what, std::string& input) { @@ -54,10 +95,28 @@ chop(std::string const& what, std::string& input) return true; } +/** Consume a non-negative integer from the front of a mutable input string. + * + * Scans leading digit characters from @p input, converts them to an `int`, + * and writes the result into @p value. The consumed digits are erased from + * @p input on success. + * + * Enforces three invariants beyond simple conversion: + * - The digit run must not be empty. + * - The value must have no leading zeroes (detected via round-trip through + * `std::to_string`; e.g. `"01"` converts to `1` but `"1" != "01"` fails). + * - The value must be in the range `[0, limit]`. + * + * @param value Receives the parsed integer on success. + * @param limit The inclusive upper bound for the parsed value. + * @param input The string being parsed; the consumed digits are erased on + * success. + * @return `true` if a valid integer was consumed; `false` otherwise, with + * @p input and @p value left unmodified. + */ bool chopUInt(int& value, int limit, std::string& input) { - // Must not be empty if (input.empty()) return false; @@ -66,21 +125,17 @@ chopUInt(int& value, int limit, std::string& input) std::string const item(input.begin(), leftIter); - // Must not be empty if (item.empty()) return false; int n = 0; - // Must be convertible to an integer if (!lexicalCastChecked(n, item)) return false; - // Must not have leading zeroes if (std::to_string(n) != item) return false; - // Must not be out of range if (n < 0 || n > limit) return false; @@ -90,21 +145,37 @@ chopUInt(int& value, int limit, std::string& input) return true; } +/** Consume a single SemVer identifier token from the front of a mutable string. + * + * An identifier is a maximal run of characters from the SemVer-allowed set + * `[a-zA-Z0-9-]`. The consumed token is written to @p value and erased from + * @p input on success. + * + * The @p allowLeadingZeroes flag reflects an asymmetry in the SemVer spec: + * pre-release identifiers must not begin with `'0'` (e.g. `"01"` is + * illegal), while build-metadata identifiers have no such restriction. Pass + * `false` for pre-release identifiers and `true` for build metadata. + * + * @param value Receives the extracted identifier token on success. + * @param allowLeadingZeroes If `false`, rejects tokens whose first character + * is `'0'`. + * @param input The string being parsed; the consumed token is erased on + * success. + * @return `true` if a non-empty valid identifier was consumed; `false` + * otherwise, with @p input and @p value left unmodified. + */ bool extractIdentifier(std::string& value, bool allowLeadingZeroes, std::string& input) { - // Must not be empty if (input.empty()) return false; - // Must not have a leading 0 if (!allowLeadingZeroes && input[0] == '0') return false; auto last = input.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"); - // Must not be empty if (last == 0) return false; @@ -113,6 +184,21 @@ extractIdentifier(std::string& value, bool allowLeadingZeroes, std::string& inpu return true; } +/** Consume a dot-separated list of SemVer identifiers from a mutable string. + * + * Repeatedly calls `extractIdentifier` to build a list, consuming `.` + * separators between tokens. Requires at least one identifier to be present. + * The @p allowLeadingZeroes flag is forwarded to each `extractIdentifier` + * call; pass `false` for pre-release lists and `true` for build metadata. + * + * @param identifiers Receives the parsed identifier tokens, appended on + * success. + * @param allowLeadingZeroes Forwarded to `extractIdentifier` for each token. + * @param input The string being parsed; consumed tokens and separators are + * erased on success. + * @return `true` if at least one valid identifier was consumed; `false` if + * the input is empty or any token fails validation. + */ bool extractIdentifiers( SemanticVersion::identifier_list& identifiers, @@ -134,12 +220,20 @@ extractIdentifiers( return true; } -//------------------------------------------------------------------------------ - SemanticVersion::SemanticVersion() : majorVersion(0), minorVersion(0), patchVersion(0) { } +/** Construct and parse a SemanticVersion, throwing on failure. + * + * Delegates to `parse()`. Use this constructor when an invalid version + * string is a programming error; use `parse()` directly when the caller + * needs to handle invalid input without exceptions (e.g., config loading). + * + * @param version The version string to parse. + * @throws std::invalid_argument if @p version does not conform to the + * Semantic Versioning 2.0 specification. + */ SemanticVersion::SemanticVersion(std::string_view version) : SemanticVersion() { if (!parse(version)) @@ -149,7 +243,6 @@ SemanticVersion::SemanticVersion(std::string_view version) : SemanticVersion() bool SemanticVersion::parse(std::string_view input) { - // May not have leading or trailing whitespace auto leftIter = std::ranges::find_if_not( input, [](std::string::value_type c) { return std::isspace(c, std::locale::classic()); }); @@ -158,54 +251,47 @@ SemanticVersion::parse(std::string_view input) return std::isspace(c, std::locale::classic()); }).base(); - // Must not be empty! if (leftIter >= rightIter) return false; std::string version(leftIter, rightIter); - // May not have leading or trailing whitespace + // Reject any leading or trailing whitespace. if (version != input) return false; - // Must have major version number if (!chopUInt(majorVersion, std::numeric_limits::max(), version)) return false; if (!chop(".", version)) return false; - // Must have minor version number if (!chopUInt(minorVersion, std::numeric_limits::max(), version)) return false; if (!chop(".", version)) return false; - // Must have patch version number if (!chopUInt(patchVersion, std::numeric_limits::max(), version)) return false; - // May have pre-release identifier list if (chop("-", version)) { if (!extractIdentifiers(preReleaseIdentifiers, false, version)) return false; - // Must not be empty if (preReleaseIdentifiers.empty()) return false; } - // May have metadata identifier list if (chop("+", version)) { if (!extractIdentifiers(metaData, true, version)) return false; - // Must not be empty if (metaData.empty()) return false; } + // Any unconsumed characters indicate an unrecognised token. return version.empty(); } @@ -232,75 +318,67 @@ SemanticVersion::print() const return s; } +/** Compare two SemanticVersion objects following SemVer 2.0 precedence rules. + * + * Comparison proceeds in order: major, minor, patch (numerically), then + * pre-release status. When the numeric cores are equal, a release version + * (no pre-release identifiers) always outranks a pre-release — e.g. + * `1.0.0 > 1.0.0-rc.1`. Pre-release identifier lists are then compared + * element by element: purely numeric identifiers compare as integers; + * alphanumeric identifiers compare lexicographically; a mixed pair (one + * numeric, one not) ranks the non-numeric higher. When one list is + * exhausted before the other, the longer list takes precedence. + * + * @note Build metadata is ignored entirely, as mandated by the spec. + * `1.0.0+meta == 1.0.0` under this function. + * @note `XRPL_ASSERT` guards the consistency between `isNumeric()` and the + * comparison branches; they fire only on internal logic bugs. + * + * @param lhs Left-hand operand. + * @param rhs Right-hand operand. + * @return Negative if `lhs < rhs`, zero if equal, positive if `lhs > rhs`. + */ int compare(SemanticVersion const& lhs, SemanticVersion const& rhs) { if (lhs.majorVersion > rhs.majorVersion) - { return 1; - } if (lhs.majorVersion < rhs.majorVersion) - { return -1; - } if (lhs.minorVersion > rhs.minorVersion) - { return 1; - } if (lhs.minorVersion < rhs.minorVersion) - { return -1; - } if (lhs.patchVersion > rhs.patchVersion) - { return 1; - } if (lhs.patchVersion < rhs.patchVersion) - { return -1; - } if (lhs.isPreRelease() || rhs.isPreRelease()) { - // Pre-releases have a lower precedence if (lhs.isRelease() && rhs.isPreRelease()) - { return 1; - } if (lhs.isPreRelease() && rhs.isRelease()) - { return -1; - } - // Compare pre-release identifiers for (int i = 0; i < std::max(lhs.preReleaseIdentifiers.size(), rhs.preReleaseIdentifiers.size()); ++i) { - // A larger list of identifiers has a higher precedence if (i >= rhs.preReleaseIdentifiers.size()) - { return 1; - } if (i >= lhs.preReleaseIdentifiers.size()) - { return -1; - } std::string const& left(lhs.preReleaseIdentifiers[i]); std::string const& right(rhs.preReleaseIdentifiers[i]); - // Numeric identifiers have lower precedence if (!isNumeric(left) && isNumeric(right)) - { return 1; - } if (isNumeric(left) && !isNumeric(right)) - { return -1; - } if (isNumeric(left)) { @@ -310,13 +388,9 @@ compare(SemanticVersion const& lhs, SemanticVersion const& rhs) int const iRight(lexicalCastThrow(right)); if (iLeft > iRight) - { return 1; - } if (iLeft < iRight) - { return -1; - } } else { @@ -330,8 +404,6 @@ compare(SemanticVersion const& lhs, SemanticVersion const& rhs) } } - // metadata is ignored - return 0; } diff --git a/src/libxrpl/beast/insight/Collector.cpp b/src/libxrpl/beast/insight/Collector.cpp index 55abb27c21..1ac6c35c1c 100644 --- a/src/libxrpl/beast/insight/Collector.cpp +++ b/src/libxrpl/beast/insight/Collector.cpp @@ -1,3 +1,18 @@ +/** @file + * Out-of-line definition of the `Collector` pure-virtual destructor. + * + * A pure-virtual destructor declared `= 0` in the header must still have + * an external definition: the compiler emits a call to every base-class + * destructor when a derived object is destroyed, so the linker must be + * able to resolve `Collector::~Collector`. Defining it here as `= default` + * anchors the vtable to this single translation unit, avoiding + * duplicate-symbol and ODR issues that would arise if the definition were + * placed inline in the header. + * + * @see beast::insight::Collector + * @see beast::insight::NullCollector + * @see beast::insight::StatsDCollector + */ #include namespace beast::insight { diff --git a/src/libxrpl/beast/insight/Groups.cpp b/src/libxrpl/beast/insight/Groups.cpp index 6a60c75aa2..b6919a2c26 100644 --- a/src/libxrpl/beast/insight/Groups.cpp +++ b/src/libxrpl/beast/insight/Groups.cpp @@ -19,12 +19,34 @@ namespace beast::insight { namespace detail { +/** Concrete `Group` implementation that transparently prefixes all metric names. + * + * Wraps an underlying `Collector` and prepends `name + "."` to every named + * metric (counter, event, gauge, meter) before forwarding to the collector. + * This gives each subsystem an isolated dot-separated namespace — e.g., a + * group named `"ledger"` turns `"validations"` into `"ledger.validations"` — + * without requiring callers to manage the prefix manually. + * + * Because `GroupImp` inherits `Group`, which inherits `Collector`, a + * `Group::ptr` is substitutable for any `Collector::ptr`. Metric consumers + * are unaware of the grouping indirection. + * + * @note Not thread-safe. Groups are expected to be created on a single thread + * during node initialisation, not during concurrent hot-path operation. + */ class GroupImp : public std::enable_shared_from_this, public Group { std::string const name_; Collector::ptr collector_; public: + /** Construct a group with the given namespace prefix and backing collector. + * + * @param name The dot-separated prefix applied to all metric names + * created through this group. + * @param collector The underlying collector that receives the prefixed + * metric-creation calls. + */ GroupImp(std::string name, Collector::ptr collector) : name_(std::move(name)), collector_(std::move(collector)) { @@ -44,30 +66,62 @@ public: return name_ + "." + name; } + /** Forward the hook to the underlying collector without applying a prefix. + * + * Hooks are polling callbacks, not named time-series, so there is no name + * to prefix. The handler is passed through verbatim. + * + * @param handler The polling callback invoked at each collection interval. + * @return A `Hook` handle whose lifetime controls callback registration. + */ Hook makeHook(HookImpl::HandlerType const& handler) override { return collector_->makeHook(handler); } + /** Create a counter whose name is automatically prefixed with this group's name. + * + * @param name The unqualified metric name; the underlying collector receives + * `groupName + "." + name`. + * @return A `Counter` handle tied to the prefixed metric. + */ Counter makeCounter(std::string const& name) override { return collector_->makeCounter(makeName(name)); } + /** Create an event whose name is automatically prefixed with this group's name. + * + * @param name The unqualified metric name; the underlying collector receives + * `groupName + "." + name`. + * @return An `Event` handle tied to the prefixed metric. + */ Event makeEvent(std::string const& name) override { return collector_->makeEvent(makeName(name)); } + /** Create a gauge whose name is automatically prefixed with this group's name. + * + * @param name The unqualified metric name; the underlying collector receives + * `groupName + "." + name`. + * @return A `Gauge` handle tied to the prefixed metric. + */ Gauge makeGauge(std::string const& name) override { return collector_->makeGauge(makeName(name)); } + /** Create a meter whose name is automatically prefixed with this group's name. + * + * @param name The unqualified metric name; the underlying collector receives + * `groupName + "." + name`. + * @return A `Meter` handle tied to the prefixed metric. + */ Meter makeMeter(std::string const& name) override { @@ -80,12 +134,28 @@ public: //------------------------------------------------------------------------------ +/** Concrete `Groups` implementation: a lazy-creating registry of named `GroupImp` instances. + * + * Maintains an `unordered_map` from group name to `Group::ptr`. The first call + * to `get()` for a given name constructs a `GroupImp`; subsequent calls return + * the cached instance. Both this registry and each individual `GroupImp` hold + * an independent `shared_ptr` to the underlying collector, so a caller that + * retains a `Group::ptr` after the `GroupsImp` is destroyed continues to + * function correctly. + * + * @note Not thread-safe. Concurrent calls to `get()` with new names would race + * on the internal map. + */ class GroupsImp : public Groups { public: + /** Map type from group name to cached `Group::ptr`, using beast's universal hash. */ using Items = std::unordered_map, Uhash<>>; + /** The underlying collector shared with every `GroupImp` this registry creates. */ Collector::ptr collector; + + /** Cache of previously created groups, keyed by name. */ Items items; explicit GroupsImp(Collector::ptr collector) : collector(std::move(collector)) @@ -94,6 +164,16 @@ public: ~GroupsImp() override = default; + /** Return the group registered under `name`, creating it if necessary. + * + * Uses `emplace()` to insert a null placeholder only when the key is new, + * then immediately replaces it with a fully constructed `GroupImp`. Returns + * a `const&` into the map, which remains stable across further insertions + * (but not across a rehash triggered by a concurrent insertion). + * + * @param name The dot-separated namespace prefix for the group. + * @return A stable `const` reference to the cached `Group::ptr` for `name`. + */ Group::ptr const& get(std::string const& name) override { diff --git a/src/libxrpl/beast/insight/Hook.cpp b/src/libxrpl/beast/insight/Hook.cpp index a20e33fad4..7cafbd8d2f 100644 --- a/src/libxrpl/beast/insight/Hook.cpp +++ b/src/libxrpl/beast/insight/Hook.cpp @@ -1,3 +1,15 @@ +/** @file + * Out-of-line destructor definition for `HookImpl`. + * + * Even though `HookImpl::~HookImpl` is declared pure virtual, the base + * destructor is always invoked at the end of every subclass destruction + * chain and therefore must have a definition. Placing `= default` here + * in the `.cpp` anchors the vtable and destructor body to a single + * translation unit, which is the idiomatic pattern for abstract base + * classes in C++. + * + * @see HookImpl, Hook + */ #include #include diff --git a/src/libxrpl/beast/insight/Metric.cpp b/src/libxrpl/beast/insight/Metric.cpp index b9ac569259..083cf8dad4 100644 --- a/src/libxrpl/beast/insight/Metric.cpp +++ b/src/libxrpl/beast/insight/Metric.cpp @@ -1,3 +1,21 @@ +/** @file + * Out-of-line destructor definitions for the `beast::insight` metric + * abstract base classes. + * + * Each of `CounterImpl`, `EventImpl`, `GaugeImpl`, and `MeterImpl` declares + * its destructor pure virtual (`virtual ~Foo() = 0`), making the class + * abstract while still serving as the polymorphic base. In C++, a pure + * virtual destructor is special: it is called implicitly by every derived + * class destructor, so the linker requires an out-of-line body even though + * the function is pure. This translation unit provides those four bodies. + * + * Defining them here, rather than inline in each header, gives the + * definitions a single, explicit home and keeps the headers free of + * implementation detail. + * + * @see CounterImpl, EventImpl, GaugeImpl, MeterImpl + */ + #include #include #include diff --git a/src/libxrpl/beast/insight/NullCollector.cpp b/src/libxrpl/beast/insight/NullCollector.cpp index 03a12ee498..56f558a062 100644 --- a/src/libxrpl/beast/insight/NullCollector.cpp +++ b/src/libxrpl/beast/insight/NullCollector.cpp @@ -1,3 +1,11 @@ +/** @file + * No-op implementations of every `beast::insight` metric type. + * + * Defines `NullCollectorImp` (private to `beast::insight::detail`) and + * implements `NullCollector::make()`. All metric operations are virtual + * dispatches that immediately return without recording anything. + */ + #include #include @@ -19,6 +27,15 @@ namespace beast::insight { namespace detail { +/** No-op `HookImpl` that silently discards the polling handler. + * + * The real collector would store the `HandlerType` and invoke it on each + * collection interval. This impl simply lets the handler be destroyed at + * construction time, producing no callbacks and no overhead. + * + * Copy assignment is deleted to prevent accidental value-copy of a + * `shared_from_this` object. + */ class NullHookImpl : public HookImpl { public: @@ -30,11 +47,17 @@ public: //------------------------------------------------------------------------------ +/** No-op `CounterImpl` whose `increment` is a silent discard. + * + * Copy assignment is deleted to prevent accidental value-copy of a + * `shared_from_this` object. + */ class NullCounterImpl : public CounterImpl { public: explicit NullCounterImpl() = default; + /** No-op: discards the increment amount without recording it. */ void increment(value_type) override { @@ -46,11 +69,17 @@ public: //------------------------------------------------------------------------------ +/** No-op `EventImpl` whose `notify` is a silent discard. + * + * Copy assignment is deleted to prevent accidental value-copy of a + * `shared_from_this` object. + */ class NullEventImpl : public EventImpl { public: explicit NullEventImpl() = default; + /** No-op: discards the event value without recording it. */ void notify(value_type const&) override { @@ -62,16 +91,28 @@ public: //------------------------------------------------------------------------------ +/** No-op `GaugeImpl` whose absolute `set` and relative `increment` are both + * silent discards. + * + * Gauges are the only metric type that supports both absolute assignment + * (`set`) and relative adjustment (`increment`), so both overrides are + * required even though neither does anything here. + * + * Copy assignment is deleted to prevent accidental value-copy of a + * `shared_from_this` object. + */ class NullGaugeImpl : public GaugeImpl { public: explicit NullGaugeImpl() = default; + /** No-op: discards the absolute gauge value without recording it. */ void set(value_type) override { } + /** No-op: discards the relative gauge adjustment without recording it. */ void increment(difference_type) override { @@ -83,11 +124,17 @@ public: //------------------------------------------------------------------------------ +/** No-op `MeterImpl` whose `increment` is a silent discard. + * + * Copy assignment is deleted to prevent accidental value-copy of a + * `shared_from_this` object. + */ class NullMeterImpl : public MeterImpl { public: explicit NullMeterImpl() = default; + /** No-op: discards the meter increment without recording it. */ void increment(value_type) override { @@ -99,6 +146,17 @@ public: //------------------------------------------------------------------------------ +/** Concrete `NullCollector` implementation that allocates a fresh no-op + * metric impl for every `make_*` call. + * + * Each returned metric handle holds a `shared_ptr` to its null impl, so + * its lifetime is tied to the holder — consistent with how the real + * `StatsDCollector` manages its metric objects. The handler passed to + * `makeHook` is silently discarded. + * + * This class is private to this translation unit; callers only ever see + * the `std::shared_ptr` returned by `NullCollector::make()`. + */ class NullCollectorImp : public NullCollector { private: @@ -107,30 +165,35 @@ public: ~NullCollectorImp() override = default; + /** Returns a `Hook` backed by a `NullHookImpl`; the handler is discarded. */ Hook makeHook(HookImpl::HandlerType const&) override { return Hook(std::make_shared()); } + /** Returns a `Counter` backed by a `NullCounterImpl`; increments are discarded. */ Counter makeCounter(std::string const&) override { return Counter(std::make_shared()); } + /** Returns an `Event` backed by a `NullEventImpl`; notifications are discarded. */ Event makeEvent(std::string const&) override { return Event(std::make_shared()); } + /** Returns a `Gauge` backed by a `NullGaugeImpl`; sets and increments are discarded. */ Gauge makeGauge(std::string const&) override { return Gauge(std::make_shared()); } + /** Returns a `Meter` backed by a `NullMeterImpl`; increments are discarded. */ Meter makeMeter(std::string const&) override { @@ -142,6 +205,15 @@ public: //------------------------------------------------------------------------------ +/** Factory that returns a `NullCollector` as a `Collector` pointer. + * + * Callers receive a `std::shared_ptr` pointing at a + * `NullCollectorImp`, keeping the concrete type invisible at every call + * site. Suitable for use wherever metrics reporting is not desired, without + * requiring any conditional checks in the consuming code. + * + * @return A shared pointer to a newly constructed `NullCollectorImp`. + */ std::shared_ptr NullCollector::make() { diff --git a/src/libxrpl/beast/insight/StatsDCollector.cpp b/src/libxrpl/beast/insight/StatsDCollector.cpp index 55042b1bf4..e1fa27a874 100644 --- a/src/libxrpl/beast/insight/StatsDCollector.cpp +++ b/src/libxrpl/beast/insight/StatsDCollector.cpp @@ -1,3 +1,15 @@ +/** @file + * Concrete StatsD backend for the Beast insight metrics framework. + * + * All metric state and UDP I/O live on a single private background thread, + * eliminating per-metric locking. Metrics post mutations via + * `boost::asio::dispatch`; a 1-second timer drains the accumulated values + * and ships them to the configured StatsD endpoint. + * + * Enable `BEAST_STATSDCOLLECTOR_TRACING_ENABLED` at compile time to log + * raw UDP payloads to `std::cerr` before each send. + */ + #include #include @@ -49,9 +61,17 @@ class StatsDCollectorImp; //------------------------------------------------------------------------------ +/** Base class for all StatsD metric implementations. + * + * Inherits from `List::Node` so each metric object is its + * own intrusive-list node, enabling O(1) insertion and removal from the + * collector's registry without separate heap allocation. `doProcess()` is + * called once per timer tick (on the I/O thread) to flush pending values. + */ class StatsDMetricBase : public List::Node { public: + /** Serialize and queue any pending metric value for the next UDP send. */ virtual void doProcess() = 0; virtual ~StatsDMetricBase() = default; @@ -63,13 +83,25 @@ public: //------------------------------------------------------------------------------ +/** StatsD implementation of a `Hook` metric. + * + * Registers a user-supplied callback that is invoked once per timer tick on + * the I/O thread, allowing callers to push derived metric values (e.g. queue + * depths) into the collector at collection time rather than continuously. + */ class StatsDHookImpl : public HookImpl, public StatsDMetricBase { public: + /** Construct and register the hook with the collector. + * + * @param handler Callback invoked each second on the I/O thread. + * @param impl The owning collector; kept alive via `shared_ptr`. + */ StatsDHookImpl(HandlerType handler, std::shared_ptr const& impl); ~StatsDHookImpl() override; + /** Invoke the registered handler. */ void doProcess() override; @@ -83,20 +115,47 @@ private: //------------------------------------------------------------------------------ +/** StatsD implementation of a `Counter` metric. + * + * Accumulates signed increments between timer ticks; on each tick `flush()` + * emits a `|c` StatsD counter line and resets the accumulator to zero. + * Increments from any thread are safe: they are marshalled onto the I/O + * thread via `boost::asio::dispatch`, so `value_` is never accessed + * concurrently. + */ class StatsDCounterImpl : public CounterImpl, public StatsDMetricBase { public: + /** Construct and register the counter with the collector. + * + * @param name Metric name appended to the collector prefix. + * @param impl The owning collector. + */ StatsDCounterImpl(std::string name, std::shared_ptr const& impl); ~StatsDCounterImpl() override; + /** Schedule an increment on the I/O thread. + * + * Thread-safe; dispatches `doIncrement` asynchronously. + * @param amount Signed delta to add to the counter. + */ void increment(CounterImpl::value_type amount) override; + /** Serialize and queue the accumulated value if the counter is dirty. + * + * Resets `value_` and `dirty_` after sending. Must be called on the + * I/O thread. + */ void flush(); + + /** Apply the increment and mark the counter dirty. I/O-thread only. */ void doIncrement(CounterImpl::value_type amount); + + /** Called by the timer tick to flush pending data. */ void doProcess() override; @@ -112,18 +171,42 @@ private: //------------------------------------------------------------------------------ +/** StatsD implementation of an `Event` metric. + * + * Unlike counters, gauges, and meters, events are not coalesced by the + * 1-second timer. Each `notify()` call immediately dispatches a `|ms` + * timing line to the I/O thread to preserve per-event latency granularity. + * Events therefore do not participate in the `StatsDMetricBase` registry. + */ class StatsDEventImpl : public EventImpl { public: + /** Construct the event (does NOT register with the collector registry). + * + * @param name Metric name appended to the collector prefix. + * @param impl The owning collector. + */ StatsDEventImpl(std::string name, std::shared_ptr const& impl); ~StatsDEventImpl() override = default; + /** Schedule immediate serialization of the timing value on the I/O thread. + * + * Thread-safe; dispatches `doNotify` asynchronously. + * @param value Duration to report, in milliseconds. + */ void notify(EventImpl::value_type const& value) override; + /** Serialize the event as a `|ms` StatsD line and queue it for sending. + * + * Must be called on the I/O thread. + * @param value Duration to report, in milliseconds. + */ void doNotify(EventImpl::value_type const& value); + + /** No-op: events bypass the periodic timer. */ void doProcess(); @@ -137,24 +220,67 @@ private: //------------------------------------------------------------------------------ +/** StatsD implementation of a `Gauge` metric. + * + * Represents an instantaneous level (e.g. queue depth, memory usage). + * Two optimizations apply beyond basic dirty-flag flushing: + * - `doSet()` suppresses the dirty flag when the incoming value equals the + * last-sent value, avoiding redundant sends every second. + * - `doIncrement()` applies saturating arithmetic: positive deltas cap at + * `uint64_t` max; negative deltas floor at zero, preventing wraparound. + */ class StatsDGaugeImpl : public GaugeImpl, public StatsDMetricBase { public: + /** Construct and register the gauge with the collector. + * + * @param name Metric name appended to the collector prefix. + * @param impl The owning collector. + */ StatsDGaugeImpl(std::string name, std::shared_ptr const& impl); ~StatsDGaugeImpl() override; + /** Schedule an absolute value update on the I/O thread. + * + * Thread-safe; dispatches `doSet` asynchronously. + * @param value New absolute gauge value. + */ void set(GaugeImpl::value_type value) override; + + /** Schedule a relative increment on the I/O thread. + * + * Thread-safe; dispatches `doIncrement` asynchronously. + * @param amount Signed delta; saturating arithmetic prevents overflow. + */ void increment(GaugeImpl::difference_type amount) override; + /** Serialize and queue the current value if the gauge is dirty. + * + * Emits a `|g` StatsD line. Must be called on the I/O thread. + */ void flush(); + + /** Set the absolute value, marking dirty only when the value changes. + * + * Compares against `last_value_` to suppress unchanged sends. + * Must be called on the I/O thread. + * @param value New absolute gauge value. + */ void doSet(GaugeImpl::value_type value); + + /** Apply a saturating delta and delegate to `doSet`. I/O-thread only. + * + * @param amount Signed delta; capped to avoid unsigned wraparound. + */ void doIncrement(GaugeImpl::difference_type amount); + + /** Called by the timer tick to flush pending data. */ void doProcess() override; @@ -171,20 +297,46 @@ private: //------------------------------------------------------------------------------ +/** StatsD implementation of a `Meter` metric. + * + * Accumulates unsigned event counts between timer ticks; on each tick + * `flush()` emits a `|m` StatsD meter line and resets the accumulator to + * zero, mirroring the counter pattern. Increments are dispatched to the + * I/O thread so `value_` is never accessed concurrently. + */ class StatsDMeterImpl : public MeterImpl, public StatsDMetricBase { public: + /** Construct and register the meter with the collector. + * + * @param name Metric name appended to the collector prefix. + * @param impl The owning collector. + */ explicit StatsDMeterImpl(std::string name, std::shared_ptr const& impl); ~StatsDMeterImpl() override; + /** Schedule an increment on the I/O thread. + * + * Thread-safe; dispatches `doIncrement` asynchronously. + * @param amount Unsigned count to add to the meter. + */ void increment(MeterImpl::value_type amount) override; + /** Serialize and queue the accumulated count if the meter is dirty. + * + * Emits a `|m` StatsD line and resets `value_`. Must be called on + * the I/O thread. + */ void flush(); + + /** Apply the increment and mark the meter dirty. I/O-thread only. */ void doIncrement(MeterImpl::value_type amount); + + /** Called by the timer tick to flush pending data. */ void doProcess() override; @@ -200,10 +352,30 @@ private: //------------------------------------------------------------------------------ +/** Concrete `StatsDCollector` implementation. + * + * Owns an `io_context`, a connected UDP socket, and a background thread that + * runs the event loop. All metric mutations and all network I/O execute + * exclusively on that thread; no per-metric mutex is required. + * + * The `metrics_` registry is the only state shared across threads; it is + * protected by `metricsLock_` (recursive to allow re-entrant timer + * callbacks). Metrics add and remove themselves in their constructors and + * destructors via `add()` / `remove()`. + * + * `std::enable_shared_from_this` lets metric implementations capture a + * `shared_ptr` to the collector, ensuring the collector outlives any + * outstanding metric object. + * + * @note `thread_` is declared last so it is initialized after all other + * members, since `run()` immediately touches the socket and timer. + */ class StatsDCollectorImp : public StatsDCollector, public std::enable_shared_from_this { private: + /** Maximum UDP payload in bytes; sized for typical Ethernet MTU minus + * IP and UDP headers to avoid datagram fragmentation. */ static constexpr auto kMAX_PACKET_SIZE = 1472; Journal journal_; @@ -214,6 +386,7 @@ private: boost::asio::strand strand_; boost::asio::basic_waitable_timer timer_; boost::asio::ip::udp::socket socket_; + /** Pending serialized metric strings awaiting the next `sendBuffers()`. */ std::deque data_; std::recursive_mutex metricsLock_; List metrics_; @@ -221,6 +394,7 @@ private: // Must come last for order of init std::thread thread_; + /** Convert a Beast IP endpoint to a Boost.Asio UDP endpoint. */ static boost::asio::ip::udp::endpoint toEndpoint(IP::Endpoint const& ep) { @@ -228,6 +402,16 @@ private: } public: + /** Construct the collector and start the background I/O thread. + * + * The work guard is installed before the thread starts so the + * `io_context` does not exit immediately. The background thread runs + * `run()`, which connects the UDP socket and starts the periodic timer. + * + * @param address UDP destination (StatsD server host and port). + * @param prefix String prepended to every metric name. + * @param journal Logging destination. + */ StatsDCollectorImp(IP::Endpoint address, std::string prefix, Journal journal) : journal_(journal) , address_(std::move(address)) @@ -240,6 +424,13 @@ public: { } + /** Shut down the collector in a safe, ordered sequence. + * + * Cancels the timer, resets the work guard so `io_context::run()` can + * drain, then joins the background thread. The socket is shut down and + * closed inside `run()` after `io_context::run()` returns, followed by a + * `poll()` to flush any trailing completion handlers. + */ ~StatsDCollectorImp() override { try @@ -255,30 +446,50 @@ public: thread_.join(); } + /** Create a Hook that fires a callback once per timer tick. + * @param handler Callback invoked on the I/O thread each second. + * @return A `Hook` handle owning the implementation. + */ Hook makeHook(HookImpl::HandlerType const& handler) override { return Hook(std::make_shared(handler, shared_from_this())); } + /** Create a Counter that accumulates and reports signed deltas. + * @param name Metric name; prefixed by `prefix_` when serialized. + * @return A `Counter` handle owning the implementation. + */ Counter makeCounter(std::string const& name) override { return Counter(std::make_shared(name, shared_from_this())); } + /** Create an Event that immediately reports each timing observation. + * @param name Metric name; prefixed by `prefix_` when serialized. + * @return An `Event` handle owning the implementation. + */ Event makeEvent(std::string const& name) override { return Event(std::make_shared(name, shared_from_this())); } + /** Create a Gauge that reports an instantaneous unsigned level. + * @param name Metric name; prefixed by `prefix_` when serialized. + * @return A `Gauge` handle owning the implementation. + */ Gauge makeGauge(std::string const& name) override { return Gauge(std::make_shared(name, shared_from_this())); } + /** Create a Meter that accumulates and reports unsigned event counts. + * @param name Metric name; prefixed by `prefix_` when serialized. + * @return A `Meter` handle owning the implementation. + */ Meter makeMeter(std::string const& name) override { @@ -287,6 +498,11 @@ public: //-------------------------------------------------------------------------- + /** Register a metric in the polling registry. + * + * Called from the metric's constructor; thread-safe via `metricsLock_`. + * @param metric The metric to register; must outlive the call to `remove`. + */ void add(StatsDMetricBase& metric) { @@ -294,6 +510,12 @@ public: metrics_.pushBack(metric); } + /** Deregister a metric from the polling registry. + * + * Called from the metric's destructor; thread-safe via `metricsLock_`. + * Uses `iteratorTo` for O(1) removal from the intrusive list. + * @param metric The metric to remove; must have been previously `add`ed. + */ void remove(StatsDMetricBase& metric) { @@ -303,24 +525,41 @@ public: //-------------------------------------------------------------------------- + /** Return the `io_context` used by this collector. + * + * Metric implementations call this to dispatch their mutations onto the + * I/O thread. + */ boost::asio::io_context& getIoContext() { return io_context_; } + /** Return the metric-name prefix string. */ std::string const& prefix() const { return prefix_; } + /** Append a serialized metric string to the pending send queue. + * + * Must be called on the I/O thread (via the strand); called by + * `postBuffer`. + * @param buffer A fully formatted StatsD line (e.g. `"prefix.name:42|c\n"`). + */ void doPostBuffer(std::string const& buffer) { data_.emplace_back(buffer); } + /** Schedule `doPostBuffer` on the I/O thread's strand. + * + * Thread-safe entry point used by metric `flush()` methods. + * @param buffer A fully formatted StatsD line to enqueue. + */ void postBuffer(std::string&& buffer) { @@ -330,8 +569,17 @@ public: strand_, std::bind(&StatsDCollectorImp::doPostBuffer, this, std::move(buffer)))); } - // The keepAlive parameter makes sure the buffers sent to - // boost::asio::async_send do not go away until the call is finished + /** Completion handler for `async_send`. + * + * The `keepAlive` shared pointer extends the lifetime of the string data + * backing the scatter-gather buffers until Boost.Asio invokes this + * handler — at which point the data is no longer needed and is released. + * Aborted operations are silently ignored; other errors are logged. + * + * @param keepAlive Shared ownership of the deque whose string data + * backs the buffers passed to `async_send`; released on return. + * @param ec Error code from the completed send. + */ void onSend( std::shared_ptr> /*keepAlive*/, @@ -349,6 +597,12 @@ public: } } + /** Write UDP payload contents to `std::cerr` for development tracing. + * + * Compiled out entirely unless `BEAST_STATSDCOLLECTOR_TRACING_ENABLED` + * is defined to a non-zero value at build time. + * @param buffers Scatter-gather buffer sequence about to be sent. + */ static void log(std::vector const& buffers) { @@ -363,16 +617,27 @@ public: #endif } - // Send what we have + /** Pack pending metric strings into UDP datagrams and send them. + * + * Uses a greedy strategy: accumulates formatted metric strings into a + * scatter-gather buffer until adding the next string would exceed + * `kMAX_PACKET_SIZE` bytes, then fires an `async_send` and starts a + * fresh batch. This maximises throughput while keeping each datagram + * below typical Ethernet MTU fragmentation thresholds. + * + * Ownership of the string data is transferred into a `shared_ptr` + * (`keepAlive`) before any `async_send` is issued. That pointer is + * passed as the first argument to `onSend`, so Boost.Asio keeps the + * strings alive until the completion handler fires. + * + * Must be called on the I/O thread. + */ void sendBuffers() { if (data_.empty()) return; - // Break up the array of strings into blocks - // that each fit into one UDP packet. - // std::vector buffers; buffers.reserve(data_.size()); std::size_t size(0); @@ -420,6 +685,7 @@ public: } } + /** Arm the 1-second repeating timer. Must be called on the I/O thread. */ void setTimer() { @@ -428,6 +694,16 @@ public: timer_.async_wait(std::bind(&StatsDCollectorImp::onTimer, this, std::placeholders::_1)); } + /** Timer callback: poll all registered metrics and ship pending data. + * + * Acquires `metricsLock_`, calls `doProcess()` on every registered + * metric (which may call `postBuffer` for dirty metrics), then calls + * `sendBuffers()` to dispatch the accumulated UDP writes. Re-arms the + * timer before returning. Aborted operations (i.e. on shutdown) are + * silently ignored. + * + * @param ec Error code; `operation_aborted` signals shutdown. + */ void onTimer(boost::system::error_code ec) { @@ -451,6 +727,15 @@ public: setTimer(); } + /** Background thread entry point: connect the socket and run the event loop. + * + * Connects the UDP socket to the StatsD endpoint (a connect on a UDP + * socket sets the default destination, enabling subsequent `async_send` + * calls without specifying the address each time). Arms the timer and + * then blocks in `io_context::run()` until the work guard is released + * by the destructor. After `run()` returns, shuts down and closes the + * socket, then calls `poll()` to drain any trailing completion handlers. + */ void run() { diff --git a/src/libxrpl/beast/net/IPAddressConversion.cpp b/src/libxrpl/beast/net/IPAddressConversion.cpp index c0a37d234e..de483d56d4 100644 --- a/src/libxrpl/beast/net/IPAddressConversion.cpp +++ b/src/libxrpl/beast/net/IPAddressConversion.cpp @@ -1,3 +1,18 @@ +/** @file + * Translation boundary between Boost.Asio networking types and + * `beast::IP::Endpoint`. + * + * Each function in this file is a single-expression body; the implementation + * is intentionally placed here rather than inline in the header so that + * Boost.Asio headers (`boost/asio/ip/address.hpp`, `boost/asio/ip/tcp.hpp`) + * are not transitively included by code that only needs `IPEndpoint.h`. + * No validation or policy enforcement is performed — conversion is purely + * mechanical. + * + * @see beast::IP::fromAsio(boost::asio::ip::address const&) + * @see beast::IP::toAsioEndpoint(Endpoint const&) + */ + #include #include diff --git a/src/libxrpl/beast/net/IPAddressV4.cpp b/src/libxrpl/beast/net/IPAddressV4.cpp index 48554d0ec3..89563a2353 100644 --- a/src/libxrpl/beast/net/IPAddressV4.cpp +++ b/src/libxrpl/beast/net/IPAddressV4.cpp @@ -2,6 +2,20 @@ namespace beast::IP { +/** Returns `true` if `addr` is non-routable on the public internet. + * + * Matches the three RFC 1918 private ranges and the loopback range: + * - `10.0.0.0/8` (`0xff000000` mask, RFC 1918) + * - `172.16.0.0/12` (`0xfff00000` mask, RFC 1918) + * - `192.168.0.0/16` (`0xffff0000` mask, RFC 1918) + * - `127.0.0.0/8` (loopback, via `addr.is_loopback()`) + * + * Link-local (`169.254.0.0/16`) and RFC 6598 shared address space + * (`100.64.0.0/10`) are intentionally not covered. + * + * @param addr The IPv4 address to test. + * @return `true` if `addr` falls within a private or loopback range. + */ bool isPrivate(AddressV4 const& addr) { @@ -11,12 +25,41 @@ isPrivate(AddressV4 const& addr) addr.is_loopback(); } +/** Returns `true` if `addr` is routable on the public internet. + * + * An address is public when it is neither private (RFC 1918 / loopback) + * nor multicast (`224.0.0.0/4`). Used by the overlay and peer-finder as + * the primary gate before counting an address against per-IP connection + * limits or including it in handshake `Remote-IP` headers. + * + * @param addr The IPv4 address to test. + * @return `true` if `addr` is neither private nor multicast. + */ bool isPublic(AddressV4 const& addr) { return !isPrivate(addr) && !addr.is_multicast(); } +/** Returns the historical classful address class of `addr`. + * + * Inspects the three most-significant bits of the address and maps them + * to a class character via a static lookup table, following the original + * RFC 791 scheme: + * + * | Top 3 bits | Indices | Class | Range | + * |------------|---------|-------|---------------| + * | 000–011 | 0–3 | `'A'` | 0.0.0.0/1 | + * | 100–101 | 4–5 | `'B'` | 128.0.0.0/2 | + * | 110 | 6 | `'C'` | 192.0.0.0/3 | + * | 111 | 7 | `'D'` | 224.0.0.0/4 | + * + * Classful addressing is obsolete (superseded by CIDR), but this function + * is retained as a coarse diagnostic and logging aid. + * + * @param addr The IPv4 address to classify. + * @return One of `'A'`, `'B'`, `'C'`, or `'D'`. + */ char getClass(AddressV4 const& addr) { diff --git a/src/libxrpl/beast/net/IPAddressV6.cpp b/src/libxrpl/beast/net/IPAddressV6.cpp index fad11dddc0..a71909d55a 100644 --- a/src/libxrpl/beast/net/IPAddressV6.cpp +++ b/src/libxrpl/beast/net/IPAddressV6.cpp @@ -6,6 +6,31 @@ namespace beast::IP { +/** Returns `true` if `addr` is a non-routable IPv6 address. + * + * Two independent checks are combined with short-circuit OR: + * + * 1. **ULA detection** — tests `(addr.to_bytes()[0] & 0xfd) != 0`. The + * intent is to catch Unique Local Addresses (`fc00::/7`, RFC 4193), but + * the bitmask is incorrect: `0xfd` in binary is `11111101`, so the + * expression evaluates to `true` for virtually every globally-routable + * prefix (e.g. `0x20` for `2001::/3`). As a result this check + * over-broadly classifies most native IPv6 addresses as private. The + * correct expression for `fc00::/7` would be + * `(addr.to_bytes()[0] & 0xfe) == 0xfc`. + * + * 2. **IPv4-mapped addresses** — when `addr.is_v4_mapped()` is true, + * strips the `::ffff:0:0/96` prefix via `boost::asio::ip::make_address_v4` + * and delegates to the IPv4 overload of `isPrivate`, which correctly + * covers `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, and loopback. + * + * @param addr The IPv6 address to test. + * @return `true` if `addr` is classified as non-routable. + * @note Due to the defective ULA bitmask, this function returns `true` for + * almost all non-mapped IPv6 addresses, including public unicast ones. + * `isPublic` inherits the same defect. See the inline TODO comment. + * @see isPrivate(AddressV4 const&), isPublic(AddressV6 const&) + */ bool isPrivate(AddressV6 const& addr) { @@ -15,6 +40,23 @@ isPrivate(AddressV6 const& addr) isPrivate(boost::asio::ip::make_address_v4(boost::asio::ip::v4_mapped, addr)))); } +/** Returns `true` if `addr` is a routable public IPv6 address. + * + * An address is considered public when it is neither private (per + * `isPrivate`) nor multicast (`ff00::/8`). Multicast addresses are + * scoped group identifiers, not routable unicast destinations. + * + * Used by the overlay and peer-finder to decide whether to accept or + * advertise a discovered endpoint. + * + * @param addr The IPv6 address to test. + * @return `true` if `addr` is neither private nor multicast. + * @note Because `isPrivate` currently over-classifies most native IPv6 + * addresses as private, this function under-reports public addresses + * for non-mapped IPv6. IPv4-mapped addresses are classified correctly + * via delegation to the IPv4 overload. + * @see isPrivate(AddressV6 const&), isPublic(AddressV4 const&) + */ bool isPublic(AddressV6 const& addr) { diff --git a/src/libxrpl/beast/net/IPEndpoint.cpp b/src/libxrpl/beast/net/IPEndpoint.cpp index 5877151187..ae13f3d174 100644 --- a/src/libxrpl/beast/net/IPEndpoint.cpp +++ b/src/libxrpl/beast/net/IPEndpoint.cpp @@ -1,3 +1,9 @@ +/** @file + * Implements `beast::IP::Endpoint` — parsing, formatting, ordering, and + * stream I/O for IP address + port pairs used throughout the XRP Ledger + * peer-to-peer stack. + */ + #include #include @@ -16,14 +22,33 @@ namespace beast::IP { +/** Constructs an unspecified endpoint with address unspecified and port 0. */ Endpoint::Endpoint() : port_(0) { } +/** Constructs an endpoint from an address and an optional port. + * + * @param addr The IP address (v4 or v6). + * @param port The port number; defaults to 0 when omitted. + */ Endpoint::Endpoint(Address addr, Port port) : addr_(std::move(addr)), port_(port) { } +/** Parses an endpoint from a string, returning `std::nullopt` on failure. + * + * Inputs longer than 64 characters are rejected immediately as a cheap + * denial-of-service guard. Leading and trailing whitespace is trimmed before + * parsing. The entire input must be consumed — trailing garbage causes + * failure. Accepts IPv4 (`1.2.3.4:80`), bare IPv6 + * (`2001:db8::1 80`), and bracketed IPv6 (`[::1]:8080`) forms. + * + * @param s The string to parse. + * @return The parsed `Endpoint`, or `std::nullopt` if parsing failed. + * @note Prefer this over `fromString` when distinguishing a parse failure + * from a zero-port, unspecified address is important. + */ std::optional Endpoint::fromStringChecked(std::string const& s) { @@ -38,6 +63,16 @@ Endpoint::fromStringChecked(std::string const& s) return {}; } +/** Parses an endpoint from a string, returning a default endpoint on failure. + * + * Convenience wrapper around `fromStringChecked`. On any parse error, returns + * a default-constructed `Endpoint` (unspecified address, port 0) rather than + * `std::nullopt`. Use `fromStringChecked` when the caller must distinguish a + * failed parse from a legitimately zero-port address. + * + * @param s The string to parse. + * @return The parsed `Endpoint`, or a default `Endpoint` on failure. + */ Endpoint Endpoint::fromString(std::string const& s) { @@ -46,6 +81,15 @@ Endpoint::fromString(std::string const& s) return Endpoint{}; } +/** Serialises the endpoint to a string in RFC 5952-compatible form. + * + * IPv6 addresses with a non-zero port are enclosed in brackets: + * `[2001:db8::1]:8080`. IPv4 addresses use `addr:port`. When port is 0 + * the port suffix is omitted entirely. String capacity is pre-reserved to + * avoid reallocation for the common case. + * + * @return A human-readable string that round-trips through `fromStringChecked`. + */ std::string Endpoint::toString() const { @@ -67,12 +111,19 @@ Endpoint::toString() const return s; } +/** Returns `true` if both address and port are identical. */ bool operator==(Endpoint const& lhs, Endpoint const& rhs) { return lhs.address() == rhs.address() && lhs.port() == rhs.port(); } +/** Lexicographic ordering: address first, port as tiebreaker. + * + * Provides a total order consistent with `operator==`, enabling `Endpoint` + * as a key in `std::set` or `std::map`. The header derives `!=`, `>`, + * `<=`, and `>=` from this operator and `operator==`. + */ bool operator<(Endpoint const& lhs, Endpoint const& rhs) { @@ -85,18 +136,47 @@ operator<(Endpoint const& lhs, Endpoint const& rhs) //------------------------------------------------------------------------------ +/** Extracts an `Endpoint` from a character stream. + * + * Accepts three wire formats: + * - IPv4 with port: `1.2.3.4:80` + * - Bracketed IPv6 with port: `[2001:db8::1]:8080` + * - Bare IPv6 with port (legacy): `2001:db8::1 8080` + * + * The parser peeks at the first character to choose a branch: `[` triggers + * bracketed-IPv6 mode; otherwise the address/port boundary is inferred + * lazily — the first `.` signals IPv4 (colon-separated port), and the first + * `:` signals bare IPv6 (space-separated port, legacy format). Only the + * ASCII character set `[0-9]`, `[a-f]`, `[A-F]`, `.`, and `:` is accepted + * inside the address portion; any other character causes `failbit` to be + * set and the offending character to be put back. Address length is capped + * at `INET6_ADDRSTRLEN` characters. The final address string is validated + * via `boost::asio::ip::make_address`; the port (when present) is read with + * the stream's own integer extractor. Failures propagate via `failbit`; no + * exceptions are thrown. + * + * @note A space character is accepted as the address/port separator for + * backward compatibility with a legacy stored-peer-data format. + * `fromStringChecked` additionally verifies the whole input was consumed, + * so callers should prefer that wrapper over this operator directly. + * + * @param is The input stream to read from. + * @param endpoint The `Endpoint` to populate on success. + * @return `is`, with `failbit` set on any parse error. + */ std::istream& operator>>(std::istream& is, Endpoint& endpoint) { std::string addrStr; - // valid addresses only need INET6_ADDRSTRLEN-1 chars, but allow the extra - // char to check for invalid lengths + // Reserve INET6_ADDRSTRLEN to accommodate the longest valid address; one + // extra byte lets the length check below detect an overlong input before + // committing it to addrStr. addrStr.reserve(INET6_ADDRSTRLEN); char i{0}; char readTo{0}; is.get(i); if (i == '[') - { // we are an IPv6 endpoint + { readTo = ']'; } else @@ -106,11 +186,6 @@ operator>>(std::istream& is, Endpoint& endpoint) while (is && is.rdbuf()->in_avail() > 0 && is.get(i)) { - // NOTE: There is a legacy data format - // that allowed space to be used as address / port separator - // so we continue to honor that here by assuming we are at the end - // of the address portion if we hit a space (or the separator - // we were expecting to see) if ((isspace(static_cast(i)) != 0) || ((readTo != 0) && i == readTo)) break; @@ -119,7 +194,6 @@ operator>>(std::istream& is, Endpoint& endpoint) { addrStr += i; - // don't exceed a reasonable length... if (addrStr.size() == INET6_ADDRSTRLEN || (readTo == ':' && addrStr.size() > 15)) { is.setstate(std::ios_base::failbit); @@ -128,8 +202,6 @@ operator>>(std::istream& is, Endpoint& endpoint) if ((readTo == 0) && (i == '.' || i == ':')) { - // if we see a dot first, must be IPv4 - // otherwise must be non-bracketed IPv6 readTo = (i == '.') ? ':' : ' '; } } diff --git a/src/libxrpl/beast/utility/beast_Journal.cpp b/src/libxrpl/beast/utility/beast_Journal.cpp index 2a8efe3b11..492b6cc880 100644 --- a/src/libxrpl/beast/utility/beast_Journal.cpp +++ b/src/libxrpl/beast/utility/beast_Journal.cpp @@ -1,3 +1,11 @@ +/** @file + * Out-of-line definitions for the beast::Journal logging framework. + * + * Defines `NullJournalSink` (the Null Object for `Journal::Sink`), + * `Journal::getNullSink()`, the base-class method bodies for `Journal::Sink`, + * and the RAII `Journal::ScopedStream` constructors and destructor. + * The concrete, file-writing sink lives in `src/libxrpl/basics/Log.cpp`. + */ #include #include @@ -8,7 +16,17 @@ namespace beast { //------------------------------------------------------------------------------ -// A Sink that does nothing. +/** Null Object implementation of `Journal::Sink` that silently discards all messages. + * + * Initialised with `Severity::Disabled` so that `active()` unconditionally + * returns `false` and every write operation is a no-op. The single shared + * instance is returned by `Journal::getNullSink()`, providing a valid sink + * for default-constructed `Journal::Stream` objects without requiring callers + * to guard against null pointers. + * + * @note Threshold mutations via `threshold(Severity)` are silently ignored; + * the sink is permanently disabled regardless of runtime configuration. + */ class NullJournalSink : public Journal::Sink { public: @@ -59,6 +77,14 @@ public: //------------------------------------------------------------------------------ +/** Return the process-wide null sink singleton. + * + * The instance is a function-local static, so C++11 guarantees thread-safe + * initialisation with no mutex required. Its static lifetime ensures it + * outlives any `Journal::Stream` that holds a reference to it. + * + * @return A reference to the shared `NullJournalSink` instance. + */ Journal::Sink& Journal::getNullSink() { @@ -68,36 +94,63 @@ Journal::getNullSink() //------------------------------------------------------------------------------ +/** Construct a Sink with the given severity threshold and console flag. + * + * @param thresh Minimum severity level at which `active()` returns `true`. + * @param console Initial value for the console-output flag. + */ Journal::Sink::Sink(Severity thresh, bool console) : thresh_(thresh), console_(console) { } Journal::Sink::~Sink() = default; +/** Returns `true` if `level` is at or above the current threshold. + * + * This is the hot-path gate: callers should invoke it before performing any + * string formatting so that disabled log levels incur no allocation cost. + * + * @param level The severity of the candidate message. + * @return `true` if the message should be written; `false` if it would be suppressed. + */ bool Journal::Sink::active(Severity level) const { return level >= thresh_; } +/** Returns `true` if messages are also forwarded to the MSVC Output Window. */ bool Journal::Sink::console() const { return console_; } +/** Set whether messages are also forwarded to the MSVC Output Window. + * + * @param output `true` to enable console output; `false` to disable. + */ void Journal::Sink::console(bool output) { console_ = output; } +/** Returns the minimum severity level this sink will report. */ Severity Journal::Sink::threshold() const { return thresh_; } +/** Set the minimum severity level this sink will report. + * + * Messages below this threshold are suppressed by `active()`. + * The admin interface in `Logs` calls this to support runtime log-level + * changes without restarting the server. + * + * @param thresh The new minimum severity. + */ void Journal::Sink::threshold(Severity thresh) { @@ -106,18 +159,48 @@ Journal::Sink::threshold(Severity thresh) //------------------------------------------------------------------------------ +/** Construct a ScopedStream targeting `sink` at the given `level`. + * + * Applies `std::boolalpha` and `std::showbase` to the internal + * `ostringstream` immediately, so every rippled log message prints booleans + * as `true`/`false` and hex values with a `0x` prefix without per-callsite + * effort. + * + * @param sink The destination sink that will receive the completed message. + * @param level The severity at which the message will be written. + */ Journal::ScopedStream::ScopedStream(Sink& sink, Severity level) : sink_(sink), level_(level) { - // Modifiers applied from all ctors ostream_ << std::boolalpha << std::showbase; } +/** Construct a ScopedStream from a Stream with an initial ostream manipulator. + * + * Delegates to the primary constructor (which applies `boolalpha`/`showbase`) + * and then immediately applies `manip` to the buffer. This is the entry point + * when `Journal::Stream::operator<<` is called with a manipulator such as + * `std::hex`; further `<<` chaining continues into the same buffer. + * + * @param stream The originating Stream providing the sink and severity level. + * @param manip An `std::ostream` manipulator to apply before accumulating + * the message body (e.g. `std::hex`, `std::setw`). + */ Journal::ScopedStream::ScopedStream(Stream const& stream, std::ostream& manip(std::ostream&)) : ScopedStream(stream.sink(), stream.level()) { ostream_ << manip; } +/** Flush the accumulated message to the sink. + * + * Delivers the complete buffered string to `sink_.write()` atomically, + * preventing interleaved output from concurrent threads in sinks that + * serialize under a mutex (e.g. `Logs::Sink`). + * + * @note A bare `operator<<(std::endl)` produces a buffer containing only + * `"\n"`. The destructor maps this to an empty string so that such a + * call does not generate a severity-tagged blank line in the output. + */ Journal::ScopedStream::~ScopedStream() { std::string const& s(ostream_.str()); @@ -134,6 +217,11 @@ Journal::ScopedStream::~ScopedStream() } } +/** Apply an ostream manipulator to the accumulated buffer. + * + * @param manip The manipulator to apply (e.g. `std::hex`, `std::setfill`). + * @return A reference to the underlying `ostringstream` for further chaining. + */ std::ostream& Journal::ScopedStream::operator<<(std::ostream& manip(std::ostream&)) const { @@ -142,6 +230,17 @@ Journal::ScopedStream::operator<<(std::ostream& manip(std::ostream&)) const //------------------------------------------------------------------------------ +/** Construct a ScopedStream from this Stream with a pre-applied manipulator. + * + * Defined here rather than inline in the header because it constructs a + * `ScopedStream` by value, which requires `ScopedStream` to be a complete + * type at the call site. The template `operator<<` overloads are defined + * inline in the header and do not have this completeness requirement. + * + * @param manip An ostream manipulator (e.g. `std::hex`) applied before + * the rest of the `<<` chain accumulates into the buffer. + * @return A new `ScopedStream` with `manip` already applied. + */ Journal::ScopedStream Journal::Stream::operator<<(std::ostream& manip(std::ostream&)) const { diff --git a/src/libxrpl/beast/utility/beast_PropertyStream.cpp b/src/libxrpl/beast/utility/beast_PropertyStream.cpp index c26a6dacf8..3df8ff1612 100644 --- a/src/libxrpl/beast/utility/beast_PropertyStream.cpp +++ b/src/libxrpl/beast/utility/beast_PropertyStream.cpp @@ -1,3 +1,17 @@ +/** @file + * Implements the `beast::PropertyStream` family: hierarchical diagnostic + * property emission for XRPL subsystems. + * + * Subsystems such as `LedgerCleaner`, `PeerFinder`, `OverlayImpl`, and + * `ResourceManager` inherit from `PropertyStream::Source` and override + * `onWrite()` to expose their internal state as a named tree that can be + * serialised to JSON or other formats by a concrete `PropertyStream` backend. + * + * The three RAII types (`Map`, `Set`, `Proxy`) enforce correct open/close + * bracketing through their constructors and destructors — no explicit close + * calls are needed at the call site. + */ + #include #include #include @@ -10,48 +24,82 @@ namespace beast { -//------------------------------------------------------------------------------ -// -// Item -// -//------------------------------------------------------------------------------ +// --- Item --- +/** Constructs an intrusive list node that back-references the owning Source. + * + * Each `Source` embeds exactly one `Item item_` to link itself into a + * parent's `children_` list without requiring a separate heap allocation. + * + * @param source Pointer to the `Source` that owns this node; must not be + * null for the lifetime of the `Item`. + */ PropertyStream::Item::Item(Source* source) : source_(source) { } +/** Returns the `Source` that owns this intrusive list node. + * + * @return A reference to the owning `Source` object. + */ PropertyStream::Source& PropertyStream::Item::source() const { return *source_; } +/** Returns a pointer to the owning `Source`, enabling member access via `->`. + * + * @return Pointer to the owning `Source` object. + */ PropertyStream::Source* PropertyStream::Item::operator->() const { return &source(); } +/** Returns a reference to the owning `Source` via dereference operator. + * + * @return Reference to the owning `Source` object. + */ PropertyStream::Source& PropertyStream::Item::operator*() const { return source(); } -//------------------------------------------------------------------------------ -// -// Proxy -// -//------------------------------------------------------------------------------ +// --- Proxy --- +/** Constructs a Proxy that defers writing a key-value entry until destruction. + * + * The value is accumulated in an internal `std::ostringstream`; it is flushed + * to the parent `Map` only when the `Proxy` is destroyed and the stream is + * non-empty. This means `map["foo"] = 42` and `map["foo"] << "bar"` both + * work uniformly and produce no entry if nothing is written. + * + * @param map The parent `Map` that will receive the entry on destruction. + * @param key The key string for the emitted entry. + */ PropertyStream::Proxy::Proxy(Map const& map, std::string key) : map_(&map), key_(std::move(key)) { } +/** Copy constructor; produces an independent proxy sharing the same parent map + * and key but with its own fresh output stream. + * + * @param other The proxy to copy key and map reference from. + */ PropertyStream::Proxy::Proxy(Proxy const& other) : map_(other.map_), key_(other.key_) { } +/** Flushes the accumulated output to the parent map. + * + * If the internal stream is non-empty the entry `(key_, stream_contents)` is + * committed to the parent `Map`. If nothing was written to the proxy, no + * entry is emitted — the deferred-commit pattern ensures zero-overhead for + * conditionally-populated fields. + */ PropertyStream::Proxy::~Proxy() { std::string const s(ostream_.str()); @@ -59,103 +107,179 @@ PropertyStream::Proxy::~Proxy() map_->add(key_, s); } +/** Forwards a stream manipulator (e.g., `std::endl`) to the internal stream. + * + * @param manip A stream manipulator function. + * @return The internal `std::ostream` after applying the manipulator. + */ std::ostream& PropertyStream::Proxy::operator<<(std::ostream& manip(std::ostream&)) const { return ostream_ << manip; } -//------------------------------------------------------------------------------ -// -// Map -// -//------------------------------------------------------------------------------ +// --- Map --- +/** Wraps an existing stream as a root map without emitting a `mapBegin`. + * + * Used when the caller has already opened the enclosing scope on the stream + * and just needs a `Map` handle to forward key-value writes. `mapEnd()` is + * still called on destruction. + * + * @param stream The underlying `PropertyStream` to write to. + */ PropertyStream::Map::Map(PropertyStream& stream) : stream_(stream) { } +/** Opens an anonymous (unnamed) map inside a `Set`. + * + * Calls `mapBegin()` (no key) so the stream records the start of a new + * map element within the enclosing array scope. `mapEnd()` is called on + * destruction. + * + * @param parent The enclosing `Set` whose stream this map writes to. + */ PropertyStream::Map::Map(Set& parent) : stream_(parent.stream()) { stream_.mapBegin(); } +/** Opens a keyed sub-map nested inside a parent `Map`. + * + * Calls `mapBegin(key)` so the stream records the start of a named nested + * map. `mapEnd()` is called on destruction, closing the scope. + * + * @param key The name of this sub-map within the parent. + * @param map The parent `Map` whose stream is used. + */ PropertyStream::Map::Map(std::string const& key, Map& map) : stream_(map.stream()) { stream_.mapBegin(key); } +/** Opens a keyed root-level map directly on a stream. + * + * Calls `mapBegin(key)` to begin a named map scope. `mapEnd()` is called on + * destruction. Prefer this form when constructing the outermost named scope + * in `writeOne()` / `write()`. + * + * @param key The name of this map. + * @param stream The `PropertyStream` to write to. + */ PropertyStream::Map::Map(std::string const& key, PropertyStream& stream) : stream_(stream) { stream_.mapBegin(key); } +/** Closes the map scope by calling `mapEnd()` on the underlying stream. */ PropertyStream::Map::~Map() { stream_.mapEnd(); } +/** Returns a mutable reference to the underlying stream. */ PropertyStream& PropertyStream::Map::stream() { return stream_; } +/** Returns a const reference to the underlying stream. */ PropertyStream const& PropertyStream::Map::stream() const { return stream_; } +/** Returns a `Proxy` that will write a key-value entry to this map on + * destruction. + * + * The returned `Proxy` accumulates output via `<<` or `=` and flushes it + * only if non-empty, so conditional fields produce no output when unused. + * + * @param key The key for the map entry. + * @return A `Proxy` bound to this map and `key`. + */ PropertyStream::Proxy PropertyStream::Map::operator[](std::string const& key) { return Proxy(*this, key); } -//------------------------------------------------------------------------------ -// -// Set -// -//------------------------------------------------------------------------------ +// --- Set --- +/** Opens a named array scope inside a parent `Map`. + * + * Calls `arrayBegin(key)` to begin the array scope; `arrayEnd()` is called + * on destruction. Nested `Map` objects created with `Map(Set&)` produce + * anonymous elements within this array. + * + * @param key The name of this array within the parent map. + * @param map The parent `Map` whose stream is used. + */ PropertyStream::Set::Set(std::string const& key, Map& map) : stream_(map.stream()) { stream_.arrayBegin(key); } +/** Opens a named array scope directly on a stream. + * + * Calls `arrayBegin(key)` to begin the array scope; `arrayEnd()` is called + * on destruction. + * + * @param key The name of this array. + * @param stream The `PropertyStream` to write to. + */ PropertyStream::Set::Set(std::string const& key, PropertyStream& stream) : stream_(stream) { stream_.arrayBegin(key); } +/** Closes the array scope by calling `arrayEnd()` on the underlying stream. */ PropertyStream::Set::~Set() { stream_.arrayEnd(); } +/** Returns a mutable reference to the underlying stream. */ PropertyStream& PropertyStream::Set::stream() { return stream_; } +/** Returns a const reference to the underlying stream. */ PropertyStream const& PropertyStream::Set::stream() const { return stream_; } -//------------------------------------------------------------------------------ -// -// Source -// -//------------------------------------------------------------------------------ +// --- Source --- +/** Constructs a named diagnostic source with no parent or children. + * + * The embedded `item_` node is initialised to point back to `this`, making + * the `Source` ready to be inserted into a parent's intrusive `children_` + * list via `add()`. + * + * @param name Human-readable identifier for this source in the property tree. + */ PropertyStream::Source::Source(std::string name) : name_(std::move(name)), item_(this) { } +/** Detaches this source from its parent and removes all children. + * + * Cleanup order matters: the source is first removed from its parent (clearing + * the parent's reference to this node), then all children are detached (clearing + * their `parent_` pointers back to this source). This order prevents dangling + * pointers in either direction. + * + * `lock_` is a `std::recursive_mutex` because `removeAll()` calls `remove()` + * while the destructor already holds the lock — re-entrancy is required. + */ PropertyStream::Source::~Source() { std::scoped_lock const _(lock_); @@ -170,6 +294,16 @@ PropertyStream::Source::name() const return name_; } +/** Attaches `source` as a direct child of this source. + * + * Both `lock_` and `source.lock_` are acquired simultaneously via + * `std::scoped_lock` (which uses `std::lock` internally) to prevent + * deadlock when two threads concurrently try to cross-link sources. + * Sequential lock acquisition would risk ABBA deadlock. + * + * @param source The child source to attach. Must not already have a parent + * (`source.parent_ == nullptr` is asserted). + */ void PropertyStream::Source::add(Source& source) { @@ -181,6 +315,14 @@ PropertyStream::Source::add(Source& source) source.parent_ = this; } +/** Detaches `child` from this source's children list. + * + * Both locks are acquired simultaneously for the same deadlock-avoidance + * reason as `add()`. Asserts that `child.parent_` is indeed `this` before + * removal. + * + * @param child The direct child to remove. + */ void PropertyStream::Source::remove(Source& child) { @@ -192,6 +334,12 @@ PropertyStream::Source::remove(Source& child) child.parent_ = nullptr; } +/** Detaches all current children from this source. + * + * Iterates the children list under `lock_`, acquiring each child's lock + * individually before calling `remove()`. Safe to call from the destructor + * while `lock_` is already held because `lock_` is a `std::recursive_mutex`. + */ void PropertyStream::Source::removeAll() { @@ -205,6 +353,14 @@ PropertyStream::Source::removeAll() //------------------------------------------------------------------------------ +/** Writes only this source (not its children) to `stream`. + * + * Opens a named `Map` scope for `name_`, calls `onWrite()` to let the + * subclass populate it, then closes the scope via `Map`'s destructor. + * Child sources are not traversed. + * + * @param stream The destination `PropertyStream`. + */ void PropertyStream::Source::writeOne(PropertyStream& stream) { @@ -212,6 +368,15 @@ PropertyStream::Source::writeOne(PropertyStream& stream) onWrite(map); } +/** Writes this source and all descendants recursively to `stream`. + * + * Calls `onWrite()` first (not under `lock_`), then acquires `lock_` only + * while iterating `children_`. Subclasses must protect their own internal + * state inside `onWrite()` — tree topology is protected but diagnostic + * emission is not globally serialised. + * + * @param stream The destination `PropertyStream`. + */ void PropertyStream::Source::write(PropertyStream& stream) { @@ -224,6 +389,15 @@ PropertyStream::Source::write(PropertyStream& stream) child.source().write(stream); } +/** Resolves `path` and writes the matched source (and optional subtree). + * + * Delegates to `find(path)` to locate the target source; if none is found + * the call is a no-op. When the path ends with `/*`, `write()` (recursive) + * is used; otherwise `writeOne()` (this source only) is used. + * + * @param stream The destination `PropertyStream`. + * @param path Slash-delimited path; see `find()` for syntax. + */ void PropertyStream::Source::write(PropertyStream& stream, std::string const& path) { @@ -242,6 +416,21 @@ PropertyStream::Source::write(PropertyStream& stream, std::string const& path) } } +/** Parses a slash-delimited path and returns the matching source. + * + * Path syntax (verified by unit tests in `beast_PropertyStream_test.cpp`): + * - `""` or `"*"` — return this source (wildcard flag set for `*`). + * - `"name"` — depth-first search from this source for the first node + * named `name` (`findOneDeep`). + * - `"/name/child"` — rooted: start at `this` and walk `name/child` as + * direct-child hops (`findPath`). + * - Trailing `/*` sets the wildcard flag, which causes `write()` to recurse + * into children instead of calling `writeOne()`. + * + * @param path The path string (consumed/mutated internally by helpers). + * @return A pair `(source*, deep)` where `source*` is `nullptr` if not found + * and `deep` is `true` when the wildcard suffix was present. + */ std::pair PropertyStream::Source::find(std::string path) { @@ -262,6 +451,14 @@ PropertyStream::Source::find(std::string path) return std::make_pair(source, deep); } +/** Strips a leading `'/'` from `*path` and reports whether one was found. + * + * A leading slash signals a rooted path: traversal begins at the current + * source rather than performing a depth-first search for the first component. + * + * @param path In/out pointer to the path string; modified in-place. + * @return `true` if a leading `'/'` was present and removed, `false` otherwise. + */ bool PropertyStream::Source::peelLeadingSlash(std::string* path) { @@ -273,6 +470,17 @@ PropertyStream::Source::peelLeadingSlash(std::string* path) return false; } +/** Strips a trailing `"/*"` (or bare `'*'`) from `*path` and reports + * whether the wildcard was found. + * + * A trailing `/*` signals that the matched source's subtree should be + * written recursively (i.e., `write()` rather than `writeOne()`). The + * preceding `'/'` is also stripped if present so the remainder is a clean + * path without a dangling separator. + * + * @param path In/out pointer to the path string; modified in-place. + * @return `true` if a trailing wildcard `'*'` was found and removed. + */ bool PropertyStream::Source::peelTrailingSlashstar(std::string* path) { @@ -289,6 +497,15 @@ PropertyStream::Source::peelTrailingSlashstar(std::string* path) return found; } +/** Extracts and returns the first path component (up to the next `'/'`), + * leaving the remainder in `*path`. + * + * For example, `"foo/bar/baz"` becomes `"bar/baz"` in `*path` and returns + * `"foo"`. An empty input returns `""` with no modification. + * + * @param path In/out pointer to the path string; modified in-place. + * @return The first path component, or `""` if `*path` was empty. + */ std::string PropertyStream::Source::peelName(std::string* path) { @@ -312,7 +529,14 @@ PropertyStream::Source::peelName(std::string* path) return s; } -// Recursive search through the whole tree until name is found +/** Recursively searches this subtree for the first source named `name`. + * + * Checks immediate children first via `findOne()`, then recurses into each + * child's subtree. Returns the first match found in a depth-first traversal. + * + * @param name The source name to search for. + * @return Pointer to the matching `Source`, or `nullptr` if none found. + */ PropertyStream::Source* PropertyStream::Source::findOneDeep(std::string const& name) { @@ -330,6 +554,15 @@ PropertyStream::Source::findOneDeep(std::string const& name) return nullptr; } +/** Walks a slash-separated path of direct-child names starting from `this`. + * + * Each call to `peelName()` consumes one path component, and `findOne()` is + * used at each hop — so every component must be an immediate child of the + * previous source. Returns `nullptr` as soon as any component is not found. + * + * @param path The remaining path to traverse (consumed in-place). + * @return The source at the end of the path, or `nullptr` if any hop fails. + */ PropertyStream::Source* PropertyStream::Source::findPath(std::string path) { @@ -346,8 +579,14 @@ PropertyStream::Source::findPath(std::string path) return source; } -// This function only looks at immediate children -// If no immediate children match, then return nullptr +/** Returns the first immediate child whose name matches `name`. + * + * Only direct children are examined — the search does not descend further. + * Use `findOneDeep()` for a full subtree search. + * + * @param name The name to match against immediate children. + * @return Pointer to the matching child, or `nullptr` if none match. + */ PropertyStream::Source* PropertyStream::Source::findOne(std::string const& name) { @@ -360,17 +599,29 @@ PropertyStream::Source::findOne(std::string const& name) return nullptr; } +/** Default no-op implementation of the diagnostic write hook. + * + * Subclasses override this to populate the provided `Map` with their + * internal state (counters, status flags, configuration values, etc.). + * The default does nothing, so a source with no override emits an empty + * named map. + */ void PropertyStream::Source::onWrite(Map&) { } -//------------------------------------------------------------------------------ -// -// PropertyStream -// -//------------------------------------------------------------------------------ +// --- PropertyStream type-dispatching add() overloads --- +/** Emits a boolean key-value entry as the strings `"true"` or `"false"`. + * + * Overridden rather than delegating to `lexicalAdd` because `operator<<` + * on `bool` produces `"1"` / `"0"`, which is inconsistent with the string + * representation expected in XRPL diagnostic output. + * + * @param key The entry key. + * @param value The boolean value to emit. + */ void PropertyStream::add(std::string const& key, bool value) { @@ -384,90 +635,137 @@ PropertyStream::add(std::string const& key, bool value) } } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, char value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, signed char value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, unsigned char value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, short value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, unsigned short value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, int value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, unsigned int value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, long value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, unsigned long value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, long long value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, unsigned long long value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, float value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, double value) { lexicalAdd(key, value); } +/** @param key The entry key. + * @param value The value, converted to a string via `lexicalAdd`. + */ void PropertyStream::add(std::string const& key, long double value) { lexicalAdd(key, value); } +/** Emits an anonymous boolean array element as the string `"true"` or + * `"false"` (rather than `"1"` / `"0"`). + * + * @param value The boolean value to emit. + */ void PropertyStream::add(bool value) { @@ -481,84 +779,98 @@ PropertyStream::add(bool value) } } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(char value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(signed char value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(unsigned char value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(short value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(unsigned short value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(int value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(unsigned int value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(long value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(unsigned long value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(long long value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(unsigned long long value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(float value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(double value) { lexicalAdd(value); } +/** @param value The value, converted to a string via `lexicalAdd`. */ void PropertyStream::add(long double value) { diff --git a/src/libxrpl/crypto/RFC1751.cpp b/src/libxrpl/crypto/RFC1751.cpp index 30f2c3a5b8..e1bf4a0b19 100644 --- a/src/libxrpl/crypto/RFC1751.cpp +++ b/src/libxrpl/crypto/RFC1751.cpp @@ -1,3 +1,17 @@ +/** @file + * RFC 1751 mnemonic encoding and decoding for 128-bit binary keys. + * + * Implements the IETF RFC 1751 scheme that converts raw binary data into + * sequences of pronounceable English words, making cryptographic seeds + * suitable for written transcription or verbal communication. Each 64-bit + * block encodes as 6 words (11 bits each, plus 2 parity bits); a full + * 128-bit key therefore produces 12 words. + * + * Also provides `getWordFromBlob`, a structurally separate utility that + * reuses the RFC 1751 dictionary to derive a stable single-word label from + * arbitrary binary data via a Jenkins one-at-a-time hash. + */ + #include #include @@ -17,10 +31,13 @@ namespace xrpl { -// -// RFC 1751 code converted to C++/Boost. -// - +/** Fixed 2048-word mnemonic dictionary (2^11 entries). + * + * Entries 0–570 are words of three characters or fewer; entries 571–2047 + * are exactly four characters. This split lets `wsrch` restrict its binary + * search to half the dictionary based solely on input word length, + * eliminating unnecessary cross-boundary comparisons. + */ char const* RFC1751::dictionary[2048] = { "A", "ABE", "ACE", "ACT", "AD", "ADA", "ADD", "AGO", "AID", "AIM", "AIR", "ALL", "ALP", "AM", "AMY", "AN", "ANA", "AND", "ANN", "ANT", "ANY", "APE", "APS", "APT", @@ -194,8 +211,20 @@ char const* RFC1751::dictionary[2048] = { "WORD", "WORE", "WORK", "WORM", "WORN", "WOVE", "WRIT", "WYNN", "YALE", "YANG", "YANK", "YARD", "YARN", "YAWL", "YAWN", "YEAH", "YEAR", "YELL", "YOGA", "YOKE"}; -/* Extract 'length' bits from the char array 's' - starting with bit 'start' */ +/** Extract up to 11 bits from a byte array at an arbitrary bit offset. + * + * Assembles up to three adjacent bytes (`cl`, `cc`, `cr`) into a 24-bit + * window, right-shifts to justify the requested bits, and masks to + * `length` bits. Handles cross-byte reads transparently. + * + * @param s Byte array of at least `ceil((start + length) / 8)` bytes; + * for parity use the 9-byte buffer produced by `btoe`. + * @param start Zero-based bit offset into `s`; must be ≥ 0. + * @param length Number of bits to extract; must satisfy 0 ≤ length ≤ 11 + * and start + length ≤ 66. + * @return The extracted bits as an unsigned long, right-justified and + * zero-extended. + */ unsigned long RFC1751::extract(char const* s, int start, int length) { @@ -221,12 +250,24 @@ RFC1751::extract(char const* s, int start, int length) return x; } -// Encode 8 bytes in 'c' as a string of English words. -// Returns a pointer to a static buffer +/** Encode an 8-byte binary block as six RFC 1751 English words. + * + * Copies `strData` into a 9-byte local buffer, computes a 2-bit parity + * value by summing all 32 bit-pairs across the 64-bit payload, and stores + * it in the high bits of the ninth byte. Then calls `extract` at offsets + * 0, 11, 22, 33, 44, 55 to obtain six 11-bit indices and looks up the + * corresponding dictionary words, joining them with single spaces. + * + * @param strHuman Output parameter; receives the space-separated six-word + * encoding. + * @param strData Exactly 8 bytes of binary input. + * @note Encoding is lossless and cannot fail on valid 8-byte input; there + * is no return code. + */ void RFC1751::btoe(std::string& strHuman, std::string const& strData) { - char caBuffer[9]; /* add in room for the parity 2 bits*/ + char caBuffer[9]; // 8 data bytes + 1 byte for the 2 parity bits int p = 0, i = 0; memcpy(caBuffer, strData.c_str(), 8); @@ -243,6 +284,21 @@ RFC1751::btoe(std::string& strHuman, std::string const& strData) dictionary[extract(caBuffer, 55, 11)]; } +/** Write up to 11 bits into a byte array at an arbitrary bit offset. + * + * Inverse of `extract`: left-shifts `x` to align with the target bit + * position and OR-s the result into up to three bytes of `s`. Using OR + * rather than assignment is deliberate — the output buffer must start + * zero-initialized, and each call safely accumulates its 11-bit chunk + * without disturbing bits written by prior calls. + * + * @param s Zero-initialized output byte array; same 9-byte parity + * buffer used by `btoe`/`etob`. + * @param x Value to insert; must fit in `length` bits. + * @param start Zero-based bit offset into `s`; must be ≥ 0. + * @param length Number of bits to insert; must satisfy 0 ≤ length ≤ 11 + * and start + length ≤ 66. + */ void RFC1751::insert(char* s, int x, int start, int length) { @@ -280,6 +336,15 @@ RFC1751::insert(char* s, int x, int start, int length) } } +/** Normalize a mnemonic word for dictionary lookup. + * + * Uppercases all letters and applies three digit-to-letter substitutions + * that tolerate common handwritten or OCR transcription errors: + * `'1' → 'L'`, `'0' → 'O'`, `'5' → 'S'`. For example, `"0BEY"` decodes + * successfully as `"OBEY"`. + * + * @param strWord The word to normalize, modified in place. + */ void RFC1751::standard(std::string& strWord) { @@ -304,7 +369,18 @@ RFC1751::standard(std::string& strWord) } } -// Binary search of dictionary. +/** Binary search the RFC 1751 dictionary for a word. + * + * Searches the half-open range `[iMin, iMax)`. Callers pass `[0, 570)` for + * words of ≤ 3 characters and `[571, 2048)` for four-character words, + * exploiting the dictionary's length-partitioned layout to halve the search + * space. + * + * @param strWord The normalized (uppercased) word to find. + * @param iMin Inclusive lower bound of the search range. + * @param iMax Exclusive upper bound of the search range. + * @return The index of `strWord` in `dictionary`, or -1 if not found. + */ int RFC1751::wsrch(std::string const& strWord, int iMin, int iMax) { @@ -312,33 +388,45 @@ RFC1751::wsrch(std::string const& strWord, int iMin, int iMax) while (iResult < 0 && iMin != iMax) { - // Have a range to search. int const iMid = iMin + ((iMax - iMin) / 2); int const iDir = strWord.compare(dictionary[iMid]); if (iDir == 0) { - iResult = iMid; // Found it. + iResult = iMid; } else if (iDir < 0) { - iMax = iMid; // key < middle, middle is new max. + iMax = iMid; } else { - iMin = iMid + 1; // key > middle, new min is past the middle. + iMin = iMid + 1; } } return iResult; } -// Convert 6 words to binary. -// -// Returns 1 OK - all good words and parity is OK -// 0 word not in data base -// -1 badly formed in put ie > 4 char word -// -2 words OK but parity is wrong +/** Decode six RFC 1751 words into an 8-byte binary block. + * + * Validates input exhaustively before writing any output: word count must + * be exactly 6, each word must be 1–4 characters, each must appear in the + * dictionary after normalization via `standard()`, and the reconstructed + * 64-bit payload must pass the 2-bit parity check stored at bit position + * 64. Distinct return codes let callers surface meaningful error messages. + * + * @param strData Output parameter; receives exactly 8 bytes of decoded + * binary data on success. Unchanged on failure. + * @param vsHuman Exactly 6 normalized mnemonic words. + * @return 1 if decoding succeeded and parity matched; 0 if a word was not + * found in the dictionary; -1 if input was malformed (wrong word count + * or a word exceeds 4 characters); -2 if words decoded successfully + * but parity did not match (likely a mis-transcribed or reordered + * mnemonic). + * @note The 2-bit parity is a transcription check, not a cryptographic + * integrity guarantee. + */ int RFC1751::etob(std::string& strData, std::vector vsHuman) { @@ -366,7 +454,6 @@ RFC1751::etob(std::string& strData, std::vector vsHuman) p += 11; } - /* now check the parity of what we got */ for (p = 0, i = 0; i < 64; i += 2) p += extract(b, i, 2); @@ -378,14 +465,22 @@ RFC1751::etob(std::string& strData, std::vector vsHuman) return 1; } -/** Convert words separated by spaces into a 128 bit key in big-endian format. - - @return - 1 if succeeded - 0 if word not in dictionary - -1 if badly formed string - -2 if words are okay but parity is wrong. -*/ +/** Decode a 12-word RFC 1751 mnemonic string into a 128-bit binary key. + * + * Trims leading/trailing whitespace, splits on whitespace with + * `token_compress_on` (tolerating multiple consecutive spaces), validates + * exactly 12 words, then calls `etob` on each 6-word half. The two 8-byte + * halves are concatenated in order. In `Seed.cpp` the caller reverses the + * 16-byte result before wrapping it into a `uint128`, matching the + * big-endian convention described in the RFC. + * + * @param strKey Output parameter; receives 16 bytes of decoded binary + * data on success. Unchanged on failure. + * @param strHuman Space-separated 12-word mnemonic string. + * @return 1 if decoding succeeded; 0 if a word was not found in the + * dictionary; -1 if input was malformed (not exactly 12 words, or a + * word exceeds 4 characters); -2 if words decoded but parity failed. + */ int RFC1751::getKeyFromEnglish(std::string& strKey, std::string const& strHuman) { @@ -414,7 +509,18 @@ RFC1751::getKeyFromEnglish(std::string& strKey, std::string const& strHuman) return rc; } -/** Convert to human from a 128 bit key in big-endian format +/** Encode a 128-bit binary key as a 12-word RFC 1751 mnemonic string. + * + * Splits `strKey` into two 8-byte halves, encodes each via `btoe`, and + * joins the resulting six-word strings with a single space to produce a + * 12-word output. In `Seed.cpp` the caller reverses the 16 bytes before + * passing them here, satisfying the RFC's big-endian convention. + * + * @param strHuman Output parameter; receives the 12-word space-separated + * mnemonic on return. + * @param strKey Exactly 16 bytes of binary input. + * @note Encoding is always lossless on valid 16-byte input; there is no + * return code. */ void RFC1751::getEnglishFromKey(std::string& strHuman, std::string const& strKey) @@ -427,12 +533,30 @@ RFC1751::getEnglishFromKey(std::string& strHuman, std::string const& strKey) strHuman = strFirst + " " + strSecond; } +/** Derive a single dictionary word from arbitrary binary data. + * + * Runs a Jenkins one-at-a-time hash over all bytes of `blob` and reduces + * the 32-bit result modulo 2048 to select a word from the RFC 1751 + * dictionary. The word is stable for a given input but carries no + * cryptographic security — the hash is not collision-resistant and the + * output space is only 2048 values. + * + * Primary use: derive a short, memorable pseudonym from a validator node's + * public key for non-admin RPC responses (`shroudedHostId` in + * `NetworkOPs.cpp`), where privacy is desired but uniqueness is not + * guaranteed. + * + * @param blob Pointer to the input data. + * @param bytes Length of `blob` in bytes. + * @return A single uppercase word (1–4 characters) from the RFC 1751 + * dictionary. + * @note Not cryptographically secure; do not use where collision resistance + * or unpredictability is required. + */ std::string RFC1751::getWordFromBlob(void const* blob, size_t bytes) { - // This is a simple implementation of the Jenkins one-at-a-time hash - // algorithm: - // http://en.wikipedia.org/wiki/Jenkins_hash_function#one-at-a-time + // Jenkins one-at-a-time hash: http://en.wikipedia.org/wiki/Jenkins_hash_function#one-at-a-time unsigned char const* data = static_cast(blob); std::uint32_t hash = 0; diff --git a/src/libxrpl/crypto/csprng.cpp b/src/libxrpl/crypto/csprng.cpp index 897432fcf1..ca9be10ad9 100644 --- a/src/libxrpl/crypto/csprng.cpp +++ b/src/libxrpl/crypto/csprng.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements the process-wide cryptographically secure PRNG. + * + * Every security-sensitive operation in xrpld — key generation, seed + * creation, nonce production — flows through the `CsprngEngine` singleton + * returned by `cryptoPrng()`. The implementation wraps OpenSSL's + * `RAND_bytes` / `RAND_add` / `RAND_poll` family and keeps all callers + * insulated from OpenSSL API differences across versions. + */ #include #include @@ -15,14 +24,14 @@ namespace xrpl { CsprngEngine::CsprngEngine() { - // This is not strictly necessary + // Eagerly poll for OS entropy so that any platform-level seeding failure + // surfaces at startup rather than silently at the first key generation. if (RAND_poll() != 1) Throw("CSPRNG: Initial polling failed"); } CsprngEngine::~CsprngEngine() { - // This cleanup function is not needed in newer versions of OpenSSL #if (OPENSSL_VERSION_NUMBER < 0x10100000L) RAND_cleanup(); #endif @@ -34,9 +43,6 @@ CsprngEngine::mixEntropy(void* buffer, std::size_t count) std::array entropy{}; { - // On every platform we support, std::random_device - // is non-deterministic and should provide some good - // quality entropy. std::random_device rd; for (auto& e : entropy) @@ -45,8 +51,9 @@ CsprngEngine::mixEntropy(void* buffer, std::size_t count) std::scoped_lock const lock(mutex_); - // We add data to the pool, but we conservatively assume that - // it contributes no actual entropy. + // Entropy estimate is 0 for both RAND_add calls: we deliberately decline + // to credit OpenSSL's seeding threshold, avoiding premature satisfaction + // of the threshold if std::random_device falls back to a software PRNG. RAND_add(entropy.data(), entropy.size() * sizeof(std::random_device::result_type), 0); if (buffer != nullptr && count != 0) @@ -56,9 +63,9 @@ CsprngEngine::mixEntropy(void* buffer, std::size_t count) void CsprngEngine::operator()(void* ptr, std::size_t count) { - // RAND_bytes is thread-safe on OpenSSL 1.1.0 and later when compiled - // with thread support, so we don't need to grab a mutex. - // https://mta.openssl.org/pipermail/openssl-users/2020-November/013146.html + // RAND_bytes is internally thread-safe on OpenSSL ≥ 1.1.0 built with + // thread support; the external mutex is only needed on older builds. + // See: https://mta.openssl.org/pipermail/openssl-users/2020-November/013146.html #if (OPENSSL_VERSION_NUMBER < 0x10100000L) || !defined(OPENSSL_THREADS) std::scoped_lock lock(mutex_); #endif diff --git a/src/libxrpl/crypto/secure_erase.cpp b/src/libxrpl/crypto/secure_erase.cpp index 2b24da7548..c2bc6c2077 100644 --- a/src/libxrpl/crypto/secure_erase.cpp +++ b/src/libxrpl/crypto/secure_erase.cpp @@ -1,3 +1,22 @@ +/** @file + * Implements `xrpl::secureErase` as a one-line delegation to + * `OPENSSL_cleanse`. + * + * The deliberate isolation of this call in its own translation unit is the + * key design decision: because the definition is opaque to every call site, + * the compiler cannot prove the write is dead and therefore cannot eliminate + * it. A naive `memset` inlined at the call site would be removed by any + * optimising compiler once it determines the buffer is never read again, + * leaving secret key material in memory. The separate-TU placement enforces + * the same effect as a `volatile` write across all supported compilers and + * platforms without requiring platform-specific APIs (`memset_s`, + * `explicit_bzero`, `SecureZeroMemory`). + * + * `OPENSSL_cleanse` itself may additionally use volatile stores or opaque + * function-pointer dispatch internally, but the separate-TU guarantee is the + * primary defence here. + */ + #include #include diff --git a/src/libxrpl/json/JsonPropertyStream.cpp b/src/libxrpl/json/JsonPropertyStream.cpp index d4d343e186..bb04828356 100644 --- a/src/libxrpl/json/JsonPropertyStream.cpp +++ b/src/libxrpl/json/JsonPropertyStream.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements JsonPropertyStream, the JSON sink for the PropertyStream + * diagnostics framework. The sole production consumer is the `print` + * admin RPC handler (doPrint), which writes the application's entire + * Source hierarchy into this stream and returns stream.top() as the + * RPC response. + */ + #include #include @@ -6,36 +14,65 @@ namespace xrpl { +/** Initialise the root object and seed the insertion-point stack. + * + * `topValue` is constructed as an empty JSON object. A pointer to it is + * pushed onto `stack` so that the first `mapBegin`/`arrayBegin` call has a + * valid parent. 64 slots are pre-reserved to avoid heap reallocations + * during a traversal; in practice the diagnostic Source tree is never + * that deep. + */ JsonPropertyStream::JsonPropertyStream() : topValue(json::ValueType::Object) { stack.reserve(64); stack.push_back(&topValue); } +/** Return the completed JSON object tree. + * + * Callers (e.g. doPrint) retrieve this after the write traversal is + * finished; the result is undefined if `stack` still has un-popped frames. + * + * @return A const reference to the root `objectValue` owned by this stream. + */ json::Value const& JsonPropertyStream::top() const { return topValue; } +/** Open an anonymous child object inside the current array context. + * + * Appends a new `objectValue` element to the top-of-stack array and pushes + * a pointer to it, making it the new insertion point. + * + * @pre The current top-of-stack value must be an array. + */ void JsonPropertyStream::mapBegin() { - // top is array json::Value& top(*stack.back()); json::Value& map(top.append(json::ValueType::Object)); stack.push_back(&map); } +/** Open a named child object inside the current map context. + * + * Assigns a new `objectValue` to `top[key]` and pushes a pointer to the + * resulting child, making it the new insertion point. + * + * @param key The property name under which the child object is stored. + * @pre The current top-of-stack value must be an object. + */ void JsonPropertyStream::mapBegin(std::string const& key) { - // top is a map json::Value& top(*stack.back()); json::Value& map(top[key] = json::ValueType::Object); stack.push_back(&map); } +/** Close the current map context and restore the parent insertion point. */ void JsonPropertyStream::mapEnd() { @@ -66,6 +103,15 @@ JsonPropertyStream::add(std::string const& key, unsigned int v) (*stack.back())[key] = v; } +/** Insert a keyed `long` value, narrowed to `int`. + * + * The underlying `json::Value` has no native 64-bit signed integer type, so + * `long` is explicitly cast to `int` before storage. Values wider than 32 + * bits will be silently truncated. + * + * @param key The property name. + * @param v The value to store; truncated to `int` range on 64-bit platforms. + */ void JsonPropertyStream::add(std::string const& key, long v) { @@ -90,24 +136,38 @@ JsonPropertyStream::add(std::string const& key, std::string const& v) (*stack.back())[key] = v; } +/** Open an anonymous child array inside the current array context. + * + * Appends a new `arrayValue` element to the top-of-stack array and pushes + * a pointer to it, making it the new insertion point. + * + * @pre The current top-of-stack value must be an array. + */ void JsonPropertyStream::arrayBegin() { - // top is array json::Value& top(*stack.back()); json::Value& vec(top.append(json::ValueType::Array)); stack.push_back(&vec); } +/** Open a named child array inside the current map context. + * + * Assigns a new `arrayValue` to `top[key]` and pushes a pointer to the + * resulting child, making it the new insertion point. + * + * @param key The property name under which the child array is stored. + * @pre The current top-of-stack value must be an object. + */ void JsonPropertyStream::arrayBegin(std::string const& key) { - // top is a map json::Value& top(*stack.back()); json::Value& vec(top[key] = json::ValueType::Array); stack.push_back(&vec); } +/** Close the current array context and restore the parent insertion point. */ void JsonPropertyStream::arrayEnd() { @@ -138,6 +198,14 @@ JsonPropertyStream::add(unsigned int v) stack.back()->append(v); } +/** Append a `long` value to the current array, narrowed to `int`. + * + * Same truncation caveat as the keyed overload: values wider than 32 bits + * are silently truncated because `json::Value` has no native 64-bit signed + * integer type. + * + * @param v The value to append; truncated to `int` range on 64-bit platforms. + */ void JsonPropertyStream::add(long v) { diff --git a/src/libxrpl/json/Output.cpp b/src/libxrpl/json/Output.cpp index dc411f92b2..deb19c1447 100644 --- a/src/libxrpl/json/Output.cpp +++ b/src/libxrpl/json/Output.cpp @@ -1,3 +1,13 @@ +/** @file + * Serializes a `Json::Value` tree to an `Output` streaming sink via `Writer`. + * + * Provides two entry points — `outputJson()` for streaming to an arbitrary + * sink without a full-size allocation, and `jsonAsString()` for callers that + * need the result as a `std::string`. The recursive worker that drives both + * is kept in an anonymous namespace so callers never need to instantiate a + * `Writer` directly when serializing a pre-built value tree. + */ + #include #include @@ -9,6 +19,23 @@ namespace json { namespace { +/** Recursively serialize @p value into @p writer. + * + * Dispatches on every case of `Json::ValueType`. Scalar types are written + * with a single `writer.output()` call. Collections use a two-step pattern: + * `writer.rawAppend()` (for array elements) or `writer.rawSet(tag)` (for + * object fields) emits the structural separator or key, then a recursive call + * writes the child value — which may itself open a nested collection. This + * lets the `Writer`'s internal collection stack handle arbitrary nesting + * depth without any state kept here. + * + * The switch has no `default` branch because `Json::ValueType` is a closed + * enumeration; every defined case is covered. + * + * @param value The JSON value to serialize. + * @param writer The `Writer` instance that owns the output sink and tracks + * open collection state. + */ void outputJson(Value const& value, Writer& writer) { @@ -71,6 +98,18 @@ outputJson(Value const& value, Writer& writer) } // namespace +/** Serialize @p value to the streaming sink @p out. + * + * Constructs a `Writer` on the stack bound to @p out, then delegates to the + * recursive worker. The `Writer` destructor calls `finishAll()`, which + * closes any collections left open — guaranteeing a well-formed JSON document + * even if an exception unwinds the stack before the recursive walk completes. + * + * @param value The JSON value tree to serialize. + * @param out Sink callback that receives serialized string fragments. + * @see jsonAsString() for a convenience wrapper that collects fragments into + * a `std::string`. + */ void outputJson(Value const& value, Output const& out) { @@ -78,6 +117,15 @@ outputJson(Value const& value, Output const& out) outputJson(value, writer); } +/** Return the minimal JSON string representation of @p value. + * + * Allocates a `std::string`, wraps it with `stringOutput()`, and forwards to + * `outputJson()`. Prefer `outputJson()` with a direct sink when avoiding a + * full-size allocation matters. + * + * @param value The JSON value tree to serialize. + * @return A minimal (no whitespace) JSON string. + */ std::string jsonAsString(Value const& value) { diff --git a/src/libxrpl/json/Writer.cpp b/src/libxrpl/json/Writer.cpp index 518573ded4..5c603980f2 100644 --- a/src/libxrpl/json/Writer.cpp +++ b/src/libxrpl/json/Writer.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements the streaming JSON Writer. + * + * All state and logic live in the private Writer::Impl class (Pimpl idiom). + * The public Writer shell holds only a std::unique_ptr, so callers + * depend on the public header without exposure to the internal collection + * stack or string-escape map. + */ + #include #include @@ -16,6 +25,12 @@ namespace json { namespace { +/** Lookup table mapping each JSON-special character to its two-character + * escape sequence. + * + * Covers the eight characters required by RFC 8259 §7: `"`, `\`, `/`, + * backspace, form-feed, newline, carriage-return, and tab. + */ std::map gJsonSpecialCharacterEscape = { {'"', "\\\""}, {'\\', "\\\\"}, @@ -26,6 +41,7 @@ std::map gJsonSpecialCharacterEscape = { {'\r', "\\r"}, {'\t', "\\t"}}; +/** Byte length of every escape sequence in gJsonSpecialCharacterEscape. */ size_t const kJSON_ESCAPE_LENGTH = 2; // All other JSON punctuation. @@ -37,8 +53,25 @@ char const kOPEN_BRACE = '{'; char const kOPEN_BRACKET = '['; char const kQUOTE = '"'; +/** Controls whether whole-number floats are serialized without a decimal point. + * + * Hard-coded `false` so that `3.0` is emitted as `"3.0"` rather than `"3"`, + * preserving the float type hint for consumers. The named constant exists to + * document that this was a deliberate, considered choice. + */ auto const kINTEGRAL_FLOATS_BECOME_INTS = false; +/** Return the used length of a float string after trimming trailing zeros. + * + * If the string contains no decimal point it is returned unchanged. + * Otherwise trailing zeros are stripped; if that would leave only the decimal + * point (e.g. `"3."`) one zero is kept, producing `"3.0"`, unless + * kINTEGRAL_FLOATS_BECOME_INTS is true, in which case the point itself is + * also dropped. + * + * @param s A float string produced by xrpl::to_string(). + * @return The number of leading bytes of `s` that should be emitted. + */ size_t lengthWithoutTrailingZeros(std::string const& s) { @@ -60,9 +93,20 @@ lengthWithoutTrailingZeros(std::string const& s) } // namespace +/** Private implementation of Writer (Pimpl idiom). + * + * Maintains a stack of open JSON collections (arrays and objects), tracks + * comma-separator state, and forwards serialized bytes to the Output sink. + * All write operations enforce the write-once contract: once the root + * collection is closed, any further write attempt throws std::logic_error. + * + * @note Not movable; ownership is managed exclusively by Writer via + * std::unique_ptr. + */ class Writer::Impl { public: + /** Construct an Impl that forwards bytes to @p output. */ explicit Impl(Output output) : output_(std::move(output)) { } @@ -72,12 +116,17 @@ public: Impl& operator=(Impl&&) = delete; + /** Return true if no collection is currently open. */ [[nodiscard]] bool empty() const { return stack_.empty(); } + /** Emit the opening delimiter for @p ct and push a new Collection entry. + * + * @param ct Array emits `[`; Object emits `{`. + */ void start(CollectionType ct) { @@ -86,6 +135,11 @@ public: stack_.emplace(Collection{.type = ct}); } + /** Forward raw bytes to the output sink, marking the writer as started. + * + * @param bytes Raw JSON fragment to emit verbatim. + * @throws std::logic_error if isFinished() is true. + */ void output(boost::beast::string_view const& bytes) { @@ -93,6 +147,15 @@ public: output_(bytes); } + /** Emit @p bytes as a JSON quoted string, escaping special characters. + * + * Runs of clean ASCII are emitted in a single output call; only bytes + * present in gJsonSpecialCharacterEscape break the run. A typical + * unescaped string therefore costs three output calls: `"`, body, `"`. + * + * @param bytes The raw string content to quote and escape. + * @throws std::logic_error if isFinished() is true. + */ void stringOutput(boost::beast::string_view const& bytes) { @@ -119,6 +182,13 @@ public: output_({&kQUOTE, 1}); } + /** Assert the writer is not yet finished and set isStarted_ = true. + * + * Called by every code path that emits bytes. Once the root collection + * has been closed, this throws to enforce the write-once contract. + * + * @throws std::logic_error if isFinished() is true. + */ void markStarted() { @@ -126,6 +196,16 @@ public: isStarted_ = true; } + /** Validate the current collection type and emit a comma if needed. + * + * Checks that the stack is non-empty and that the innermost collection + * matches @p type. On the first entry in a collection the comma is + * suppressed; on all subsequent entries a `,` is emitted. + * + * @param type Expected collection type (Array or Object). + * @param message Context string included in the error if checks fail. + * @throws std::logic_error if the stack is empty or the types mismatch. + */ void nextCollectionEntry(CollectionType type, std::string const& message) { @@ -148,6 +228,13 @@ public: } } + /** Emit an object key followed by `:`, with duplicate-key detection in + * debug builds. + * + * @param tag The object key to emit as a quoted, escaped string. + * @throws std::logic_error (debug builds only) if @p tag was already + * used in the current object. + */ void writeObjectTag(std::string const& tag) { @@ -162,12 +249,20 @@ public: output_({&kCOLON, 1}); } + /** Return true when the writer has started and all collections are closed. + * + * Once true, any further write attempt will throw via markStarted(). + */ [[nodiscard]] bool isFinished() const { return isStarted_ && empty(); } + /** Emit the closing delimiter of the innermost collection and pop the stack. + * + * @throws std::logic_error if the collection stack is empty. + */ void finish() { @@ -179,6 +274,12 @@ public: stack_.pop(); } + /** Close all open collections in innermost-first order. + * + * A no-op if the writer was never started. Called by ~Writer() to + * guarantee a syntactically complete JSON document even when an exception + * or early return interrupts the caller's serialization loop. + */ void finishAll() { @@ -189,6 +290,7 @@ public: } } + /** Return the underlying Output sink for use by the public Writer layer. */ [[nodiscard]] Output const& getOutput() const { @@ -220,21 +322,32 @@ private: bool isStarted_ = false; }; +/** Construct a Writer that sends serialized bytes to @p output. */ Writer::Writer(Output const& output) : impl_(std::make_unique(output)) { } +/** Close all open collections and destroy the writer. + * + * Calls finishAll() so the output stream always ends with a syntactically + * valid JSON document, even if the caller threw or returned early. + */ Writer::~Writer() { if (impl_) impl_->finishAll(); } +/** Transfer ownership of the implementation from @p w, leaving it empty. */ Writer::Writer(Writer&& w) noexcept { impl_ = std::move(w.impl_); } +/** Transfer ownership of the implementation from @p w, leaving it empty. + * + * @return *this + */ Writer& Writer::operator=(Writer&& w) noexcept { @@ -242,18 +355,33 @@ Writer::operator=(Writer&& w) noexcept return *this; } +/** Emit @p s as a JSON quoted, escaped string. + * + * @param s Null-terminated C string to serialize. + */ void Writer::output(char const* s) { impl_->stringOutput(s); } +/** Emit @p s as a JSON quoted, escaped string. + * + * @param s String to serialize. + */ void Writer::output(std::string const& s) { impl_->stringOutput(s); } +/** Serialize @p value by streaming its minimal JSON representation. + * + * Delegates to outputJson() so the entire json::Value tree is written + * directly to the output sink without an intermediate string allocation. + * + * @param value The json::Value to serialize. + */ void Writer::output(json::Value const& value) { @@ -261,6 +389,13 @@ Writer::output(json::Value const& value) outputJson(value, impl_->getOutput()); } +/** Emit @p f as a decimal string with trailing zeros removed. + * + * Integral values such as `3.0` are kept as `"3.0"` (not `"3"`) because + * kINTEGRAL_FLOATS_BECOME_INTS is false. + * + * @param f Float value to serialize. + */ void Writer::output(float f) { @@ -268,6 +403,13 @@ Writer::output(float f) impl_->output({s.data(), lengthWithoutTrailingZeros(s)}); } +/** Emit @p f as a decimal string with trailing zeros removed. + * + * Integral values such as `3.0` are kept as `"3.0"` (not `"3"`) because + * kINTEGRAL_FLOATS_BECOME_INTS is false. + * + * @param f Double value to serialize. + */ void Writer::output(double f) { @@ -275,24 +417,38 @@ Writer::output(double f) impl_->output({s.data(), lengthWithoutTrailingZeros(s)}); } +/** Emit the JSON literal `null`. */ void Writer::output(std::nullptr_t) { impl_->output("null"); } +/** Emit the JSON literal `true` or `false`. */ void Writer::output(bool b) { impl_->output(b ? "true" : "false"); } +/** Emit a pre-formatted value string produced by the template output(). + * + * Called by the template overload after converting the value to a string + * via std::to_string(). Not intended for direct use by callers. + * + * @param s String representation of the value, emitted verbatim (unquoted). + */ void Writer::implOutput(std::string const& s) { impl_->output(s); } +/** Close all open collections in innermost-first order. + * + * Safe to call on a moved-from Writer (impl_ may be null). + * @see ~Writer(), which calls this automatically. + */ void Writer::finishAll() { @@ -300,12 +456,30 @@ Writer::finishAll() impl_->finishAll(); } +/** Prepare to append a value to the current array. + * + * Emits a comma separator if this is not the first element. + * Use this when you will emit the value yourself rather than through + * append(). + * + * @throws std::logic_error if the innermost open collection is not an array. + */ void Writer::rawAppend() { impl_->nextCollectionEntry(CollectionType::Array, "append"); } +/** Prepare to set a key-value pair in the current object. + * + * Emits a comma separator if needed, then emits `"key":`. Use this when + * you will emit the value yourself rather than through set(). + * + * @param tag The object key; must be non-empty. + * @throws std::logic_error if @p tag is empty, the innermost collection is + * not an object, or (debug builds) @p tag was already used in this + * object. + */ void Writer::rawSet(std::string const& tag) { @@ -315,12 +489,25 @@ Writer::rawSet(std::string const& tag) impl_->writeObjectTag(tag); } +/** Open a new top-level collection, emitting `[` or `{`. + * + * Must be the first output call on this writer. + * + * @param type Array or Object. + */ void Writer::startRoot(CollectionType type) { impl_->start(type); } +/** Open a nested collection as the next element of the current array. + * + * Emits a comma if needed, then emits `[` or `{`. + * + * @param type Array or Object. + * @throws std::logic_error if the innermost open collection is not an array. + */ void Writer::startAppend(CollectionType type) { @@ -328,6 +515,15 @@ Writer::startAppend(CollectionType type) impl_->start(type); } +/** Open a nested collection as the value of a key in the current object. + * + * Emits a comma if needed, then emits `"key":[` or `"key":{`. + * + * @param type Array or Object. + * @param key The object key for this nested collection. + * @throws std::logic_error if the innermost open collection is not an object, + * or (debug builds) if @p key was already used in this object. + */ void Writer::startSet(CollectionType type, std::string const& key) { @@ -336,6 +532,12 @@ Writer::startSet(CollectionType type, std::string const& key) impl_->start(type); } +/** Close the innermost open collection, emitting `]` or `}`. + * + * Safe to call on a moved-from Writer (impl_ may be null). + * + * @throws std::logic_error if the collection stack is empty. + */ void Writer::finish() { diff --git a/src/libxrpl/json/json_reader.cpp b/src/libxrpl/json/json_reader.cpp index 3e8eb1e094..4a29354aa3 100644 --- a/src/libxrpl/json/json_reader.cpp +++ b/src/libxrpl/json/json_reader.cpp @@ -1,3 +1,20 @@ +/** @file + * Recursive-descent JSON parser implementation for the XRP Ledger. + * + * Implements `Json::Reader`, the sole JSON deserialization component in + * libxrpl. Every inbound JSON document — RPC requests, configuration files, + * and test fixtures — passes through this parser. The implementation follows + * a classic lexer + recursive-descent design: `readToken()` classifies source + * bytes into `TokenType` values, and `readValue()` / `readObject()` / + * `readArray()` drive the lexer and populate a `Json::Value` tree. + * + * Two deliberate deviations from the JSON specification are worth noting: + * - The document root must be an object or array (RFC 4627 semantics). + * - Duplicate object keys are rejected outright rather than shadowed. + * + * Both constraints are intentional hardening for a network-facing service. + */ + #include #include @@ -13,9 +30,18 @@ #include namespace json { -// Implementation of class Reader -// //////////////////////////////// +/** Encode a Unicode scalar value as a UTF-8 byte sequence. + * + * Covers all four byte-length cases defined by RFC 3629: + * U+0000–U+007F (1 byte), U+0080–U+07FF (2 bytes), + * U+0800–U+FFFF (3 bytes), and U+10000–U+10FFFF (4 bytes). + * Values above U+10FFFF produce an empty string. + * + * @param cp Unicode code point to encode. + * @return UTF-8 encoded string of 1–4 bytes, or an empty string if + * `cp` exceeds U+10FFFF. + */ static std::string codePointToUTF8(unsigned int cp) { @@ -53,9 +79,18 @@ codePointToUTF8(unsigned int cp) return result; } -// Class Reader -// ////////////////////////////////////////////////////////////////// +// --- Reader implementation --- +/** Parse a UTF-8 JSON document from a `std::string`. + * + * Copies `document` into `document_` so the source lifetime is managed + * internally, then delegates to the `char const*` overload. + * + * @param document UTF-8 encoded JSON text to parse. + * @param root Output value; populated on success, state unspecified + * on failure. + * @return `true` if the document was parsed without errors. + */ bool Reader::parse(std::string const& document, Value& root) { @@ -65,21 +100,40 @@ Reader::parse(std::string const& document, Value& root) return parse(begin, end, root); } +/** Parse a JSON document read from an input stream. + * + * Slurps the entire stream into a `std::string` via `std::getline` and + * delegates to the string overload. The whole document is buffered before + * any token is processed — streaming is not supported. + * + * @param sin Input stream positioned at the start of the JSON text. + * @param root Output value; populated on success. + * @return `true` if the document was parsed without errors. + */ bool Reader::parse(std::istream& sin, Value& root) { - // std::istream_iterator begin(sin); - // std::istream_iterator end; - // Those would allow streamed input from a file, if parse() were a - // template function. - - // Since std::string is reference-counted, this at least does not - // create an extra copy. std::string doc; std::getline(sin, doc, (char)EOF); return parse(doc, root); } +/** Parse a JSON document from a raw byte range. + * + * This is the core parse entry point; both other overloads delegate here. + * Initialises parser state, pushes `root` onto `nodes_`, then calls + * `readValue(0)`. After parsing, enforces the RFC 4627 constraint that the + * document root must be an object or array — bare scalars are rejected even + * if lexically valid JSON. + * + * @param beginDoc Pointer to the first byte of the JSON document. + * @param endDoc One-past-the-end pointer. + * @param root Output value; populated on success. + * @return `true` if the document was parsed without errors and the root + * value is an object or array. + * @note Errors are accumulated in `errors_` rather than reported immediately; + * call `getFormattedErrorMessages()` after a `false` return. + */ bool Reader::parse(char const* beginDoc, char const* endDoc, Value& root) { @@ -100,8 +154,6 @@ Reader::parse(char const* beginDoc, char const* endDoc, Value& root) if (!root.isNull() && !root.isArray() && !root.isObject()) { - // Set error location to start of doc, ideally should be first token - // found in doc token.type = TokenType::Error; token.start = beginDoc; token.end = endDoc; @@ -112,6 +164,16 @@ Reader::parse(char const* beginDoc, char const* endDoc, Value& root) return successful; } +/** Dispatch the next JSON value into the current node. + * + * Reads one token via `skipCommentTokens()`, then branches on its type to + * populate `currentValue()`. Recursion through `readObject()` and + * `readArray()` is bounded by `kNEST_LIMIT` (25 levels), preventing stack + * exhaustion from adversarially deep documents. + * + * @param depth Current nesting depth (0 at the document root). + * @return `true` if the value was read successfully. + */ bool Reader::readValue(unsigned depth) { @@ -162,6 +224,13 @@ Reader::readValue(unsigned depth) return successful; } +/** Advance `token` past any comment tokens to the next meaningful token. + * + * Both C-style (`/* ... *\/`) and C++-style (`// ...`) comments are treated + * as transparent to the parser. + * + * @param token Receives the first non-comment token found. + */ void Reader::skipCommentTokens(Token& token) { @@ -171,6 +240,14 @@ Reader::skipCommentTokens(Token& token) } while (token.type == TokenType::Comment); } +/** Read the next token and verify it matches the expected type. + * + * @param type The required `TokenType`. + * @param token Receives the token that was read. + * @param message Error message recorded if the token type does not match. + * @return `true` if the token type matched; `false` (with an error recorded) + * otherwise. + */ bool Reader::expectToken(TokenType type, Token& token, char const* message) { @@ -182,6 +259,18 @@ Reader::expectToken(TokenType type, Token& token, char const* message) return true; } +/** Lexer: classify the next token and advance `current_`. + * + * Skips leading whitespace, records `token.start`, consumes one or more + * bytes, and sets `token.type` and `token.end`. Single-character punctuation + * is classified directly; multi-character tokens (`true`, `false`, `null`, + * strings, numbers, comments) are dispatched to specialised helpers. + * + * @param token Receives the classified token with start/end pointers into + * the source buffer. + * @return Always `true`; a failed classification sets `token.type` to + * `TokenType::Error` rather than returning `false`. + */ bool Reader::readToken(Token& token) { @@ -271,6 +360,7 @@ Reader::readToken(Token& token) return true; } +/** Advance `current_` past any ASCII whitespace (space, tab, CR, LF). */ void Reader::skipSpaces() { @@ -289,6 +379,15 @@ Reader::skipSpaces() } } +/** Attempt to match `patternLength` bytes at `current_` against `pattern`. + * + * Used after the first character of a keyword (`t`, `f`, `n`) has already + * been consumed by `readToken()`. Advances `current_` only on a full match. + * + * @param pattern Pointer to the remaining expected bytes. + * @param patternLength Number of bytes to compare. + * @return `true` if all bytes matched and `current_` was advanced. + */ bool Reader::match(Location pattern, int patternLength) { @@ -307,6 +406,13 @@ Reader::match(Location pattern, int patternLength) return true; } +/** Dispatch a comment after the opening `/` has been consumed. + * + * Peeks at the next character: `*` routes to `readCStyleComment()`, + * `/` routes to `readCppStyleComment()`, anything else is an error. + * + * @return `true` if a valid comment was consumed. + */ bool Reader::readComment() { @@ -321,6 +427,10 @@ Reader::readComment() return false; } +/** Consume a C-style block comment after `/*` has been consumed. + * + * @return `true` if the closing `*\/` was found before end of input. + */ bool Reader::readCStyleComment() { @@ -335,6 +445,13 @@ Reader::readCStyleComment() return getNextChar() == '/'; } +/** Consume a C++-style line comment after `//` has been consumed. + * + * Advances `current_` to the end of the line (CR or LF) or end of input. + * Always returns `true` — a missing newline (end of input) is not an error. + * + * @return Always `true`. + */ bool Reader::readCppStyleComment() { @@ -349,6 +466,16 @@ Reader::readCppStyleComment() return true; } +/** Consume a number token and classify it as integer or double. + * + * Assumes the first digit (or `-`) has already been consumed by `readToken()`. + * Scans the remaining digits; if any of `.`, `e`, `E`, `+`, or `-` is + * encountered the token is classified as `TokenType::Double`, otherwise + * `TokenType::Integer`. Actual conversion is deferred to `decodeNumber()` + * or `decodeDouble()`. + * + * @return `TokenType::Integer` or `TokenType::Double`. + */ Reader::TokenType Reader::readNumber() { @@ -380,6 +507,13 @@ Reader::readNumber() return type; } +/** Advance `current_` past a quoted string, consuming the closing `"`. + * + * Only validates structure (balanced quotes, escape prefix handling). + * Actual escape decoding is performed later by `decodeString()`. + * + * @return `true` if the closing `"` was found before end of input. + */ bool Reader::readString() { @@ -402,6 +536,18 @@ Reader::readString() return c == '"'; } +/** Parse a JSON object body after `{` has been consumed. + * + * Iterates over key-value pairs, decoding each string key and recursing + * into `readValue()` for each value. Duplicate keys are rejected — unlike + * the JSON specification, which permits them with implementation-defined + * winner semantics. This is intentional: in transaction parsing a silent + * duplicate (e.g. a second `"Amount"` field) could shadow the first. + * + * @param tokenStart Token for the opening `{`; used for error reporting. + * @param depth Current nesting depth, passed through to `readValue()`. + * @return `true` if the object was well-formed and closed with `}`. + */ bool Reader::readObject(Token& tokenStart, unsigned depth) { @@ -438,7 +584,6 @@ Reader::readObject(Token& tokenStart, unsigned depth) "Missing ':' after object member name", colon, TokenType::ObjectEnd); } - // Reject duplicate names if (currentValue().isMember(name)) return addError("Key '" + name + "' appears twice.", tokenName); @@ -472,6 +617,17 @@ Reader::readObject(Token& tokenStart, unsigned depth) return addErrorAndRecover("Missing '}' or object member name", tokenName, TokenType::ObjectEnd); } +/** Parse a JSON array body after `[` has been consumed. + * + * Iterates over elements, assigning each to a sequentially-indexed child of + * `currentValue()` and recursing into `readValue()`. An empty array (`[]`) + * is detected by peeking at the next non-whitespace character before entering + * the loop, avoiding an unnecessary `readValue()` call on `]`. + * + * @param tokenStart Token for the opening `[`; used for error reporting. + * @param depth Current nesting depth, passed through to `readValue()`. + * @return `true` if the array was well-formed and closed with `]`. + */ bool Reader::readArray(Token& tokenStart, unsigned depth) { @@ -522,6 +678,18 @@ Reader::readArray(Token& tokenStart, unsigned depth) return true; } +/** Decode an integer token and assign it to `currentValue()`. + * + * Uses a `std::int64_t` accumulator (wider than `Value::maxUInt`) to detect + * overflow before committing to a `Value::Int` or `Value::UInt`. When the + * magnitude fits in `Value::kMAX_INT`, the result is stored as a signed + * `Value::Int` to reduce surprises in downstream comparisons; larger + * non-negative values use `Value::UInt`. + * + * @param token Token with `start`/`end` pointing into the source buffer. + * @return `true` on success; `false` (with an error recorded) if the token + * is not a valid integer or exceeds the representable range. + */ bool Reader::decodeNumber(Token& token) { @@ -537,8 +705,6 @@ Reader::decodeNumber(Token& token) "'" + std::string(token.start, token.end) + "' is not a valid number.", token); } - // The existing Json integers are 32-bit so using a 64-bit value here avoids - // overflows in the conversion code below. std::int64_t value = 0; static_assert( @@ -558,7 +724,6 @@ Reader::decodeNumber(Token& token) value = (value * 10) + (c - '0'); } - // More tokens left -> input is larger than largest possible return value if (current != token.end) { return addError( @@ -587,7 +752,6 @@ Reader::decodeNumber(Token& token) token); } - // If it's representable as a signed integer, construct it as one. if (value <= Value::kMAX_INT) { currentValue() = static_cast(value); @@ -601,6 +765,18 @@ Reader::decodeNumber(Token& token) return true; } +/** Decode a floating-point token and assign it to `currentValue()`. + * + * Uses `sscanf` rather than `std::stod` to work around a crash on some OS X + * versions when a string-constant format argument is used with certain + * compiler flags. The format string is stored in a `char[]` array rather + * than passed as a literal to avoid the issue. Tokens up to 32 characters + * use a stack buffer; longer tokens fall back to a `std::string`. + * + * @param token Token with `start`/`end` pointing into the source buffer. + * @return `true` on success; `false` if `sscanf` does not consume exactly + * one value. + */ bool Reader::decodeDouble(Token& token) { @@ -608,16 +784,10 @@ Reader::decodeDouble(Token& token) int const bufferSize = 32; int count = 0; int const length = int(token.end - token.start); - // Sanity check to avoid buffer overflow exploits. if (length < 0) { return addError("Unable to parse token length", token); } - // Avoid using a string constant for the format control string given to - // sscanf, as this can cause hard to debug crashes on OS X. See here for - // more info: - // - // http://developer.apple.com/library/mac/#DOCUMENTATION/DeveloperTools/gcc-4.0.1/gcc/Incompatibilities.html char format[] = "%lf"; if (length <= bufferSize) { @@ -637,6 +807,15 @@ Reader::decodeDouble(Token& token) return true; } +/** Decode a string token into `currentValue()`. + * + * Convenience wrapper that calls the two-argument overload and assigns the + * result into the current output node. + * + * @param token String token with `start`/`end` including the surrounding + * quotes. + * @return `true` on success. + */ bool Reader::decodeString(Token& token) { @@ -649,6 +828,17 @@ Reader::decodeString(Token& token) return true; } +/** Decode a string token into `decoded`, processing all escape sequences. + * + * Handles the standard JSON escape sequences (`\"`, `\\`, `\/`, `\b`, `\f`, + * `\n`, `\r`, `\t`) and `\uXXXX` Unicode escapes via + * `decodeUnicodeCodePoint()`, which handles UTF-16 surrogate pairs and + * encodes the result as UTF-8. + * + * @param token String token with `start`/`end` including surrounding quotes. + * @param decoded Accumulator for the decoded output; not cleared on entry. + * @return `true` on success; `false` if an invalid escape sequence is found. + */ bool Reader::decodeString(Token& token, std::string& decoded) { @@ -728,6 +918,22 @@ Reader::decodeString(Token& token, std::string& decoded) return true; } +/** Decode a `\uXXXX` escape and, if necessary, a following surrogate pair. + * + * Calls `decodeUnicodeEscapeSequence()` for the first four hex digits. If + * the result is a UTF-16 high surrogate (U+D800–U+DBFF), a second + * `\uXXXX` sequence must immediately follow; the pair is combined into a + * supplementary-plane scalar using: + * `0x10000 + ((high & 0x3FF) << 10) + (low & 0x3FF)`. + * + * @param token Enclosing string token, used for error location. + * @param current In/out: pointer into the source buffer, positioned after + * the `u` of the first escape; advanced past all consumed characters. + * @param end End of the string token's content region. + * @param unicode Output: decoded Unicode scalar value. + * @return `true` on success; `false` if the escape is malformed or an + * expected low surrogate is missing. + */ bool Reader::decodeUnicodeCodePoint(Token& token, Location& current, Location end, unsigned int& unicode) { @@ -736,7 +942,6 @@ Reader::decodeUnicodeCodePoint(Token& token, Location& current, Location end, un if (unicode >= 0xD800 && unicode <= 0xDBFF) { - // surrogate pairs if (end - current < 6) { return addError( @@ -756,7 +961,7 @@ Reader::decodeUnicodeCodePoint(Token& token, Location& current, Location end, un current); } - current += 2; // skip two characters checked above + current += 2; if (!decodeUnicodeEscapeSequence(token, current, end, surrogatePair)) return false; @@ -767,6 +972,16 @@ Reader::decodeUnicodeCodePoint(Token& token, Location& current, Location end, un return true; } +/** Decode exactly four hex digits from a `\uXXXX` escape sequence. + * + * @param token Enclosing string token, used for error location. + * @param current In/out: pointer into source positioned at the first hex + * digit; advanced by four on success. + * @param end End of the content region. + * @param unicode Output: the decoded 16-bit value (0–0xFFFF). + * @return `true` on success; `false` if fewer than four characters remain + * or any character is not a valid hex digit. + */ bool Reader::decodeUnicodeEscapeSequence( Token& token, @@ -812,6 +1027,16 @@ Reader::decodeUnicodeEscapeSequence( return true; } +/** Record a parse error and return `false`. + * + * Appends an `ErrorInfo` to `errors_`. Always returns `false` so callers + * can write `return addError(...)` as a one-liner. + * + * @param message Human-readable description of the error. + * @param token Token at which the error occurred. + * @param extra Optional secondary source location for additional context. + * @return Always `false`. + */ bool Reader::addError(std::string const& message, Token& token, Location extra) { @@ -823,6 +1048,18 @@ Reader::addError(std::string const& message, Token& token, Location extra) return false; } +/** Skip tokens until `skipUntilToken` or end of stream is found. + * + * Called after a structural error (missing colon, bad value, etc.) to allow + * the parser to continue and report additional errors from the same document. + * Any secondary errors produced during the skip are discarded so they do not + * pollute the error list with noise from recovery. + * + * @param skipUntilToken The terminator to seek (`TokenType::ObjectEnd` or + * `TokenType::ArrayEnd`). + * @return Always `false` (the error that triggered recovery was already + * recorded by the caller). + */ bool Reader::recoverFromError(TokenType skipUntilToken) { @@ -842,6 +1079,15 @@ Reader::recoverFromError(TokenType skipUntilToken) return false; } +/** Record an error and attempt to recover by skipping to `skipUntilToken`. + * + * Convenience composition of `addError()` and `recoverFromError()`. + * + * @param message Error message to record. + * @param token Token at which the error occurred. + * @param skipUntilToken Terminator to seek during recovery. + * @return Always `false`. + */ bool Reader::addErrorAndRecover(std::string const& message, Token& token, TokenType skipUntilToken) { @@ -849,12 +1095,22 @@ Reader::addErrorAndRecover(std::string const& message, Token& token, TokenType s return recoverFromError(skipUntilToken); } +/** Return a reference to the top-of-stack output node. + * + * The caller is responsible for ensuring `nodes_` is non-empty. + * + * @return Reference to the `Value` currently being populated. + */ Value& Reader::currentValue() { return *(nodes_.top()); } +/** Return the byte at `current_` and advance it, or return 0 at end of input. + * + * @return The consumed byte, or `0` if `current_ == end_`. + */ Reader::Char Reader::getNextChar() { @@ -864,6 +1120,16 @@ Reader::getNextChar() return *current_++; } +/** Compute the 1-based line and column numbers for a source location. + * + * Walks the source buffer from `begin_` to `location`, counting CR, LF, and + * CR+LF line endings. This is O(n) per call, acceptable because it is only + * invoked when formatting error messages. + * + * @param location Pointer into the source buffer to locate. + * @param line Output: 1-based line number. + * @param column Output: 1-based column number. + */ void Reader::getLocationLineAndColumn(Location location, int& line, int& column) const { @@ -890,11 +1156,15 @@ Reader::getLocationLineAndColumn(Location location, int& line, int& column) cons } } - // column & line start at 1 column = int(location - lastLineStart) + 1; ++line; } +/** Format a source location as a human-readable string. + * + * @param location Pointer into the source buffer. + * @return A string of the form `"Line N, Column M"` (1-based). + */ std::string Reader::getLocationLineAndColumn(Location location) const { @@ -921,6 +1191,19 @@ Reader::getFormattedErrorMessages() const return formattedMessage; } +/** Extract a JSON value from an input stream, throwing on parse failure. + * + * Unlike `Reader::parse()`, which returns `false` on error, this overload + * throws `std::runtime_error` (via `xrpl::Throw<>`) with the formatted error + * messages. Use this on code paths where parse failure is truly exceptional + * and propagation via return value would be burdensome. + * + * @param sin Input stream positioned at the start of a JSON document. + * @param root Output value populated on success. + * @return `sin` (to support chaining). + * @throws std::runtime_error if the stream does not contain valid JSON. + * @see Reader::parse(std::istream&, Value&) + */ std::istream& operator>>(std::istream& sin, Value& root) { diff --git a/src/libxrpl/json/json_value.cpp b/src/libxrpl/json/json_value.cpp index b150ea0842..e9998dbd73 100644 --- a/src/libxrpl/json/json_value.cpp +++ b/src/libxrpl/json/json_value.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements `Json::Value`, the discriminated-union type that represents any + * JSON datum used throughout rippled for API construction, parsing, and RPC + * message handling. Every inbound request and outbound response passes + * through `Value` objects. + * + * The implementation covers the `CZString` map-key helper, string memory + * management via `DefaultValueAllocator`, all seven `ValueType` paths for + * copy/move/destruction, type-coercion accessors, and the sparse-array + * model used by `arrayValue`. + */ #include #include @@ -19,23 +30,50 @@ namespace json { Value const Value::kNULL; +/** Sole concrete `ValueAllocator` implementation. + * + * Allocates string memory via `malloc`/`free` rather than `new`/`delete` so + * that a future custom allocator (pool, arena) could be dropped in by + * replacing the global singleton without recompiling callers. + */ class DefaultValueAllocator : public ValueAllocator { public: ~DefaultValueAllocator() override = default; + /** Duplicate a member name string into heap storage. + * + * @param memberName Null-terminated string to copy. + * @return Heap-allocated copy; caller is responsible for calling + * releaseMemberName() when done. + */ char* makeMemberName(char const* memberName) override { return duplicateStringValue(memberName); } + /** Release a member name previously allocated by makeMemberName(). + * + * @param memberName Pointer returned by makeMemberName(); may be null. + */ void releaseMemberName(char* memberName) override { releaseStringValue(memberName); } + /** Heap-copy a string value, optionally using a pre-computed length. + * + * Passing an explicit `length` avoids a `strlen` scan for callers that + * already know the size (e.g., `std::string`). If `value` is null, + * length is forced to zero and the result is a heap-allocated empty + * string. + * + * @param value Source string; may be null (produces an empty string). + * @param length Byte count to copy, or `kUNKNOWN` to compute via `strlen`. + * @return Null-terminated heap copy; free with releaseStringValue(). + */ char* duplicateStringValue(char const* value, unsigned int length = kUNKNOWN) override { @@ -53,6 +91,10 @@ public: return newString; } + /** Release a string previously allocated by duplicateStringValue(). + * + * @param value Pointer to free; no-op if null. + */ void releaseStringValue(char* value) override { @@ -61,6 +103,14 @@ public: } }; +/** Return the process-wide `ValueAllocator` singleton by reference. + * + * Constructed on first call as a `DefaultValueAllocator`. The reference + * return allows the pointer to be swapped at runtime, although no production + * code currently does so. + * + * @return Mutable reference to the current allocator pointer. + */ static ValueAllocator*& valueAllocator() { @@ -68,6 +118,14 @@ valueAllocator() return kVALUE_ALLOCATOR; } +/** Forces `valueAllocator()` to be initialised before `main()` starts. + * + * Without this guard, a `Value` object constructed during static + * initialisation (before `main`) would call `valueAllocator()` before its + * internal static is initialised, invoking undefined behaviour. The global + * instance of this struct calls `valueAllocator()` during its own + * construction, which is sequenced before user-code statics. + */ static struct DummyValueAllocatorInitializer { DummyValueAllocatorInitializer() @@ -77,21 +135,27 @@ static struct DummyValueAllocatorInitializer } } gDummyValueAllocatorInitializer; -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// class Value::CZString -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// - -// Notes: index_ indicates if the string was allocated when -// a string is stored. +// --- Value::CZString --- +/** Construct a numeric (array-index) key. + * + * Leaves `cstr_` null; `index_` carries the array position. + * + * @param index Zero-based array index. + */ Value::CZString::CZString(int index) : cstr_(0), index_(index) { } +/** Construct a string (object-member) key with an explicit duplication policy. + * + * When `allocate` is `Duplicate`, the string is heap-copied immediately via + * the global allocator. Otherwise `cstr_` stores the raw pointer and the + * caller is responsible for keeping the pointed-to string alive. + * + * @param cstr Null-terminated key string. + * @param allocate Ownership policy; stored in `index_` as its integer value. + */ Value::CZString::CZString(char const* cstr, DuplicationPolicy allocate) : cstr_( allocate == DuplicationPolicy::Duplicate ? valueAllocator()->makeMemberName(cstr) : cstr) @@ -99,6 +163,13 @@ Value::CZString::CZString(char const* cstr, DuplicationPolicy allocate) { } +/** Copy-construct a `CZString`, respecting the source's ownership policy. + * + * If the source used `NoDuplication` (static string), the copy stores the + * same pointer without allocating. If the source owned a heap copy, a new + * heap copy is made so each instance owns its memory independently. + * Numeric keys (null `cstr_`) are copied by value. + */ Value::CZString::CZString(CZString const& other) : cstr_( other.index_ != static_cast(DuplicationPolicy::NoDuplication) && other.cstr_ != 0 @@ -114,12 +185,19 @@ Value::CZString::CZString(CZString const& other) { } +/** Destroy the `CZString`, freeing heap-owned string memory if applicable. */ Value::CZString::~CZString() { if ((cstr_ != nullptr) && index_ == static_cast(DuplicationPolicy::Duplicate)) valueAllocator()->releaseMemberName(const_cast(cstr_)); } +/** Total order over `CZString` values for use as `std::map` keys. + * + * String keys compare lexicographically; numeric keys compare by index. + * Mixing string and numeric keys in the same map is not expected and would + * fall into the numeric branch (one `cstr_` is null). + */ bool Value::CZString::operator<(CZString const& other) const { @@ -129,6 +207,7 @@ Value::CZString::operator<(CZString const& other) const return index_ < other.index_; } +/** Equality comparison matching the ordering defined by `operator<`. */ bool Value::CZString::operator==(CZString const& other) const { @@ -138,35 +217,42 @@ Value::CZString::operator==(CZString const& other) const return index_ == other.index_; } +/** Return the numeric array index, or the encoded `DuplicationPolicy` for string keys. */ int Value::CZString::index() const { return index_; } +/** Return the raw string pointer, or null for numeric (array-index) keys. */ char const* Value::CZString::cStr() const { return cstr_; } +/** Return true if this key points at a static (non-owned) string. + * + * A static key's underlying C-string must outlive the `CZString`. This is + * guaranteed when the key was constructed via `StaticString` or with + * `DuplicationPolicy::NoDuplication`. + */ bool Value::CZString::isStaticString() const { return index_ == static_cast(DuplicationPolicy::NoDuplication); } -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// class Value::Value -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// +// --- Value constructors and lifecycle --- -/*! \internal Default constructor initialization must be equivalent to: - * memset( this, 0, sizeof(Value) ) - * This optimization is used in ValueInternalMap fast allocator. +/** Construct a default `Value` of the given type. + * + * Scalars are zero-initialised; strings are null; arrays and objects receive + * a freshly heap-allocated `ObjectValues` map. The zero-initialisation + * contract is equivalent to `memset(this, 0, sizeof(Value))` and must be + * preserved if the storage layout ever changes. + * + * @param type The `ValueType` tag; defaults to `ValueType::Null`. */ Value::Value(ValueType type) : type_(type) { @@ -204,48 +290,71 @@ Value::Value(ValueType type) : type_(type) } } +/** Construct a signed-integer `Value`. */ Value::Value(Int value) : type_(ValueType::Int) { value_.intVal = value; } +/** Construct an unsigned-integer `Value`. */ Value::Value(UInt value) : type_(ValueType::UInt) { value_.uintVal = value; } +/** Construct a double-precision floating-point `Value`. */ Value::Value(double value) : type_(ValueType::Real) { value_.realVal = value; } +/** Construct a string `Value` by heap-copying a null-terminated C-string. + * + * Sets `allocated_` so the destructor frees the copy. + */ Value::Value(char const* value) : type_(ValueType::String), allocated_(true) { value_.stringVal = valueAllocator()->duplicateStringValue(value); } +/** Construct a string `Value` from an `xrpl::Number`, serialised via `to_string`. + * + * The resulting string is heap-allocated with `allocated_` set. + */ Value::Value(xrpl::Number const& value) : type_(ValueType::String), allocated_(true) { auto const tmp = to_string(value); value_.stringVal = valueAllocator()->duplicateStringValue(tmp.c_str(), tmp.length()); } +/** Construct a string `Value` by heap-copying a `std::string`. + * + * Uses the known length to skip an extra `strlen` scan. + */ Value::Value(std::string const& value) : type_(ValueType::String), allocated_(true) { value_.stringVal = valueAllocator()->duplicateStringValue(value.c_str(), (unsigned int)value.length()); } +/** Construct a string `Value` that references a static string without copying. + * + * `allocated_` remains 0, so the destructor will not free the pointer. The + * pointed-to string must have program-lifetime storage (e.g., a string + * literal or a `static const char[]`). + */ Value::Value(StaticString const& value) : type_(ValueType::String) { value_.stringVal = const_cast(value.cStr()); } +/** Construct a boolean `Value`. */ Value::Value(bool value) : type_(ValueType::Boolean) { value_.boolVal = value; } +/** Deep-copy constructor; heap-allocated strings and maps are duplicated. */ Value::Value(Value const& other) : type_(other.type_) { switch (type_) @@ -283,6 +392,11 @@ Value::Value(Value const& other) : type_(other.type_) } } +/** Destroy the `Value`, releasing heap-owned strings and maps. + * + * Static strings (`allocated_ == 0`) are not freed — their lifetime is + * managed externally by the `StaticString` contract. + */ Value::~Value() { switch (type_) @@ -313,6 +427,11 @@ Value::~Value() } } +/** Copy-assign using copy-and-swap; provides strong exception safety. + * + * A copy-constructed temporary is swapped into `*this`; the temporary's + * destructor then cleans up the old state. + */ Value& Value::operator=(Value const& other) { @@ -321,6 +440,11 @@ Value::operator=(Value const& other) return *this; } +/** Move-construct by stealing the source's union members. + * + * The source is left as `nullValue` with `allocated_ = 0` so its destructor + * is a no-op. + */ Value::Value(Value&& other) noexcept : value_(other.value_), type_(other.type_), allocated_(other.allocated_) { @@ -328,6 +452,11 @@ Value::Value(Value&& other) noexcept other.allocated_ = 0; } +/** Move-assign using move-and-swap. + * + * The old contents of `*this` are destroyed when the temporary falls out of + * scope after the swap. + */ Value& Value::operator=(Value&& other) { @@ -336,6 +465,7 @@ Value::operator=(Value&& other) return *this; } +/** Swap all data members with `other` in O(1) without allocation. */ void Value::swap(Value& other) noexcept { @@ -350,25 +480,42 @@ Value::swap(Value& other) noexcept other.allocated_ = temp2; } +/** Return the `ValueType` tag indicating which union branch is active. */ ValueType Value::type() const { return type_; } +/** Compare a signed integer against an unsigned integer without UB. + * + * Direct comparison would promote `i` to `unsigned`, giving the wrong result + * for negative values. This helper handles the sign mismatch explicitly: + * any negative `i` is always less than any `UInt`. + * + * @param i Signed comparand. + * @param ui Unsigned comparand. + * @return -1, 0, or 1 in the usual three-way convention. + */ static int integerCmp(Int i, UInt ui) { - // All negative numbers are less than all unsigned numbers. if (i < 0) return -1; - // Now we can safely compare. if (i < ui) return -1; return (i == ui) ? 0 : 1; } +/** Total order over `Value` objects. + * + * For equal types, comparison delegates to the underlying scalar or + * container comparison. For the `Int`/`UInt` cross-type pair, + * `integerCmp()` is used to avoid signed/unsigned promotion UB. All other + * type mismatches fall back to ordering by `ValueType` enum value, giving + * `Value` a deterministic total order suitable for use as a `std::map` key. + */ bool operator<(Value const& x, Value const& y) { @@ -424,6 +571,12 @@ operator<(Value const& x, Value const& y) return false; // unreachable } +/** Equality comparison consistent with `operator<`. + * + * The `Int`/`UInt` cross-type case uses `integerCmp()` so that + * `Value(-1) != Value(0u)` and `Value(5) == Value(5u)` hold correctly. + * All other type mismatches return false without inspecting the payloads. + */ bool operator==(Value const& x, Value const& y) { @@ -472,6 +625,12 @@ operator==(Value const& x, Value const& y) return false; // unreachable } +/** Return the raw C-string pointer for a `stringValue`. + * + * @pre `type() == ValueType::String` (asserted in debug builds). + * @return Pointer into the internal string buffer; valid until the `Value` + * is modified or destroyed. + */ char const* Value::asCString() const { @@ -479,6 +638,17 @@ Value::asCString() const return value_.stringVal; } +/** Convert the `Value` to a `std::string` with permissive coercion. + * + * - `nullValue` → `""` + * - `stringValue` → the stored string (empty string if the pointer is null) + * - `boolValue` → `"true"` or `"false"` + * - `intValue` / `uintValue` / `realValue` → decimal via `std::to_string` + * - `arrayValue` / `objectValue` → asserts and aborts in debug builds + * + * @return String representation of the value. + * @throw Does not throw; containers abort via `JSON_ASSERT_MESSAGE`. + */ std::string Value::asString() const { @@ -515,6 +685,19 @@ Value::asString() const return ""; // unreachable } +/** Convert the `Value` to a signed integer with range-checked coercion. + * + * - `nullValue` → 0 + * - `intValue` → identity + * - `uintValue` → asserts `value < kMAX_INT` + * - `realValue` → asserts within `[kMIN_INT, kMAX_INT]`, truncates + * - `boolValue` → 0 or 1 + * - `stringValue` → parsed via `beast::lexicalCastThrow` + * - `arrayValue` / `objectValue` → asserts and aborts + * + * @return The value as `Int`. + * @throws std::exception (or similar) on unparseable string input. + */ Value::Int Value::asInt() const { @@ -558,6 +741,18 @@ Value::asInt() const return 0; // unreachable; } +/** Return the absolute value of any numeric type as an unsigned integer. + * + * XRPL-specific addition. Negative `intValue` is negated through `int64_t` + * before the cast to avoid undefined overflow for `INT_MIN` (which cannot + * be represented as a positive `int`). Negative `realValue` is similarly + * negated after a range check. Strings are parsed as `int64_t` first, then + * the absolute value is returned. + * + * @return Absolute value of the numeric payload as `UInt`. + * @note Asserts (debug-aborts) if the value is out of `UInt` range or if + * the type is `arrayValue`/`objectValue`. + */ UInt Value::asAbsUInt() const { @@ -616,6 +811,19 @@ Value::asAbsUInt() const return 0; // unreachable; } +/** Convert the `Value` to an unsigned integer with range-checked coercion. + * + * - `nullValue` → 0 + * - `intValue` → asserts non-negative + * - `uintValue` → identity + * - `realValue` → asserts within `[0, kMAX_UINT]`, truncates + * - `boolValue` → 0 or 1 + * - `stringValue` → parsed via `beast::lexicalCastThrow` + * - `arrayValue` / `objectValue` → asserts and aborts + * + * @return The value as `UInt`. + * @throws std::exception (or similar) on unparseable string input. + */ Value::UInt Value::asUInt() const { @@ -659,6 +867,15 @@ Value::asUInt() const return 0; // unreachable; } +/** Convert numeric or boolean `Value` to `double`. + * + * - `nullValue` → 0.0 + * - `intValue` / `uintValue` / `realValue` → widening conversion + * - `boolValue` → 0.0 or 1.0 + * - `stringValue` / `arrayValue` / `objectValue` → asserts and aborts + * + * @return Double representation. + */ double Value::asDouble() const { @@ -693,6 +910,17 @@ Value::asDouble() const return 0; // unreachable; } +/** Convert any `Value` to a boolean with Python-style truthiness rules. + * + * - `nullValue` → false + * - `intValue` / `uintValue` → true iff non-zero (shares `intVal` branch) + * - `realValue` → true iff non-zero + * - `boolValue` → identity + * - `stringValue` → true iff pointer is non-null and first char is non-NUL + * - `arrayValue` / `objectValue` → true iff the map is non-empty + * + * @return Boolean interpretation of the value. + */ bool Value::asBool() const { @@ -727,6 +955,22 @@ Value::asBool() const return false; // unreachable; } +/** Test whether this `Value` can be coerced to `other` without data loss. + * + * Encodes the same rules as the `asXxx()` accessors in predicate form so + * callers can check feasibility cheaply before committing to a conversion. + * Notable rules: + * - Any type can be coerced to `Null` iff its payload is the zero/empty + * value for that type. + * - `realValue` → `UInt` additionally requires that the double has no + * fractional component (checked via `fabs(round(x) - x) < epsilon`). + * - `stringValue` is only convertible to `String` or (if empty) `Null`. + * - `arrayValue` / `objectValue` are only convertible to themselves or + * (if empty) `Null`. + * + * @param other Target `ValueType` to test conversion to. + * @return True if the conversion would succeed without assertion failure. + */ bool Value::isConvertibleTo(ValueType other) const { @@ -783,7 +1027,15 @@ Value::isConvertibleTo(ValueType other) const return false; // unreachable; } -/// Number of values in array or object +/** Return the number of elements in an array or object, or 0 for scalars. + * + * For `arrayValue`, size is the index of the highest-indexed entry plus one + * (sparse-array semantics). Writing `arr[5] = x` on an empty array makes + * `size()` return 6, even though only one slot is occupied. For + * `objectValue`, size is the actual entry count. All scalar types return 0. + * + * @return Element count (sparse for arrays). + */ Value::UInt Value::size() const { @@ -797,7 +1049,7 @@ Value::size() const case ValueType::String: return 0; - case ValueType::Array: // size of the array is highest index + 1 + case ValueType::Array: if (!value_.mapVal->empty()) { ObjectValues::const_iterator itLast = value_.mapVal->end(); @@ -819,6 +1071,12 @@ Value::size() const return 0; // unreachable; } +/** Return false for null, empty string, empty array, or empty object; true otherwise. + * + * Provides Python-style truthiness: a non-null, non-empty `Value` is truthy. + * Unlike `asBool()`, this operator is valid for all types including + * containers. + */ Value:: operator bool() const { @@ -834,6 +1092,11 @@ operator bool() const return !(isArray() || isObject()) || (size() != 0u); } +/** Remove all elements from an array or object; no-op for null. + * + * @pre `type()` is `Array`, `Object`, or `Null` (asserted). + * @post `type()` is unchanged and `size() == 0`. + */ void Value::clear() { @@ -853,6 +1116,17 @@ Value::clear() } } +/** Access or auto-create an array element at `index` (non-const). + * + * If the `Value` is `nullValue`, it is promoted to `arrayValue`. If no + * entry exists at `index`, a `nullValue` is inserted and returned — the + * sparse-array representation means all intermediate indices remain absent + * until explicitly written. + * + * @param index Zero-based array index. + * @return Reference to the element, default-inserting null if absent. + * @pre `type()` is `Null` or `Array` (asserted). + */ Value& Value::operator[](UInt index) { @@ -874,6 +1148,15 @@ Value::operator[](UInt index) return (*it).second; } +/** Access an array element at `index` (const); returns `kNULL` if absent. + * + * Never inserts elements. Returns `Value::kNULL` for out-of-range indices + * or when the `Value` is `nullValue`. + * + * @param index Zero-based array index. + * @return Const reference to the element, or `kNULL` if not found. + * @pre `type()` is `Null` or `Array` (asserted). + */ Value const& Value::operator[](UInt index) const { @@ -893,12 +1176,33 @@ Value::operator[](UInt index) const return (*it).second; } +/** Access or create an object member by string key (non-const). + * + * The key is treated as a transient string and will be duplicated into the + * map. For hot-path code that reuses the same key, prefer `operator[](StaticString)` + * to avoid repeated allocation. + * + * @param key Null-terminated member name. + * @return Reference to the member `Value`, default-inserting null if absent. + */ Value& Value::operator[](char const* key) { return resolveReference(key, false); } +/** Insert or locate a member key, respecting its static/dynamic ownership. + * + * When `isStatic` is true, the key is stored with `NoDuplication` policy + * (no heap copy); when false, `DuplicateOnCopy` is used so the key is + * copied the first time it is inserted into the map. Promotes `nullValue` + * to `objectValue` if necessary. + * + * @param key Null-terminated member name. + * @param isStatic True if `key` has static/program-lifetime storage. + * @return Reference to the (possibly newly inserted) member `Value`. + * @pre `type()` is `Null` or `Object` (asserted). + */ Value& Value::resolveReference(char const* key, bool isStatic) { @@ -924,6 +1228,12 @@ Value::resolveReference(char const* key, bool isStatic) return value; } +/** Return the array element at `index`, or `defaultValue` if absent. + * + * @param index Zero-based array index. + * @param defaultValue Value to return when the index is out of range. + * @return Copy of the element or `defaultValue`. + */ Value Value::get(UInt index, Value const& defaultValue) const { @@ -931,12 +1241,27 @@ Value::get(UInt index, Value const& defaultValue) const return value == &kNULL ? defaultValue : *value; } +/** Return true if `index` is within the current array bounds. + * + * @param index Zero-based array index to test. + * @return True iff `index < size()`. + */ bool Value::isValidIndex(UInt index) const { return index < size(); } +/** Look up a const object member; returns `kNULL` if the key is absent. + * + * The key is compared without duplication (`NoDuplication` policy), so no + * heap allocation occurs. Returns `Value::kNULL` for missing keys or when + * the `Value` is `nullValue`. + * + * @param key Null-terminated member name. + * @return Const reference to the member or `kNULL`. + * @pre `type()` is `Null` or `Object` (asserted). + */ Value const& Value::operator[](char const* key) const { @@ -956,42 +1281,71 @@ Value::operator[](char const* key) const return (*it).second; } +/** Access or create an object member by `std::string` key (non-const). */ Value& Value::operator[](std::string const& key) { return (*this)[key.c_str()]; } +/** Look up a const object member by `std::string` key; returns `kNULL` if absent. */ Value const& Value::operator[](std::string const& key) const { return (*this)[key.c_str()]; } +/** Access or create an object member using a `StaticString` key (non-const). + * + * The key pointer is stored without duplication, avoiding heap allocation. + * Use this overload for frequently-accessed keys held in `static const + * StaticString` constants. + */ Value& Value::operator[](StaticString const& key) { return resolveReference(key, true); } +/** Look up a const object member by `StaticString` key; returns `kNULL` if absent. */ Value const& Value::operator[](StaticString const& key) const { return (*this)[key.cStr()]; } +/** Append a copy of `value` to the end of this array. + * + * Equivalent to `(*this)[size()] = value`. + * + * @param value Element to append. + * @return Reference to the newly inserted element. + */ Value& Value::append(Value const& value) { return (*this)[size()] = value; } +/** Append a moved `value` to the end of this array. + * + * Equivalent to `(*this)[size()] = std::move(value)`. + * + * @param value Element to move-append. + * @return Reference to the newly inserted element. + */ Value& Value::append(Value&& value) { return (*this)[size()] = std::move(value); } +/** Return the named object member, or `defaultValue` if the key is absent. + * + * @param key Null-terminated member name. + * @param defaultValue Fallback value when the key does not exist. + * @return Copy of the member or `defaultValue`. + */ Value Value::get(char const* key, Value const& defaultValue) const { @@ -999,12 +1353,26 @@ Value::get(char const* key, Value const& defaultValue) const return value == &kNULL ? defaultValue : *value; } +/** Return the named object member, or `defaultValue` if the key is absent. + * + * @param key Member name. + * @param defaultValue Fallback value when the key does not exist. + * @return Copy of the member or `defaultValue`. + */ Value Value::get(std::string const& key, Value const& defaultValue) const { return get(key.c_str(), defaultValue); } +/** Remove and return the named member from this object. + * + * @param key Null-terminated member name to remove. + * @return The removed value, or `kNULL` if the key was absent or the + * `Value` is `nullValue`. + * @pre `type()` is `Object` or `Null` (asserted). + * @post `type()` is unchanged; the member is no longer present. + */ Value Value::removeMember(char const* key) { @@ -1026,12 +1394,26 @@ Value::removeMember(char const* key) return old; } +/** Remove and return the named member from this object. + * + * Delegates to `removeMember(char const*)`. + * + * @param key Member name to remove. + * @return The removed value, or `kNULL` if absent. + */ Value Value::removeMember(std::string const& key) { return removeMember(key.c_str()); } +/** Return true if this object contains a member with the given key. + * + * Returns false for non-object types without asserting. + * + * @param key Null-terminated member name to look up. + * @return True iff the key exists in this object. + */ bool Value::isMember(char const* key) const { @@ -1042,18 +1424,27 @@ Value::isMember(char const* key) const return value != &kNULL; } +/** Return true if this object contains a member with the given key. */ bool Value::isMember(std::string const& key) const { return isMember(key.c_str()); } +/** Return true if this object contains a member with the given key. */ bool Value::isMember(StaticString const& key) const { return isMember(key.cStr()); } +/** Return a vector of all member names in this object. + * + * @return `Members` (vector of strings) in map iteration order. Returns an + * empty vector for `nullValue`. + * @pre `type()` is `Object` or `Null` (asserted). + * @post `type()` is unchanged. + */ Value::Members Value::getMemberNames() const { @@ -1075,78 +1466,100 @@ Value::getMemberNames() const return members; } +/** Return true if the value is `null`. */ bool Value::isNull() const { return type_ == ValueType::Null; } +/** Return true if the value is a boolean. */ bool Value::isBool() const { return type_ == ValueType::Boolean; } +/** Return true if the value is a signed integer. */ bool Value::isInt() const { return type_ == ValueType::Int; } +/** Return true if the value is an unsigned integer. */ bool Value::isUInt() const { return type_ == ValueType::UInt; } +/** Return true if the value is an integer type or boolean. + * + * Booleans are included because they coerce losslessly to 0/1. + */ bool Value::isIntegral() const { return type_ == ValueType::Int || type_ == ValueType::UInt || type_ == ValueType::Boolean; } +/** Return true if the value is a double (`realValue`). */ bool Value::isDouble() const { return type_ == ValueType::Real; } +/** Return true if the value is any numeric type (integral or double). */ bool Value::isNumeric() const { return isIntegral() || isDouble(); } +/** Return true if the value is a string. */ bool Value::isString() const { return type_ == ValueType::String; } +/** Return true if the value is an array. */ bool Value::isArray() const { return type_ == ValueType::Array; } +/** Return true if the value is an array or null. */ bool Value::isArrayOrNull() const { return type_ == ValueType::Null || type_ == ValueType::Array; } +/** Return true if the value is an object. */ bool Value::isObject() const { return type_ == ValueType::Object; } +/** Return true if the value is an object or null. */ bool Value::isObjectOrNull() const { return type_ == ValueType::Null || type_ == ValueType::Object; } +/** Serialise this `Value` to a human-readable, indented JSON string. + * + * Delegates to `StyledWriter::write()`. Suitable for logging and debugging; + * not intended for wire-format serialisation where compact output is preferred. + * + * @return Pretty-printed JSON string. + */ std::string Value::toStyledString() const { @@ -1154,6 +1567,11 @@ Value::toStyledString() const return writer.write(*this); } +/** Return a const iterator to the first element of an array or object. + * + * Returns a default-constructed (null) iterator for any other value type, + * which compares equal to `end()` so range-based loops are safe. + */ Value::const_iterator Value::begin() const { @@ -1172,6 +1590,10 @@ Value::begin() const return const_iterator(); } +/** Return a const past-the-end iterator for an array or object. + * + * Returns a default-constructed (null) iterator for other value types. + */ Value::const_iterator Value::end() const { @@ -1190,6 +1612,10 @@ Value::end() const return const_iterator(); } +/** Return a mutable iterator to the first element of an array or object. + * + * Returns a default-constructed (null) iterator for other value types. + */ Value::iterator Value::begin() { @@ -1207,6 +1633,10 @@ Value::begin() return iterator(); } +/** Return a mutable past-the-end iterator for an array or object. + * + * Returns a default-constructed (null) iterator for other value types. + */ Value::iterator Value::end() { diff --git a/src/libxrpl/json/json_valueiterator.cpp b/src/libxrpl/json/json_valueiterator.cpp index 5a3a5ffcdb..e849a8f715 100644 --- a/src/libxrpl/json/json_valueiterator.cpp +++ b/src/libxrpl/json/json_valueiterator.cpp @@ -1,3 +1,12 @@ +/** @file + * Iterator implementation for Json::Value traversal. + * + * This file is not compiled as an independent translation unit; it is + * `#include`-d directly by `json_value.cpp`. The split is a readability + * convention inherited from the upstream JsonCpp library: the iterator + * implementation lives in its own file while still sharing internal types + * that are private to `json_value.cpp`. + */ // included by json_value.cpp #include @@ -5,18 +14,24 @@ namespace json { -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// class ValueIteratorBase -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// +// --- ValueIteratorBase --- +/** Construct a null-state iterator. + * + * Sets `isNull_` to true so that comparisons between two default-constructed + * iterators short-circuit before touching `current_`, which is left as a + * default-constructed (and therefore non-comparable) `std::map` iterator. + */ ValueIteratorBase::ValueIteratorBase() : isNull_(true) { } +/** Construct an iterator wrapping a live map iterator. + * + * @param current A valid iterator into the `Value::ObjectValues` map. + * The caller is responsible for ensuring `current` is not past-the-end + * before any dereference operation. + */ ValueIteratorBase::ValueIteratorBase(Value::ObjectValues::iterator const& current) : current_(current), isNull_(false) { @@ -40,23 +55,34 @@ ValueIteratorBase::decrement() --current_; } +/** Compute the number of steps from this iterator to @p other. + * + * Walks forward from `current_` to `other.current_`, counting steps. + * The result is undefined if `other` precedes `*this` in the underlying map. + * + * @param other The target iterator; must refer to the same container. + * @return The non-negative distance, or 0 if both iterators are null. + * + * @note When both iterators are null (i.e., iterators over a JSON null + * value), their underlying `current_` members are default-constructed + * `std::map` iterators that cannot be compared portably — two separate + * default-constructed `std::map::iterator` instances are not guaranteed + * to compare equal even when logically equivalent. The null check + * short-circuits this undefined behavior and returns 0 immediately. + * + * @note `std::distance()` is intentionally avoided: the Sun Studio 12 + * RogueWave STL (the default on Solaris at the time) failed to compile + * `std::distance` for non-random-access iterators. The hand-rolled loop + * is an O(n) portability tradeoff. + */ ValueIteratorBase::difference_type ValueIteratorBase::computeDistance(SelfType const& other) const { - // Iterator for null value are initialized using the default - // constructor, which initialize current_ to the default - // std::map::iterator. As begin() and end() are two instance - // of the default std::map::iterator, they can not be compared. - // To allow this, we handle this comparison specifically. if (isNull_ && other.isNull_) { return 0; } - // Usage of std::distance is not portable (does not compile with Sun Studio - // 12 RogueWave STL, which is the one used by default). Using a portable - // hand-made version for non random iterator instead: - // return difference_type( std::distance( current_, other.current_ ) ); difference_type myDistance = 0; for (Value::ObjectValues::iterator it = current_; it != other.current_; ++it) @@ -67,6 +93,16 @@ ValueIteratorBase::computeDistance(SelfType const& other) const return myDistance; } +/** Test iterator equality, handling the null-iterator edge case. + * + * Two null iterators compare equal regardless of their `current_` state, + * avoiding undefined behavior from comparing default-constructed map + * iterators. + * + * @param other The iterator to compare against. + * @return True if both iterators refer to the same position or are + * both null. + */ bool ValueIteratorBase::isEqual(SelfType const& other) const { @@ -84,6 +120,16 @@ ValueIteratorBase::copy(SelfType const& other) current_ = other.current_; } +/** Return the current key as a typed Value. + * + * For object members, returns a string `Value`. If the underlying + * `CZString` key was created from a compile-time-constant (static string), + * a `StaticString` wrapper is used to avoid heap allocation. For array + * elements, returns an integer `Value` holding the element's index. + * + * @return A `Value` of type string or integer depending on the container + * being iterated. + */ Value ValueIteratorBase::key() const { @@ -100,6 +146,12 @@ ValueIteratorBase::key() const return Value(czString.index()); } +/** Return the array index of the current element. + * + * @return The zero-based array index when iterating an array, or + * `Value::UInt(-1)` (the maximum unsigned value, used as "not + * applicable") when iterating an object. + */ UInt ValueIteratorBase::index() const { @@ -111,6 +163,16 @@ ValueIteratorBase::index() const return Value::UInt(-1); } +/** Return the object member name of the current element. + * + * @return A non-null, NUL-terminated C string holding the member name + * when iterating a JSON object, or `""` when iterating an array + * (where keys are integer indices rather than strings). + * + * @note The empty-string fallback ensures callers never receive a null + * pointer, preventing null-dereference crashes when the iterator is + * used on a non-object container. + */ char const* ValueIteratorBase::memberName() const { @@ -118,19 +180,31 @@ ValueIteratorBase::memberName() const return (name != nullptr) ? name : ""; } -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// class ValueConstIterator -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// +// --- ValueConstIterator --- +/** Construct a const iterator wrapping a live map iterator. + * + * Only `Value` (a friend) calls this constructor directly; external code + * obtains const iterators via `Value::begin()` / `Value::end()`. + * + * @param current A valid iterator into the `Value::ObjectValues` map. + */ ValueConstIterator::ValueConstIterator(Value::ObjectValues::iterator const& current) : ValueIteratorBase(current) { } +/** Assign from any `ValueIteratorBase`, including a mutable `ValueIterator`. + * + * Accepting `ValueIteratorBase const&` rather than `SelfType const&` allows + * a mutable `ValueIterator` to be implicitly converted to a const iterator + * via assignment, mirroring the standard-library pattern of + * `const_iterator = iterator`. + * + * @param other The source iterator; may be a `ValueIterator` or another + * `ValueConstIterator`. + * @return Reference to `*this`. + */ ValueConstIterator& ValueConstIterator::operator=(ValueIteratorBase const& other) { @@ -138,25 +212,38 @@ ValueConstIterator::operator=(ValueIteratorBase const& other) return *this; } -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// class ValueIterator -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////// +// --- ValueIterator --- +/** Construct a mutable iterator wrapping a live map iterator. + * + * Only `Value` (a friend) calls this constructor directly; external code + * obtains mutable iterators via `Value::begin()` / `Value::end()`. + * + * @param current A valid iterator into the `Value::ObjectValues` map. + */ ValueIterator::ValueIterator(Value::ObjectValues::iterator const& current) : ValueIteratorBase(current) { } +/** Construct a mutable iterator from a const iterator. + * + * Allows code that holds a `ValueConstIterator` to seed a mutable + * `ValueIterator` at construction time. + * + * @param other The const iterator to copy position from. + */ ValueIterator::ValueIterator(ValueConstIterator const& other) : ValueIteratorBase(other) { } ValueIterator::ValueIterator(ValueIterator const& other) = default; +/** Copy-assign from another mutable iterator. + * + * @param other The source iterator. + * @return Reference to `*this`. + */ ValueIterator& ValueIterator::operator=(SelfType const& other) { diff --git a/src/libxrpl/json/json_writer.cpp b/src/libxrpl/json/json_writer.cpp index 4c38bdcf92..4d07f1eb46 100644 --- a/src/libxrpl/json/json_writer.cpp +++ b/src/libxrpl/json/json_writer.cpp @@ -1,3 +1,16 @@ +/** @file + * Serialization engine for XRPL's embedded JSON library. + * + * Implements three output strategies over `Json::Value` trees: + * - `FastWriter` — compact single-line output, no heap cost per node. + * - `StyledWriter` — human-readable indented output to a `std::string`. + * - `StyledStreamWriter` — human-readable indented output directly to a + * `std::ostream`, avoiding intermediate string copies. + * + * The file also provides the primitive helpers (`valueToString` overloads, + * `valueToQuotedString`) used by all three writers and by the header-only + * `detail::write_value` template. + */ #include #include @@ -15,12 +28,29 @@ namespace json { +/** Returns true if @a ch is a JSON control character (U+0001–U+001F). + * + * U+0000 (NUL) is excluded; it terminates C-strings and cannot appear + * in a valid JSON string literal without escaping via `\u0000`. + * + * @param ch The character to test. + * @return True if @a ch falls in the range [0x01, 0x1F]. + */ static bool isControlCharacter(char ch) { return ch > 0 && ch <= 0x1F; } +/** Returns true if @a str contains at least one JSON control character. + * + * Scans the NUL-terminated string for any byte in U+0001–U+001F. + * Used by `valueToQuotedString` as part of its fast-path check before + * committing to a character-by-character escape walk. + * + * @param str NUL-terminated input string. + * @return True if any control character is present. + */ static bool containsControlCharacter(char const* str) { @@ -32,6 +62,22 @@ containsControlCharacter(char const* str) return false; } + +/** Write the decimal representation of @a value into a stack buffer. + * + * Digits are written in reverse order — from the end of the buffer toward + * the front — so no string reversal is needed. On return, @a current + * points to the first character of the number string and a NUL terminator + * has been placed one position past the last digit. + * + * The 32-byte buffer supplied by the caller is far larger than the maximum + * decimal representation of any 64-bit integer (20 digits), so the pointer + * can never escape the buffer. + * + * @param value The unsigned integer to convert. + * @param current In/out pointer into the caller's buffer; advanced backward + * past each digit and the trailing NUL on each call. + */ static void uintToString(unsigned int value, char*& current) { @@ -44,6 +90,14 @@ uintToString(unsigned int value, char*& current) } while (value != 0); } +/** Serialize a signed integer to its decimal string representation. + * + * Uses a stack-allocated 32-byte buffer and `uintToString` to avoid heap + * allocation. A leading '-' is prepended for negative values. + * + * @param value The signed integer to convert. + * @return Decimal string representation of @a value. + */ std::string valueToString(Int value) { @@ -63,6 +117,14 @@ valueToString(Int value) return current; } +/** Serialize an unsigned integer to its decimal string representation. + * + * Uses a stack-allocated 32-byte buffer and `uintToString` to avoid heap + * allocation. + * + * @param value The unsigned integer to convert. + * @return Decimal string representation of @a value. + */ std::string valueToString(UInt value) { @@ -73,15 +135,20 @@ valueToString(UInt value) return current; } +/** Serialize a double to its JSON string representation at full precision. + * + * Formats with `%.16g` to preserve 16 significant digits. The `%g` + * specifier is used rather than `%#g` because JSON does not distinguish + * reals from integers in its grammar, so a mandatory trailing decimal + * point is unnecessary. + * + * @param value The double to convert. + * @return String containing the formatted number. + */ std::string valueToString(double value) { - // Allocate a buffer that is more than large enough to store the 16 digits - // of precision requested below. char buffer[32]; - // Print into the buffer. We need not request the alternative representation - // that always has a decimal point because JSON doesn't distinguish the - // concepts of reals and integers. #if defined(_MSC_VER) && defined(__STDC_SECURE_LIB__) // Use secure version with visual studio 2005 // to avoid warning. sprintf_s(buffer, sizeof(buffer), "%.16g", value); @@ -91,25 +158,46 @@ valueToString(double value) return buffer; } +/** Serialize a boolean to its JSON literal representation. + * + * @param value The boolean to convert. + * @return `"true"` or `"false"`. + */ std::string valueToString(bool value) { return value ? "true" : "false"; } +/** Produce a properly JSON-escaped, double-quote-delimited string. + * + * Fast path: if the input contains none of the named special characters + * (`"`, `\`, `\b`, `\f`, `\n`, `\r`, `\t`) and no control characters + * (U+0001–U+001F), the string is wrapped in quotes and returned directly. + * + * Slow path: each character is inspected and emitted as the appropriate + * two-character JSON escape sequence. Control characters outside the + * named set are emitted as `\uXXXX`. The result buffer is pre-reserved + * to `2 * strlen + 3` bytes to avoid repeated reallocation. + * + * @note Forward slashes are intentionally *not* escaped. They are legal + * unescaped in JSON. Escaping them (`\/`) would help avoid the + * JavaScript `= rightMargin_` (three characters per + * element saturates the line), or if any element is a non-empty object + * or array, the array is immediately deemed multi-line. + * + * 2. **Dry-run check** — sets `addChildValues_` so that `pushValue` diverts + * rendered strings into `childValues_` instead of `document_`, then + * calls `writeValue` on each element to measure actual widths. If the + * total computed line length reaches `rightMargin_`, the array becomes + * multi-line. The populated `childValues_` is reused by + * `writeArrayValue` during the real render pass, avoiding a second + * serialization of each element. + * + * @note Each element of a borderline array may be serialized twice: once + * during measurement and once during final output. This is negligible + * for the short arrays common in ledger data. + * + * @param value An array-typed `Value` node. + * @return True if the array should be rendered with one element per line. + */ bool StyledWriter::isMultilineArray(Value const& value) { @@ -430,6 +575,15 @@ StyledWriter::isMultilineArray(Value const& value) return isMultiLine; } +/** Append @a value to the output, or capture it for measurement. + * + * When `addChildValues_` is set (during the `isMultilineArray` dry run), + * the string is pushed onto `childValues_` instead of being written to + * `document_`. This lets the measurement pass share the same write path + * as the final render pass. + * + * @param value The serialized scalar or composite string to emit. + */ void StyledWriter::pushValue(std::string const& value) { @@ -443,6 +597,12 @@ StyledWriter::pushValue(std::string const& value) } } +/** Emit a newline and the current indent string, avoiding double-indentation. + * + * If the last character in `document_` is already a space, the call is a + * no-op (the line is considered already indented). If the last character + * is not a newline, one is appended before the indent string. + */ void StyledWriter::writeIndent() { @@ -460,6 +620,10 @@ StyledWriter::writeIndent() document_ += indentString_; } +/** Emit a newline+indent then append @a value to `document_`. + * + * @param value The string to write after the indent. + */ void StyledWriter::writeWithIndent(std::string const& value) { @@ -467,12 +631,14 @@ StyledWriter::writeWithIndent(std::string const& value) document_ += value; } +/** Increase the current indentation level by `indentSize_` spaces. */ void StyledWriter::indent() { indentString_ += std::string(indentSize_, ' '); } +/** Decrease the current indentation level by `indentSize_` spaces. */ void StyledWriter::unindent() { @@ -482,14 +648,31 @@ StyledWriter::unindent() indentString_.resize(indentString_.size() - indentSize_); } -// Class StyledStreamWriter -// ////////////////////////////////////////////////////////////////// +// --- StyledStreamWriter --- +/** Construct a `StyledStreamWriter` with the given per-level indentation unit. + * + * Unlike `StyledWriter`, which hard-codes 3-space indentation, the stream + * variant accepts any string so callers can choose tabs, 2-space, or + * 4-space indent without subclassing. + * + * @param indentation String appended once per nesting level. Defaults to `"\t"`. + */ StyledStreamWriter::StyledStreamWriter(std::string indentation) : indentation_(std::move(indentation)) { } +/** Write the styled JSON representation of @a root directly to @a out. + * + * Resets internal state, writes the full value tree, emits a trailing + * newline, then sets `document_` to `nullptr` as a defensive measure + * against use-after-write if the caller holds a reference to this writer + * and accidentally invokes methods outside a `write()` session. + * + * @param out The output stream to write to. + * @param root The value tree to serialize. + */ void StyledStreamWriter::write(std::ostream& out, Value const& root) { @@ -501,6 +684,14 @@ StyledStreamWriter::write(std::ostream& out, Value const& root) document_ = nullptr; // Forget the stream, for safety. } +/** Recursively write the styled JSON representation of @a value to the stream. + * + * Mirrors `StyledWriter::writeValue` but emits directly to `*document_` + * rather than accumulating into a string. Arrays delegate to + * `writeArrayValue`; objects always expand to one member per indented line. + * + * @param value The node to serialize. + */ void StyledStreamWriter::writeValue(Value const& value) { @@ -569,6 +760,15 @@ StyledStreamWriter::writeValue(Value const& value) } } +/** Write an array value to the stream, choosing single-line or multi-line layout. + * + * Mirrors `StyledWriter::writeArrayValue`. An empty array emits `[]`. + * Non-empty arrays consult `isMultilineArray` and either expand one + * element per line or emit `[ e1, e2, ... ]` using the pre-rendered + * strings in `childValues_`. + * + * @param value An array-typed `Value` node. + */ void StyledStreamWriter::writeArrayValue(Value const& value) { @@ -632,6 +832,15 @@ StyledStreamWriter::writeArrayValue(Value const& value) } } +/** Determine whether @a value should be rendered as a multi-line array. + * + * Identical heuristic to `StyledWriter::isMultilineArray`: quick size + * check first, then a dry-run pass using `addChildValues_` to capture + * rendered element widths in `childValues_` for reuse during final output. + * + * @param value An array-typed `Value` node. + * @return True if the array should be rendered with one element per line. + */ bool StyledStreamWriter::isMultilineArray(Value const& value) { @@ -665,6 +874,14 @@ StyledStreamWriter::isMultilineArray(Value const& value) return isMultiLine; } +/** Write @a value to the stream, or capture it for measurement. + * + * When `addChildValues_` is set (during the `isMultilineArray` dry run), + * the string is pushed onto `childValues_`. Otherwise it is written + * directly to `*document_`. + * + * @param value The serialized scalar or composite string to emit. + */ void StyledStreamWriter::pushValue(std::string const& value) { @@ -678,24 +895,24 @@ StyledStreamWriter::pushValue(std::string const& value) } } +/** Emit `'\n'` followed by the current indent string to the stream. + * + * Unlike `StyledWriter::writeIndent`, this implementation unconditionally + * emits the newline without checking whether the last character was + * already a space or newline. This simplification is acceptable because + * the stream variant is used for final human-readable output rather than + * intermediate string composition. + */ void StyledStreamWriter::writeIndent() { - /* - Some comments in this method would have been nice. ;-) - - if ( !document_.empty() ) - { - char last = document_[document_.length()-1]; - if ( last == ' ' ) // already indented - return; - if ( last != '\n' ) // Comments may add new-line - *document_ << '\n'; - } - */ *document_ << '\n' << indentString_; } +/** Emit a newline+indent then write @a value to the stream. + * + * @param value The string to write after the indent. + */ void StyledStreamWriter::writeWithIndent(std::string const& value) { @@ -703,12 +920,14 @@ StyledStreamWriter::writeWithIndent(std::string const& value) *document_ << value; } +/** Increase the current indentation level by one `indentation_` unit. */ void StyledStreamWriter::indent() { indentString_ += indentation_; } +/** Decrease the current indentation level by one `indentation_` unit. */ void StyledStreamWriter::unindent() { @@ -718,6 +937,16 @@ StyledStreamWriter::unindent() indentString_.resize(indentString_.size() - indentation_.size()); } +/** Stream @a root as styled, human-readable JSON using `StyledStreamWriter`. + * + * This is the format produced when a `json::Value` appears in log output + * or anywhere an undecorated stream insertion operator is used. For + * compact single-line output, use `json::Compact{std::move(jv)}` instead. + * + * @param sout The output stream. + * @param root The value tree to serialize. + * @return @a sout, to allow chaining. + */ std::ostream& operator<<(std::ostream& sout, Value const& root) { diff --git a/src/libxrpl/json/to_string.cpp b/src/libxrpl/json/to_string.cpp index b51c8f1eb6..a951303dd0 100644 --- a/src/libxrpl/json/to_string.cpp +++ b/src/libxrpl/json/to_string.cpp @@ -1,3 +1,14 @@ +/** @file + * String-returning façade over the JSON writer hierarchy. + * + * Provides `to_string` (compact, single-line output via `FastWriter`) and + * `pretty` (indented, human-readable output via `StyledWriter`). Both + * functions construct their writer on the stack, making each call + * self-contained and thread-safe with no shared mutable state. + * + * @see json::FastWriter, json::StyledWriter + */ + #include #include @@ -6,12 +17,37 @@ namespace json { +/** Serialize a JSON value to a compact, single-line string. + * + * Delegates to `FastWriter`, which emits the entire document on one line + * with no extraneous whitespace. Suitable for network RPC responses and + * log output where bandwidth and parse overhead matter. + * + * @param value The JSON value to serialize. + * @return A compact JSON string with no newlines or extra whitespace. + * @note No validation is performed; all type-dispatch and escaping are + * handled inside `FastWriter::writeValue`. + * @see json::pretty + */ std::string to_string(Value const& value) { return FastWriter().write(value); } +/** Serialize a JSON value to an indented, human-readable string. + * + * Delegates to `StyledWriter`, which applies 3-space indentation per + * nesting level and a 74-character right-margin heuristic to decide + * when arrays should break across lines. Intended for diagnostic output + * and human inspection. + * + * @param value The JSON value to serialize. + * @return A formatted JSON string with indentation and line breaks. + * @note No validation is performed; all type-dispatch and escaping are + * handled inside `StyledWriter::writeValue`. + * @see json::to_string + */ std::string pretty(Value const& value) { diff --git a/src/libxrpl/ledger/AcceptedLedgerTx.cpp b/src/libxrpl/ledger/AcceptedLedgerTx.cpp index 2275c3552f..e77994a76a 100644 --- a/src/libxrpl/ledger/AcceptedLedgerTx.cpp +++ b/src/libxrpl/ledger/AcceptedLedgerTx.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements AcceptedLedgerTx: the post-acceptance packaging wrapper that + * assembles a closed-ledger transaction into the envelope used by WebSocket + * subscription delivery and the relational transaction database backend. + * + * All serialization — JSON payload, raw metadata blob, affected-account set — + * is performed once at construction time so downstream consumers share an + * immutable, thread-safe object. + */ #include #include @@ -55,7 +64,11 @@ AcceptedLedgerTx::AcceptedLedgerTx( auto const& account = txn_->getAccountID(sfAccount); auto const amount = txn_->getFieldAmount(sfTakerGets); - // If the offer create is not self funded then add the owner balance + // Self-issued offers are excluded: an issuer's balance of its own + // currency is effectively unbounded, making owner_funds meaningless. + // FreezeHandling::IgnoreFreeze and AuthHandling::IgnoreAuth are + // intentional — owner_funds is a raw economic snapshot for order-book + // subscribers, not an effective-spendable-under-compliance query. if (account != amount.getIssuer()) { auto const ownerFunds = accountFunds( diff --git a/src/libxrpl/ledger/ApplyStateTable.cpp b/src/libxrpl/ledger/ApplyStateTable.cpp index 608648de12..c0af33c06f 100644 --- a/src/libxrpl/ledger/ApplyStateTable.cpp +++ b/src/libxrpl/ledger/ApplyStateTable.cpp @@ -1,3 +1,18 @@ +/** @file + * Implementation of `ApplyStateTable`, the per-transaction write-staging + * buffer used by all `ApplyView`/`ApplyViewImpl` instances. + * + * Algorithm notes relevant to this file but not visible from the header: + * - The full `apply(OpenView&...)` overload drives `TxMeta` construction. + * Metadata field selection is controlled entirely by `SField` metadata + * flags (`kSMD_CHANGE_ORIG`, `kSMD_ALWAYS`, `kSMD_DELETE_FINAL`, + * `kSMD_CHANGE_NEW`, `kSMD_CREATE`) baked into the XRPL protocol schema. + * - Threading-only modifications accumulate in a local `Mods` scratch map + * and are flushed via `rawReplace` after the main loop, keeping them + * separate from the transaction's own write-intent entries. + * - `succ()` merges two sorted key spaces (base ledger + `items_`) in + * O(log n) to present a consistent ordered view to directory walkers. + */ #include #include @@ -118,7 +133,6 @@ ApplyStateTable::apply( bool isDryRun, beast::Journal j) { - // Build metadata and insert auto const sTx = std::make_shared(); tx.add(*sTx); std::shared_ptr sMeta; @@ -167,8 +181,6 @@ ApplyStateTable::apply( STObject prevs(sfPreviousFields); for (auto const& obj : *origNode) { - // go through the original node for - // modified fields saved on modification if (obj.getFName().shouldMeta(SField::kSMD_CHANGE_ORIG) && !curNode->hasMatchingEntry(obj)) prevs.emplaceBack(obj); @@ -180,7 +192,6 @@ ApplyStateTable::apply( STObject finals(sfFinalFields); for (auto const& obj : *curNode) { - // go through the final node for final fields if (obj.getFName().shouldMeta(SField::kSMD_ALWAYS | SField::kSMD_DELETE_FINAL)) finals.emplaceBack(obj); } @@ -196,15 +207,11 @@ ApplyStateTable::apply( "modification"); if (curNode->isThreadedType(to.rules())) - { // thread transaction to node - // item modified threadItem(meta, curNode); - } STObject prevs(sfPreviousFields); for (auto const& obj : *origNode) { - // search the original node for values saved on modify if (obj.getFName().shouldMeta(SField::kSMD_CHANGE_ORIG) && !curNode->hasMatchingEntry(obj)) prevs.emplaceBack(obj); @@ -216,7 +223,6 @@ ApplyStateTable::apply( STObject finals(sfFinalFields); for (auto const& obj : *curNode) { - // search the final node for values saved always if (obj.getFName().shouldMeta(SField::kSMD_ALWAYS | SField::kSMD_CHANGE_NEW)) finals.emplaceBack(obj); } @@ -224,7 +230,7 @@ ApplyStateTable::apply( if (!finals.empty()) meta.getAffectedNode(item.first).emplaceBack(std::move(finals)); } - else if (type == &sfCreatedNode) // if created, thread to owner(s) + else if (type == &sfCreatedNode) { XRPL_ASSERT( curNode && !origNode, @@ -232,13 +238,12 @@ ApplyStateTable::apply( "creation"); threadOwners(to, meta, curNode, newMod, j); - if (curNode->isThreadedType(to.rules())) // always thread to self + if (curNode->isThreadedType(to.rules())) threadItem(meta, curNode); STObject news(sfNewFields); for (auto const& obj : *curNode) { - // save non-default values if (!obj.isDefault() && obj.getFName().shouldMeta(SField::kSMD_CREATE | SField::kSMD_ALWAYS)) news.emplaceBack(obj); @@ -259,7 +264,6 @@ ApplyStateTable::apply( if (!isDryRun) { - // add any new modified nodes to the modification set for (auto const& mod : newMod) to.rawReplace(mod.second); } @@ -312,8 +316,6 @@ ApplyStateTable::succ( { std::optional next = key; items_t::const_iterator iter; - // Find base successor that is - // not also deleted in our list do { next = base.succ(*next, last); @@ -321,19 +323,15 @@ ApplyStateTable::succ( break; iter = items_.find(*next); } while (iter != items_.end() && iter->second.first == Action::Erase); - // Find non-deleted successor in our list for (iter = items_.upper_bound(key); iter != items_.end(); ++iter) { if (iter->second.first != Action::Erase) { - // Found both, return the lower key if (!next || next > iter->first) next = iter->first; break; } } - // Nothing in our list, return - // what we got from the parent. if (last && next >= last) return std::nullopt; return next; @@ -534,7 +532,6 @@ ApplyStateTable::destroyXRP(XRPAmount const& fee) //------------------------------------------------------------------------------ -// Insert this transaction to the SLE's threading list void ApplyStateTable::threadItem(TxMeta& meta, std::shared_ptr const& sle) { @@ -586,9 +583,6 @@ ApplyStateTable::getForMod(ReadView const& base, key_type const& key, Mods& mods auto const& item = iter->second; if (item.first == Action::Erase) { - // The Destination of an Escrow or a PayChannel may have been - // deleted. In that case the account we're threading to will - // not be found and it is appropriate to return a nullptr. JLOG(j.warn()) << "Trying to thread to deleted node"; return nullptr; } @@ -602,9 +596,6 @@ ApplyStateTable::getForMod(ReadView const& base, key_type const& key, Mods& mods auto c = base.read(keylet::unchecked(key)); if (!c) { - // The Destination of an Escrow or a PayChannel may have been - // deleted. In that case the account we're threading to will - // not be found and it is appropriate to return a nullptr. JLOG(j.warn()) << "ApplyStateTable::getForMod: key not found"; return nullptr; } @@ -624,13 +615,9 @@ ApplyStateTable::threadTx( auto const sle = getForMod(base, keylet::account(to).key, mods, j); if (!sle) { - // The Destination of an Escrow or PayChannel may have been deleted. - // In that case the account we are threading to will not be found. - // So this logging is just a warning. JLOG(j.warn()) << "Threading to non-existent account: " << toBase58(to); return; } - // threadItem only applied to AccountRoot XRPL_ASSERT( sle->isThreadedType(base.rules()), "xrpl::ApplyStateTable::threadTx : SLE is threaded"); threadItem(meta, sle); @@ -648,7 +635,6 @@ ApplyStateTable::threadOwners( switch (ledgerType) { case ltACCOUNT_ROOT: { - // Nothing to do break; } case ltRIPPLE_STATE: { @@ -657,11 +643,9 @@ ApplyStateTable::threadOwners( break; } default: { - // If sfAccount is present, thread to that account if (auto const optSleAcct{(*sle)[~sfAccount]}) threadTx(base, meta, *optSleAcct, mods, j); - // If sfDestination is present, thread to that account if (auto const optSleDest{(*sle)[~sfDestination]}) threadTx(base, meta, *optSleDest, mods, j); } diff --git a/src/libxrpl/ledger/ApplyView.cpp b/src/libxrpl/ledger/ApplyView.cpp index 3343748a75..c0b090a11f 100644 --- a/src/libxrpl/ledger/ApplyView.cpp +++ b/src/libxrpl/ledger/ApplyView.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements the XRPL ledger directory data structure. + * + * A directory is a circular doubly-linked list of `ltDIR_NODE` pages used to + * associate sets of ledger object keys with an index. Two flavors exist: + * owner directories (all objects owned by one account) and book directories + * (all open offers at one price point). This file provides `ApplyView`'s + * `dirAdd`, `dirRemove`, `emptyDirDelete`, and `dirDelete` implementations, + * plus the low-level helpers in `namespace xrpl::directory`. + */ #include #include @@ -26,6 +36,21 @@ namespace xrpl { namespace directory { +/** Bootstrap a brand-new single-entry directory. + * + * Allocates the root `ltDIR_NODE` SLE at `directory`, stamps it with + * `sfRootIndex`, invokes `describe` so callers can inject type-specific + * fields (e.g. `sfOwner` for owner directories, `sfTakerPays`/`sfTakerGets` + * for book directories), pushes `key` as the sole entry, and inserts the SLE + * into `view`. + * + * @param view The mutable ledger view to insert into. + * @param directory Keylet of the root page (page 0) to create. + * @param key The first entry to store in the new directory. + * @param describe Callback that stamps type-specific fields onto every new + * page SLE; called once here for the root page. + * @return Always returns 0, the page number of the root page. + */ std::uint64_t createRoot( ApplyView& view, @@ -45,6 +70,20 @@ createRoot( return std::uint64_t{0}; } +/** Locate the last page of a directory in O(1) time. + * + * The root's `sfIndexPrevious` always points to the tail page, so no + * traversal is needed regardless of chain length. If `sfIndexPrevious` is + * non-zero but the referenced page cannot be found, the directory is + * structurally corrupt and `LogicError` terminates the process. + * + * @param view The mutable ledger view to read from. + * @param directory Keylet of the directory root (used to build page keylets). + * @param start The root page SLE (page 0). + * @return A tuple of `(pageNumber, pageSLE, sfIndexes)` for the last page. + * @throw std::logic_error if `sfIndexPrevious` is non-zero but the page is + * absent — indicates corrupted ledger state, not a recoverable error. + */ auto findPreviousPage(ApplyView& view, Keylet const& directory, SLE::ref start) { @@ -66,6 +105,26 @@ findPreviousPage(ApplyView& view, Keylet const& directory, SLE::ref start) return std::make_tuple(page, node, indexes); } +/** Insert `key` into an existing directory page and write it back. + * + * Two insertion strategies are controlled by `preserveOrder`: + * - **`true`** (book directories / `dirAppend`): appends at the tail, + * maintaining insertion order. Duplicates call `LogicError`. + * - **`false`** (owner directories / `dirInsert`): sorts the page first + * (a legacy concession — older code may not have maintained sort order), + * then binary-searches for the insertion point. Duplicates call + * `LogicError`. + * + * @param view The mutable ledger view. + * @param node The page SLE to modify (obtained via `peek`). + * @param page The page number; returned unchanged. + * @param preserveOrder If `true`, append; if `false`, sort then insert. + * @param indexes The current `sfIndexes` vector (modified in place). + * @param key The key to insert. + * @return The page number `page` (unchanged). + * @throw std::logic_error if `key` is already present — double-insertion is + * a programming error, not a protocol error. + */ std::uint64_t insertKey( ApplyView& view, @@ -101,6 +160,36 @@ insertKey( return page; } +/** Append a new trailing page to a full directory and insert `key` into it. + * + * Increments `page` to produce the new page number, links the new page at + * the tail (updating both the former-last page's `sfIndexNext` and the root's + * `sfIndexPrevious`), then inserts `key` as the sole entry. + * + * Page-counter overflow is detected via deliberate `uint64_t` modulo + * wraparound (defined behavior for unsigned integers per + * [basic.fundamental] ¶2). Two `static_assert` guards confirm at compile + * time that the type is unsigned and that `max + 1 == 0`. When the counter + * wraps to zero, or when the pre-`fixDirectoryLimit` ceiling + * (`kDIR_NODE_MAX_PAGES`) is reached, `std::nullopt` is returned — the + * caller surfaces `tecDIR_FULL` to the transaction. + * + * `nextPage` is always passed as 0 (appends only); the commented-out block + * for setting `sfIndexNext` is reserved for a hypothetical future + * insert-in-middle operation. + * + * @param view The mutable ledger view. + * @param page The current last page number; incremented to get the new + * page number. + * @param node The current last page SLE (its `sfIndexNext` is updated). + * @param nextPage Reserved; must be 0. + * @param next The root page SLE (its `sfIndexPrevious` is updated). + * @param key The key to store in the new page. + * @param directory Keylet of the directory root. + * @param describe Callback to stamp type-specific fields on the new page. + * @return The new page number on success, or `std::nullopt` if the directory + * has reached its maximum page capacity. + */ std::optional insertPage( ApplyView& view, @@ -161,6 +250,22 @@ insertPage( } // namespace directory +/** Single entry point for all directory insertions. + * + * Dispatches to one of three helpers depending on the current state of the + * directory: + * 1. No root present → `createRoot()` (first insertion ever). + * 2. Last page has room → `insertKey()` (fast path, no new SLE). + * 3. Last page is full → `insertPage()` (allocates a new trailing page). + * + * @param preserveOrder `true` for book directories (append order); `false` + * for owner directories (sorted order). + * @param directory Keylet of the directory root. + * @param key The `uint256` entry to insert. + * @param describe Callback to stamp type-specific fields on any new page. + * @return The page number into which `key` was inserted, or `std::nullopt` + * if the directory has reached its maximum page capacity. + */ std::optional ApplyView::dirAdd( bool preserveOrder, @@ -187,6 +292,27 @@ ApplyView::dirAdd( return directory::insertPage(*this, page, node, 0, root, key, directory, describe); } +/** Delete a directory root that is already empty, handling one legacy edge case. + * + * Validates that `directory` refers to an `ltDIR_NODE` whose `sfRootIndex` + * matches its own key (i.e. it really is a root, not an interior page). + * Returns `false` without erasing if the root's `sfIndexes` is non-empty. + * + * **Legacy cleanup:** older code occasionally left the last page empty rather + * than deleting it. If the root's `sfIndexNext` and `sfIndexPrevious` both + * point to the same non-root page, and that page is empty, this function + * unlinks and erases it before proceeding to erase the root. + * + * Structural invariants are asserted with `LogicError` (not recoverable): + * a forward link without a matching reverse link, or vice versa, indicates + * corrupted ledger state. + * + * @param directory Keylet of the root page (page 0) of the directory. + * @return `true` if the directory was found and successfully erased; + * `false` if not found or still contains entries. + * @note Only call this with the root keylet. Passing an interior page + * keylet triggers `UNREACHABLE` and returns `false`. + */ bool ApplyView::emptyDirDelete(Keylet const& directory) { @@ -252,6 +378,36 @@ ApplyView::emptyDirDelete(Keylet const& directory) return true; } +/** Remove one key from a directory, cleaning up empty pages as needed. + * + * Peeks the page identified by `page`, finds `key` in `sfIndexes`, erases it + * (preserving the relative order of remaining keys), and writes the page + * back. If the page is still non-empty, returns immediately. + * + * **Empty-page cleanup** follows two distinct paths: + * - **Root page (page 0):** never deleted when `keepRoot` is `true`; erased + * only when `keepRoot` is `false` and no other pages remain. A legacy + * empty trailing page (`sfIndexNext == sfIndexPrevious != 0`) is also + * reaped here. + * - **Non-root page:** unlinked from both its predecessor and successor, + * then erased. An additional check reaps the new last page if it happens + * to be empty (another legacy artifact). If the chain has now collapsed + * to only an empty root and `keepRoot` is `false`, the root is erased too. + * + * Any structural inconsistency (missing neighbor page, self-referential link + * on a non-root node, asymmetric forward/reverse links) calls `LogicError` + * and terminates — these paths are `LCOV_EXCL_*`-marked because they are + * only reachable via corrupted ledger state. + * + * @param directory Keylet of the directory root. + * @param page Page number where `key` resides (stored by the caller + * alongside the owning ledger entry at insertion time). + * @param key The `uint256` entry to remove. + * @param keepRoot If `true`, retain the root page even when it becomes + * empty (used while the owning account still exists). + * @return `true` if `key` was found and removed; `false` if the page or key + * was not found. + */ bool ApplyView::dirRemove(Keylet const& directory, std::uint64_t page, uint256 const& key, bool keepRoot) { @@ -392,6 +548,19 @@ ApplyView::dirRemove(Keylet const& directory, std::uint64_t page, uint256 const& return true; } +/** Bulk-destroy a directory, invoking `callback` for every stored key. + * + * Walks the page chain via `sfIndexNext` starting at page 0, calling + * `callback` for each `uint256` entry on every page, then erasing each page. + * Used when an entire directory must be torn down — for example, during + * account deletion. + * + * @param directory Keylet of the directory root. + * @param callback Invoked once per stored key before its page is erased; + * allows callers to perform per-object cleanup (e.g. releasing reserves). + * @return `true` if the root page was found and all pages were erased; + * `false` if the root page was not present. + */ bool ApplyView::dirDelete(Keylet const& directory, std::function const& callback) { diff --git a/src/libxrpl/ledger/ApplyViewBase.cpp b/src/libxrpl/ledger/ApplyViewBase.cpp index e5a8e11b4c..b9933bb92e 100644 --- a/src/libxrpl/ledger/ApplyViewBase.cpp +++ b/src/libxrpl/ledger/ApplyViewBase.cpp @@ -1,3 +1,25 @@ +/** @file + * Implements `ApplyViewBase`, the shared concrete base for all mutable + * ledger views that buffer per-transaction state changes. + * + * The file contains only thin delegation: every `ReadView` query that does + * not need awareness of pending writes forwards directly to `base_`, while + * every change-aware read and every mutation routes through `items_` (an + * `ApplyStateTable`). + * + * Two design choices here are non-obvious: + * - `slesBegin`, `slesEnd`, and `slesUpperBound` bypass `items_` and forward + * to `base_` directly. The apply phase never needs to iterate over its own + * pending inserts; bypassing the buffer keeps SLE iteration consistent with + * the base snapshot and avoids materialising the full merged key space. + * - `rawInsert` and `rawErase` use *different* `ApplyStateTable` entry points. + * `rawInsert` calls `items_.insert()` — the same validated path as the + * high-level `insert()`. `rawErase` calls `items_.rawErase()`, which + * bypasses the pointer-identity ownership check enforced by `items_.erase()`. + * This asymmetry exists because `RawView` callers (e.g., `Sandbox::apply`) + * flush changes from another view's table and cannot satisfy the + * same-pointer ownership invariant. + */ #include #include diff --git a/src/libxrpl/ledger/ApplyViewImpl.cpp b/src/libxrpl/ledger/ApplyViewImpl.cpp index 9650190a3e..6b2a25d8d2 100644 --- a/src/libxrpl/ledger/ApplyViewImpl.cpp +++ b/src/libxrpl/ledger/ApplyViewImpl.cpp @@ -1,3 +1,19 @@ +/** @file + * Implements `ApplyViewImpl`, the concrete per-transaction ledger-mutation + * view handed to every `Transactor` during the apply phase. + * + * This file is intentionally minimal. All buffering logic lives in + * `ApplyStateTable` (see `detail/ApplyStateTable.h`); `ApplyViewImpl` + * contributes only the `deliver_` field (payment-delivery annotation for + * `TxMeta`) and the `apply()` commit boundary, which drains the buffer into + * a live `OpenView` and returns the generated `TxMeta`. After `apply()` + * returns the object must be destroyed — the buffer is drained and reuse + * would double-apply mutations or corrupt metadata. + * + * @see ApplyViewImpl + * @see detail::ApplyStateTable + * @see detail::ApplyViewBase + */ #include #include diff --git a/src/libxrpl/ledger/BookDirs.cpp b/src/libxrpl/ledger/BookDirs.cpp index fbe876669e..65a784196a 100644 --- a/src/libxrpl/ledger/BookDirs.cpp +++ b/src/libxrpl/ledger/BookDirs.cpp @@ -1,3 +1,17 @@ +/** @file + * Implements `BookDirs` and its `const_iterator`, which expose all offers in + * one XRPL order-book direction as a flat forward-iterable range. + * + * The underlying ledger stores offers in a two-level, quality-keyed directory + * structure. `BookDirs` hides that structure: the constructor eagerly locates + * the first quality directory via `ReadView::succ`, and `operator++` crosses + * quality boundaries transparently using `cdirNext` / `succ` / `cdirFirst`. + * + * End-sentinel encoding: both `begin()` and `end()` start from the same + * `key_` anchor; the end position is distinguished by `entry_ == 0`, + * `cur_key_ == key_`, and `index_ == beast::zero` — the state that + * `operator++` restores when iteration is exhausted. + */ #include #include @@ -11,6 +25,21 @@ namespace xrpl { +/** Locate and prime the first offer in the book. + * + * `root_` is the quality-zero key for this book; `next_quality_` is the + * exclusive upper bound of the book's key-space. `succ` scans the SHAMap + * for the smallest key in `(root_, next_quality_)`, which is the first + * quality directory that holds at least one offer. If the book is empty, + * `succ` returns nothing and `key_` is `beast::kZERO`, which propagates as + * the "empty book" sentinel so that `begin() == end()`. + * + * When a quality directory is found, `cdirFirst` advances `sle_`, `entry_`, + * and `index_` to the first offer in that directory. A well-formed ledger + * never has an empty quality directory (offers are removed when consumed), + * so the `cdirFirst` failure path is marked `UNREACHABLE` and excluded from + * coverage. + */ BookDirs::BookDirs(ReadView const& view, Book const& book) : view_(&view) , root_(keylet::page(getBookBase(book)).key) @@ -29,6 +58,15 @@ BookDirs::BookDirs(ReadView const& view, Book const& book) } } +/** Copy the pre-seeded state into the returned iterator. + * + * The constructor has already called `succ` and `cdirFirst`, so this + * method simply transfers `next_quality_`, `sle_`, `entry_`, and `index_` + * into the new iterator without any additional ledger reads. When the book + * is empty (`key_ == beast::kZERO`) those fields are left at their + * zero-initialised defaults, producing an iterator that immediately compares + * equal to `end()`. + */ auto BookDirs::begin() const -> BookDirs::const_iterator { @@ -43,12 +81,33 @@ BookDirs::begin() const -> BookDirs::const_iterator return it; } +/** Return the end-sentinel iterator. + * + * The private constructor sets `key_ == cur_key_` and leaves `entry_`, + * `sle_`, and `index_` at zero — the identical state that `operator++` + * restores when the book is exhausted. Comparing a freshly exhausted + * begin iterator against this end iterator therefore yields equality. + */ auto BookDirs::end() const -> BookDirs::const_iterator { return BookDirs::const_iterator(*view_, root_, key_); } +/** Compare two iterators by position within the two-level directory. + * + * The null-pointer early-return guards the common sentinel comparison in + * range-for loops: the end iterator constructed by `BookDirs::end()` always + * has a valid view, but a default-constructed `const_iterator` does not. + * Returning `false` rather than asserting keeps sentinel comparisons safe. + * + * The `XRPL_ASSERT` that follows enforces that the two iterators originate + * from the same view and the same book root; cross-book or cross-view + * comparisons are programming errors and fire in debug builds. + * + * Position equality requires `entry_`, `cur_key_`, and `index_` to all + * agree — together they uniquely identify a slot in the two-level directory. + */ bool BookDirs::const_iterator::operator==(BookDirs::const_iterator const& other) const { @@ -62,6 +121,14 @@ BookDirs::const_iterator::operator==(BookDirs::const_iterator const& other) cons return entry_ == other.entry_ && cur_key_ == other.cur_key_ && index_ == other.index_; } +/** Lazily load and cache the current offer SLE. + * + * `index_` is the ledger key of the current offer (set by `cdirFirst` or + * `cdirNext`). The SLE is read from the view on first access and stored in + * `cache_` so that repeated dereferences of the same position are cheap. + * `operator++` clears `cache_` unconditionally, so callers that advance + * without dereferencing pay no read cost for skipped entries. + */ BookDirs::const_iterator::reference BookDirs::const_iterator::operator*() const { @@ -72,6 +139,37 @@ BookDirs::const_iterator::operator*() const return *cache_; } +/** Advance to the next offer, crossing quality directories when necessary. + * + * Navigation has three stages: + * + * 1. **Within the current quality**: `cdirNext` tries to step to the next + * offer in the page chain of `cur_key_`. On success, `entry_` and + * `index_` are updated and we are done. + * + * 2. **Cross to the next quality**: if `cdirNext` returns false *and* + * `index_` is zero (the page chain truly has no more offers), `succ` is + * called with a pre-incremented `cur_key_` to find the next quality + * directory strictly after the current one. + * + * @note The `if (index_ == 0)` guard distinguishes "page chain exhausted" + * from the rare case where `cdirNext` returned false because the page + * contained no `sfIndexes` entries — in that (well-formed-ledger- + * impossible) case `index_` is non-zero from the previous step and + * the `succ` call is intentionally skipped. + * + * 3. **End of book**: if `succ` returns nothing (`cur_key_ == kZERO`), or + * if `index_` was non-zero after `cdirNext` failure (see note above), + * the iterator is reset to the end-sentinel state (`cur_key_ = key_`, + * `entry_ = 0`, `index_ = kZERO`). + * + * When a new quality directory is found, `cdirFirst` positions at its first + * offer. A well-formed ledger never has an empty quality directory, so that + * failure path is `UNREACHABLE` and excluded from coverage. + * + * The dereference cache is cleared unconditionally at the end so that + * `operator*` on the new position always reads fresh from the view. + */ BookDirs::const_iterator& BookDirs::const_iterator::operator++() { @@ -101,6 +199,12 @@ BookDirs::const_iterator::operator++() return *this; } +/** Post-increment: save a copy, advance via `operator++`, return the copy. + * + * The copy preserves `cache_` so the caller can still dereference the + * pre-increment position via the returned value; however, this copies the + * shared_ptr to the cached SLE rather than re-reading the view. + */ BookDirs::const_iterator BookDirs::const_iterator::operator++(int) { diff --git a/src/libxrpl/ledger/BookListeners.cpp b/src/libxrpl/ledger/BookListeners.cpp index d78da4c73e..c683fca83d 100644 --- a/src/libxrpl/ledger/BookListeners.cpp +++ b/src/libxrpl/ledger/BookListeners.cpp @@ -1,3 +1,21 @@ +/** @file + * Implements `BookListeners`: per-book subscriber fan-out for WebSocket + * order-book subscriptions. + * + * Each `BookListeners` instance holds the set of `InfoSub` weak pointers that + * care about a specific currency-pair `Book`. `OrderBookDB` owns all instances + * and calls `publish()` for each affected book when a transaction is accepted. + * + * Three design choices worth noting: + * - **Weak-pointer storage**: subscribers are kept as `InfoSub::wptr` so that + * `BookListeners` never extends a connection's lifetime. + * - **Lazy GC**: dead entries are evicted inside `publish()` when + * `weak_ptr::lock()` fails; no separate sweep is needed. + * - **Cross-book deduplication**: the caller threads a single + * `hash_set` through every `publish()` call for a transaction so + * a client subscribed to multiple affected books receives the message only + * once. + */ #include #include @@ -36,7 +54,6 @@ BookListeners::publish(MultiApiJson const& jvObj, hash_set& haveP if (p) { - // Only publish jvObj if this is the first occurrence if (havePublished.emplace(p->getSeq()).second) { jvObj.visit( diff --git a/src/libxrpl/ledger/CachedView.cpp b/src/libxrpl/ledger/CachedView.cpp index 2dc28d67e0..79d4b3fe91 100644 --- a/src/libxrpl/ledger/CachedView.cpp +++ b/src/libxrpl/ledger/CachedView.cpp @@ -1,3 +1,16 @@ +/** @file + * Implements `detail::CachedViewImpl::read()` and `::exists()` — the two + * methods that differentiate a `CachedView` from its underlying + * `DigestAwareReadView`. + * + * All caching logic is compiled here once so that the public `CachedView` + * template does not generate a separate instantiation of these bodies for every + * concrete `Base` type. + * + * @see CachedView + * @see CachedSLEs + */ + #include #include @@ -22,9 +35,9 @@ CachedViewImpl::exists(Keylet const& k) const std::shared_ptr CachedViewImpl::read(Keylet const& k) const { - static CountedObjects::Counter kHITS{"CachedView::hit"}; - static CountedObjects::Counter kHITSEXPIRED{"CachedView::hitExpired"}; - static CountedObjects::Counter kMISSES{"CachedView::miss"}; + static CountedObjects::Counter kHITS{"CachedView::hit"}; ///< key→digest in map_ AND SLE live in CachedSLEs; no base reads. + static CountedObjects::Counter kHITSEXPIRED{"CachedView::hitExpired"}; ///< key→digest in map_, but SLE evicted from CachedSLEs; base_.read() required. + static CountedObjects::Counter kMISSES{"CachedView::miss"}; ///< key absent from map_; both base_.digest() and base_.read() required. bool cacheHit = false; bool baseRead = false; diff --git a/src/libxrpl/ledger/CanonicalTXSet.cpp b/src/libxrpl/ledger/CanonicalTXSet.cpp index bfa3d811e6..381ce74426 100644 --- a/src/libxrpl/ledger/CanonicalTXSet.cpp +++ b/src/libxrpl/ledger/CanonicalTXSet.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements `CanonicalTXSet`: the deterministically ordered transaction queue + * used to retry deferred transactions between consensus passes. + * + * The ordering guarantee — identical iteration order on every validator for + * a given salt — is the mechanism that lets the network converge to the same + * ledger state when replaying retried transactions. + */ #include #include @@ -12,6 +20,18 @@ namespace xrpl { +/** Three-level composite ordering for `CanonicalTXSet::Key`. + * + * Compares in the order: salted account ID → `SeqProxy` → transaction ID. + * This groups all transactions from the same account contiguously, orders + * them within the account by `SeqProxy` (sequences always sort before tickets, + * regardless of numeric value), and uses the transaction hash as a + * deterministic tiebreaker. + * + * @note The `SeqProxy` ordering enforces the dependency that a + * `TicketCreate` transaction (sequence-based) must appear before any + * transaction that consumes one of its tickets. + */ bool operator<(CanonicalTXSet::Key const& lhs, CanonicalTXSet::Key const& rhs) { @@ -53,17 +73,6 @@ CanonicalTXSet::insert(std::shared_ptr const& txn) std::shared_ptr CanonicalTXSet::popAcctTransaction(std::shared_ptr const& tx) { - // Determining the next viable transaction for an account with Tickets: - // - // 1. Prioritize transactions with Sequences over transactions with - // Tickets. - // - // 2. For transactions not using Tickets, look for consecutive Sequence - // numbers. For transactions using Tickets, don't worry about - // consecutive Sequence numbers. Tickets can process out of order. - // - // 3. After handling all transactions with Sequences, return Tickets - // with the lowest Ticket ID first. std::shared_ptr result; uint256 const effectiveAccount{accountKey(tx->getAccountID(sfAccount))}; diff --git a/src/libxrpl/ledger/Dir.cpp b/src/libxrpl/ledger/Dir.cpp index 8744d0bc67..dd12f38327 100644 --- a/src/libxrpl/ledger/Dir.cpp +++ b/src/libxrpl/ledger/Dir.cpp @@ -15,6 +15,13 @@ namespace xrpl { using const_iterator = Dir::ConstIterator; +/** Construct the range by reading the root `DirectoryNode` SLE. + * + * `indexes_` is set to point directly into the SLE's `sfIndexes` field + * data, which remains valid for the lifetime of `sle_`. If the root page + * does not exist, `sle_` and `indexes_` stay null and `begin()` returns an + * end-sentinel. + */ Dir::Dir(ReadView const& view, Keylet const& key) : view_(&view), root_(key), sle_(view_->read(root_)) { @@ -22,6 +29,15 @@ Dir::Dir(ReadView const& view, Keylet const& key) indexes_ = &sle_->getFieldV256(sfIndexes); } +/** Build an iterator positioned at the first directory entry. + * + * Starts from an end-sentinel-shaped `ConstIterator` and promotes it only + * if the root SLE exists and `sfIndexes` is non-empty. The root SLE's + * `shared_ptr` is copied into the iterator so the SLE stays alive. + * + * An empty `sfIndexes` on a non-null root is valid (the directory has been + * allocated but all entries removed) and still yields `end()`. + */ auto Dir::begin() const -> ConstIterator { @@ -40,12 +56,29 @@ Dir::begin() const -> ConstIterator return it; } +/** Return the past-the-end sentinel. + * + * The sentinel encodes the end state structurally: `page_.key == root_.key` + * and `index_ == beast::zero`. This matches the state that `nextPage()` + * converges to when `sfIndexNext` is 0 on the last directory page. + */ auto Dir::end() const -> ConstIterator { return ConstIterator(*view_, root_, root_); } +/** Compare two iterators for equality. + * + * Two iterators are equal when they share the same `view_` pointer and + * `root_.key`, and their current `page_.key` and `index_` agree. + * The end-sentinel state (`page_.key == root_.key && index_ == beast::zero`) + * compares equal to any other iterator in that state, making + * range-based `for` loops terminate correctly. + * + * @note Comparing iterators from different views or different directories is + * a programming error; `XRPL_ASSERT` fires in debug builds. + */ bool const_iterator::operator==(ConstIterator const& other) const { @@ -58,6 +91,15 @@ const_iterator::operator==(ConstIterator const& other) const return page_.key == other.page_.key && index_ == other.index_; } +/** Lazily dereference to the SLE pointed to by the current entry key. + * + * The SLE is loaded from the view on first access and cached in `cache_`. + * Advancing the iterator clears the cache. This avoids loading SLEs when + * the caller only needs the entry key (via `index()`) rather than the full + * ledger object. + * + * @pre `index_ != beast::zero` — calling on an end-sentinel is undefined. + */ const_iterator::reference const_iterator::operator*() const { @@ -67,6 +109,14 @@ const_iterator::operator*() const return *cache_; } +/** Advance to the next directory entry, crossing page boundaries if needed. + * + * If incrementing `it_` within the current page's `sfIndexes` vector does + * not reach the end, `index_` is updated and the SLE cache is cleared. + * Otherwise `nextPage()` is called to follow the `sfIndexNext` link. + * + * @pre `index_ != beast::zero` — incrementing an end-sentinel is undefined. + */ const_iterator& const_iterator::operator++() { @@ -81,6 +131,11 @@ const_iterator::operator++() return nextPage(); } +/** Post-increment: advance and return a copy of the pre-advance state. + * + * @pre `index_ != beast::zero` — post-incrementing an end-sentinel is + * undefined. + */ const_iterator const_iterator::operator++(int) { @@ -91,6 +146,23 @@ const_iterator::operator++(int) return tmp; } +/** Advance past the current page to the next linked `DirectoryNode`. + * + * Reads `sfIndexNext` from the current page SLE: + * - **Zero** means this is the last page. `page_.key` and `index_` are + * reset to the end-sentinel values (`root_.key` and `beast::zero`). + * - **Non-zero** constructs `keylet::page(root_, next)`, reads the SLE, + * updates `indexes_` and `it_`, and loads the first entry key. An empty + * `sfIndexes` on a continuation page is treated as end-of-directory rather + * than a hard error, matching the empty-root-page handling in `begin()`. + * + * @note A non-zero `sfIndexNext` that does not resolve to an existing SLE is + * a ledger integrity violation; `XRPL_ASSERT` fires in that case. + * + * This method is also exposed publicly (declared in `Dir.h`) to let callers + * skip all remaining entries on the current page and jump directly to the + * start of the next one — useful when only per-page counts are needed. + */ const_iterator& const_iterator::nextPage() { @@ -120,6 +192,12 @@ const_iterator::nextPage() return *this; } +/** Return the number of entries on the current page. + * + * Equivalent to the size of the current page's `sfIndexes` vector. Used + * with `nextPage()` for page-at-a-time traversal that avoids per-entry SLE + * loads when only entry counts are required. + */ std::size_t const_iterator::pageSize() { diff --git a/src/libxrpl/ledger/Ledger.cpp b/src/libxrpl/ledger/Ledger.cpp index 70d49de71e..e56fbc987e 100644 --- a/src/libxrpl/ledger/Ledger.cpp +++ b/src/libxrpl/ledger/Ledger.cpp @@ -1,3 +1,22 @@ +/** @file + * Implements the `Ledger` class — the concrete, cryptographically-committed + * snapshot of XRP Ledger global state at a single sequence number. + * + * Responsibilities of this translation unit: + * - All five `Ledger` constructor variants (genesis, successor, load, + * header-only, database reconstruction). + * - The mutable-→-immutable state machine (`setImmutable`, `setAccepted`). + * - `setup()`: populating in-memory `fees_` and `rules_` from on-ledger SLEs, + * including the old-integer vs. `featureXRPFees` drop-native format migration. + * - Type-erased SHAMap iterator wrappers (`SlesIterImpl`, `TxsIterImpl`) + * implementing `ReadView::detail::ReadViewFwdRange::iter_base`. + * - Low-level raw mutation primitives (`rawInsert`, `rawReplace`, `rawErase`, + * `rawTxInsert`) that directly manipulate `stateMap_` and `txMap_`. + * - Two-tier skip-list maintenance (`updateSkipList`) for O(1) historical + * hash lookup. + * - Negative UNL read/write helpers (`negativeUNL`, `updateNegativeUNL`). + * - Integrity checks (`walkLedger`, `isSensible`). + */ #include #include @@ -50,6 +69,14 @@ CreateGenesisT const kCREATE_GENESIS{}; //------------------------------------------------------------------------------ +/** Type-erased `SlesType::iter_base` implementation backed by a SHAMap iterator. + * + * Wraps a `SHAMap::ConstIterator` and implements the polymorphic + * `ReadViewFwdRange::iter_base` interface so callers can iterate state + * entries via `ReadView::sles` without knowing the underlying storage type. + * Each `dereference()` deserializes the raw SHAMap item into a freshly + * allocated `SLE const`. + */ class Ledger::SlesIterImpl : public SlesType::iter_base { private: @@ -62,16 +89,23 @@ public: SlesIterImpl(SlesIterImpl const&) = default; + /** Construct from an existing SHAMap iterator position. + * + * @param iter Iterator into the ledger's `stateMap_`; may be + * `begin()` or `end()`. + */ SlesIterImpl(SHAMap::ConstIterator iter) : iter_(iter) { } + /** Return a heap-allocated copy of this iterator (value-semantics clone). */ [[nodiscard]] std::unique_ptr copy() const override { return std::make_unique(*this); } + /** Return `true` if `impl` is a `SlesIterImpl` at the same map position. */ [[nodiscard]] bool equal(base_type const& impl) const override { @@ -80,12 +114,14 @@ public: return false; } + /** Advance to the next SHAMap leaf. */ void increment() override { ++iter_; } + /** Deserialize and return the current SHAMap item as an immutable `SLE`. */ [[nodiscard]] SlesType::value_type dereference() const override { @@ -96,9 +132,21 @@ public: //------------------------------------------------------------------------------ +/** Type-erased `TxsType::iter_base` implementation backed by a SHAMap iterator. + * + * Wraps a `SHAMap::ConstIterator` over `txMap_` and implements the polymorphic + * `ReadViewFwdRange::iter_base` interface for `ReadView::txs` traversal. + * + * The `metadata_` flag captures whether the ledger was closed at iterator + * construction time. Closed ledgers store each item as + * `addVL(txBytes) || addVL(metaBytes)`; open ledgers store only `txBytes`. + * `dereference()` dispatches to `deserializeTxPlusMeta` or `deserializeTx` + * accordingly — this dual-path deserialization is a protocol-level invariant. + */ class Ledger::TxsIterImpl : public TxsType::iter_base { private: + /** `true` for closed ledgers (each item encodes tx + metadata). */ bool metadata_; SHAMap::ConstIterator iter_; @@ -109,16 +157,23 @@ public: TxsIterImpl(TxsIterImpl const&) = default; + /** Construct from a metadata flag and a SHAMap iterator position. + * + * @param metadata Pass `!ledger.open()` — `true` for closed ledgers. + * @param iter Iterator into the ledger's `txMap_`. + */ TxsIterImpl(bool metadata, SHAMap::ConstIterator iter) : metadata_(metadata), iter_(iter) { } + /** Return a heap-allocated copy of this iterator (value-semantics clone). */ [[nodiscard]] std::unique_ptr copy() const override { return std::make_unique(*this); } + /** Return `true` if `impl` is a `TxsIterImpl` at the same map position. */ [[nodiscard]] bool equal(base_type const& impl) const override { @@ -127,12 +182,18 @@ public: return false; } + /** Advance to the next transaction SHAMap leaf. */ void increment() override { ++iter_; } + /** Deserialize and return the current transaction, with metadata if closed. + * + * Returns `{STTx, STObject}` for closed ledgers and `{STTx, nullptr}` for + * open ones, reflecting the protocol wire format difference. + */ [[nodiscard]] TxsType::value_type dereference() const override { @@ -145,6 +206,15 @@ public: //------------------------------------------------------------------------------ +/** @see Ledger::Ledger(CreateGenesisT, Rules, Fees const&, + * std::vector const&, Family&) in Ledger.h for full contract. + * + * The master account ID is derived as a `static` local — it is computed + * exactly once per process from the well-known seed `"masterpassphrase"`. + * The fee SLE uses drop-native `sfBaseFeeDrops` fields if `featureXRPFees` + * is in `amendments`, and legacy integer fields otherwise; both paths must + * remain valid for test environments that boot with modern amendments. + */ Ledger::Ledger( CreateGenesisT, Rules rules, @@ -162,6 +232,8 @@ Ledger::Ledger( header_.drops = kINITIAL_XRP; header_.closeTimeResolution = kLEDGER_GENESIS_TIME_RESOLUTION; + // The master account ID is consensus-critical and must be identical on + // every node; the static ensures the key derivation runs only once. static auto const kID = calcAccountID(generateKeyPair(KeyType::Secp256k1, generateSeed("masterpassphrase")).first); { @@ -205,6 +277,15 @@ Ledger::Ledger( setImmutable(); } +/** @see Ledger::Ledger(LedgerHeader const&, bool&, bool, Rules, Fees const&, + * Family&, beast::Journal) in Ledger.h for full contract. + * + * `loaded` starts `true` and is set `false` on any of three failure + * conditions: missing tx root, missing state root, or `setup()` detecting + * a malformed fee SLE. All three are checked independently so the caller + * always gets a complete diagnosis in the journal. When `!loaded && acquire`, + * async acquisition is triggered only after the canonical hash is computed. + */ Ledger::Ledger( LedgerHeader const& info, bool& loaded, @@ -250,7 +331,14 @@ Ledger::Ledger( } } -// Create a new ledger that follows this one +/** @see Ledger::Ledger(Ledger const&, NetClock::time_point) in Ledger.h. + * + * `stateMap_` is copy-on-write cloned from `prevLedger.stateMap_` so that + * modifications to the new ledger do not touch the parent's nodes. + * `txMap_` is freshly empty — no transactions have been applied yet. + * The temporary `header_.hash = prevLedger.hash + 1` is a placeholder that + * is replaced by the real hash when `setImmutable()` is called. + */ Ledger::Ledger(Ledger const& prevLedger, NetClock::time_point closeTime) : immutable_(false) , txMap_(SHAMapType::TRANSACTION, prevLedger.txMap_.family()) @@ -278,6 +366,12 @@ Ledger::Ledger(Ledger const& prevLedger, NetClock::time_point closeTime) } } +/** @see Ledger::Ledger(LedgerHeader const&, Rules, Family&) in Ledger.h. + * + * Immediately computes `header_.hash` so callers can use this ledger as a + * reference object (e.g. in validation pipelines) even though no SHAMap + * nodes are fetched. + */ Ledger::Ledger(LedgerHeader const& info, Rules rules, Family& family) : immutable_(true) , txMap_(SHAMapType::TRANSACTION, info.txHash, family) @@ -289,6 +383,12 @@ Ledger::Ledger(LedgerHeader const& info, Rules rules, Family& family) header_.hash = calculateLedgerHash(header_); } +/** @see Ledger::Ledger(uint32_t, NetClock::time_point, Rules, Fees const&, + * Family&) in Ledger.h for full contract. + * + * `setup()` is called immediately so that any state already loaded into + * the empty maps (e.g. via `addSLE`) takes effect before the ledger is used. + */ Ledger::Ledger( std::uint32_t ledgerSeq, NetClock::time_point closeTime, @@ -308,6 +408,13 @@ Ledger::Ledger( setup(); } +/** @see Ledger::setImmutable(bool) in Ledger.h for full contract. + * + * Hash computation is guarded by `!immutable_ && rehash` because once + * `immutable_` is `true` the SHAMaps are locked and `getHash()` is safe to + * call without the rehash branch. `setup()` is always called last so + * `fees_` and `rules_` reflect the final, committed state map. + */ void Ledger::setImmutable(bool rehash) { @@ -328,6 +435,14 @@ Ledger::setImmutable(bool rehash) setup(); } +/** @see Ledger::setAccepted(NetClock::time_point, NetClock::duration, bool) + * in Ledger.h for full contract. + * + * Sets `kS_LCF_NO_CONSENSUS_TIME` in `closeFlags` when `correctCloseTime` + * is `false`, signalling that the network did not agree on a precise close + * time. Always delegates to `setImmutable()` to finalize hashes and lock + * both SHAMaps. + */ void Ledger::setAccepted( NetClock::time_point closeTime, @@ -352,6 +467,14 @@ Ledger::addSLE(SLE const& sle) //------------------------------------------------------------------------------ +/** Deserialize a transaction-only SHAMap item (open-ledger format). + * + * Open ledgers store the raw transaction bytes directly in the SHAMap item + * with no metadata blob. Each deserialization allocates a new `STTx`. + * + * @param item SHAMap leaf containing only serialized `STTx` bytes. + * @return Shared pointer to the deserialized transaction. + */ std::shared_ptr Ledger::deserializeTx(SHAMapItem const& item) { @@ -359,6 +482,17 @@ Ledger::deserializeTx(SHAMapItem const& item) return std::make_shared(sit); } +/** Deserialize a transaction + metadata SHAMap item (closed-ledger format). + * + * Closed ledgers pack each item as `addVL(txBytes) || addVL(metaBytes)`. + * The two variable-length fields are read sequentially from the same + * `SerialIter`; the outer `sit` advances past the first VL prefix to reach + * the second. + * + * @param item SHAMap leaf containing VL-prefixed tx followed by VL-prefixed + * metadata. + * @return Pair of `(STTx const*, STObject const*)` shared pointers. + */ std::pair, std::shared_ptr> Ledger::deserializeTxPlusMeta(SHAMapItem const& item) { @@ -401,6 +535,14 @@ Ledger::succ(uint256 const& key, std::optional const& last) const return item->key(); } +/** @see Ledger::read(Keylet const&) in Ledger.h for full contract. + * + * The zero-key guard is a programming-error trap: a zero keylet indicates a + * bug in the caller's key computation, so `UNREACHABLE` fires in debug builds + * and `nullptr` is returned in release builds to prevent a corrupt SHAMap + * lookup. The `k.check()` call validates that the deserialized SLE type + * matches the keylet type before handing back the pointer. + */ std::shared_ptr Ledger::read(Keylet const& k) const { @@ -523,6 +665,12 @@ Ledger::rawReplace(std::shared_ptr const& sle) } } +/** @see Ledger::rawTxInsert(uint256 const&, ...) in Ledger.h for full contract. + * + * Encodes the item as `addVL(txBytes) || addVL(metaBytes)` — the closed-ledger + * wire format consumed by `deserializeTxPlusMeta`. The `+16` in the initial + * capacity accounts for the two VL-length prefix bytes plus padding. + */ void Ledger::rawTxInsert( uint256 const& key, @@ -531,7 +679,6 @@ Ledger::rawTxInsert( { XRPL_ASSERT(metaData, "xrpl::Ledger::rawTxInsert : non-null metadata input"); - // low-level - just add to table Serializer s(txn->getDataLength() + metaData->getDataLength() + 16); s.addVL(txn->peekData()); s.addVL(metaData->peekData()); @@ -539,6 +686,20 @@ Ledger::rawTxInsert( logicError("duplicate_tx: " + to_string(key)); } +/** @see Ledger::setup() (private) in Ledger.h for the contract summary. + * + * Fee-format migration logic: the on-ledger `keylet::fees()` SLE may carry + * either old-style integer fields (`sfBaseFee` as `uint64`, `sfReserveBase` + * as `uint32`) or new-style drop-native `STAmount` fields (`sfBaseFeeDrops`, + * etc., gated by `featureXRPFees`). Both sets are probed independently so + * the validation can distinguish "neither present" (OK), "one present" (OK), + * and "both present" (malformed → `ret = false`). A new-format field found + * before `featureXRPFees` is enabled is also treated as malformed. + * + * `SHAMapMissingNode` from either `makeRulesGivenLedger` or the fee read is + * caught and mapped to `ret = false`; other exceptions are re-thrown after + * logging because they indicate a programming or data-integrity error. + */ bool Ledger::setup() { @@ -635,6 +796,13 @@ Ledger::peek(Keylet const& k) const return sle; } +/** @see Ledger::negativeUNL() in Ledger.h for full contract. + * + * Each `sfDisabledValidator` inner object is validated via `publicKeyType` + * before insertion. Entries with an unrecognised key type are silently + * skipped rather than causing an error, preserving forward compatibility + * if a future key type is introduced. + */ hash_set Ledger::negativeUNL() const { @@ -688,6 +856,17 @@ Ledger::validatorToReEnable() const return std::nullopt; } +/** @see Ledger::updateNegativeUNL() in Ledger.h for full contract. + * + * State machine: the function rebuilds `sfDisabledValidators` in `newNUnl` + * by copying every existing entry except the one matching + * `sfValidatorToReEnable`, then appends a new entry for + * `sfValidatorToDisable` if present. The pending-action fields are cleared + * with `makeFieldAbsent` once consumed. If the resulting list is empty, the + * entire SLE is erased rather than leaving an empty-array object on the ledger. + * + * @note Must be called at flag ledgers only, before `UNLModify` transactions. + */ void Ledger::updateNegativeUNL() { @@ -737,6 +916,16 @@ Ledger::updateNegativeUNL() } //------------------------------------------------------------------------------ + +/** @see Ledger::walkLedger(beast::Journal, bool) in Ledger.h for full contract. + * + * The parallel path (`walkMapParallel` with 32 workers) returns early on the + * first missing state-map node; sequential `walkMap` continues collecting up + * to 32 missing nodes before returning. The transaction map is always walked + * sequentially regardless of `parallel`. A zero `stateMap_.getHash()` with a + * non-zero `header_.accountHash` means the root itself is absent; this is + * reported as a single synthetic missing-node entry without walking further. + */ bool Ledger::walkLedger(beast::Journal j, bool parallel) const { @@ -802,8 +991,25 @@ Ledger::isSensible() const return true; } -// update the skip list with the information from our previous ledger -// VFALCO TODO Document this skip list concept +/** @see Ledger::updateSkipList() in Ledger.h for the public contract. + * + * Two-tier skip list implementation: + * + * **Tier 1 — sparse permanent records** (`keylet::skip(prevIndex)`): + * Written only when `(prevIndex & 0xff) == 0`, i.e., every 256 ledgers. + * Stores a growing list of up to 256 ancestor hashes for that aligned + * sequence window. These SLEs are never deleted once created. + * + * **Tier 2 — rolling recent window** (`keylet::skip()`): + * Always updated. Maintains the 256 most recent parent hashes in order. + * When the list is full (`size() == 256`), the oldest entry at `begin()` + * is evicted before appending the new one, keeping the window fixed-size. + * + * Together the two tiers support `hashOfSeq`: O(1) lookup for any ledger + * within the last 256 (via the rolling window) and O(1) lookup at + * 256-aligned sequences deep in history (via the permanent records). + * Non-aligned ledgers older than 256 are not directly reachable. + */ void Ledger::updateSkipList() { @@ -812,7 +1018,7 @@ Ledger::updateSkipList() std::uint32_t const prevIndex = header_.seq - 1; - // update record of every 256th ledger + // --- Tier 1: permanent record for every 256-aligned predecessor --- if ((prevIndex & 0xff) == 0) { auto const k = keylet::skip(prevIndex); @@ -846,7 +1052,7 @@ Ledger::updateSkipList() } } - // update record of past 256 ledger + // --- Tier 2: rolling window of the 256 most recent parent hashes --- auto const k = keylet::skip(); auto sle = peek(k); std::vector hashes; diff --git a/src/libxrpl/ledger/OpenView.cpp b/src/libxrpl/ledger/OpenView.cpp index afe826d78d..2ac5066089 100644 --- a/src/libxrpl/ledger/OpenView.cpp +++ b/src/libxrpl/ledger/OpenView.cpp @@ -1,3 +1,16 @@ +/** @file + * Implements `OpenView`, the mutable in-memory ledger scratchpad used during + * transaction processing. + * + * `OpenView` maintains two independent delta layers over an immutable base + * `ReadView`: a `RawStateTable` for SLE mutations and a PMR `std::map` for + * transaction records. Neither layer touches the base until `apply()` is + * called, making speculative execution and rollback straightforward. + * + * Both maps are backed by a 256 KB `monotonic_buffer_resource`. The resource + * must outlive the maps; the `unique_ptr` member ordering guarantees this via + * reverse-declaration-order destruction. + */ #include #include @@ -24,6 +37,22 @@ namespace xrpl { +/** Polymorphic iterator adapter over `OpenView`'s flat `txs_map`. + * + * Implements the `TxsType::iter_base` interface expected by `ReadView`'s + * range adapters. Two constructors are not exposed — the only entry point + * is via `txsBegin()`/`txsEnd()`, both of which pass `!open()` as the + * `metadata` flag. + * + * When `metadata_` is `false` (open ledger), `dereference()` only + * deserialises the transaction body and leaves the metadata slot null. + * When `metadata_` is `true` (closed-ledger view), both the transaction + * and its `sfMetadata` object are deserialised on demand. + * + * `equal()` uses `dynamic_cast` to guard against cross-type comparison: + * if the operand is not a `TxsIterImpl` the iterators are considered + * unequal, preventing undefined behaviour when mixing iterator types. + */ class OpenView::TxsIterImpl : public TxsType::iter_base { private: diff --git a/src/libxrpl/ledger/PaymentSandbox.cpp b/src/libxrpl/ledger/PaymentSandbox.cpp index a730b247ba..091151eb9c 100644 --- a/src/libxrpl/ledger/PaymentSandbox.cpp +++ b/src/libxrpl/ledger/PaymentSandbox.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements `detail::DeferredCredits` and `PaymentSandbox` — the deferred + * credit accounting layer that makes multi-hop IOU and MPT payments safe. + * + * The core invariant: a credit recorded during one step of a payment must + * not become visible as spendable balance to any subsequent step in the same + * payment. `DeferredCredits` enforces this by intercepting every credit via + * `creditIOU`/`creditMPT` and returning a pre-credit balance whenever a step + * calls `balanceHookIOU`/`balanceHookMPT`. + */ #include #include @@ -22,6 +32,17 @@ namespace xrpl { namespace detail { +/** Build a canonical, direction-independent map key for an IOU trust-line pair. + * + * The lower `AccountID` is always placed first so that the pair (A→B) and + * (B→A) share a single `creditsIOU_` entry, mirroring the ledger's own + * bidirectional `RippleState` storage convention. + * + * @param a1 One endpoint of the trust line. + * @param a2 The other endpoint. + * @param c The currency in question. + * @return A `(min, max, currency)` tuple suitable as a `creditsIOU_` key. + */ auto DeferredCredits::makeKeyIOU(AccountID const& a1, AccountID const& a2, Currency const& c) -> KeyIOU { @@ -33,6 +54,21 @@ DeferredCredits::makeKeyIOU(AccountID const& a1, AccountID const& a2, Currency c return std::make_tuple(a2, a1, c); } +/** Record a deferred IOU credit from `sender` to `receiver`. + * + * On the first call for a given (sender, receiver, currency) triple the + * sender's pre-credit balance snapshot is captured in `lowAcctOrigBalance`. + * Subsequent calls for the same triple accumulate the credit amount but do + * **not** update the snapshot — this is the post-switchover invariant that + * avoids floating-point cancellation when `amount` is large relative to the + * original balance. + * + * @param sender Account delivering the funds. + * @param receiver Account receiving the funds. + * @param amount Positive IOU amount being credited. + * @param preCreditSenderBalance Sender's balance on the trust line before + * this credit is applied; captured only on the first call per pair. + */ void DeferredCredits::creditIOU( AccountID const& sender, @@ -82,6 +118,24 @@ DeferredCredits::creditIOU( } } +/** Record a deferred MPT credit from `sender` to `receiver`. + * + * Maintains per-holder debits and, for issuer→holder transfers, a running + * `credit` total against the issuer's `OutstandingAmount`. The issuer's + * original `OutstandingAmount` (`preCreditBalanceIssuer`) and each holder's + * original balance (`preCreditBalanceHolder`) are captured only on the first + * call for each MPTID / holder pair; later calls accumulate debits without + * overwriting snapshots. + * + * @param sender Account delivering the MPT. + * @param receiver Account receiving the MPT. + * @param amount Positive MPT amount being credited. + * @param preCreditBalanceHolder Receiver's MPToken balance before this + * credit; captured only on first call per holder. + * @param preCreditBalanceIssuer Issuer's `OutstandingAmount` before this + * credit (may temporarily exceed `MaximumAmount` during reverse-path + * execution); captured only on first call per MPTID. + */ void DeferredCredits::creditMPT( AccountID const& sender, @@ -147,6 +201,19 @@ DeferredCredits::creditMPT( } } +/** Record an MPT issuer self-debit arising from a sell offer owned by the issuer. + * + * The payment engine executes paths in reverse (credit before debit). When + * the issuer owns a sell offer, the credit step temporarily inflates + * `OutstandingAmount` beyond `MaximumAmount`. `selfDebit` accumulates the + * total amount the issuer has self-debited so that `balanceHookSelfIssueMPT` + * can cap issuable supply to `origBalance - selfDebit`. + * + * @param issue Identifies the MPT issuance. + * @param amount Positive amount the issuer is self-debiting this step. + * @param origBalance Issuer's `OutstandingAmount` before any path execution; + * captured only on the first call per MPTID. + */ void DeferredCredits::issuerSelfDebitMPT( MPTIssue const& issue, @@ -169,6 +236,18 @@ DeferredCredits::issuerSelfDebitMPT( } } +/** Update the peak owner count recorded for `id`. + * + * Stores `max(cur, next)` and then takes the running maximum with any + * previously recorded value. Using the maximum rather than the final count + * ensures that reserve checks during the payment reflect the highest + * obligation incurred at any point, even if trust lines created mid-payment + * are subsequently deleted. + * + * @param id Account whose owner count is changing. + * @param cur Owner count before the adjustment. + * @param next Owner count after the adjustment. + */ void DeferredCredits::ownerCount(AccountID const& id, std::uint32_t cur, std::uint32_t next) { @@ -181,6 +260,12 @@ DeferredCredits::ownerCount(AccountID const& id, std::uint32_t cur, std::uint32_ } } +/** Return the peak owner count recorded for `id`, if any. + * + * @param id Account to query. + * @return The maximum owner count seen for this account during the payment, + * or `std::nullopt` if no adjustment has been recorded for `id`. + */ std::optional DeferredCredits::ownerCount(AccountID const& id) const { @@ -190,7 +275,19 @@ DeferredCredits::ownerCount(AccountID const& id) const return std::nullopt; } -// Get the adjustments for the balance between main and other. +/** Return the recorded IOU adjustments for the trust line between `main` and `other`. + * + * The returned `AdjustmentIOU` is oriented from `main`'s perspective: + * `debits` is what `main` has sent, `credits` is what `main` has received, + * and `origBalance` is `main`'s balance before any deferred credit was + * recorded. + * + * @param main The account whose perspective the adjustment is expressed in. + * @param other The counterparty on the trust line. + * @param currency The IOU currency. + * @return Adjustments from `main`'s perspective, or `std::nullopt` if no + * credit has been recorded for this triple. + */ auto DeferredCredits::adjustmentsIOU( AccountID const& main, @@ -216,6 +313,13 @@ DeferredCredits::adjustmentsIOU( return result; } +/** Return the recorded MPT adjustments for the given issuance. + * + * @param mptID Identifier of the MPT issuance. + * @return The full `IssuerValueMPT` record (aliased as `AdjustmentMPT`) + * containing per-holder debits and the issuer's credit/self-debit totals, + * or `std::nullopt` if no credit has been recorded for this MPTID. + */ auto DeferredCredits::adjustmentsMPT(xrpl::MPTID const& mptID) const -> std::optional { @@ -225,6 +329,15 @@ DeferredCredits::adjustmentsMPT(xrpl::MPTID const& mptID) const -> std::optional return i->second; } +/** Merge this sandbox's deferred-credit tables into a parent `DeferredCredits`. + * + * Credits and debits are accumulated additively into `to`. Existing + * `origBalance` snapshots in `to` are **never** overwritten — the parent + * recorded the true pre-payment balance first, and that must remain + * authoritative. Owner counts are merged by taking the per-account maximum. + * + * @param to Destination table, typically belonging to a parent `PaymentSandbox`. + */ void DeferredCredits::apply(DeferredCredits& to) { @@ -237,7 +350,6 @@ DeferredCredits::apply(DeferredCredits& to) auto const& fromVal = i.second; toVal.lowAcctDebits += fromVal.lowAcctDebits; toVal.highAcctDebits += fromVal.highAcctDebits; - // Do not update the orig balance, it's already correct } } @@ -261,7 +373,6 @@ DeferredCredits::apply(DeferredCredits& to) toVal.holders[k].debit += v.debit; } } - // Do not update the orig balance, it's already correct } } @@ -279,6 +390,28 @@ DeferredCredits::apply(DeferredCredits& to) } // namespace detail +/** Return `account`'s IOU balance adjusted for deferred credits recorded in + * this sandbox and all ancestor sandboxes. + * + * Uses the post-switchover algorithm: rather than computing `(B+C) - C` + * (which suffers catastrophic cancellation when the credit `C` dwarfs the + * original balance `B`), the first `creditIOU` call for a pair captures `B` + * directly. This function walks the `ps_` chain accumulating the total + * debit `delta` and the earliest-seen original balance `lastBal`, then + * returns `min(amount, lastBal - delta, minBal)` to prevent the adjusted + * amount from ever exceeding any ancestor's pre-credit snapshot. + * + * @param account Account whose available IOU balance is being queried. + * @param issuer Issuer of the IOU currency. + * @param amount Post-credit balance as reported by the underlying ledger + * view (i.e. `B+C`). + * @return Pre-credit available balance, clamped to zero for XRP when the + * arithmetic produces a negative value due to large mid-payment credits. + * + * @note A calculated negative XRP result is not an error: it can arise when + * a large XRP credit is recorded and then debited within the same path. + * The negative value is clamped to zero rather than treated as a fault. + */ STAmount PaymentSandbox::balanceHookIOU( AccountID const& account, @@ -287,17 +420,6 @@ PaymentSandbox::balanceHookIOU( { XRPL_ASSERT(amount.holds(), "balanceHookIOU: amount is for Issue"); - /* - There are two algorithms here. The pre-switchover algorithm takes the - current amount and subtracts the recorded credits. The post-switchover - algorithm remembers the original balance, and subtracts the debits. The - post-switchover algorithm should be more numerically stable. Consider a - large credit with a small initial balance. The pre-switchover algorithm - computes (B+C)-C (where B+C will the amount passed in). The - post-switchover algorithm returns B. When B and C differ by large - magnitudes, (B+C)-C may not equal B. - */ - auto const& currency = amount.get().currency; auto delta = amount.zeroed(); @@ -314,25 +436,31 @@ PaymentSandbox::balanceHookIOU( } } - // The adjusted amount should never be larger than the balance. In - // some circumstances, it is possible for the deferred credits table - // to compute usable balance just slightly above what the ledger - // calculates (but always less than the actual balance). auto adjustedAmt = std::min({amount, lastBal - delta, minBal}); adjustedAmt.get().account = amount.getIssuer(); if (isXRP(issuer) && adjustedAmt < beast::kZERO) { - // A calculated negative XRP balance is not an error case. Consider a - // payment snippet that credits a large XRP amount and then debits the - // same amount. The credit can't be used, but we subtract the debit and - // calculate a negative value. It's not an error case. adjustedAmt.clear(); } return adjustedAmt; } +/** Return `account`'s MPT balance adjusted for deferred credits in this sandbox chain. + * + * Walks the `ps_` ancestor chain accumulating total debits (`delta`) and the + * earliest pre-credit snapshot (`lastBal`), then returns + * `min(amount, lastBal - delta, minBal)`. For holders `delta` is the sum of + * per-holder debits; for the issuer `delta` is the running `credit` total + * against `OutstandingAmount`. + * + * @param account Account being queried (holder or issuer). + * @param issue The MPT issuance. + * @param amount Current raw balance as seen by the underlying ledger view. + * @return Pre-credit available balance as an `STAmount`, or zero if the + * adjusted amount would be non-positive. + */ STAmount PaymentSandbox::balanceHookMPT(AccountID const& account, MPTIssue const& issue, std::int64_t amount) const @@ -364,13 +492,24 @@ PaymentSandbox::balanceHookMPT(AccountID const& account, MPTIssue const& issue, } } - // The adjusted amount should never be larger than the balance. - auto const adjustedAmt = std::min({amount, lastBal - delta, minBal}); return adjustedAmt > 0 ? STAmount{issue, adjustedAmt} : STAmount{issue}; } +/** Return the issuer's available MPT issuance capacity adjusted for self-debits. + * + * When the issuer owns a sell offer, the payment engine credits the buyer + * before debiting the issuer, which can transiently inflate + * `OutstandingAmount` beyond `MaximumAmount`. This hook caps the issuer's + * available issuance to `origBalance - selfDebit`, where `selfDebit` + * accumulates across the sandbox chain via `issuerSelfDebitMPT`. + * + * @param issue The MPT issuance for which the issuer's capacity is queried. + * @param amount Current `OutstandingAmount` as reported by the underlying + * ledger view. + * @return Adjusted available issuance, or zero if `selfDebit >= origBalance`. + */ STAmount PaymentSandbox::balanceHookSelfIssueMPT(xrpl::MPTIssue const& issue, std::int64_t amount) const { @@ -391,6 +530,17 @@ PaymentSandbox::balanceHookSelfIssueMPT(xrpl::MPTIssue const& issue, std::int64_ return STAmount{issue}; } +/** Return the peak owner count for `account` seen across the sandbox chain. + * + * Walks all ancestor sandboxes and returns the maximum of `count` and any + * recorded peak, ensuring reserve checks reflect the highest owner count + * incurred at any point during the payment even if trust lines created + * mid-payment have since been deleted. + * + * @param account Account being queried. + * @param count Current owner count from the underlying ledger view. + * @return Maximum of `count` and the peak recorded across all sandboxes. + */ std::uint32_t PaymentSandbox::ownerCountHook(AccountID const& account, std::uint32_t count) const { @@ -403,6 +553,18 @@ PaymentSandbox::ownerCountHook(AccountID const& account, std::uint32_t count) co return result; } +/** Intercept an IOU credit and record it in the deferred-credit table. + * + * Called by the payment engine whenever an IOU amount flows from `from` to + * `to`. Forwards directly to `tab_.creditIOU` so the credit is deferred + * and invisible to subsequent balance queries within the same payment. + * + * @param from Sending account. + * @param to Receiving account. + * @param amount Positive IOU amount being credited. + * @param preCreditBalance Sender's trust-line balance before this credit; + * used as the original-balance snapshot on first call per pair. + */ void PaymentSandbox::creditHookIOU( AccountID const& from, @@ -415,6 +577,17 @@ PaymentSandbox::creditHookIOU( tab_.creditIOU(from, to, amount, preCreditBalance); } +/** Intercept an MPT credit and record it in the deferred-credit table. + * + * Called by the payment engine whenever an MPT amount flows from `from` to + * `to`. Forwards directly to `tab_.creditMPT`. + * + * @param from Sending account (issuer or holder). + * @param to Receiving account. + * @param amount Positive MPT amount being credited. + * @param preCreditBalanceHolder Receiver's MPToken balance before this credit. + * @param preCreditBalanceIssuer Issuer's `OutstandingAmount` before this credit. + */ void PaymentSandbox::creditHookMPT( AccountID const& from, @@ -428,6 +601,16 @@ PaymentSandbox::creditHookMPT( tab_.creditMPT(from, to, amount, preCreditBalanceHolder, preCreditBalanceIssuer); } +/** Intercept an MPT issuer self-debit and record it in the deferred-credit table. + * + * Called when the issuer executes a sell offer for their own MPT. Forwards + * to `tab_.issuerSelfDebitMPT` so `balanceHookSelfIssueMPT` can later cap + * available issuance correctly. + * + * @param issue Identifies the MPT issuance. + * @param amount Positive amount the issuer is self-debiting. + * @param origBalance Issuer's `OutstandingAmount` before path execution began. + */ void PaymentSandbox::issuerSelfDebitHookMPT( MPTIssue const& issue, @@ -439,6 +622,15 @@ PaymentSandbox::issuerSelfDebitHookMPT( tab_.issuerSelfDebitMPT(issue, amount, origBalance); } +/** Record an owner-count change for `account` in this sandbox's peak table. + * + * Forwards to `tab_.ownerCount(account, cur, next)`, which stores + * `max(cur, next)` and retains the running maximum across calls. + * + * @param account Account whose owner count is changing. + * @param cur Owner count before the adjustment. + * @param next Owner count after the adjustment. + */ void PaymentSandbox::adjustOwnerCountHook( AccountID const& account, @@ -448,6 +640,14 @@ PaymentSandbox::adjustOwnerCountHook( tab_.ownerCount(account, cur, next); } +/** Commit this sandbox's ledger state changes to the underlying raw view. + * + * Terminal form: asserts that `ps_ == nullptr`, confirming this is the + * outermost sandbox with no unresolved parent. The deferred-credit table + * is not forwarded here; only `items_` (ledger object mutations) are flushed. + * + * @param to Destination raw view (typically the `ApplyView` for the tx). + */ void PaymentSandbox::apply(RawView& to) { @@ -455,6 +655,14 @@ PaymentSandbox::apply(RawView& to) items_.apply(to); } +/** Merge this sandbox into a parent `PaymentSandbox`. + * + * Propagates both ledger state changes (`items_`) and the deferred-credit + * tables (`tab_`) into the parent. Asserts that `ps_ == &to`, enforcing + * that only the direct parent may be the merge target. + * + * @param to Parent sandbox into which this sandbox's state is merged. + */ void PaymentSandbox::apply(PaymentSandbox& to) { @@ -463,6 +671,10 @@ PaymentSandbox::apply(PaymentSandbox& to) tab_.apply(to.tab_); } +/** Return the total XRP destroyed (burned as fees) within this sandbox. + * + * @return Drop count forwarded from `items_.dropsDestroyed()`. + */ XRPAmount PaymentSandbox::xrpDestroyed() const { diff --git a/src/libxrpl/ledger/RawStateTable.cpp b/src/libxrpl/ledger/RawStateTable.cpp index 69e2f5c0fe..1a275f2065 100644 --- a/src/libxrpl/ledger/RawStateTable.cpp +++ b/src/libxrpl/ledger/RawStateTable.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements `RawStateTable`, the write-buffer that accumulates SLE + * mutations (erase/insert/replace) and defers flushing them to a backing + * `RawView` until `apply()` is called. + * + * This file also defines the private nested `SlesIterImpl` class, which + * provides a sorted merged iteration over the base ledger's SLE range and + * the pending delta, making pending inserts and erases transparently visible + * to callers who iterate the view as if it were already committed. + */ #include #include @@ -17,6 +27,27 @@ namespace xrpl::detail { +/** Sorted merged iterator over a base ledger's SLE range and the pending + * `RawStateTable` delta. + * + * Implements the `ReadView::SlesType::iter_base` virtual interface so that + * callers iterating `sles` on an `OpenView` see a consistent merged view: + * pending inserts appear in their sorted position, pending erases are hidden, + * and pending replaces shadow the base entry at the same key. + * + * Two parallel cursor pairs are maintained: + * - `iter0_` / `sle0_`: current position in the base view's sorted SLE range. + * - `iter1_` / `sle1_`: current position in `items_` (sorted, `std::map` + * order). `sle1_` is null when `iter1_` points to an `Action::Erase` + * entry that has already been consumed by `skip()`. + * + * On a key tie, the local entry (from `items_`) always shadows the base. + * Both iterators advance simultaneously so the base copy is consumed. + * + * @note `equal()` uses `dynamic_cast` to guard against cross-type comparison. + * Comparing iterators from different `ReadView` instances fires an + * `XRPL_ASSERT` in debug builds. + */ class RawStateTable::SlesIterImpl : public ReadView::SlesType::iter_base { private: @@ -30,6 +61,17 @@ private: public: SlesIterImpl(SlesIterImpl const&) = default; + /** Construct the merged iterator at the given start positions. + * + * Immediately calls `skip()` after initialising `sle1_` so that the + * iterator starts at the first non-erased position, even if the very + * first `items_` entry is an `Action::Erase`. + * + * @param iter1 Start of the `items_` range to merge. + * @param end1 End sentinel for the `items_` range. + * @param iter0 Start of the base view's SLE iterator range. + * @param end0 End sentinel for the base view's SLE iterator range. + */ SlesIterImpl( items_t::const_iterator iter1, items_t::const_iterator end1, @@ -46,12 +88,26 @@ public: } } + /** Return a heap-allocated copy of this iterator (value semantics). + * + * @return A `unique_ptr` to an independent copy of the current position. + */ std::unique_ptr copy() const override { return std::make_unique(*this); } + /** Test iterator equality. + * + * Uses `dynamic_cast` to reject cross-type comparisons safely. + * Both `iter0_` and `iter1_` must agree for the iterators to be equal. + * + * @param impl The other iterator to compare against. + * @return `true` if both iterators point to the same merged position. + * @note Asserts in debug builds that both end sentinels match, which + * would be violated by comparing iterators from different views. + */ bool equal(base_type const& impl) const override { @@ -67,6 +123,16 @@ public: return false; } + /** Advance to the next SLE in merged sorted order. + * + * Determines which of `sle0_` and `sle1_` has the smaller key and + * advances that cursor (or both on a key tie, since the local entry + * shadows the base). Calls `skip()` afterward to consume any + * immediately following `Action::Erase` entries. + * + * @note Asserts that at least one of `sle0_` / `sle1_` is non-null, + * which would be violated by incrementing past end. + */ void increment() override { @@ -103,6 +169,15 @@ public: skip(); } + /** Return the SLE at the current merged position. + * + * Returns the local entry when its key is less than or equal to the + * base entry's key (local always wins on a tie), otherwise the base + * entry. Pending inserts and replaces are returned directly; base + * entries shadowed by an insert or replace are never exposed. + * + * @return The `SLE const` at the current position. + */ value_type dereference() const override { @@ -120,6 +195,7 @@ public: } private: + /** Advance the base iterator and refresh `sle0_`. */ void inc0() { @@ -134,6 +210,7 @@ private: } } + /** Advance the local iterator and refresh `sle1_`. */ void inc1() { @@ -148,6 +225,13 @@ private: } } + /** Consume `Action::Erase` entries that mask the current base entry. + * + * When `iter1_` points to an erase and its key matches `sle0_->key()`, + * both iterators advance in tandem so the deleted SLE is invisible to + * callers. The loop terminates when the keys no longer match, either + * iterator is exhausted, or the local action is no longer `Erase`. + */ void skip() { @@ -164,8 +248,19 @@ private: //------------------------------------------------------------------------------ -// Base invariants are checked by the base during apply() - +/** Flush all buffered mutations to a backing `RawView`. + * + * Replays `dropsDestroyed_` as a single `rawDestroyXRP` call, then + * iterates `items_` dispatching each pending action to the corresponding + * `rawErase`, `rawInsert`, or `rawReplace` method on `to`. + * + * Precondition invariants (e.g., the key must exist for erase/replace, + * must not exist for insert) are enforced by the target `RawView`, not + * here. `RawStateTable` only enforces transition correctness within its + * own pending buffer. + * + * @param to The `RawView` target that will absorb the buffered changes. + */ void RawStateTable::apply(RawView& to) const { @@ -188,6 +283,16 @@ RawStateTable::apply(RawView& to) const } } +/** Test whether an SLE exists, overlaying the pending delta onto `base`. + * + * Checks `items_` first: a pending erase returns `false`; a pending insert + * or replace returns `true` only if the keylet type check passes. Falls + * through to `base.exists(k)` when the key has no pending action. + * + * @param base The underlying read-only ledger state. + * @param k The keylet whose key and type to check. + * @return `true` if the entry exists and its SLE type satisfies `k.check()`. + */ bool RawStateTable::exists(ReadView const& base, Keylet const& k) const { @@ -203,18 +308,29 @@ RawStateTable::exists(ReadView const& base, Keylet const& k) const return true; } -/* This works by first calculating succ() on the parent, - then calculating succ() our internal list, and taking - the lower of the two. -*/ +/** Find the smallest key strictly greater than `key`, overlaying the pending + * delta onto `base`. + * + * Computes the result in two independent passes then returns the minimum: + * 1. Walks `base.succ()` repeatedly, skipping any key that has a pending + * `Action::Erase` in `items_`, to find the smallest non-deleted base + * successor. + * 2. Scans `items_` forward from `key` for the first non-erase entry. + * + * The lower of the two candidates is the answer. If `last` is provided and + * the result is `>= last`, returns `std::nullopt` (half-open range semantics). + * + * @param base The underlying read-only ledger state. + * @param key The current key; the search begins strictly after this value. + * @param last Optional exclusive upper bound; `std::nullopt` means unbounded. + * @return The next existing key, or `std::nullopt` if none exists in range. + */ auto RawStateTable::succ(ReadView const& base, key_type const& key, std::optional const& last) const -> std::optional { std::optional next = key; items_t::const_iterator iter; - // Find base successor that is - // not also deleted in our list do { next = base.succ(*next, last); @@ -222,28 +338,38 @@ RawStateTable::succ(ReadView const& base, key_type const& key, std::optionalsecond.action == Action::Erase); - // Find non-deleted successor in our list for (iter = items_.upper_bound(key); iter != items_.end(); ++iter) { if (iter->second.action != Action::Erase) { - // Found both, return the lower key if (!next || next > iter->first) next = iter->first; break; } } - // Nothing in our list, return - // what we got from the parent. if (last && next >= last) return std::nullopt; return next; } +/** Stage an SLE deletion, applying the pending-state transition rules. + * + * State machine transitions on the key: + * - No prior action → records `Action::Erase`. + * - Prior `Insert` → removes the entry entirely (net-zero; nothing to + * propagate to the base on `apply()`). + * - Prior `Replace` → downgrades to `Action::Erase`. + * - Prior `Erase` → `LogicError` (double-delete). + * + * The base-view invariant (key must exist) is enforced by the target + * `RawView` at `apply()` time, not here. + * + * @param sle The ledger entry to stage for deletion. + * @throws std::logic_error if the key already has a pending erase. + */ void RawStateTable::erase(std::shared_ptr const& sle) { - // The base invariant is checked during apply auto const result = items_.emplace( std::piecewise_construct, std::forward_as_tuple(sle->key()), @@ -266,6 +392,18 @@ RawStateTable::erase(std::shared_ptr const& sle) } } +/** Stage an SLE creation, applying the pending-state transition rules. + * + * State machine transitions on the key: + * - No prior action → records `Action::Insert`. + * - Prior `Erase` → upgrades to `Action::Replace` (delete-then-recreate + * at the same key within one transaction batch). + * - Prior `Insert` → `LogicError` (duplicate insert). + * - Prior `Replace` → `LogicError` (key already exists in the delta). + * + * @param sle The new ledger entry to stage for insertion. + * @throws std::logic_error if the key is already pending insert or replace. + */ void RawStateTable::insert(std::shared_ptr const& sle) { @@ -291,6 +429,18 @@ RawStateTable::insert(std::shared_ptr const& sle) } } +/** Stage an SLE field update, applying the pending-state transition rules. + * + * State machine transitions on the key: + * - No prior action → records `Action::Replace`. + * - Prior `Insert` → updates the stored SLE pointer, preserving `Insert` + * (from the base view's perspective the key is still being created). + * - Prior `Replace` → updates the stored SLE pointer. + * - Prior `Erase` → `LogicError` (cannot replace a deleted key). + * + * @param sle The updated ledger entry to stage. + * @throws std::logic_error if the key has a pending erase. + */ void RawStateTable::replace(std::shared_ptr const& sle) { @@ -313,6 +463,17 @@ RawStateTable::replace(std::shared_ptr const& sle) } } +/** Read an SLE, overlaying the pending delta onto `base`. + * + * Checks `items_` first: a pending erase returns `nullptr`; a pending + * insert or replace returns the buffered SLE only if `k.check()` passes + * (guarding against type mismatches at the same key). Falls through to + * `base.read(k)` when the key has no pending action. + * + * @param base The underlying read-only ledger state. + * @param k The keylet specifying the key and expected SLE type. + * @return The SLE if it exists and the type matches, otherwise `nullptr`. + */ std::shared_ptr RawStateTable::read(ReadView const& base, Keylet const& k) const { @@ -322,19 +483,32 @@ RawStateTable::read(ReadView const& base, Keylet const& k) const auto const& item = iter->second; if (item.action == Action::Erase) return nullptr; - // Convert to SLE const std::shared_ptr sle = item.sle; if (!k.check(*sle)) return nullptr; return sle; } +/** Accumulate XRP drops to destroy at `apply()` time. + * + * Drops are not forwarded to the target `RawView` individually; they are + * summed here and replayed as a single `rawDestroyXRP` call in `apply()`, + * keeping fee-burn accounting atomic with the rest of the flush. + * + * @param fee The quantity of XRP drops to add to the accumulated burn total. + */ void RawStateTable::destroyXRP(XRPAmount const& fee) { dropsDestroyed_ += fee; } +/** Return a begin iterator for the merged SLE range over `base` and the + * pending delta. + * + * @param base The underlying read-only ledger state to merge with. + * @return A heap-allocated `iter_base` positioned at the first merged SLE. + */ std::unique_ptr RawStateTable::slesBegin(ReadView const& base) const { @@ -342,6 +516,12 @@ RawStateTable::slesBegin(ReadView const& base) const items_.begin(), items_.end(), base.sles.begin(), base.sles.end()); } +/** Return an end sentinel for the merged SLE range over `base` and the + * pending delta. + * + * @param base The underlying read-only ledger state to merge with. + * @return A heap-allocated `iter_base` positioned past the last merged SLE. + */ std::unique_ptr RawStateTable::slesEnd(ReadView const& base) const { @@ -349,6 +529,13 @@ RawStateTable::slesEnd(ReadView const& base) const items_.end(), items_.end(), base.sles.end(), base.sles.end()); } +/** Return an iterator to the first merged SLE with key strictly greater than + * `key`. + * + * @param base The underlying read-only ledger state to merge with. + * @param key The exclusive lower bound for the search. + * @return A heap-allocated `iter_base` positioned at the first qualifying SLE. + */ std::unique_ptr RawStateTable::slesUpperBound(ReadView const& base, uint256 const& key) const { diff --git a/src/libxrpl/ledger/ReadView.cpp b/src/libxrpl/ledger/ReadView.cpp index 1877edb383..c3e2c920c5 100644 --- a/src/libxrpl/ledger/ReadView.cpp +++ b/src/libxrpl/ledger/ReadView.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements the range-protocol adapters for `ReadView::SlesType` and + * `ReadView::TxsType`, and the `makeRulesGivenLedger` factory that + * bootstraps a `Rules` object from live ledger state. + * + * The range adapters delegate entirely to virtual methods on the owning + * `ReadView`, keeping this file free of storage concerns. The rules factory + * uses `DigestAwareReadView::digest` so the resulting `Rules` object can + * detect amendment changes between ledger closes without re-parsing the SLE. + */ + #include #include @@ -39,6 +50,12 @@ ReadView::TxsType::TxsType(ReadView const& view) : ReadViewFwdRange(view) { } +/** Returns `true` when the transaction map contains no entries. + * + * Implemented as `begin() == end()` rather than a virtual to avoid adding + * another virtual method to `ReadView`. The cost is two virtual calls instead + * of one; this is acceptable because `empty()` is rarely called on the hot path. + */ bool ReadView::TxsType::empty() const { @@ -69,10 +86,21 @@ makeRulesGivenLedger( std::unordered_set> const& presets) { Keylet const k = keylet::amendments(); + // The digest is fetched before the SLE for two reasons: + // 1. `digest()` reads directly from the SHAMap trie node without + // deserializing the leaf, making it cheaper than `read()`. + // 2. The digest is threaded into the `Rules` constructor so that + // subsequent calls can compare digests to detect whether amendments + // have changed between ledger closes without re-parsing the SLE. std::optional const digest = ledger.digest(k.key); if (digest) { auto const sle = ledger.read(k); + // The inner guard handles the genesis-ledger case: the amendments + // object (`ltAMENDMENTS`) does not exist on the very first ledger, + // so digest() can return a value while read() returns nullptr when + // a concurrent apply creates the key but the SLE is not yet visible. + // Falling through to Rules(presets) is the correct baseline. if (sle) return Rules(presets, digest, sle->getFieldV256(sfAmendments)); } diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 9fb03a230c..c71f31b28f 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -1,3 +1,13 @@ +/** @file + * Free-function utility layer over ReadView and ApplyView. + * + * Implements business-logic queries (expiry, freeze detection, ledger chain + * validation, amendment introspection) and state mutations (directory + * management, withdrawal validation and execution, account cleanup) that are + * shared across many transaction types. The file is split into two sections: + * read-only observers that take `ReadView const&`, and state-mutating + * modifiers that require `ApplyView&`. + */ #include #include @@ -243,7 +253,6 @@ getMajorityAmendments(ReadView const& view) std::optional hashOfSeq(ReadView const& ledger, LedgerIndex seq, beast::Journal journal) { - // Easy cases... if (seq > ledger.seq()) { JLOG(journal.warn()) << "Can't get seq " << seq << " from " << ledger.seq() << " future"; @@ -256,7 +265,6 @@ hashOfSeq(ReadView const& ledger, LedgerIndex seq, beast::Journal journal) if (int const diff = ledger.seq() - seq; diff <= 256) { - // Within 256... auto const hashIndex = ledger.read(keylet::skip()); if (hashIndex) { @@ -276,13 +284,14 @@ hashOfSeq(ReadView const& ledger, LedgerIndex seq, beast::Journal journal) } } + // Non-256-aligned sequences older than 256 steps cannot be resolved; the + // skip list only stores permanent anchors at 256-aligned boundaries. if ((seq & 0xff) != 0) { JLOG(journal.debug()) << "Can't get seq " << seq << " from " << ledger.seq() << " past"; return std::nullopt; } - // in skiplist auto const hashIndex = ledger.read(keylet::skip(seq)); if (hashIndex) { @@ -319,21 +328,27 @@ dirLink( return tesSUCCESS; } -/* - * Checks if a withdrawal amount into the destination account exceeds - * any applicable receiving limit. - * Called by VaultWithdraw and LoanBrokerCoverWithdraw. +/** Checks whether a withdrawal amount would push the destination account over + * its applicable receiving limit. * - * IOU : Performs the trustline check against the destination account's - * credit limit to ensure the account's trust maximum is not exceeded. + * Called by VaultWithdraw and LoanBrokerCoverWithdraw. * - * MPT: The limit check is effectively skipped (returns true). This is - * because MPT MaximumAmount relates to token supply, and withdrawal does not - * involve minting new tokens that could exceed the global cap. - * On withdrawal, tokens are simply transferred from the vault's pseudo-account - * to the destination account. Since no new MPT tokens are minted during this - * transfer, the withdrawal cannot violate the MPT MaximumAmount/supply cap - * even if `from` is the issuer. + * For IOU assets, the destination's trust-line credit limit is checked: the + * withdrawal is rejected with `tecNO_LINE` if it would cause the recipient's + * balance to exceed that limit. + * + * For MPT assets, the limit check is unconditionally skipped. MPT + * `MaximumAmount` governs token supply (minting), not transfers. A vault + * withdrawal moves existing tokens from the pseudo-account to the destination + * without minting new ones, so the supply cap cannot be violated regardless + * of whether `from` is the issuer. + * + * @param view The ledger state to inspect. + * @param from The source account (vault or broker pseudo-account). + * @param to The destination account. + * @param amount The asset and quantity being withdrawn. + * @return `tecNO_LINE` if the IOU credit limit would be exceeded; + * `tesSUCCESS` otherwise. */ static TER withdrawToDestExceedsLimit( @@ -418,7 +433,6 @@ doWithdraw( STAmount const& amount, beast::Journal j) { - // Create trust line or MPToken for the receiving account if (dstAcct == senderAcct) { if (auto const ter = addEmptyHolding(view, senderAcct, priorBalance, amount.asset(), j); @@ -432,7 +446,6 @@ doWithdraw( return err; } - // Sanity check if (accountHolds( view, sourceAcct, @@ -447,8 +460,6 @@ doWithdraw( // LCOV_EXCL_STOP } - // Move the funds directly from the broker's pseudo-account to the - // dstAcct return accountSend(view, sourceAcct, dstAcct, amount, j, WaiveTransferFee::Yes); } @@ -460,7 +471,6 @@ cleanupOnAccountDelete( beast::Journal j, std::optional maxNodesToDelete) { - // Delete all the entries in the account directory. std::shared_ptr sleDirNode{}; unsigned int uDirEntry{0}; uint256 dirEntry{beast::kZERO}; @@ -474,7 +484,6 @@ cleanupOnAccountDelete( if (maxNodesToDelete && ++deleted > *maxNodesToDelete) return tecINCOMPLETE; - // Choose the right way to delete each directory node. auto sleItem = view.peek(keylet::child(dirEntry)); if (!sleItem) { @@ -489,8 +498,6 @@ cleanupOnAccountDelete( LedgerEntryType const nodeType{ safeCast(sleItem->getFieldU16(sfLedgerEntryType))}; - // Deleter handles the details of specific account-owned object - // deletion auto const [ter, skipEntry] = deleter(nodeType, dirEntry, sleItem); if (!isTesSuccess(ter)) return ter; diff --git a/src/libxrpl/nodestore/BatchWriter.cpp b/src/libxrpl/nodestore/BatchWriter.cpp index cb42b4e92a..d8e99d77e1 100644 --- a/src/libxrpl/nodestore/BatchWriter.cpp +++ b/src/libxrpl/nodestore/BatchWriter.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements BatchWriter, the write-coalescing buffer for NodeStore backends. + * + * Individual NodeObject writes are accumulated into a Batch and flushed as a + * single scheduled task, amortising the per-write overhead of backends such as + * RocksDB. Back-pressure, load estimation, and shutdown safety are all handled + * here; see BatchWriter.h for the public interface contract. + */ + #include #include @@ -13,24 +22,51 @@ namespace xrpl::NodeStore { +/** Construct a BatchWriter tied to the given sink and scheduler. + * + * Pre-allocates `writeSet_` to `kBATCH_WRITE_PREALLOCATION_SIZE` to avoid + * repeated small reallocations during normal operation. + * + * @param callback The sink that will receive each flushed Batch via + * `writeBatch()`. Typically the owning backend (e.g. RocksDBBackend). + * @param scheduler The scheduler used to dispatch the flush task. May be + * a synchronous DummyScheduler (tests/import) or the production async + * scheduler; both are supported via the recursive mutex. + */ BatchWriter::BatchWriter(Callback& callback, Scheduler& scheduler) : callback_(callback), scheduler_(scheduler) { writeSet_.reserve(kBATCH_WRITE_PREALLOCATION_SIZE); } +/** Destroy the BatchWriter, draining any pending writes first. + * + * Blocks until `writePending_` is false so that no accumulated objects + * are silently abandoned when a backend tears down. + */ BatchWriter::~BatchWriter() { waitForWriting(); } +/** Enqueue a NodeObject for the next batch flush. + * + * Appends `object` to `writeSet_` and, if no flush task is already + * outstanding (`writePending_` is false), schedules one via the + * `Scheduler`. Subsequent stores during the same window piggyback on + * the single in-flight task. + * + * @param object The NodeObject to persist. + * @note Blocks the caller when `writeSet_` reaches `kBATCH_WRITE_LIMIT_SIZE` + * (65,536 objects) until the in-flight batch is fully written. This + * back-pressure prevents unbounded memory growth when disk I/O falls + * behind producers. + */ void BatchWriter::store(std::shared_ptr const& object) { std::unique_lock sl(writeMutex_); - // If the batch has reached its limit, we wait - // until the batch writer is finished while (writeSet_.size() >= kBATCH_WRITE_PREALLOCATION_SIZE) writeCondition_.wait(sl); @@ -44,6 +80,15 @@ BatchWriter::store(std::shared_ptr const& object) } } +/** Return a conservative estimate of pending write I/O. + * + * Returns the larger of `writeLoad_` (the item count of the batch + * currently being written to disk) and `writeSet_.size()` (the items + * not yet dispatched). Taking the maximum captures pressure in both the + * in-flight and accumulating phases simultaneously. + * + * @return Estimated number of NodeObjects awaiting or undergoing write. + */ int BatchWriter::getWriteLoad() { @@ -52,12 +97,30 @@ BatchWriter::getWriteLoad() return std::max(writeLoad_, static_cast(writeSet_.size())); } +/** Task entry-point invoked by the Scheduler; delegates to writeBatch(). */ void BatchWriter::performScheduledTask() { writeBatch(); } +/** Drain accumulated objects to the backend via the double-buffer swap pattern. + * + * Under the lock, atomically swaps `writeSet_` with a local vector (O(1)), + * then releases the lock before calling `callback_.writeBatch()`. This + * keeps the lock held for only the swap, never across I/O. The loop + * continues draining until `writeSet_` is empty after a swap, at which + * point `writePending_` is cleared and waiters on `writeCondition_` are + * notified. + * + * After each successful flush, records elapsed time and item count in a + * `BatchWriteReport` and forwards it to `scheduler_.onBatchWrite()` for + * telemetry. + * + * @note The `XRPL_ASSERT` after the swap guards the invariant that the swap + * leaves `writeSet_` empty — a defence against future refactors that + * might violate atomicity. + */ void BatchWriter::writeBatch() { @@ -98,6 +161,11 @@ BatchWriter::writeBatch() } } +/** Block until any in-flight batch flush has completed. + * + * Waits on `writeCondition_` until `writePending_` is false. Called by + * the destructor to guarantee no pending objects are abandoned on teardown. + */ void BatchWriter::waitForWriting() { diff --git a/src/libxrpl/nodestore/Database.cpp b/src/libxrpl/nodestore/Database.cpp index cda3307317..712dfb543f 100644 --- a/src/libxrpl/nodestore/Database.cpp +++ b/src/libxrpl/nodestore/Database.cpp @@ -31,6 +31,26 @@ namespace xrpl::NodeStore { +/** Construct the node store and start the async read thread pool. + * + * Validates three configuration parameters, then spawns and detaches + * `readThreads` worker threads: + * - `earliest_seq`: minimum ledger sequence the store will serve; + * defaults to `kXRP_LEDGER_EARLIEST_SEQ` (32570). Must be >= 1. + * - `rq_bundle`: batch dequeue size per lock acquisition; clamped [1, 64], + * defaults to 4. Amortises mutex overhead under load. + * + * Threads are detached rather than joined; their lifetime is controlled by + * `readStopping_`. `stop()` spin-waits until `readThreads_` reaches zero, + * providing a bounded shutdown guarantee (asserted within 30 s). + * + * @param scheduler Task scheduler for async I/O dispatch and telemetry callbacks. + * @param readThreads Number of prefetch worker threads to create. Must be > 0. + * @param config Section from the node configuration, read for `earliest_seq` + * and `rq_bundle` keys. + * @param journal Logging sink. + * @throws std::runtime_error if `earliest_seq` < 1 or `rq_bundle` is outside [1, 64]. + */ Database::Database( Scheduler& scheduler, int readThreads, @@ -78,8 +98,8 @@ Database::Database( if (isStopping()) break; - // extract multiple object at a time to minimize the - // overhead of acquiring the mutex. + // Extract up to requestBundle_ entries per lock acquisition + // to amortise mutex overhead without starving other threads. for (int cnt = 0; !read_.empty() && cnt != requestBundle_; ++cnt) read.insert(read_.extract(read_.begin())); } @@ -122,23 +142,48 @@ Database::Database( } } +/** Destroy the node store. + * + * Calls `stop()` as a safety net to drain the read queue and wait for worker + * threads to exit. However, derived classes **must** call `stop()` in their + * own destructors before this base destructor runs. Worker threads hold a raw + * `this` pointer and invoke the virtual `fetchNodeObject()`; if the derived + * vtable has already been dismantled when a thread wakes, the call resolves + * against a destroyed object, causing undefined behaviour. + */ Database::~Database() { - // NOTE! - // Any derived class should call the stop() method in its - // destructor. Otherwise, occasionally, the derived class may - // crash during shutdown when its members are accessed by one of - // these threads after the derived class is destroyed but before - // this base class is destroyed. stop(); } +/** Return whether a stop has been requested. + * + * Uses a relaxed load — only the `readStopping_` flag itself need be + * observed; surrounding operations are not required to be sequentially + * consistent with this check. + * + * @return `true` once `stop()` has been called. + */ bool Database::isStopping() const { return readStopping_.load(std::memory_order_relaxed); } +/** Signal all worker threads to stop and block until they have fully exited. + * + * On the first call, sets `readStopping_`, clears the pending read queue, + * and broadcasts on `readCondVar_` so all blocked threads wake immediately. + * Then spin-waits (yielding the CPU) until `readThreads_` drops to zero, + * confirming every thread has fully exited. + * + * Idempotent: subsequent calls are no-ops (the `exchange` guards against + * double-clear). + * + * @note Safe to call from any thread. The 30-second assertion in the + * spin-wait is a hard upper bound; exceeding it indicates a deadlock + * in `fetchNodeObject()` or the scheduler. + */ void Database::stop() { @@ -173,6 +218,24 @@ Database::stop() << " milliseconds"; } +/** Schedule a non-blocking fetch and invoke a callback on completion. + * + * Appends `(ledgerSeq, cb)` to the entry in `read_` keyed by `hash`, + * creating the entry if absent. Multiple callers requesting the same hash + * coalesce into a single backend read — all their callbacks fire when the + * single I/O completes. Wakes exactly one worker thread via + * `readCondVar_.notify_one()`. + * + * Silently discards the request (without invoking `cb`) if `isStopping()` + * is already true at the point of the lock acquisition. + * + * @param hash The 256-bit hash key of the desired `NodeObject`. + * @param ledgerSeq Ledger sequence associated with this request; used by + * `isSameDB()` to decide whether a cached fetch result can be reused + * across multi-backend configurations. + * @param cb Callback invoked on the worker thread when the fetch completes. + * Receives the fetched `NodeObject`, or `nullptr` on cache miss. + */ void Database::asyncFetch( uint256 const& hash, @@ -188,6 +251,22 @@ Database::asyncFetch( } } +/** Copy all objects from `srcDB` into `dstBackend` in batches. + * + * Iterates `srcDB` via `forEach()` and accumulates `NodeObject`s into a + * `Batch`. When the batch reaches `kBATCH_WRITE_PREALLOCATION_SIZE`, it is + * flushed to `dstBackend.storeBatch()` and byte statistics are recorded via + * `storeStats()`. Any remaining objects after iteration are flushed in a + * final partial batch. + * + * Exceptions from `storeBatch()` are caught and logged but do not abort the + * import — partial progress is preferred over a complete rollback in what may + * be a long-running migration. + * + * @param dstBackend Destination backend to write objects into. + * @param srcDB Source database to read objects from; `forEach()` is called + * on this object and must not be called concurrently. + */ void Database::importInternal(Backend& dstBackend, Database& srcDB) { @@ -225,7 +304,24 @@ Database::importInternal(Backend& dstBackend, Database& srcDB) storeBatch(); } -// Perform a fetch and report the time it took +/** Instrumentation shim around the private virtual `fetchNodeObject`. + * + * Delegates to the subclass implementation, then records wall-clock elapsed + * time in `fetchDurationUs_`, increments `fetchHitCount_` and `fetchSz_` on + * a hit, increments `fetchTotalCount_` unconditionally, and reports the + * completed fetch to the `Scheduler` via `scheduler_.onFetch()`. The + * scheduler hook allows the task-prioritisation layer to monitor backend + * performance dynamically. + * + * @param hash The 256-bit hash key of the desired `NodeObject`. + * @param ledgerSeq Ledger sequence passed through to the backend; used by + * rotating-backend implementations to select writable vs. archive. + * @param fetchType Whether the fetch originates from a synchronous or + * asynchronous code path; passed through to `FetchReport`. + * @param duplicate When `true`, the caller already has a copy of this object; + * the backend may skip promotion to the writable store. + * @return The retrieved `NodeObject`, or `nullptr` on cache miss or error. + */ std::shared_ptr Database::fetchNodeObject( uint256 const& hash, @@ -253,6 +349,23 @@ Database::fetchNodeObject( return nodeObject; } +/** Populate a JSON object with live operational metrics for this database. + * + * Writes the following keys into `obj`: + * - `read_queue`: current depth of the pending async read map. + * - `read_threads_total` / `read_threads_running`: total and actively + * processing worker thread counts. + * - `read_request_bundle`: configured batch dequeue size (`rq_bundle`). + * - `node_writes`, `node_written_bytes`: cumulative store count and bytes. + * - `node_reads_total`, `node_reads_hit`, `node_read_bytes`: fetch counts + * and bytes, distinguishing hits from total attempts. + * - `node_reads_duration_us`: cumulative backend read latency in microseconds. + * + * This data surfaces via the `get_counts` RPC command and is valuable for + * diagnosing I/O bottlenecks in production nodes. + * + * @param obj A JSON object to populate; must satisfy `obj.isObject()`. + */ void Database::getCountsJson(json::Value& obj) { diff --git a/src/libxrpl/nodestore/DatabaseNodeImp.cpp b/src/libxrpl/nodestore/DatabaseNodeImp.cpp index ef84862de6..d7a609f108 100644 --- a/src/libxrpl/nodestore/DatabaseNodeImp.cpp +++ b/src/libxrpl/nodestore/DatabaseNodeImp.cpp @@ -1,3 +1,16 @@ +/** @file + * Single-backend `Database` implementation for the XRPL NodeStore. + * + * `DatabaseNodeImp` wraps one pluggable `Backend` (NuDB, RocksDB, etc.) and + * satisfies the `Database` contract for store, synchronous fetch, batch fetch, + * and async fetch. It is the primary node database path; the two-backend + * rotation variant is `DatabaseRotatingImp`. + * + * Ledger-sequence parameters are accepted by the interface but intentionally + * ignored here — a single backend stores all objects regardless of which + * ledger they belong to. + */ + #include #include @@ -22,6 +35,18 @@ namespace xrpl::NodeStore { +/** Serialize and persist a node object to the backend. + * + * Records the byte count in store statistics before moving `data` into the + * `NodeObject`, because the move invalidates the size. The ledger sequence + * parameter is part of the `Database` interface contract but is unused here — + * the single backend accepts objects from any ledger. + * + * @param type The object type tag stored alongside the payload. + * @param data Payload blob; ownership is transferred — the caller's variable + * is left in a valid but unspecified state after this call. + * @param hash 256-bit content-address key for the object. + */ void DatabaseNodeImp::store(NodeObjectType type, Blob&& data, uint256 const& hash, std::uint32_t) { @@ -31,6 +56,18 @@ DatabaseNodeImp::store(NodeObjectType type, Blob&& data, uint256 const& hash, st backend_->store(obj); } +/** Enqueue a non-blocking fetch request on the base-class read thread pool. + * + * Delegates unconditionally to `Database::asyncFetch()`, which coalesces + * duplicate hash requests and dispatches the callback from a worker thread. + * There are no per-backend scheduling adjustments for the single-backend case. + * + * @param hash Key of the object to retrieve. + * @param ledgerSeq Sequence of the ledger the object belongs to; forwarded + * to the base class for hash-coalescing decisions (`isSameDB`). + * @param callback Invoked with the fetched `NodeObject` (or `nullptr` if not + * found) once the backend read completes. Called from a worker thread. + */ void DatabaseNodeImp::asyncFetch( uint256 const& hash, @@ -40,6 +77,29 @@ DatabaseNodeImp::asyncFetch( Database::asyncFetch(hash, ledgerSeq, std::move(callback)); } +/** Private virtual hook called by the base-class thread pool to perform a + * single synchronous backend lookup. + * + * Wraps `backend_->fetch()` with structured error logging: + * - `Status::Ok` and `Status::NotFound` are silent — a missing object is + * normal during ledger history traversal. + * - `Status::DataCorrupt` emits a `fatal` log entry; on-disk corruption is a + * ledger integrity failure requiring operator intervention. + * - Any other status code emits a `warn` log but does not abort. + * - A thrown `std::exception` is logged at `fatal` level and then re-raised + * via `Rethrow()` so the crash propagates rather than being swallowed. + * + * Sets `fetchReport.wasFound = true` only when `backend_->fetch()` populates + * the output pointer, which feeds the base-class hit-count metric. + * + * @param hash 256-bit key of the object to retrieve. + * @param fetchReport In/out report updated with `wasFound` on a cache hit; + * timing is applied by the calling base-class wrapper. + * @param duplicate Whether this request was deduplicated from another in-flight + * fetch for the same hash; passed through from `Database::asyncFetch()`. + * @return The fetched `NodeObject`, or `nullptr` if not found or on error. + * @throws Any exception re-raised from `backend_->fetch()` after logging. + */ std::shared_ptr DatabaseNodeImp::fetchNodeObject( uint256 const& hash, @@ -81,15 +141,39 @@ DatabaseNodeImp::fetchNodeObject( return nodeObject; } +/** Synchronously fetch a batch of node objects from the backend, bypassing the + * async read queue. + * + * Calls `backend_->fetchBatch()` directly and enforces the positional contract + * that `results[i]` corresponds to `hashes[i]`: + * + * - Asserts the backend returned either exactly `hashes.size()` results or an + * empty vector (both are valid; partial batches are a backend bug). + * - Resizes an empty result vector to `hashes.size()` null pointers, so + * callers always receive a fully-sized vector regardless of which sentinel + * the backend chose. + * - Logs each null slot at `error` level to make missing history visible in + * diagnostics without throwing. + * + * Wall-clock duration is recorded and forwarded to `updateFetchMetrics()` for + * operator dashboards. Hit count is not updated here — batch fetches are + * expected to return a mix of hits and misses, and per-slot tracking is the + * caller's responsibility. + * + * @note The batch-level `Status` returned by `backend_->fetchBatch()` is + * discarded; only the object vector is used. Per-object availability is + * inferred from null slots. + * + * @param hashes Ordered list of 256-bit keys to retrieve. + * @return Vector of the same length as `hashes`; null entries indicate objects + * not found in the backend. + */ std::vector> DatabaseNodeImp::fetchBatch(std::vector const& hashes) { using namespace std::chrono; auto const before = steady_clock::now(); - // Get the node objects that match the hashes from the backend. To protect - // against the backends returning fewer or more results than expected, the - // container is resized to the number of hashes. auto results = backend_->fetchBatch(hashes).first; XRPL_ASSERT( results.size() == hashes.size() || results.empty(), diff --git a/src/libxrpl/nodestore/DatabaseRotatingImp.cpp b/src/libxrpl/nodestore/DatabaseRotatingImp.cpp index 24b0e2de2e..0fbf8976b8 100644 --- a/src/libxrpl/nodestore/DatabaseRotatingImp.cpp +++ b/src/libxrpl/nodestore/DatabaseRotatingImp.cpp @@ -1,3 +1,15 @@ +/** @file + * Implements `DatabaseRotatingImp`, the concrete two-backend node store that + * powers XRPL's online deletion feature. + * + * The key design constraint throughout this file is the capture-under-lock / + * use-outside-lock pattern: `mutex_` protects only the `shared_ptr` swap, not + * backend I/O. Holding the lock across disk operations would serialise all + * readers and destroy concurrency. Every method that reads either backend + * pointer first copies it into a local `shared_ptr` under the lock, releases + * the lock, then performs I/O through the local. `sync()` is the sole + * exception — it is a maintenance call and not latency-sensitive. + */ #include #include @@ -45,12 +57,12 @@ DatabaseRotatingImp::rotate( std::unique_ptr&& newBackend, std::function const& f) { - // Pass these two names to the callback function + // Capture the new backend's name before the lock; getName() on the + // incoming backend needs no synchronisation (it is owned exclusively here). std::string const newWritableBackendName = newBackend->getName(); std::string newArchiveBackendName; - // Hold on to current archive backend pointer until after the - // callback finishes. Only then will the archive directory be - // deleted. + // Keeps the old archive alive past the callback so its directory is + // deleted only after the callback has persisted the new layout. std::shared_ptr oldArchiveBackend; { std::scoped_lock const lock(mutex_); @@ -149,7 +161,6 @@ DatabaseRotatingImp::fetchNodeObject( return nodeObject; }; - // See if the node object exists in the cache std::shared_ptr nodeObject; auto [writable, archive] = [&] { @@ -157,21 +168,20 @@ DatabaseRotatingImp::fetchNodeObject( return std::make_pair(writableBackend_, archiveBackend_); }(); - // Try to fetch from the writable backend nodeObject = fetch(writable); if (!nodeObject) { - // Otherwise try to fetch from the archive backend nodeObject = fetch(archive); if (nodeObject) { { - // Refresh the writable backend pointer + // Re-snapshot writable under lock: a rotation may have + // occurred between the archive fetch and this write-back, + // so we must not promote into the now-demoted old writable. std::scoped_lock const lock(mutex_); writable = writableBackend_; } - // Update writable backend with data from the archive backend if (duplicate) writable->store(nodeObject); } @@ -191,10 +201,7 @@ DatabaseRotatingImp::forEach(std::function)> f) return std::make_pair(writableBackend_, archiveBackend_); }(); - // Iterate the writable backend writable->forEach(f); - - // Iterate the archive backend archive->forEach(f); } diff --git a/src/libxrpl/nodestore/DecodedBlob.cpp b/src/libxrpl/nodestore/DecodedBlob.cpp index fb7569bd8c..199ee09ae6 100644 --- a/src/libxrpl/nodestore/DecodedBlob.cpp +++ b/src/libxrpl/nodestore/DecodedBlob.cpp @@ -1,3 +1,13 @@ +/** @file + * @brief NodeStore binary deserialization: parses raw backend blobs into + * `NodeObject` instances. + * + * `DecodedBlob` is the read-direction half of the NodeStore on-disk format + * (the write direction lives in `EncodedBlob`). The two classes together + * define the complete binary schema; any format change must be reflected in + * both. See `EncodedBlob.h` for the authoritative format description. + */ + #include #include @@ -12,25 +22,47 @@ namespace xrpl::NodeStore { +/** Parse the raw backend value buffer into its constituent fields. + * + * Validates the on-disk layout without performing any heap allocation. + * `m_objectData` is set to a non-owning pointer into `value`; the actual + * `Blob` copy is deferred to `createObject()`. + * + * On-disk layout (mirrors `EncodedBlob`): + * - Bytes 0–7: Ignored prefix (historically stored the ledger index; + * always zero in current writes). Bytes 4–7 were likely never defined + * when the original field was widened from 4 to 8 bytes. + * - Byte 8: `NodeObjectType` discriminant cast via `safeCast`. + * - Bytes 9+: Raw serialized payload. + * + * `success_` is set to `true` only when `valueBytes > 9` **and** the type + * byte matches one of the four recognised `NodeObjectType` values. An + * unrecognised byte — whether from a future format version or actual + * corruption — leaves `success_ = false`, making the decoder safely + * forward-incompatible without throwing. + * + * @param key Pointer to the 32-byte hash key (not validated here; + * ownership lies with the caller for the lifetime of this object). + * @param value Pointer to the raw value buffer from the backend. + * @param valueBytes Total byte length of `value`. A value ≤ 9 produces a + * failed parse (`wasOk()` returns `false`). + */ DecodedBlob::DecodedBlob(void const* key, void const* value, int valueBytes) { - /* Data format: - - Bytes - - 0...7 Unused - 8 char One of NodeObjectType - 9...end The body of the object data + /* On-disk format: + Bytes 0–7 Unused prefix (legacy ledger-index field; always zero today) + Byte 8 NodeObjectType discriminant + Bytes 9+ Serialized payload */ success_ = false; key_ = key; objectType_ = NodeObjectType::Unknown; objectData_ = nullptr; + // std::max guards against a negative length when valueBytes < 9. dataBytes_ = std::max(0, valueBytes - 9); - // VFALCO NOTE What about bytes 4 through 7 inclusive? - + // Bytes 0–7 are intentionally ignored; byte 8 carries the type. if (valueBytes > 8) { unsigned char const* byte = static_cast(value); @@ -41,6 +73,9 @@ DecodedBlob::DecodedBlob(void const* key, void const* value, int valueBytes) { objectData_ = static_cast(value) + 9; + // Only the four currently known types are accepted. Any unrecognised + // byte (corruption or future format) leaves success_ = false so the + // caller can discard the record via wasOk() without an exception. switch (objectType_) { default: @@ -56,6 +91,20 @@ DecodedBlob::DecodedBlob(void const* key, void const* value, int valueBytes) } } +/** Allocate and return a `NodeObject` from the previously parsed fields. + * + * This is the only allocation point in the decode path. It copies the + * payload slice (`objectData_[0..dataBytes_)`) into an owning `Blob` and + * reconstructs the full key from the stored pointer. + * + * @pre `wasOk()` must return `true`; calling this on a failed parse fires + * `XRPL_ASSERT` in debug builds. In release builds the guard returns a + * null `shared_ptr` instead of invoking undefined behaviour. + * + * @return A fully constructed `NodeObject`, or `nullptr` if the parse had + * failed (release-build defensive path only — callers should always + * check `wasOk()` first). + */ std::shared_ptr DecodedBlob::createObject() { diff --git a/src/libxrpl/nodestore/DummyScheduler.cpp b/src/libxrpl/nodestore/DummyScheduler.cpp index 1f93ed3d0f..9dfa6941af 100644 --- a/src/libxrpl/nodestore/DummyScheduler.cpp +++ b/src/libxrpl/nodestore/DummyScheduler.cpp @@ -1,3 +1,11 @@ +/** @file + * Synchronous no-op implementation of the NodeStore `Scheduler` interface. + * + * All three virtual methods are trivial: `scheduleTask` runs the task + * inline on the calling thread; the two telemetry hooks are empty. See + * `DummyScheduler.h` for the full behavioral contract and usage guidance. + */ + #include #include @@ -8,7 +16,6 @@ namespace xrpl::NodeStore { void DummyScheduler::scheduleTask(Task& task) { - // Invoke the task synchronously. task.performScheduledTask(); } diff --git a/src/libxrpl/nodestore/ManagerImp.cpp b/src/libxrpl/nodestore/ManagerImp.cpp index 0cfda7d86a..1c4e6f913c 100644 --- a/src/libxrpl/nodestore/ManagerImp.cpp +++ b/src/libxrpl/nodestore/ManagerImp.cpp @@ -1,3 +1,10 @@ +/** @file + * Concrete implementation of the NodeStore Manager singleton. + * + * Provides the Meyers-singleton `ManagerImp::instance()`, registers the four + * built-in storage backends (NuDB, RocksDB, Null, Memory), and implements the + * `Manager` interface for backend/database construction. + */ #include #include @@ -23,6 +30,13 @@ namespace xrpl::NodeStore { +/** Return the process-wide ManagerImp singleton. + * + * Uses a Meyers function-local static so construction is thread-safe and + * lazy. All four built-in backends are registered during the first call. + * + * @return Reference to the single ManagerImp instance. + */ ManagerImp& ManagerImp::instance() { @@ -30,6 +44,12 @@ ManagerImp::instance() return k_; } +/** Throw a user-facing error when the `[node_db]` config entry is absent or + * names an unrecognised backend type. + * + * @throws std::runtime_error Always — directs the operator to add a + * `[node_db]` section to `xrpld.cfg`. + */ void ManagerImp::missingBackend() { @@ -38,10 +58,12 @@ ManagerImp::missingBackend() "please see the xrpld-example.cfg file!"); } -// We shouldn't rely on global variables for lifetime management because their -// lifetime is not well-defined. ManagerImp may get destroyed before the Factory -// classes, and then, calling Manager::instance().erase() in the destructors of -// the Factory classes is an undefined behaviour. +// Each register* function creates a function-local static Factory whose +// constructor calls manager.insert(*this). Function-local statics are +// initialised after ManagerImp and destroyed before it, so the erase() calls +// in their destructors are safe. Using global variables instead would leave +// the destruction order undefined across translation units and could invoke +// erase() on an already-destroyed ManagerImp — undefined behaviour. void registerNuDBFactory(Manager& manager); void @@ -51,6 +73,13 @@ registerNullFactory(Manager& manager); void registerMemoryFactory(Manager& manager); +/** Register all built-in backend factories. + * + * Calls the four `register*Factory` free functions, each of which holds a + * function-local static factory that self-registers via `insert()`. The + * function-local-static lifetime guarantee ensures factories are always + * destroyed before this ManagerImp. + */ ManagerImp::ManagerImp() { registerNuDBFactory(*this); @@ -59,6 +88,23 @@ ManagerImp::ManagerImp() registerMemoryFactory(*this); } +/** Construct an unopened backend from a configuration section. + * + * Reads the `"type"` key from `parameters`, performs a case-insensitive + * lookup against registered factories, and delegates to + * `Factory::createInstance`. The returned backend has not yet had `open()` + * called on it. + * + * @param parameters Config section containing at minimum `type=` and + * typically `path=`. + * @param burstSize Backend burst size hint passed through to the factory. + * @param scheduler Scheduler for async tasks used by the backend. + * @param journal Logging sink. + * @return An unopened `Backend` instance for the requested type. + * @throws std::runtime_error If the `"type"` key is absent or names no + * registered factory — the message directs the operator to fix + * `xrpld.cfg`. + */ std::unique_ptr ManagerImp::makeBackend( Section const& parameters, @@ -80,6 +126,22 @@ ManagerImp::makeBackend( NodeObject::kKEY_BYTES, parameters, burstSize, scheduler, journal); } +/** Construct and open a fully ready `Database` from a configuration section. + * + * Calls `makeBackend` to create the backend, explicitly calls `open()` on it + * so that I/O errors surface before the `Database` stack is assembled, then + * wraps it in a `DatabaseNodeImp` with the requested read-thread pool. + * + * @param burstSize Backend burst size hint forwarded to `makeBackend`. + * @param scheduler Scheduler for async tasks. + * @param readThreads Number of async read threads to spawn. + * @param config Config section forwarded to `makeBackend`; must contain + * `type=` at minimum. + * @param journal Logging sink. + * @return An open, ready-to-use `Database` instance. + * @throws std::runtime_error From `makeBackend` on bad config, or from + * `Backend::open()` if the storage layer cannot be initialised. + */ std::unique_ptr ManagerImp::makeDatabase( std::size_t burstSize, @@ -94,6 +156,14 @@ ManagerImp::makeDatabase( scheduler, readThreads, std::move(backend), config, journal); } +/** Register a factory so it can be found by `find()` and `makeBackend()`. + * + * Appends `factory` to the internal registry under `mutex_`. Typically + * called only from the `register*Factory` free functions during + * `ManagerImp` construction. + * + * @param factory The factory to register; the caller retains ownership. + */ void ManagerImp::insert(Factory& factory) { @@ -101,6 +171,13 @@ ManagerImp::insert(Factory& factory) list_.push_back(&factory); } +/** Remove a previously registered factory from the registry. + * + * Uses `XRPL_ASSERT` to verify the pointer is present — passing an + * unregistered factory is a programming error, not a recoverable condition. + * + * @param factory The factory to remove; must currently be in the registry. + */ void ManagerImp::erase(Factory& factory) { @@ -111,6 +188,11 @@ ManagerImp::erase(Factory& factory) list_.erase(iter); } +/** Find a registered factory by name, using case-insensitive comparison. + * + * @param name The backend type name to look up (e.g. `"NuDB"`, `"nudb"`). + * @return Pointer to the matching `Factory`, or `nullptr` if not found. + */ Factory* ManagerImp::find(std::string const& name) { @@ -124,6 +206,13 @@ ManagerImp::find(std::string const& name) //------------------------------------------------------------------------------ +/** Return the process-wide Manager singleton. + * + * Forwards to `ManagerImp::instance()`, keeping the abstract `Manager` + * header free of implementation details. + * + * @return Reference to the singleton Manager (actually a ManagerImp). + */ Manager& Manager::instance() { diff --git a/src/libxrpl/nodestore/NodeObject.cpp b/src/libxrpl/nodestore/NodeObject.cpp index 99432eb495..ef5609544a 100644 --- a/src/libxrpl/nodestore/NodeObject.cpp +++ b/src/libxrpl/nodestore/NodeObject.cpp @@ -1,3 +1,9 @@ +/** @file + * Implementation of NodeObject — the immutable (type, hash, blob) value unit + * stored in the XRPL node store. Construction is exclusively through the + * `createObject()` factory; see NodeObject.h for the class-level contract. + */ + #include #include @@ -8,8 +14,21 @@ namespace xrpl { -//------------------------------------------------------------------------------ - +/** Construct a NodeObject, transferring ownership of @p data. + * + * Only reachable via `createObject()`: the `PrivateAccess` parameter is a + * private sentinel type that external callers cannot construct, making this + * constructor effectively private while remaining compatible with + * `std::make_shared`. + * + * @param type The category of ledger object being stored. + * @param data Serialized payload; moved into the object — the caller's + * variable is left in a valid but unspecified state. + * @param hash 256-bit content-address key. Not verified against @p data; + * the caller is responsible for correctness. + * @param PrivateAccess sentinel — pass `PrivateAccess{}` from within + * the class only. + */ NodeObject::NodeObject(NodeObjectType type, Blob&& data, uint256 const& hash, PrivateAccess) : type_(type), hash_(hash), data_(std::move(data)) { diff --git a/src/libxrpl/nodestore/backend/MemoryFactory.cpp b/src/libxrpl/nodestore/backend/MemoryFactory.cpp index 13c82696cb..1d892a8080 100644 --- a/src/libxrpl/nodestore/backend/MemoryFactory.cpp +++ b/src/libxrpl/nodestore/backend/MemoryFactory.cpp @@ -1,3 +1,12 @@ +/** @file + * In-memory NodeStore backend for testing and ephemeral storage. + * + * Defines `MemoryDB` (the raw storage cell), `MemoryBackend` (the `Backend` + * interface implementation), and `MemoryFactory` (the `Factory` and named-DB + * registry). Multiple `MemoryBackend` instances opened with the same path + * share a single `MemoryDB`, so the store survives backend close/reopen + * within a process. No data is ever written to disk. + */ #include #include #include @@ -26,15 +35,42 @@ namespace xrpl::NodeStore { +/** Raw storage cell shared by all `MemoryBackend` instances with the same path. + * + * `MemoryFactory` owns one `MemoryDB` per distinct path name and hands out + * raw pointers to `MemoryBackend`. Separating storage into this struct lets + * a backend close (nulling its pointer) without discarding the map — a + * subsequent `open()` on the same path recovers the same data. + * + * @note `mutex` must be held for all reads and writes to `table`. + * `forEach` intentionally reads `table` without the lock; callers must + * guarantee no concurrent writes during enumeration. + */ struct MemoryDB { explicit MemoryDB() = default; + /** Protects concurrent access to `table`. */ std::mutex mutex; + + /** Guard flag checked by `MemoryFactory::open()`; never set to `true` in + * current code — the open-guard is effectively dead. */ bool open = false; + + /** Hash-keyed object store; keys are `const` to prevent accidental rehash. */ std::map> table; }; +/** Factory and named-registry for in-memory NodeStore backends. + * + * Implements `Factory` (creating `MemoryBackend` instances) and acts as the + * owner of all `MemoryDB` storage cells, keyed by path name. Path lookups + * are case-insensitive via `boost::beast::iless`. + * + * A single process-level instance is created by `registerMemoryFactory()`; + * its address is also stored in `gMemoryFactory` so `MemoryBackend::open()` + * can call back without holding a reference. + */ class MemoryFactory : public Factory { private: @@ -43,11 +79,28 @@ private: Manager& manager_; public: + /** Constructs and self-registers with `manager`. + * + * @param manager The `Manager` singleton that this factory registers with. + */ explicit MemoryFactory(Manager& manager); + /** Returns `"Memory"`, the config `type=` token for this backend. */ [[nodiscard]] std::string getName() const override; + /** Creates a new `MemoryBackend` for the given configuration. + * + * @param keyBytes Size of the hash key in bytes (must be 32 for + * `uint256`). + * @param keyValues Configuration section; must contain a non-empty + * `"path"` key. + * @param burstSize Ignored by this backend. + * @param scheduler Ignored by this backend. + * @param journal Retained but currently unused by the backend. + * @return Owning pointer to the new `MemoryBackend`. + * @throw std::runtime_error if `"path"` is absent or empty. + */ std::unique_ptr createInstance( size_t keyBytes, @@ -56,6 +109,17 @@ public: Scheduler& scheduler, beast::Journal journal) override; + /** Returns a reference to the `MemoryDB` for the given path, creating it + * if it does not yet exist. + * + * Multiple backends opened with the same path share the returned + * `MemoryDB`, so the map contents survive individual backend lifecycles. + * + * @param path The logical store name (case-insensitive). + * @return Reference into `map_`; valid for the lifetime of this factory. + * @throw std::runtime_error if `db.open` is `true` (dead code in the + * current implementation — `open` is never set to `true`). + */ MemoryDB& open(std::string const& path) { @@ -69,8 +133,24 @@ public: } }; +/** Module-level pointer to the singleton `MemoryFactory`. + * + * Set by `registerMemoryFactory()` and used by `MemoryBackend::open()` to + * call back into the factory without requiring a stored reference. Valid + * for the entire process lifetime after `registerMemoryFactory()` is called. + */ MemoryFactory* gMemoryFactory = nullptr; +/** Registers the in-memory backend with the global `Manager`. + * + * Creates a function-local static `MemoryFactory`, which self-registers via + * its constructor and stores its address in `gMemoryFactory`. Safe to call + * from multiple threads — C++11 guarantees static-local initialization is + * performed exactly once. Must be called before any `type=Memory` config + * section is instantiated. + * + * @param manager The `Manager` singleton to register with. + */ void registerMemoryFactory(Manager& manager) { @@ -80,6 +160,19 @@ registerMemoryFactory(Manager& manager) //------------------------------------------------------------------------------ +/** `Backend` implementation backed by a heap-resident `std::map`. + * + * Wraps a `MemoryDB` pointer obtained from `MemoryFactory`. All reads and + * writes are protected by `MemoryDB::mutex` except `forEach`, which requires + * the caller to guarantee no concurrent writes. The `createIfMissing` + * argument to `open()` is silently ignored — the store always starts empty + * and is created implicitly. + * + * @note This backend consumes no file descriptors and has no write queue, + * so `fdRequired()` returns 0 and `getWriteLoad()` returns 0. + * @note `sync()` and `setDeletePath()` are intentional no-ops; there is no + * I/O to flush and no filesystem path to remove. + */ class MemoryBackend : public Backend { private: @@ -90,6 +183,13 @@ private: MemoryDB* db_{nullptr}; public: + /** Constructs the backend and validates configuration. + * + * @param keyBytes Key size in bytes; expected to be 32 for `uint256`. + * @param keyValues Config section; must contain a non-empty `"path"` key. + * @param journal Retained for future diagnostics; currently unused. + * @throw std::runtime_error if `"path"` is absent or empty. + */ MemoryBackend(size_t keyBytes, Section const& keyValues, beast::Journal journal) : name_(get(keyValues, "path")), journal_(journal) { @@ -103,24 +203,36 @@ public: close(); } + /** Returns the path name used to identify the underlying `MemoryDB`. */ std::string getName() override { return name_; } + /** Acquires a pointer to the shared `MemoryDB` for this backend's path. + * + * The `createIfMissing` flag is ignored; an in-memory store is always + * created on first access and never needs to be opened from disk. + */ void open(bool) override { db_ = &gMemoryFactory->open(name_); } + /** Returns `true` while the backend holds a valid `MemoryDB` pointer. */ bool isOpen() override { return static_cast(db_); } + /** Releases the `MemoryDB` pointer without destroying the underlying data. + * + * The `MemoryDB` remains alive in `MemoryFactory::map_`, so a subsequent + * `open()` with the same path recovers all previously stored objects. + */ void close() override { @@ -129,6 +241,14 @@ public: //-------------------------------------------------------------------------- + /** Looks up a single object by hash. + * + * @param hash The 256-bit key to look up. + * @param pObject Set to the found object on success; reset to null if not + * found. + * @return `Status::Ok` if found, `Status::NotFound` otherwise. + * @pre `isOpen()` must be true; asserted via `XRPL_ASSERT`. + */ Status fetch(uint256 const& hash, std::shared_ptr* pObject) override { @@ -146,6 +266,17 @@ public: return Status::Ok; } + /** Looks up multiple objects by hash, one at a time. + * + * Calls `fetch()` in a loop; the mutex is acquired and released for each + * element individually. Missing hashes produce a null entry in the result + * vector. The overall status is always `Status::Ok`. + * + * @param hashes Ordered list of hashes to fetch. + * @return A pair of (result vector, `Status::Ok`). The result vector has + * the same length as `hashes`; missing entries are null pointers. + * @pre `isOpen()` must be true (asserted inside each `fetch()` call). + */ std::pair>, Status> fetchBatch(std::vector const& hashes) override { @@ -168,6 +299,14 @@ public: return {results, Status::Ok}; } + /** Inserts a single object, keyed by its hash. + * + * If an object with the same hash already exists, `emplace` is a no-op + * (content-addressed: same hash implies same data). + * + * @param object The object to store. + * @pre `isOpen()` must be true; asserted via `XRPL_ASSERT`. + */ void store(std::shared_ptr const& object) override { @@ -176,6 +315,11 @@ public: db_->table.emplace(object->getHash(), object); } + /** Stores each object in `batch` by calling `store()` individually. + * + * @param batch The collection of objects to persist. + * @pre `isOpen()` must be true (asserted inside each `store()` call). + */ void storeBatch(Batch const& batch) override { @@ -183,11 +327,20 @@ public: store(e); } + /** No-op. There is no I/O to flush for an in-memory store. */ void sync() override { } + /** Invokes `f` for every object in the store. + * + * Iterates `db_->table` without holding `db_->mutex`. The caller must + * ensure no concurrent writes occur during enumeration. + * + * @param f Callback invoked once per stored object. + * @pre `isOpen()` must be true; asserted via `XRPL_ASSERT`. + */ void forEach(std::function)> f) override { @@ -196,17 +349,20 @@ public: f(e.second); } + /** Returns 0; there is no write queue for an in-memory backend. */ int getWriteLoad() override { return 0; } + /** No-op. There is no filesystem path to schedule for deletion. */ void setDeletePath() override { } + /** Returns 0; the memory backend uses no file descriptors. */ [[nodiscard]] int fdRequired() const override { diff --git a/src/libxrpl/nodestore/backend/NuDBFactory.cpp b/src/libxrpl/nodestore/backend/NuDBFactory.cpp index db9dbcbec1..e5ac73f5b5 100644 --- a/src/libxrpl/nodestore/backend/NuDBFactory.cpp +++ b/src/libxrpl/nodestore/backend/NuDBFactory.cpp @@ -1,3 +1,18 @@ +/** @file + * NuDB storage backend for the XRPL NodeStore. + * + * Implements `NuDBBackend`, the default persistence engine for `rippled`. + * NuDB is an append-mostly, hash-keyed store whose I/O profile matches the + * XRPL access pattern (random reads, append writes, no deletes) with lower + * write-amplification than general-purpose engines like RocksDB. + * + * Every stored value passes through the codec layer (`detail/codec.h`): LZ4 + * by default, with a specialized sparse-hash encoding for SHAMap inner nodes. + * See `nodeobjectCompress` / `nodeobjectDecompress` for the format details. + * + * `NuDBFactory` registers itself with the global `Manager` singleton so that + * a `[node_db]` section specifying `type=NuDB` resolves to this backend. + */ #include #include #include @@ -46,24 +61,56 @@ namespace xrpl::NodeStore { +/** NuDB storage backend for the NodeStore. + * + * Wraps a NuDB database (three on-disk files: `.dat`, `.key`, `.log`) and + * implements the `Backend` interface. Construction is lightweight; all I/O is + * deferred until `open()`. Every stored value is compressed by the codec layer + * before insertion and decompressed inside the `db_.fetch()` callback on read. + * + * @note `fetch()` and `store()` are thread-safe. `forEach()` and `verify()` + * close and reopen the database and must not be called concurrently with + * any other operation. + * @see nodeobjectCompress, nodeobjectDecompress, NuDBFactory + */ class NuDBBackend : public Backend { public: - // "appnum" is an application-defined constant stored in the header of a - // NuDB database. We used it to identify shard databases before that code - // was removed. For now, its only use is a sanity check that the database - // was created by xrpld. + /** Application-defined tag stored in the NuDB file header. + * + * Originally used to distinguish shard databases; that code has been + * removed. Today the sole purpose is a sanity check on `open()` that the + * files were created by xrpld and not by another NuDB application. + */ static constexpr std::uint64_t kAPPNUM = 1; - beast::Journal const j; - size_t const keyBytes; - std::size_t const burstSize; - std::string const name; - std::size_t const blockSize; - nudb::store db; + beast::Journal const j; /**< Logging sink for diagnostics. */ + size_t const keyBytes; /**< Fixed key width in bytes (always 32). */ + std::size_t const burstSize; /**< NuDB in-memory write buffer limit in bytes. */ + std::string const name; /**< Path to the database directory (from `path=` config). */ + std::size_t const blockSize; /**< NuDB key-file block size in bytes. */ + nudb::store db; /**< Underlying NuDB database handle. */ + /** Set to `true` when `close()` should delete the database directory. + * + * Written from a potentially different thread than the one that calls + * `close()`, so declared atomic to avoid a data race. + */ std::atomic deletePath; - Scheduler& scheduler; + Scheduler& scheduler; /**< Async task dispatcher for write telemetry. */ + /** Construct a NuDBBackend without a shared NuDB I/O context. + * + * Parses configuration and validates the `path` key but does not touch + * the filesystem. Call `open()` to initialize the database files. + * + * @param keyBytes Fixed key width in bytes (always 32 in production). + * @param keyValues Configuration key/value pairs from `[node_db]`. + * @param burstSize NuDB in-memory write buffer size in bytes. + * @param scheduler Async task dispatcher used for write telemetry. + * @param journal Logging sink for backend diagnostics. + * @throws std::runtime_error if `path` is absent from `keyValues` or if + * `nudb_block_size` is present but invalid. + */ NuDBBackend( size_t keyBytes, Section const& keyValues, @@ -82,6 +129,22 @@ public: Throw("nodestore: Missing path in NuDB backend"); } + /** Construct a NuDBBackend sharing an existing NuDB I/O context. + * + * The `nudb::context` owns background I/O threads used by NuDB for + * asynchronous buffering. Providing one enables shared I/O across + * multiple backends (e.g., when several shards are open simultaneously). + * Apart from the shared context, construction semantics are identical to + * the context-free overload. + * + * @param keyBytes Fixed key width in bytes. + * @param keyValues Configuration key/value pairs from `[node_db]`. + * @param burstSize NuDB in-memory write buffer size in bytes. + * @param scheduler Async task dispatcher for write telemetry. + * @param context Shared NuDB I/O thread pool. + * @param journal Logging sink for backend diagnostics. + * @throws std::runtime_error if `path` is absent or `nudb_block_size` is invalid. + */ NuDBBackend( size_t keyBytes, Section const& keyValues, @@ -102,11 +165,16 @@ public: Throw("nodestore: Missing path in NuDB backend"); } + /** Close the database and optionally delete its directory. + * + * Any `nudb::system_error` thrown by `close()` is caught and suppressed + * because destructors must not propagate exceptions. The error is already + * logged at `fatal` level inside `close()` before being swallowed here. + */ ~NuDBBackend() override { try { - // close can throw and we don't want the destructor to throw. close(); } catch (nudb::system_error const&) // NOLINT(bugprone-empty-catch) @@ -116,18 +184,45 @@ public: } } + /** Return the database directory path used in diagnostics. */ std::string getName() override { return name; } + /** Return the NuDB key-file block size in bytes. + * + * Callers can use this to make storage-layout decisions without + * downcasting. Always set at construction from `nudb_block_size` config + * or the filesystem-native default (typically 4096). + * + * @return Key-file block size in bytes. + */ [[nodiscard]] std::optional getBlockSize() const override { return blockSize; } + /** Open the backend with deterministic NuDB header parameters. + * + * When `createIfMissing` is `true` the three NuDB files are created with + * the given `appType`, `uid`, and `salt` embedded in their headers. If the + * files already exist the `file_exists` error is silently cleared and + * `db_.open()` proceeds normally — creation is idempotent. After opening, + * `appnum` is checked against `kAPPNUM` to confirm the files belong to + * xrpld, and `db_.set_burst()` is called to configure the in-memory write + * buffer. + * + * @param createIfMissing Create the database directory and files if absent. + * @param appType Application type tag written into the NuDB file header. + * @param uid Deterministic unique identifier for this database instance. + * @param salt Deterministic salt used during NuDB key-file construction. + * @throws nudb::system_error on I/O failure during creation or open. + * @throws std::runtime_error if the existing database `appnum` does not + * equal `kAPPNUM`. + */ void open(bool createIfMissing, uint64_t appType, uint64_t uid, uint64_t salt) override { @@ -166,18 +261,41 @@ public: db.set_burst(burstSize); } + /** Return `true` if the underlying NuDB database is currently open. */ bool isOpen() override { return db.is_open(); } + /** Open the backend with randomly generated uid and salt. + * + * Delegates to the four-argument overload using `nudb::make_uid()` and + * `nudb::make_salt()` for the identifier fields. Appropriate for the main + * node store where exactly one instance is created for a given path and + * reproducible identifiers are not required. + * + * @param createIfMissing Create the database files if they do not exist. + * @throws nudb::system_error on I/O failure. + * @throws std::runtime_error if an existing database has an unexpected appnum. + */ void open(bool createIfMissing) override { open(createIfMissing, kAPPNUM, nudb::make_uid(), nudb::make_salt()); } + /** Flush pending writes and close all NuDB files. + * + * A NuDB close failure is logged at `fatal` level before the exception is + * thrown, ensuring the error reaches the operator even if the caller + * catches and discards the exception (as the destructor does). If + * `deletePath` was set, the entire database directory is removed with + * `boost::filesystem::remove_all()` after a successful close; removal + * failures are logged at `fatal` but do not throw. + * + * @throws nudb::system_error if NuDB reports an error during close. + */ void close() override { @@ -204,6 +322,22 @@ public: } } + /** Fetch a single object by its 256-bit hash. + * + * Calls `db_.fetch()` with a lambda callback that receives a raw pointer + * into NuDB's internal read buffer (valid only for the callback's + * duration). Decompression via `nodeobjectDecompress()` and + * `DecodedBlob::createObject()` both happen inside the callback — the + * zero-copy design means no intermediate heap allocation for the raw blob. + * + * @param hash The 256-bit hash key identifying the object. + * @param pno Output parameter; set to the reconstructed `NodeObject` on + * success, or reset on any non-Ok outcome. + * @return `Status::Ok` on success, `Status::NotFound` if the key is + * absent, or `Status::DataCorrupt` if the stored blob fails + * `DecodedBlob` validation. + * @throws nudb::system_error on unexpected I/O errors. + */ Status fetch(uint256 const& hash, std::shared_ptr* pno) override { @@ -232,6 +366,18 @@ public: return status; } + /** Fetch a batch of objects by their 256-bit hashes. + * + * Implemented as a sequential loop over individual `fetch()` calls — + * NuDB provides no native batch-read operation. Missing or corrupt entries + * produce empty slots (`{}`) in the result vector rather than aborting + * the entire batch; the aggregate status is always `Status::Ok`. + * + * @param hashes Ordered list of 256-bit hash keys to fetch. + * @return A pair of (results vector, `Status::Ok`). Each element in the + * results vector is the fetched `NodeObject`, or an empty + * `shared_ptr` if the corresponding hash was not found or corrupted. + */ std::pair>, Status> fetchBatch(std::vector const& hashes) override { @@ -254,6 +400,17 @@ public: return {results, Status::Ok}; } + /** Compress and insert a single `NodeObject` into the NuDB store. + * + * The write path is: `EncodedBlob` serializes the object to raw bytes, + * `nodeobjectCompress()` applies LZ4 or inner-node sparse encoding, then + * `db_.insert()` appends to the data file. A `key_exists` error from + * NuDB is silently discarded — the store is content-addressed, so the + * same hash always maps to the same data. + * + * @param no The `NodeObject` to serialize and store. + * @throws nudb::system_error on any NuDB error other than `key_exists`. + */ void doInsert(std::shared_ptr const& no) { @@ -266,6 +423,15 @@ public: Throw(ec); } + /** Store a single object and report write telemetry to the scheduler. + * + * NuDB writes are synchronous — `doInsert()` returns only after the data + * is in the NuDB write buffer. There is no internal queue; `getWriteLoad()` + * therefore always returns 0. + * + * @param no The `NodeObject` to persist. + * @throws nudb::system_error on I/O failure. + */ void store(std::shared_ptr const& no) override { @@ -278,6 +444,15 @@ public: scheduler.onBatchWrite(report); } + /** Store a batch of objects as sequential synchronous inserts. + * + * Unlike RocksDB's `WriteBatch`, NuDB has no native atomic multi-write + * operation. Each object is inserted via `doInsert()` in order. Telemetry + * covers the entire batch as a single `BatchWriteReport`. + * + * @param batch The collection of `NodeObject`s to persist. + * @throws nudb::system_error if any individual insert fails. + */ void storeBatch(Batch const& batch) override { @@ -291,18 +466,31 @@ public: scheduler.onBatchWrite(report); } + /** No-op: NuDB's write-ahead log provides implicit durability. */ void sync() override { } + /** Invoke a callback for every object in the database. + * + * `nudb::visit()` reads the data file sequentially, which is incompatible + * with concurrent access through a live `nudb::store`. This method + * therefore closes the database before visiting and reopens it afterward. + * It must not be called while any other I/O operation is in flight. + * + * @param f Callback invoked once per stored object with the reconstructed + * `shared_ptr`. Entries that fail `DecodedBlob` validation + * are skipped and cause `nudb::error::missing_value` to be set, + * aborting the visit. + * @throws nudb::system_error on close, visit, or reopen failure. + */ void forEach(std::function)> f) override { auto const dp = db.dat_path(); auto const kp = db.key_path(); auto const lp = db.log_path(); - // auto const appnum = db_.appnum(); nudb::error_code ec; db.close(ec); if (ec) @@ -333,18 +521,38 @@ public: Throw(ec); } + /** Return 0: NuDB writes are synchronous with no internal queue. */ int getWriteLoad() override { return 0; } + /** Schedule the database directory for deletion on the next `close()`. + * + * Thread-safe: `deletePath` is `std::atomic` to allow this to be + * called from a different thread than the one that calls `close()`. + */ void setDeletePath() override { deletePath = true; } + /** Perform an offline consistency check of the NuDB key and data files. + * + * `nudb::verify()` re-hashes every stored key and + * confirms it matches the key-file index. The `xxhasher` template + * parameter must match the one used at database creation time. + * + * Because `nudb::verify` needs exclusive file access the live database is + * closed before the check and reopened afterward. Must not be called + * concurrently with any other operation. + * + * @throws nudb::system_error on close, verification failure, or reopen. + * @note Not currently called at startup; if it were, on-disk corruption + * could be caught before it causes a runtime crash. + */ void verify() override { @@ -364,6 +572,14 @@ public: Throw(ec); } + /** Return 3: the data, key, and log files NuDB keeps open simultaneously. + * + * The `Database` base class aggregates this value across all backends so + * the process can verify its file-descriptor budget before opening any + * databases. + * + * @return 3 + */ [[nodiscard]] int fdRequired() const override { @@ -371,6 +587,22 @@ public: } private: + /** Parse and validate the `nudb_block_size` configuration key. + * + * Returns the filesystem-native block size (via `nudb::block_size()`, + * typically 4096 bytes) if the key is absent. When present, the value + * must be a power of 2 in the range [4096, 32768]; values outside this + * range cause I/O amplification or exceed NuDB's supported limits. A + * valid custom size is logged at `info` level. + * + * @param name Database directory path (used to derive the key-file path + * for the `nudb::block_size()` default lookup). + * @param keyValues Configuration key/value pairs from `[node_db]`. + * @param journal Logging sink for the custom-size info message. + * @return Block size in bytes to use for the NuDB key file. + * @throws std::runtime_error if `nudb_block_size` is present but not a + * valid power-of-2 integer in [4096, 32768], or cannot be parsed. + */ static std::size_t parseBlockSize(std::string const& name, Section const& keyValues, beast::Journal journal) { @@ -378,20 +610,17 @@ private: auto const folder = path(name); auto const kp = (folder / "nudb.key").string(); - std::size_t const defaultSize = nudb::block_size(kp); // Default 4K from NuDB + std::size_t const defaultSize = nudb::block_size(kp); std::size_t const blockSize = defaultSize; std::string blockSizeStr; if (!getIfExists(keyValues, "nudb_block_size", blockSizeStr)) - { - return blockSize; // Early return with default - } + return blockSize; try { std::size_t const parsedBlockSize = beast::lexicalCastThrow(blockSizeStr); - // Validate: must be power of 2 between 4K and 32K if (parsedBlockSize < 4096 || parsedBlockSize > 32768 || (parsedBlockSize & (parsedBlockSize - 1)) != 0) { @@ -415,23 +644,44 @@ private: //------------------------------------------------------------------------------ +/** `Factory` implementation that creates `NuDBBackend` instances. + * + * Registers itself with the global `Manager` at construction time so that + * `[node_db]` sections specifying `type=NuDB` (case-insensitive) resolve to + * this factory. The instance is held as a function-local static by + * `registerNuDBFactory()`, giving it program lifetime and safe destruction + * order relative to `Manager`. + * + * @see NuDBBackend, registerNuDBFactory, Manager + */ class NuDBFactory : public Factory { private: Manager& manager_; public: + /** Register this factory with `manager` on construction. */ explicit NuDBFactory(Manager& manager) : manager_(manager) { manager_.insert(*this); } + /** Return `"NuDB"` — the configuration type string for this backend. */ [[nodiscard]] std::string getName() const override { return "NuDB"; } + /** Construct a `NuDBBackend` without a shared NuDB I/O context. + * + * @param keyBytes Fixed key width in bytes (always 32 in production). + * @param keyValues Configuration key/value pairs from `[node_db]`. + * @param burstSize NuDB in-memory write buffer size in bytes. + * @param scheduler Async task dispatcher for write telemetry. + * @param journal Logging sink for backend diagnostics. + * @return An unopened `NuDBBackend` instance. + */ std::unique_ptr createInstance( size_t keyBytes, @@ -443,6 +693,19 @@ public: return std::make_unique(keyBytes, keyValues, burstSize, scheduler, journal); } + /** Construct a `NuDBBackend` sharing an existing NuDB I/O context. + * + * Enables shared background I/O threads across multiple backends (e.g., + * when several shards are open simultaneously). + * + * @param keyBytes Fixed key width in bytes. + * @param keyValues Configuration key/value pairs from `[node_db]`. + * @param burstSize NuDB in-memory write buffer size in bytes. + * @param scheduler Async task dispatcher for write telemetry. + * @param context Shared NuDB I/O thread pool. + * @param journal Logging sink for backend diagnostics. + * @return An unopened `NuDBBackend` instance. + */ std::unique_ptr createInstance( size_t keyBytes, @@ -457,6 +720,18 @@ public: } }; +/** Register the NuDB backend factory with the global NodeStore `Manager`. + * + * Creates a function-local `static NuDBFactory` instance, which registers + * itself with `manager` via `Manager::insert()` in its constructor. The + * function-local static has program lifetime and initializes exactly once, + * guaranteeing that the factory outlives any backend instance it creates and + * is safely destroyed after `Manager` (reverse construction order). + * + * Called once at program startup from `ManagerImp`'s constructor. + * + * @param manager The global `Manager` singleton to register with. + */ void registerNuDBFactory(Manager& manager) { diff --git a/src/libxrpl/nodestore/backend/NullFactory.cpp b/src/libxrpl/nodestore/backend/NullFactory.cpp index 38f35e91ff..ab8edd43b6 100644 --- a/src/libxrpl/nodestore/backend/NullFactory.cpp +++ b/src/libxrpl/nodestore/backend/NullFactory.cpp @@ -1,3 +1,11 @@ +/** @file + * No-op NodeStore backend: every read misses, every write is discarded. + * + * Provides the `"none"` backend registered with the `Manager` singleton so + * that `type=none` in `[node_db]` is a valid, safe configuration rather than + * a fatal startup error. Also useful in tests that need a `Backend` interface + * without real storage. + */ #include #include #include @@ -17,6 +25,17 @@ namespace xrpl::NodeStore { +/** No-op `Backend` implementation that stores nothing. + * + * All reads return `Status::NotFound` immediately. All writes are silently + * dropped. The backend never transitions to an open state, so `isOpen()` + * always returns `false` and `fdRequired()` returns zero. + * + * Serves as the minimal reference implementation of the `Backend` interface: + * every pure-virtual method is present in its simplest possible form. + * + * @see NullFactory, registerNullFactory + */ class NullBackend : public Backend { public: @@ -24,72 +43,94 @@ public: ~NullBackend() override = default; + /** Returns an empty string; the null backend has no named file or path. */ std::string getName() override { return std::string(); } + /** No-op: the null backend requires no initialization. */ void open(bool createIfMissing) override { } + /** Always returns `false`; the null backend never enters an open state. */ bool isOpen() override { return false; } + /** No-op: there is nothing to flush or close. */ void close() override { } + /** Always reports the key as absent; no storage is consulted. + * + * @param hash Ignored. + * @param pObject Ignored; left unchanged. + * @return `Status::NotFound` unconditionally. + */ Status fetch(uint256 const&, std::shared_ptr*) override { return Status::NotFound; } + /** Always reports every key as absent; no storage is consulted. + * + * @param hashes Ignored. + * @return A default-constructed pair: an empty results vector and a + * default `Status`, indicating nothing was found. + */ std::pair>, Status> fetchBatch(std::vector const& hashes) override { return {}; } + /** No-op: the object is silently discarded. */ void store(std::shared_ptr const& object) override { } + /** No-op: the batch is silently discarded. */ void storeBatch(Batch const& batch) override { } + /** No-op: there is no write buffer to flush. */ void sync() override { } + /** No-op: the callback is never invoked because there are no stored objects. */ void forEach(std::function)> f) override { } + /** Always returns zero; all writes are immediately discarded. */ int getWriteLoad() override { return 0; } + /** No-op: there are no on-disk files to schedule for deletion. */ void setDeletePath() override { } - /** Returns the number of file descriptors the backend expects to need */ + /** Always returns zero; the null backend opens no file descriptors. */ [[nodiscard]] int fdRequired() const override { @@ -101,23 +142,48 @@ private: //------------------------------------------------------------------------------ +/** `Factory` that produces `NullBackend` instances and registers as `"none"`. + * + * Instantiated as a function-local static inside `registerNullFactory()` so + * that it is constructed after the `Manager` singleton and destroyed before + * it, avoiding initialization-order hazards between translation units. + * + * @see NullBackend, registerNullFactory + */ class NullFactory : public Factory { private: Manager& manager_; public: + /** Register this factory with `manager` on construction. + * + * @param manager The `Manager` singleton into which this factory + * is inserted. The reference must remain valid for the lifetime + * of this object (guaranteed when used as a function-local static + * inside `registerNullFactory`). + */ explicit NullFactory(Manager& manager) : manager_(manager) { manager_.insert(*this); } + /** Returns `"none"`, the configuration type string for this backend. + * + * Matched case-insensitively against the `type=` key in `[node_db]`, + * so `type=none`, `type=None`, and `type=NONE` all select this backend. + */ [[nodiscard]] std::string getName() const override { return "none"; } + /** Constructs a new `NullBackend`; all parameters are ignored. + * + * @return An unopened `NullBackend`. Calling `open()` on it is a no-op, + * so the returned instance is immediately usable. + */ std::unique_ptr createInstance(size_t, Section const&, std::size_t, Scheduler&, beast::Journal) override { @@ -125,6 +191,17 @@ public: } }; +/** Register the null backend factory with the `Manager` singleton. + * + * Called once by `ManagerImp`'s constructor alongside the equivalent + * registration functions for NuDB, RocksDB, and the memory backend. The + * `NullFactory` is held as a `const` function-local static, guaranteeing + * thread-safe, once-only initialization and a destruction order that is + * always before the `Manager` singleton — preventing any use-after-destroy + * when the factory's destructor eventually calls `manager.erase()`. + * + * @param manager The `Manager` singleton to register the factory with. + */ void registerNullFactory(Manager& manager) { diff --git a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp index 7f5ac6b14e..cbfc919ade 100644 --- a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp +++ b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp @@ -1,3 +1,13 @@ +/** @file + * RocksDB backend implementation for the XRPL NodeStore. + * + * Provides `RocksDBBackend`, a concrete `Backend` that persists serialized + * ledger objects in a RocksDB instance, and `RocksDBFactory`, which + * registers the backend with the NodeStore plugin registry. The entire + * file is compiled only when `XRPL_ROCKSDB_AVAILABLE` is defined. Prefer + * NuDB for typical deployments; RocksDB is available as an alternative for + * operators with different I/O access patterns. + */ #include #include #include @@ -48,6 +58,19 @@ namespace xrpl::NodeStore { +/** RocksDB environment shim that assigns human-readable names to RocksDB's + * internal threads. + * + * RocksDB creates background threads (compaction workers, flush threads) + * through its `Env` abstraction. By overriding `StartThread`, this class + * intercepts each new thread and calls `beast::setCurrentThreadName` to + * assign a stable `"rocksdb #N"` identifier before handing control to the + * original entry point. This makes the threads visible in profilers and + * crash dumps with no effect on correctness. + * + * A single `RocksDBEnv` instance is shared across all backends created by + * the `RocksDBFactory` singleton. + */ class RocksDBEnv : public rocksdb::EnvWrapper { public: @@ -55,6 +78,9 @@ public: { } + /** Carries the original RocksDB thread function and argument across the + * `StartThread` trampoline so they can be invoked after thread naming. + */ struct ThreadParams { ThreadParams(void (*f)(void*), void* a) : f(f), a(a) @@ -65,6 +91,16 @@ public: void* a; }; + /** Trampoline invoked on each new RocksDB thread. + * + * Deletes the heap-allocated `ThreadParams`, assigns a unique + * `"rocksdb #N"` thread name via `beast::setCurrentThreadName`, then + * calls the original entry point. The monotonically incrementing counter + * is process-wide, so names are unique across all `RocksDBEnv` instances. + * + * @param ptr Heap-allocated `ThreadParams*`; ownership is transferred to + * this function, which deletes it before calling the original entry. + */ static void threadEntry(void* ptr) { @@ -81,6 +117,14 @@ public: f(a); } + /** Intercept RocksDB thread creation to install the naming trampoline. + * + * Wraps `f` and `a` in a heap-allocated `ThreadParams` and delegates to + * `EnvWrapper::StartThread` with `threadEntry` as the actual entry point. + * + * @param f The thread entry function RocksDB wants to run. + * @param a Opaque argument passed through to `f`. + */ void StartThread(void (*f)(void*), void* a) override { @@ -91,20 +135,59 @@ public: //------------------------------------------------------------------------------ +/** RocksDB storage backend for the XRPL NodeStore. + * + * Implements both `Backend` (the NodeStore storage contract) and + * `BatchWriter::Callback` (the async-write sink). The dual inheritance is + * deliberate: `writeBatch()` is visible to `BatchWriter` but not to the + * broader `Backend` consumers. Individual `store()` calls are coalesced by + * `BatchWriter` and flushed as a single `rocksdb::WriteBatch`, which RocksDB + * writes atomically to its WAL. + * + * Configuration accepts legacy defaults that may be silently promoted to + * production-appropriate values unless `hard_set = true` is also present. + * See the constructor for the full escalation table. + * + * @note `fetch()` and `store()` are called concurrently; all other methods + * follow the `Backend` single-caller contract. @see Backend + */ class RocksDBBackend : public Backend, public BatchWriter::Callback { private: std::atomic deletePath_; public: - beast::Journal journal; - size_t const keyBytes; - BatchWriter batch; - std::string name; - std::unique_ptr db; - int fdMinRequired = 2048; - rocksdb::Options options; + beast::Journal journal; /**< Logging sink used throughout the backend. */ + size_t const keyBytes; /**< Fixed key width in bytes (always 32 in production). */ + BatchWriter batch; /**< Write coalescer; flushes to `writeBatch()`. */ + std::string name; /**< Filesystem path to the RocksDB directory. */ + std::unique_ptr db; /**< Owning handle to the open RocksDB instance; null when closed. */ + int fdMinRequired = 2048; /**< Minimum file descriptors needed; updated from `max_open_files + 128`. */ + rocksdb::Options options; /**< Fully resolved RocksDB options, built in the constructor. */ + /** Construct and configure a RocksDB backend from key/value config pairs. + * + * Translates the `[node_db]` config section into `rocksdb::Options` and + * `rocksdb::BlockBasedTableOptions`. Several settings apply a legacy + * default escalation when `hard_set` is absent: + * - `cache_mb = 256` is promoted to 1024 MB + * - `open_files = 2000` is promoted to 8000 + * - `file_size_mb = 8` is promoted to 256 MB + * + * After construction the database is not yet open; call `open()` to + * start I/O. The resolved `DBOptions` and `ColumnFamilyOptions` are + * logged at debug level so operators can verify escalation took effect. + * + * @param keyBytes Fixed key width in bytes; always 32 in production. + * @param keyValues Key/value config pairs from the `[node_db]` section. + * Must contain a `path` key. + * @param scheduler Scheduler for dispatching deferred batch-write tasks. + * @param journal Logging sink for diagnostics and error reporting. + * @param env Shared `RocksDBEnv` that names background threads; must + * outlive this object. + * @throws std::runtime_error if `path` is absent, if `bbt_options` or + * `options` strings fail to parse, or on any other config error. + */ RocksDBBackend( int keyBytes, Section const& keyValues, @@ -226,6 +309,18 @@ public: close(); } + /** Open the RocksDB database, optionally creating it if absent. + * + * Sets `create_if_missing` on the options and calls `rocksdb::DB::Open`, + * adopting the returned raw pointer into `db`. Calling this method on an + * already-open backend is a programming error and triggers `UNREACHABLE`. + * + * @param createIfMissing If `true`, create the database directory and + * files when they do not exist. Pass `false` to fail fast on a + * missing database. + * @throws std::runtime_error if RocksDB reports a failure status or + * returns a null pointer. + */ void open(bool createIfMissing) override { @@ -250,12 +345,21 @@ public: db.reset(localDb); } + /** Return `true` if the database has been opened and not yet closed. */ bool isOpen() override { return static_cast(db); } + /** Close the database and, if `setDeletePath()` was called, remove the + * directory from the filesystem. + * + * Resetting `db` triggers RocksDB's own cleanup (WAL flush, file close). + * Directory removal happens only after RocksDB has cleanly shut down, + * ensuring no open file handles are left on the path being deleted. + * No-op if the database is already closed. + */ void close() override { @@ -270,6 +374,7 @@ public: } } + /** Return the filesystem path to the RocksDB directory. */ std::string getName() override { @@ -278,6 +383,22 @@ public: //-------------------------------------------------------------------------- + /** Fetch a single object by its 256-bit hash. + * + * Constructs a `rocksdb::Slice` directly over the `uint256` bytes to + * avoid copying the key. The value string is parsed by `DecodedBlob`. + * Three distinct failure modes are reported: + * - `Status::DataCorrupt` — RocksDB reported corruption *or* `DecodedBlob` + * rejected the stored value. + * - `Status::NotFound` — clean miss; the key is absent. + * - `Status::CustomCode + status.code()` — an unexpected RocksDB error; + * also logged at error level. + * + * @param hash The 256-bit hash key identifying the object to retrieve. + * @param pObject Output parameter; set to the decoded `NodeObject` on + * `Status::Ok`, reset to null on any other outcome. + * @return The fetch status; see above for the three non-`Ok` cases. + */ Status fetch(uint256 const& hash, std::shared_ptr* pObject) override { @@ -330,6 +451,21 @@ public: return status; } + /** Fetch a batch of objects by their 256-bit hashes. + * + * Serially calls `fetch()` for each hash and inserts a null `shared_ptr` + * for any miss or error. The aggregate status is always `Status::Ok` + * because per-entry failures are surfaced via null entries in the results + * vector, not via the return code. + * + * @note Unlike `storeBatch()`, this operation has no group atomicity — each + * hash is fetched independently. RocksDB provides no multi-get + * semantics that would change this. + * @param hashes Ordered list of 256-bit hash keys to fetch. + * @return A pair of (results vector parallel to `hashes`, `Status::Ok`). + * A null element at position `i` means `hashes[i]` was not found or + * could not be decoded. + */ std::pair>, Status> fetchBatch(std::vector const& hashes) override { @@ -352,12 +488,23 @@ public: return {results, Status::Ok}; } + /** Enqueue a single object for the next `BatchWriter`-coalesced flush. */ void store(std::shared_ptr const& object) override { batch.store(object); } + /** Encode and persist a batch of objects as a single atomic RocksDB write. + * + * Each `NodeObject` is serialized via `EncodedBlob` and packed into a + * `rocksdb::WriteBatch`. The entire batch is committed with a single + * `db->Write()` call, which RocksDB records atomically in its WAL — + * either all objects in the group are recoverable after a crash, or none. + * + * @param batch The collection of `NodeObject`s to persist. + * @throws std::runtime_error if `db->Write()` returns a non-ok status. + */ void storeBatch(Batch const& batch) override { @@ -384,11 +531,31 @@ public: Throw("storeBatch failed: " + ret.ToString()); } + /** No-op durability barrier. + * + * RocksDB's write-ahead log provides crash durability automatically for + * every committed `WriteBatch`. An explicit fsync barrier at the NodeStore + * level is unnecessary. + */ void sync() override { } + /** Invoke a callback for every object stored in the database. + * + * Creates a plain `rocksdb::Iterator` (no snapshot pinning) and decodes + * each entry via `DecodedBlob`. Used exclusively during database import; + * per the `Backend` contract it is never called concurrently with other + * operations. + * + * Entries with an unexpected key size are logged at fatal level and + * skipped rather than thrown, allowing the iterator to continue past + * potential on-disk corruption. + * + * @param f Callback invoked once per valid object; receives a + * `shared_ptr` for each successfully decoded entry. + */ void forEach(std::function)> f) override { @@ -422,12 +589,27 @@ public: } } + /** Return an estimate of the number of pending write operations. + * + * Delegates to `BatchWriter::getWriteLoad()`, which reports the larger of + * the item count currently being flushed and the count waiting in the + * accumulation buffer. + * + * @return Approximate count of `NodeObject`s awaiting or undergoing write. + */ int getWriteLoad() override { return batch.getWriteLoad(); } + /** Schedule the on-disk files for deletion when the backend is closed. + * + * Sets an atomic flag; the actual directory removal happens in `close()` + * after RocksDB cleanly shuts down. The flag is atomic because this method + * may be called from a different thread than the one that destroys the + * backend. + */ void setDeletePath() override { @@ -436,13 +618,27 @@ public: //-------------------------------------------------------------------------- + /** `BatchWriter::Callback` sink — forwards the completed batch to `storeBatch()`. + * + * Called by `BatchWriter` on the scheduler thread once per flush cycle. + * The thin delegation keeps batching logic separate from storage logic. + * + * @param batch The coalesced collection of `NodeObject`s to persist. + */ void writeBatch(Batch const& batch) override { storeBatch(batch); } - /** Returns the number of file descriptors the backend expects to need */ + /** Return the number of file descriptors this backend expects to consume. + * + * Computed as `max_open_files + 128` when `open_files` is configured, + * giving the process headroom beyond RocksDB's own limit. Defaults to + * 2048 when `open_files` is not set in the config. + * + * @return Expected file descriptor count for this backend instance. + */ [[nodiscard]] int fdRequired() const override { @@ -452,25 +648,53 @@ public: //------------------------------------------------------------------------------ +/** NodeStore `Factory` that creates `RocksDBBackend` instances. + * + * Owns a single `RocksDBEnv` shared across all backends it produces, so + * RocksDB's background threads are all named through the same counter. + * Registers itself with the `Manager` on construction; `Manager::find("RocksDB")` + * (case-insensitive) resolves to this factory. + * + * @note Instantiated as a function-local static inside + * `registerRocksDBFactory()`, guaranteeing a single registration per + * process and safe destruction order relative to the `Manager` singleton. + */ class RocksDBFactory : public Factory { private: Manager& manager_; public: - RocksDBEnv env; + RocksDBEnv env; /**< Shared environment; assigns names to all RocksDB background threads. */ + /** Register this factory with the NodeStore `Manager`. + * + * @param manager The singleton `Manager` that this factory is inserted into. + */ RocksDBFactory(Manager& manager) : manager_(manager) { manager_.insert(*this); } + /** Return `"RocksDB"` — the config `type=` string for this backend. */ [[nodiscard]] std::string getName() const override { return "RocksDB"; } + /** Construct an unopened `RocksDBBackend` from configuration. + * + * The `burstSize` parameter is intentionally ignored; RocksDB manages its + * own internal buffering independently of any externally imposed burst + * limit. Call `Backend::open()` on the returned instance before doing I/O. + * + * @param keyBytes Fixed key width in bytes; always 32 in production. + * @param keyValues Key/value pairs from the `[node_db]` config section. + * @param scheduler Scheduler for dispatching deferred batch-write tasks. + * @param journal Logging sink passed through to the new backend. + * @return An unopened, uniquely-owned `RocksDBBackend`. + */ std::unique_ptr createInstance( size_t keyBytes, @@ -483,6 +707,16 @@ public: } }; +/** Register the RocksDB backend with the NodeStore `Manager` singleton. + * + * Uses a function-local static `RocksDBFactory` to guarantee exactly one + * registration per process and correct destruction order: the factory is + * initialized after `ManagerImp` and destroyed before it, avoiding + * use-after-free on teardown. + * + * @param manager The `Manager` to register with; typically the singleton + * returned by `Manager::instance()`. + */ void registerRocksDBFactory(Manager& manager) { diff --git a/src/libxrpl/protocol/AMMCore.cpp b/src/libxrpl/protocol/AMMCore.cpp index b4c44f0f36..681569a624 100644 --- a/src/libxrpl/protocol/AMMCore.cpp +++ b/src/libxrpl/protocol/AMMCore.cpp @@ -1,3 +1,12 @@ +/** @file + * Stateless validation and utility functions shared by all AMM transactors. + * + * Every AMM transaction handler (AMMCreate, AMMDeposit, AMMWithdraw, AMMBid, + * AMMVote, AMMDelete) calls into these functions during preflight to validate + * inputs before touching ledger state. The file also provides LP token + * identity derivation and auction slot time arithmetic. No ledger access or + * state mutation occurs here. + */ #include #include @@ -25,6 +34,22 @@ namespace xrpl { +/** Compute the LP token currency for an AMM asset pair. + * + * Produces a deterministic `Currency` identifier whose first byte is `0x03` + * (the AMM LP token marker) followed by 19 bytes taken from the SHA-512 half + * of the canonically ordered asset pair. The inputs are sorted via + * `std::minmax` before hashing, so `ammLPTCurrency(A, B)` and + * `ammLPTCurrency(B, A)` always return the same value. + * + * For an `Issue` asset the hash covers its `Currency` field; for an + * `MPTIssue` asset it covers the 192-bit `MPTID`. + * + * @param asset1 One asset of the pool. + * @param asset2 The other asset of the pool. + * @return A `Currency` with prefix byte `0x03` that uniquely identifies the + * LP token for this pair. + */ Currency ammLPTCurrency(Asset const& asset1, Asset const& asset2) { @@ -49,12 +74,39 @@ ammLPTCurrency(Asset const& asset1, Asset const& asset2) return currency; } +/** Construct the complete LP token `Issue` for an AMM asset pair. + * + * Combines the currency derived by `ammLPTCurrency` with the AMM account + * to form an `Issue` that fully identifies the LP token on the ledger. + * + * @param asset1 One asset of the pool. + * @param asset2 The other asset of the pool. + * @param ammAccountID Account ID of the AMM instance. + * @return An `Issue` identifying the LP token issued by `ammAccountID`. + */ Issue ammLPTIssue(Asset const& asset1, Asset const& asset2, AccountID const& ammAccountID) { return Issue(ammLPTCurrency(asset1, asset2), ammAccountID); } +/** Validate a single AMM asset for structural correctness. + * + * Dispatches on the asset type: + * - `MPTIssue`: rejects a zero issuer (`temBAD_MPT`). + * - `Issue`: rejects `badCurrency()` (`temBAD_CURRENCY`) and any XRP + * `Issue` that carries a non-zero issuer (`temBAD_ISSUER`), which is + * structurally impossible on the XRP Ledger. + * + * When `pair` is provided the asset must also be one of the two pool assets, + * otherwise `temBAD_AMM_TOKENS` is returned. Omit `pair` to perform a + * format-only check with no pool membership constraint. + * + * @param asset The asset to validate. + * @param pair Optional known asset pair for the pool; when supplied, the + * asset must match `pair->first` or `pair->second`. + * @return `tesSUCCESS` if valid; a `tem*` error code otherwise. + */ NotTEC invalidAMMAsset(Asset const& asset, std::optional> const& pair) { @@ -78,6 +130,19 @@ invalidAMMAsset(Asset const& asset, std::optional> const return tesSUCCESS; } +/** Validate that two assets form a legal AMM pool pair. + * + * First asserts the assets are distinct (`temBAD_AMM_TOKENS` if equal), then + * validates each individually via `invalidAMMAsset`. Assets of different + * types are never equal, so cross-type self-pairing is implicitly prevented. + * + * @param asset1 First pool asset. + * @param asset2 Second pool asset; must differ from `asset1`. + * @param pair Optional known pool pair forwarded to `invalidAMMAsset` for + * pool membership checks on each asset. + * @return `tesSUCCESS` if both assets are valid and distinct; a `tem*` + * error code otherwise. + */ NotTEC invalidAMMAssetPair( Asset const& asset1, @@ -93,6 +158,20 @@ invalidAMMAssetPair( return tesSUCCESS; } +/** Validate an `STAmount` for use in an AMM transaction. + * + * Extracts the amount's asset and passes it through `invalidAMMAsset`, then + * checks the numeric value. Negative amounts always fail (`temBAD_AMOUNT`). + * Zero amounts fail unless `validZero` is `true`. + * + * @param amount The amount to validate. + * @param pair Optional pool asset pair forwarded to `invalidAMMAsset`; + * when supplied, the amount's asset must be one of the pair. + * @param validZero Set `true` to permit a zero amount. Use `true` for + * fields that act as optional sentinels (e.g., a minimum bid of zero in + * `AMMBid`, or a deposit amount when an EPrice limit is provided). + * @return `tesSUCCESS` if the amount is valid; a `tem*` error code otherwise. + */ NotTEC invalidAMMAmount( STAmount const& amount, @@ -106,6 +185,28 @@ invalidAMMAmount( return tesSUCCESS; } +/** Map a ledger close timestamp to an auction slot interval index. + * + * The 24-hour auction window is divided into `kAUCTION_SLOT_TIME_INTERVALS` + * (20) equal sub-intervals of `kAUCTION_SLOT_INTERVAL_DURATION` (4320 s / + * 72 min) each. The window runs from `expiration - kTOTAL_TIME_SLOT_SECS` + * to `expiration`. + * + * Returns `std::nullopt` when `current` falls outside the window (i.e., the + * slot has not yet started or has already expired). Callers treat + * `std::nullopt` as "no active slot holder" and `kAUCTION_SLOT_TIME_INTERVALS` + * (20) as the sentinel for an expired slot in RPC responses. + * + * @param current Current ledger close time as seconds since the network + * epoch. + * @param auctionSlot The `sfAuctionSlot` inner object from the AMM ledger + * entry; must contain `sfExpiration`. + * @return The zero-based interval index in [0, 19] if `current` is within the + * active auction window, or `std::nullopt` if outside. + * @note Slot index 19 is the "tailing slot": the holder is not refunded when + * outbid during this final interval (checked by callers with + * `*timeSlot < kTAILING_SLOT`). + */ std::optional ammAuctionTimeSlot(std::uint64_t current, STObject const& auctionSlot) { @@ -125,6 +226,18 @@ ammAuctionTimeSlot(std::uint64_t current, STObject const& auctionSlot) return std::nullopt; } +/** Return `true` if AMM transactions are enabled on this ledger. + * + * Requires both `featureAMM` and `fixUniversalNumber` to be active. + * The second amendment is mandatory because AMM math relies on the `Number` + * type from `xrpl/basics/Number.h`; `fixUniversalNumber` corrects edge cases + * in that type's arithmetic. Tying both amendments together prevents subtle + * calculation bugs on ledgers where `featureAMM` was enabled before the + * numeric foundation was sound. + * + * @param rules The active ledger rules snapshot. + * @return `true` if both `featureAMM` and `fixUniversalNumber` are enabled. + */ bool ammEnabled(Rules const& rules) { diff --git a/src/libxrpl/protocol/AccountID.cpp b/src/libxrpl/protocol/AccountID.cpp index 6639835b41..2b4ab9119f 100644 --- a/src/libxrpl/protocol/AccountID.cpp +++ b/src/libxrpl/protocol/AccountID.cpp @@ -1,3 +1,9 @@ +/** @file + * Implements AccountID derivation, Base58Check encoding/decoding, sentinel + * constants, and the optional direct-mapped cache that amortises repeated + * Base58Check encoding in hot ledger-processing paths. + */ + #include #include @@ -21,33 +27,87 @@ namespace xrpl { namespace detail { -/** Caches the base58 representations of AccountIDs */ +/** Direct-mapped cache of Base58Check-encoded AccountID strings. + * + * Converting an AccountID to a base58 string requires a SHA-256 checksum + * computation. Because the same account IDs appear repeatedly during ledger + * processing, this cache amortises that cost. + * + * The cache is a flat `std::vector` with no heap allocation + * per slot — the encoded string is stored inline in a fixed 40-byte buffer. + * Slot selection uses a DoS-resistant `HardenedHash<>` (xxHash + random seed) + * so adversarial inputs cannot force systematic collisions. + * + * Concurrency is handled by lock sharding: a single `atomic` + * encodes 64 independent spinlocks via `PackedSpinlock`, one per + * `slot % 64`. Up to 64 distinct slots may be written concurrently without + * blocking each other, while the entire lock state fits in one cache line. + * + * @note This class lives in `namespace detail`; callers should use the + * `toBase58(AccountID const&)` free function and `initAccountIdCache()`. + */ class AccountIdCache { private: + /** One cache slot: the account whose encoding is stored, plus the + * inline 40-byte buffer that holds the NUL-terminated Base58Check string. + * + * XRPL account addresses are at most 34 characters; 40 bytes gives a + * comfortable margin (asserted at ≤38 in `toBase58`). All bytes are + * zero-initialised so that an empty slot is distinguishable from a + * legitimate entry for the all-zero account (`xrpAccount()`). + */ struct CachedAccountID { AccountID id; char encoding[40] = {0}; }; - // The actual cache + /** Flat slot array; size fixed at construction and never reallocated. */ std::vector cache_; - // We use a hash function designed to resist algorithmic complexity attacks + /** DoS-resistant hash used to map an AccountID to a slot index. + * + * Uses xxHash seeded with a random value at startup. A predictable hash + * would be a denial-of-service vector: an attacker could craft account IDs + * that all collide, degrading the cache to O(n) effective slots. + */ HardenedHash<> hasher_; - // 64 spinlocks, packed into a single 64-bit value + /** 64 spinlocks packed into a single 64-bit atomic word. + * + * Each spinlock guards one lock shard (`slot % 64`). Using + * `PackedSpinlock` keeps the entire lock state on a single cache line and + * avoids the overhead of 64 separate `std::mutex` objects. + */ std::atomic locks_ = 0; public: + /** Construct the cache with exactly `count` slots. + * + * `shrink_to_fit` is called after construction to release any excess + * memory that the vector implementation may have reserved. + * + * @param count Number of cache slots to allocate. + */ AccountIdCache(std::size_t count) : cache_(count) { - // This is non-binding, but we try to avoid wasting memory that - // is caused by overallocation. cache_.shrink_to_fit(); } + /** Return the Base58Check encoding of `id`, using the cache when possible. + * + * Acquires the shard spinlock for the target slot before reading or + * writing. The hit check `encoding[0] != 0 && id == id` guards against + * treating an uninitialised (all-zero) slot as a valid hit for + * `xrpAccount()`, whose ID is also all zeros. + * + * On a cache miss the encoding is computed outside the lock to minimise + * contention, then written back under the lock. + * + * @param id The account identifier to encode. + * @return The Base58Check-encoded account string. + */ std::string toBase58(AccountID const& id) { @@ -58,8 +118,9 @@ public: { std::scoped_lock const lock(sl); - // The check against the first character of the encoding ensures - // that we don't mishandle the case of the all-zero account: + // encoding[0] != 0 distinguishes a populated slot from an + // uninitialised one whose id bytes happen to be all zero + // (i.e. xrpAccount()), preventing a false cache hit. if (cache_[index].encoding[0] != 0 && cache_[index].id == id) return cache_[index].encoding; } @@ -80,6 +141,12 @@ public: } // namespace detail +/** Module-level singleton for the optional AccountID→base58 cache. + * + * Null until `initAccountIdCache()` is called with a non-zero count. + * The `toBase58()` free function falls through to the uncached path when + * this pointer is null, so the cache is entirely optional. + */ static std::unique_ptr gAccountIdCache; void @@ -108,41 +175,25 @@ parseBase58(std::string const& s) return AccountID{result}; } -//------------------------------------------------------------------------------ -/* - Calculation of the Account ID - - The AccountID is a 160-bit identifier that uniquely - distinguishes an account. The account may or may not - exist in the ledger. Even for accounts that are not in - the ledger, cryptographic operations may be performed - which affect the ledger. For example, designating an - account not in the ledger as a regular key for an - account that is in the ledger. - - Why did we use half of SHA512 for most things but then - SHA256 followed by RIPEMD160 for account IDs? Why didn't - we do SHA512 half then RIPEMD160? Or even SHA512 then RIPEMD160? - For that matter why RIPEMD160 at all why not just SHA512 and keep - only 160 bits? - - Answer (David Schwartz): - - The short answer is that we kept Bitcoin's behavior. - The longer answer was that: - 1) Using a single hash could leave ripple - vulnerable to length extension attacks. - 2) Only RIPEMD160 is generally considered safe at 160 bits. - - Any of those schemes would have been acceptable. However, - the one chosen avoids any need to defend the scheme chosen. - (Against any criticism other than unnecessary complexity.) - - "The historical reason was that in the very early days, - we wanted to give people as few ways to argue that we were - less secure than Bitcoin. So where there was no good reason - to change something, it was not changed." -*/ +/** Derive a 160-bit AccountID from a public key using SHA-256 + RIPEMD-160. + * + * Feeds the raw public-key bytes through `RipeshaHasher`, which internally + * runs SHA-256 followed by RIPEMD-160 — the same two-step derivation used + * by Bitcoin. The design rationale (David Schwartz): + * + * - A single SHA-256 hash is vulnerable to length-extension attacks; the + * double-hash eliminates that risk. + * - RIPEMD-160 is considered secure at 160 bits, whereas simply truncating + * SHA-256 or SHA-512 output is a less well-analysed approach. + * - XRPL adopted Bitcoin's scheme to avoid any claim of weaker security: + * "where there was no good reason to change something, it was not changed." + * + * The `static_assert` below confirms that `AccountID::kBYTES` equals + * `sizeof(RipeshaHasher::result_type)`, making the cast safe by construction. + * + * @param pk The public key whose address is to be derived. + * @return The 20-byte AccountID. + */ AccountID calcAccountID(PublicKey const& pk) { diff --git a/src/libxrpl/protocol/Asset.cpp b/src/libxrpl/protocol/Asset.cpp index 57843ea6bf..922f8a277d 100644 --- a/src/libxrpl/protocol/Asset.cpp +++ b/src/libxrpl/protocol/Asset.cpp @@ -1,3 +1,13 @@ +/** @file + * Out-of-line implementations for the Asset unified asset abstraction. + * + * The header contains everything that can be inlined or templated; this + * translation unit provides the handful of methods whose bodies are + * non-trivial enough to keep out of the header, together with the two + * free-function JSON helpers (@ref validJSONAsset and @ref assetFromJson) + * that gate JSON ingestion for all asset kinds. + */ + #include #include @@ -17,36 +27,98 @@ namespace xrpl { +/** Return the issuing account for the active asset alternative. + * + * Dispatches via `std::visit` to the same-named method on whichever of + * `Issue` or `MPTIssue` is currently held. For XRP (`Issue` with zero + * currency), this returns `xrpAccount()`; for MPT it returns the issuing + * account embedded in the `MPTID`. + * + * @return A reference into the active alternative's issuer field. + * The reference is stable for the lifetime of the `Asset`. + */ AccountID const& Asset::getIssuer() const { return std::visit([&](auto&& issue) -> AccountID const& { return issue.getIssuer(); }, issue_); } +/** Return a human-readable representation of the asset. + * + * Delegates to the `getText()` method on the active alternative. For + * `Issue` this is the currency code (e.g. `"XRP"` or a 3-letter ISO code); + * for `MPTIssue` it is the hex-encoded `MPTID`. + * + * @return A string describing the asset, suitable for logging and display. + * Not guaranteed to round-trip through a parser. + */ std::string Asset::getText() const { return std::visit([&](auto&& issue) { return issue.getText(); }, issue_); } +/** Populate a JSON object with the fields that identify this asset. + * + * Delegates to the `setJson()` method on the active alternative: + * - `Issue` writes `currency` (and `issuer` for non-XRP). + * - `MPTIssue` writes `mpt_issuance_id`. + * + * These field sets are mutually exclusive, which is the invariant that + * @ref validJSONAsset checks on input. + * + * @param jv JSON object to populate; existing fields are preserved. + */ void Asset::setJson(json::Value& jv) const { std::visit([&](auto&& issue) { issue.setJson(jv); }, issue_); } +/** Factory that constructs an `STAmount` for this asset from a `Number`. + * + * Provides ergonomic syntax: `myAsset(quantity)` instead of + * `STAmount{myAsset, quantity}`. No arithmetic is performed; the call + * purely forwards into the appropriate `STAmount` constructor. + * + * @param number The magnitude to associate with this asset. + * @return An `STAmount` carrying this asset and the given value. + */ STAmount Asset::operator()(Number const& number) const { return STAmount{*this, number}; } +/** Return a string representation of an asset for logging and display. + * + * Delegates to the `to_string()` overload for the active alternative + * (`Issue` or `MPTIssue`). + * + * @param asset The asset to convert. + * @return A human-readable string identifying the asset. + */ std::string to_string(Asset const& asset) { return std::visit([&](auto const& issue) { return to_string(issue); }, asset.value()); } +/** Validate that a JSON object contains a well-formed asset description. + * + * Enforces mutual exclusion between the two asset representations: + * - If `mpt_issuance_id` is present, neither `currency` nor `issuer` may + * appear — those fields belong exclusively to the `Issue` representation. + * - If `mpt_issuance_id` is absent, `currency` must be present. + * + * This is a pure predicate: it returns `bool` and never throws. It is + * intended to be called before @ref assetFromJson so that callers can + * produce a clean error response instead of catching a constructor exception. + * + * @param jv JSON object to inspect. + * @return `true` if the object contains a structurally valid asset + * description; `false` otherwise. + */ bool validJSONAsset(json::Value const& jv) { @@ -55,6 +127,22 @@ validJSONAsset(json::Value const& jv) return jv.isMember(jss::currency); } +/** Construct an `Asset` from a JSON object. + * + * Exactly one of `currency` or `mpt_issuance_id` must be present. When + * `currency` is present the object is parsed as an `Issue` via + * `issueFromJson`; otherwise it is parsed as an `MPTIssue` via + * `mptIssueFromJson`. Deeper field validation (format, consistency) is + * delegated to those two sub-parsers. + * + * @note Callers should call @ref validJSONAsset first if they want to + * distinguish structural errors from a clean parse failure. + * + * @param v JSON object containing the asset description. + * @return The parsed `Asset`. + * @throws std::runtime_error if neither `currency` nor `mpt_issuance_id` + * is present in `v`. + */ Asset assetFromJson(json::Value const& v) { @@ -66,6 +154,15 @@ assetFromJson(json::Value const& v) return mptIssueFromJson(v); } +/** Write an asset to an output stream in its human-readable form. + * + * Delegates to the stream-insertion operator of the active alternative + * (`Issue` or `MPTIssue`). + * + * @param os Destination stream. + * @param x Asset to serialize. + * @return `os`, to allow chained insertions. + */ std::ostream& operator<<(std::ostream& os, Asset const& x) { diff --git a/src/libxrpl/protocol/Book.cpp b/src/libxrpl/protocol/Book.cpp index f71800b786..b2705273db 100644 --- a/src/libxrpl/protocol/Book.cpp +++ b/src/libxrpl/protocol/Book.cpp @@ -7,18 +7,53 @@ namespace xrpl { +/** Validate that a Book is self-consistent. + * + * Composes `isConsistent` across both legs and adds the invariant that the + * two legs must differ. A book is inconsistent if either `in` or `out` is + * internally inconsistent (e.g. an XRP currency paired with a non-XRP + * issuer), or if both legs name the same asset — which would represent + * trading a currency against itself. + * + * @note `book.domain` is not validated here; semantic validity of the + * domain identifier belongs to higher-level transaction processing. + * @note Returns `false` rather than throwing, matching the soft-validation + * convention used throughout the protocol layer. Callers decide whether + * inconsistency is fatal. In practice this is used as a hard guard in + * the Subscribe RPC handler and as an assertion in `getBookBase()`. + * @param book The order book to validate. + * @return `true` if both legs are individually consistent and `in != out`. + */ bool isConsistent(Book const& book) { return isConsistent(book.in) && isConsistent(book.out) && book.in != book.out; } +/** Produce a diagnostic string representation of a Book. + * + * Formats the book as `->` using the `to_string` representations + * of each leg. The arrow makes directionality explicit, reflecting that + * order books are one-way markets. Intended for logging and debugging only; + * this format is not part of the wire protocol. + * + * @param book The order book to convert. + * @return A string of the form `"->"`. + */ std::string to_string(Book const& book) { return to_string(book.in) + "->" + to_string(book.out); } +/** Write a Book to an output stream in diagnostic form. + * + * Delegates to `to_string(book)`, keeping a single formatting path. + * + * @param os The output stream to write to. + * @param x The order book to write. + * @return `os`, to allow chaining. + */ std::ostream& operator<<(std::ostream& os, Book const& x) { @@ -26,6 +61,20 @@ operator<<(std::ostream& os, Book const& x) return os; } +/** Return the mirror-image order book with `in` and `out` swapped. + * + * Swaps the two asset legs while preserving `domain` unchanged — a + * domain-scoped market is the same market when viewed from either direction. + * This is a pure function; the original book is not modified. + * + * Used by the Subscribe/Unsubscribe RPC handlers when a client requests the + * `both` flag, so updates from both the bid and ask sides of the same market + * are delivered, and by `BookDirs` tests to traverse offer directories from + * the opposite direction. + * + * @param book The order book to reverse. + * @return A new `Book` with `in` and `out` exchanged and `domain` unchanged. + */ Book reversed(Book const& book) { diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 7ea934fe3a..36f503acae 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -1,3 +1,20 @@ +/** + * @file BuildInfo.cpp + * @brief Version identity, wire encoding, and peer-comparison utilities for xrpld. + * + * Owns the single source-of-truth version string (`versionString`), its + * transformation into a SemVer-validated public string, the composite + * `systemName-version` identifier used in HTTP headers and peer handshakes, + * and the 64-bit wire encoding that allows integer comparison of peer versions + * during consensus without string parsing. + * + * Every flag ledger (every 256 ledgers), `RCLConsensus` writes the result of + * `getEncodedVersion()` into `sfServerVersion` in each validation message. + * `LedgerMaster` then tallies those values with `isXrpldVersion()` and + * `isNewerVersion()` to detect when a significant fraction of the UNL is + * running newer software and emit an upgrade notification. + */ + #include #include @@ -17,20 +34,37 @@ namespace xrpl::BuildInfo { namespace { -//-------------------------------------------------------------------------- -// The build version number. You must edit this for each release -// and follow the format described at http://semver.org/ -//------------------------------------------------------------------------------ +/** The release version string — the single edit point when cutting a release. + * + * Must be a valid Semantic Version (http://semver.org/) in canonical form. + * `getVersionString()` validates this at startup via a round-trip through + * `beast::SemanticVersion`; a malformed or non-canonical string triggers + * `logicError` and kills the process immediately. + */ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) char const* const versionString = "3.2.0-b0" // clang-format on ; -// -// Don't touch anything below this line -// - +/** Build the version string, optionally appending SemVer build metadata. + * + * In `DEBUG` or sanitizer builds, metadata is appended as a `+`-separated + * suffix following SemVer §10. The metadata components are, in order: + * - the short Git commit hash (if available from `xrpl::git::getCommitHash()`), + * - the literal token `DEBUG` (when `DEBUG` is defined), + * - the stringified value of the `SANITIZERS` preprocessor macro (e.g. + * `address,undefined` when `-DSANITIZERS=address,undefined` is passed). + * + * `BOOST_PP_STRINGIZE` is used to convert the token list supplied via + * `-DSANITIZERS=...` into a runtime string; there is no other way to + * capture an arbitrary preprocessor token sequence as a string literal. + * + * In release (non-DEBUG, non-sanitizer) builds this simply returns + * `versionString` unchanged. + * + * @return the version string, with build metadata appended when applicable. + */ std::string buildVersionString() { @@ -64,6 +98,19 @@ buildVersionString() } // namespace +/** Return the validated SemVer version string for this build. + * + * Memoized via a function-local `static const`; the initializer runs exactly + * once (C++11 guarantee, thread-safe). On first call, the string produced by + * `buildVersionString()` is round-tripped through `beast::SemanticVersion`: + * if parsing fails, or if the canonical re-serialization (`v.print()`) differs + * from the original, `logicError` is called and the process terminates. This + * catches both malformed strings and strings that are semantically valid but + * not in canonical form (e.g., superfluous leading zeros in a version + * component). + * + * @return a reference to the cached, validated version string (e.g. `"3.2.0-b0"`). + */ std::string const& getVersionString() { @@ -78,6 +125,15 @@ getVersionString() return kVALUE; } +/** Return the composite `systemName-version` identifier for this build. + * + * Prepends `systemName()` (always `"xrpld"`) to `getVersionString()`, + * separated by `"-"`, e.g. `"xrpld-3.2.0-b0"`. This form is used in + * the `User-Agent` header of outbound HTTP requests and in startup log + * messages. Memoized on first call. + * + * @return a reference to the cached composite version string. + */ std::string const& getFullVersionString() { @@ -85,9 +141,38 @@ getFullVersionString() return kVALUE; } +/** Upper-16-bit fingerprint that identifies an encoded version as xrpld. + * + * Any `uint64_t` whose top 16 bits equal this constant was produced by + * `encodeSoftwareVersion()` in this implementation. Checked by + * `isXrpldVersion()` before any numeric comparison, preventing a + * non-xrpld peer from appearing "newer" through a spuriously large integer. + */ static constexpr std::uint64_t kIMPLEMENTATION_VERSION_IDENTIFIER = 0x183B'0000'0000'0000LLU; + +/** Bitmask to extract the upper 16 bits of an encoded version for fingerprint comparison. */ static constexpr std::uint64_t kIMPLEMENTATION_VERSION_IDENTIFIER_MASK = 0xFFFF'0000'0000'0000LLU; +/** Compress a SemVer string into the 64-bit wire encoding described in `BuildInfo.h`. + * + * The `TYPE` field uses a deliberate encoding so that a plain integer comparison + * on the resulting `uint64_t` yields correct semantic ordering across pre-release + * types: `0b11` (release) > `0b10` (RC) > `0b01` (beta). + * + * Pre-release parsing is performed by an internal `parsePreRelease` lambda that: + * - checks for a `"rc"` or `"b"` prefix (case-sensitive), + * - converts the numeric suffix via `beast::lexicalCastChecked` (safe, no throw), + * - clamps the result to [0, 63] — the 6-bit `N` field. + * Any malformed pre-release identifier (empty suffix, non-numeric, out-of-range) + * silently yields zero for the pre-release byte, which sorts below any known type. + * + * If `versionStr` does not parse as a valid SemVer string, the return value + * contains only the xrpld fingerprint (`0x183B`) in the upper 16 bits and + * zeros elsewhere. + * + * @param versionStr a SemVer-formatted version string (e.g. `"3.2.0-b0"`). + * @return the encoded version as a `uint64_t`; see `BuildInfo.h` for bit layout. + */ std::uint64_t encodeSoftwareVersion(std::string_view versionStr) { @@ -115,6 +200,8 @@ encodeSoftwareVersion(std::string_view versionStr) for (auto const& id : v.preReleaseIdentifiers) { + // Returns `number + key` when `identifier` begins with `prefix` + // and its numeric suffix is in [lok, hik]; returns 0 on any failure. auto parsePreRelease = [](std::string_view identifier, std::string_view prefix, std::uint8_t key, @@ -152,6 +239,14 @@ encodeSoftwareVersion(std::string_view versionStr) return c; } +/** Return this node's own version packed in the 64-bit wire encoding. + * + * Lazily encodes `getVersionString()` on first call and caches the result as a + * function-local static. Written into `sfServerVersion` in every validation + * message emitted on flag ledgers (every 256 ledgers) by `RCLConsensus`. + * + * @return the cached encoded version for this build. + */ std::uint64_t getEncodedVersion() { @@ -159,6 +254,16 @@ getEncodedVersion() return kCOOKIE; } +/** Return true if `version` carries the xrpld implementation fingerprint. + * + * Checks only the upper 16 bits against `kIMPLEMENTATION_VERSION_IDENTIFIER` + * (`0x183B`). Must be called before any numeric comparison of version values + * to guard against non-xrpld peers advertising an arbitrarily large integer + * that would otherwise appear "newer". + * + * @param version an encoded software version read from `sfServerVersion`. + * @return true iff the upper 16 bits equal `0x183B`. + */ bool isXrpldVersion(std::uint64_t version) { @@ -166,6 +271,19 @@ isXrpldVersion(std::uint64_t version) kIMPLEMENTATION_VERSION_IDENTIFIER; } +/** Return true if `version` represents a newer xrpld release than this node. + * + * Guards against non-xrpld peers by calling `isXrpldVersion()` first; + * any value whose upper 16 bits differ from `0x183B` unconditionally + * returns false regardless of its numeric magnitude. For confirmed xrpld + * versions, a plain integer comparison is sufficient because + * `encodeSoftwareVersion()` encodes major/minor/patch and the release-type + * bits in descending significance order. + * + * @param version an encoded software version read from `sfServerVersion`. + * @return true iff `version` is an xrpld version strictly greater than + * `getEncodedVersion()`. + */ bool isNewerVersion(std::uint64_t version) { diff --git a/src/libxrpl/protocol/ErrorCodes.cpp b/src/libxrpl/protocol/ErrorCodes.cpp index a51bb9b56d..0ae68216b0 100644 --- a/src/libxrpl/protocol/ErrorCodes.cpp +++ b/src/libxrpl/protocol/ErrorCodes.cpp @@ -1,3 +1,12 @@ +/** @file + * RPC error code registry and JSON serialization for the XRPL RPC layer. + * + * Defines the complete set of named error conditions that can be returned to + * API clients, a compile-time-validated lookup table sorted by error code, + * O(1) metadata retrieval, JSON stamping helpers, and the HTTP-status mapping + * used by the transport layer for load-balancer failover decisions. + */ + #include #include @@ -13,22 +22,22 @@ namespace RPC { namespace detail { -// Unordered array of ErrorInfos, so we don't have to maintain the list -// ordering by hand. -// -// This array will be omitted from the object file; only the sorted version -// will remain in the object file. But the string literals will remain. -// -// There's a certain amount of tension in determining the correct HTTP -// status to associate with a given RPC error. Initially all RPC errors -// returned 200 (OK). And that's the default behavior if no HTTP status code -// is specified below. -// -// The codes currently selected target the load balancer fail-over use case. -// If a query fails on one node but is likely to have a positive outcome -// on a different node, then the failure should return a 4xx/5xx range -// status code. - +/** Authoring table of every recognized RPC error condition. + * + * Entries may appear in any order and are grouped thematically for + * readability. `sortErrorInfos` re-indexes them into `kSORTED_ERROR_INFOS` + * at compile time, so numeric ordering is never a maintenance concern here. + * + * HTTP status assignments target the load-balancer failover use case. + * Errors that might resolve on a different node (amendment-blocked, + * not-synced, too-busy) use 5xx/429 so upstream proxies can retry + * elsewhere. Permanent or client-side errors (bad syntax, bad credentials) + * use 4xx. Everything else defaults to 200 (OK), preserving the original + * JSON-RPC semantics where the HTTP transaction always succeeded. + * + * @note This array is elided from the object file; only the sorted copy + * `kSORTED_ERROR_INFOS` is retained. The string literals do remain. + */ // clang-format off constexpr static ErrorInfo kUNORDERED_ERROR_INFOS[]{ {RpcActMalformed, "actMalformed", "Account malformed."}, @@ -105,8 +114,29 @@ constexpr static ErrorInfo kUNORDERED_ERROR_INFOS[]{ }; // clang-format on -// Sort and validate unorderedErrorInfos at compile time. Should be -// converted to consteval when get to C++20. +/** Sort and integrity-check an unordered `ErrorInfo` array at compile time. + * + * Produces a `std::array` indexed by `code - 1`, enabling + * O(1) lookup in `getErrorInfo()`. Three invariants are enforced: + * + * 1. **Range** — every code must satisfy `rpcSUCCESS < code <= rpcLAST`; + * codes outside that window throw `std::out_of_range`. + * 2. **Uniqueness** — if two entries claim the same slot, the second + * throws `std::invalid_argument` (duplicate code). + * 3. **Contiguity** — after placement, each occupied slot must carry the + * code value that matches its index position, and the count of occupied + * slots must equal `N` (the input size). This rejects off-by-one typos + * that would silently land an entry at the wrong index. + * + * @tparam M Output array size; must equal `rpcLAST`. + * @tparam N Input array size; deduced from the argument. + * @param unordered C-array of `ErrorInfo` entries in any order. + * @return A `std::array` in canonical index order. + * @throw std::out_of_range If any code is outside `(rpcSUCCESS, rpcLAST]`. + * @throw std::invalid_argument If any code is duplicated or the contiguity + * check fails. + * @note Should become `consteval` when the codebase moves to C++20. + */ template constexpr auto sortErrorInfos(ErrorInfo const (&unordered)[N]) -> std::array @@ -118,7 +148,7 @@ sortErrorInfos(ErrorInfo const (&unordered)[N]) -> std::array if (info.code <= RpcSuccess || info.code > RpcLast) throw(std::out_of_range("Invalid error_code_i")); - // The first valid code follows rpcSUCCESS immediately. + // rpcSUCCESS == 0, so the first valid code maps to index 0. static_assert(RpcSuccess == 0, "Unexpected error_code_i layout."); int const index{info.code - 1}; @@ -128,11 +158,8 @@ sortErrorInfos(ErrorInfo const (&unordered)[N]) -> std::array ret[index] = info; } - // Verify that all entries are filled in starting with 1 and proceeding - // to rpcLAST. - // - // It's okay for there to be missing entries; they will contain the code - // rpcUNKNOWN. But other than that all entries should match their index. + // Slots left at rpcUNKNOWN represent intentional gaps in the enum + // (retired or reserved codes). All other slots must match their index. int codeCount{0}; int expect{RpcBadSyntax - 1}; for (ErrorInfo const& info : ret) @@ -153,14 +180,28 @@ sortErrorInfos(ErrorInfo const (&unordered)[N]) -> std::array return ret; } +/** Compile-time lookup table indexed by `code - 1`. + * + * Built from `kUNORDERED_ERROR_INFOS` by `sortErrorInfos`, which validates + * range, uniqueness, and contiguity. Gaps in `ErrorCodeI` remain as + * default-constructed `ErrorInfo` entries whose `code` field equals + * `rpcUNKNOWN`. `getErrorInfo()` accesses this array via direct subscript. + */ constexpr auto kSORTED_ERROR_INFOS{sortErrorInfos(kUNORDERED_ERROR_INFOS)}; +/** Fallback returned by `getErrorInfo()` for out-of-range or unknown codes. */ constexpr ErrorInfo kUNKNOWN_ERROR; } // namespace detail //------------------------------------------------------------------------------ +/** Stamp `error`, `error_code`, and `error_message` fields onto @p json using + * the default message for @p code. + * + * @param code The RPC error code whose metadata to inject. + * @param json The JSON object to mutate; existing fields are overwritten. + */ void injectError(ErrorCodeI code, json::Value& json) { @@ -170,6 +211,18 @@ injectError(ErrorCodeI code, json::Value& json) json[jss::error_message] = info.message; } +/** Stamp `error`, `error_code`, and `error_message` fields onto @p json, + * replacing the default message with a caller-supplied diagnostic string. + * + * The `error` token and numeric `error_code` are taken from the registry; + * only `error_message` is overridden, enabling context-specific diagnostics + * while keeping the stable machine-readable fields intact. + * + * @param code The RPC error code whose token and numeric code to inject. + * @param message Context-specific human-readable message to use instead of + * the registry default. + * @param json The JSON object to mutate; existing fields are overwritten. + */ void injectError(ErrorCodeI code, std::string const& message, json::Value& json) { @@ -179,6 +232,12 @@ injectError(ErrorCodeI code, std::string const& message, json::Value& json) json[jss::error_message] = message; } +/** O(1) lookup of the `ErrorInfo` for @p code via direct array subscript. + * + * @param code The error code to look up. + * @return Reference to the matching `ErrorInfo`, or `detail::kUNKNOWN_ERROR` + * if @p code is out of the valid range `(rpcSUCCESS, rpcLAST]`. + */ ErrorInfo const& getErrorInfo(ErrorCodeI code) { @@ -187,6 +246,12 @@ getErrorInfo(ErrorCodeI code) return detail::kSORTED_ERROR_INFOS[code - 1]; } +/** Construct and return a new JSON error object for @p code. + * + * @param code The RPC error code. + * @return A fresh `Json::Value` object with `error`, `error_code`, and + * `error_message` fields populated from the registry. + */ json::Value makeError(ErrorCodeI code) { @@ -195,6 +260,13 @@ makeError(ErrorCodeI code) return json; } +/** Construct and return a new JSON error object with a custom message. + * + * @param code The RPC error code. + * @param message Context-specific message to use for `error_message`. + * @return A fresh `Json::Value` object with `error` and `error_code` from + * the registry and `error_message` set to @p message. + */ json::Value makeError(ErrorCodeI code, std::string const& message) { @@ -203,12 +275,27 @@ makeError(ErrorCodeI code, std::string const& message) return json; } +/** Return `true` if @p json is an object containing an `"error"` member. + * + * @param json The value to probe. + * @return `true` if @p json carries an RPC error; `false` otherwise. + * @note Only the presence of the `"error"` key is checked; the specific + * code is not inspected. Use `getErrorInfo()` for code-level branching. + */ bool containsError(json::Value const& json) { return json.isObject() && json.isMember(jss::error); } +/** Return the HTTP status integer associated with @p code. + * + * Used by the HTTP transport layer when constructing the response header. + * Codes not given an explicit status in the registry default to 200. + * + * @param code The RPC error code. + * @return HTTP status integer (e.g., 200, 400, 403, 503). + */ int errorCodeHttpStatus(ErrorCodeI code) { @@ -217,6 +304,18 @@ errorCodeHttpStatus(ErrorCodeI code) } // namespace RPC +/** Concatenate the `error` token and `error_message` from a JSON error value. + * + * Convenience helper for logging and diagnostic strings. Lives in the `xrpl` + * namespace rather than `xrpl::RPC` for broader accessibility. + * + * @param jv A `Json::Value` that must already contain an RPC error (i.e., + * `containsError(jv)` is true). + * @return Concatenation of the `error` token string and `error_message` + * string with no separator. + * @note An `XRPL_ASSERT` fires in debug builds if @p jv does not contain + * an error, making misuse diagnosable early. + */ std::string rpcErrorString(json::Value const& jv) { diff --git a/src/libxrpl/protocol/Feature.cpp b/src/libxrpl/protocol/Feature.cpp index 3862b52e27..6393d11ecb 100644 --- a/src/libxrpl/protocol/Feature.cpp +++ b/src/libxrpl/protocol/Feature.cpp @@ -1,3 +1,16 @@ +/** @file + * Implements the central amendment registry for the XRP Ledger. + * + * Every conditional code path gated by an amendment queries this registry at + * runtime. The registry is populated entirely during static initialization — + * before `main()` — via the X-macro expansion of `features.macro`. Once the + * last file-scope variable is initialized, a `readOnly` fence is set and the + * registry becomes permanently immutable. No runtime lock is ever needed. + * + * @see Feature.h for `FeatureBitset` and the public free-function declarations. + * @see include/xrpl/protocol/detail/features.macro for the amendment master list. + */ + #include #include @@ -22,6 +35,15 @@ namespace xrpl { +/** Boost hash extension for `uint256`, required by `hashed_unique` indexes. + * + * Combines each byte of the 256-bit value into a seed using + * `boost::hash_combine`. ADL finds this overload from the + * `boost::multi_index` hashed indexes used in `FeatureCollections`. + * + * @param feature The amendment identifier to hash. + * @return A hash suitable for use in Boost unordered containers. + */ inline std::size_t // NOLINTNEXTLINE(readability-identifier-naming) hash_value(xrpl::uint256 const& feature) @@ -35,37 +57,40 @@ hash_value(xrpl::uint256 const& feature) namespace { +/** Whether this build supports (and can vote for) a given amendment. */ enum class Supported : bool { No = false, Yes }; -// *NOTE* +// --- Amendment lifecycle notes --- // -// Features, or Amendments as they are called elsewhere, are enabled on the -// network at some specific time based on Validator voting. Features are -// enabled using run-time conditionals based on the state of the amendment. -// There is value in retaining that conditional code for some time after -// the amendment is enabled to make it simple to replay old transactions. -// However, once an amendment has been enabled for, say, more than two years -// then retaining that conditional code has less value since it is -// uncommon to replay such old transactions. +// Amendment conditionals from before January 2018 have been removed (as of +// January 2020). Replaying ledgers from before that date requires an older +// server build. A warning in Application.cpp documents this boundary. // -// Starting in January of 2020 Amendment conditionals from before January -// 2018 are being removed. So replaying any ledger from before January -// 2018 needs to happen on an older version of the server code. There's -// a log message in Application.cpp that warns about replaying old ledgers. -// -// At some point in the future someone may wish to remove amendment -// conditional code for amendments that were enabled after January 2018. -// When that happens then the log message in Application.cpp should be -// updated. -// -// Generally, amendments which introduce new features should be set as -// "VoteBehavior::DefaultNo" whereas in rare cases, amendments that fix -// critical bugs should be set as "VoteBehavior::DefaultYes", if off-chain -// consensus is reached amongst reviewers, validator operators, and other -// participants. +// New feature amendments use VoteBehavior::DefaultNo so that external +// governance controls activation timing. Critical bug-fix amendments may +// use VoteBehavior::DefaultYes after explicit off-chain community consensus. +/** Central registry for all XRPL protocol amendments. + * + * Maintains three simultaneous views over the amendment set: + * - `features_` — a `multi_index_container` providing O(1) lookup by + * insertion-order index (for `FeatureBitset` mapping), by `uint256` + * hash, and by string name. + * - `all_` — every registered amendment including retired ones, keyed by + * name, mapped to `AmendmentSupport`. + * - `supported_` — only amendments the server can vote on, keyed by name, + * mapped to `VoteBehavior`. + * + * The registry is write-only during static initialization. Once + * `registrationIsDone()` is called, `readOnly_` is set to `true` and every + * query method asserts this fence. No locking is required after that point. + * + * @note `kNUM_FEATURES` is a compile-time ceiling, not an exact count. It + * may exceed the actual number of registered amendments. + */ class FeatureCollections { + /** Stores one amendment's string name and its on-chain `uint256` identifier. */ struct Feature { std::string name; @@ -77,44 +102,52 @@ class FeatureCollections { } - // These structs are used by the `features` multi_index_container to - // provide access to the features collection by size_t index, string - // name, and uint256 feature identifier + /** Tag type for the random-access (insertion-order) index. */ struct ByIndex { }; + /** Tag type for the hashed-unique name index. */ struct ByName { }; + /** Tag type for the hashed-unique `uint256` feature-hash index. */ struct ByFeature { }; }; - // Intermediate types to help with readability + /** Alias for a hashed-unique index over one `Feature` member. */ template using feature_hashed_unique = boost::multi_index::hashed_unique< boost::multi_index::tag, boost::multi_index::member>; - // Intermediate types to help with readability + /** Combined index specification: random-access by insertion order, + * hashed-unique by `uint256`, and hashed-unique by name. + */ using feature_indexing = boost::multi_index::indexed_by< boost::multi_index::random_access>, feature_hashed_unique, feature_hashed_unique>; - // This multi_index_container provides access to the features collection by - // name, index, and uint256 feature identifier + /** All registered amendments, accessible by index, name, or `uint256`. */ boost::multi_index::multi_index_container features_; + /** Every amendment (including retired) mapped to its support status. */ std::map all_; + /** Only supported amendments, mapped to their default vote behavior. */ std::map supported_; + /** Count of supported amendments with `VoteBehavior::DefaultYes`. */ std::size_t upVotes_ = 0; + /** Count of supported amendments with `VoteBehavior::DefaultNo` or `Obsolete`. */ std::size_t downVotes_ = 0; + /** Write-once fence; flipped by `registrationIsDone()` after all static vars init. */ mutable std::atomic readOnly_ = false; - // These helper functions provide access to the features collection by name, - // index, and uint256 feature identifier, so the details of - // multi_index_container can be hidden + /** Return the `Feature` at insertion-order position `i`. + * + * @param i Zero-based index into the registration sequence. + * @throws LogicError if `i` is out of bounds. + */ Feature const& getByIndex(size_t i) const { @@ -123,6 +156,12 @@ class FeatureCollections auto const& sequence = features_.get(); return sequence[i]; } + + /** Return the insertion-order index of a `Feature` element. + * + * @param feature A reference into `features_`; must belong to this container. + * @return The zero-based position used as the corresponding `FeatureBitset` bit. + */ size_t getIndex(Feature const& feature) const { @@ -130,6 +169,12 @@ class FeatureCollections auto const itTo = sequence.iterator_to(feature); return itTo - sequence.begin(); } + + /** Look up an amendment by its on-chain `uint256` identifier. + * + * @param feature The hash to search for. + * @return Pointer to the matching `Feature`, or `nullptr` if not found. + */ Feature const* getByFeature(uint256 const& feature) const { @@ -137,6 +182,12 @@ class FeatureCollections auto const featureIt = featureIndex.find(feature); return featureIt == featureIndex.end() ? nullptr : &*featureIt; } + + /** Look up an amendment by its string name. + * + * @param name The amendment name to search for. + * @return Pointer to the matching `Feature`, or `nullptr` if not found. + */ Feature const* getByName(std::string const& name) const { @@ -146,24 +197,75 @@ class FeatureCollections } public: + /** Reserve storage for `kNUM_FEATURES` entries. */ FeatureCollections(); + /** Look up an amendment by name, returning its `uint256` hash if registered. + * + * @param name The amendment's string name (e.g. `"Checks"`). + * @return The `uint256` identifier, or `std::nullopt` if not registered. + * @note Asserts that `registrationIsDone()` has been called; it is a + * programming error to query before static initialization completes. + */ std::optional getRegisteredFeature(std::string const& name) const; + /** Register a new amendment and return its deterministic `uint256` hash. + * + * Computes the hash as `sha512Half(name)` and inserts into all three + * internal indexes. Enforces at registration time: + * - `Supported::No` implies `VoteBehavior::DefaultNo`. + * - No duplicate names are allowed. + * - Total count must stay within `detail::kNUM_FEATURES`. + * - `upVotes_ + downVotes_` must equal `supported_.size()` after insertion. + * + * @param name The amendment's ASCII name (validated at call site). + * @param support Whether this build supports the amendment. + * @param vote The server's default vote behavior. + * @return The `uint256` on-chain identifier derived from `name`. + * @throws LogicError on any invariant violation or duplicate registration. + */ uint256 registerFeature(std::string const& name, Supported support, VoteBehavior vote); - /** Tell FeatureCollections when registration is complete. */ + /** Flip the write-once fence, marking registration as complete. + * + * Called exactly once by the file-scope `kREAD_ONLY_SET` variable after all + * amendment globals have been initialized. After this call, all query methods + * become safe to use and all write methods are permanently disabled. + * + * @return Always `true` (allows use as a static variable initializer). + */ bool registrationIsDone(); + /** Translate an amendment's `uint256` to its `FeatureBitset` bit position. + * + * @param f The amendment identifier. + * @return The zero-based bit index within `FeatureBitset`. + * @throws LogicError if `f` is not a registered amendment. + * @note Asserts `readOnly_` — must not be called before registration completes. + */ std::size_t featureToBitsetIndex(uint256 const& f) const; + /** Translate a `FeatureBitset` bit position back to the amendment's `uint256`. + * + * @param i The zero-based bit index. + * @return A reference to the amendment's `uint256` hash (stable for process lifetime). + * @throws LogicError if `i` is out of bounds. + * @note Asserts `readOnly_` — must not be called before registration completes. + */ uint256 const& bitsetIndexToFeature(size_t i) const; + /** Return the string name for an amendment hash, or its hex string if unknown. + * + * @param f The amendment identifier to look up. + * @return The registered name (e.g. `"Checks"`), or `to_string(f)` if `f` + * is not in the registry. + * @note Asserts `readOnly_` — must not be called before registration completes. + */ std::string featureToName(uint256 const& f) const; @@ -216,6 +318,15 @@ FeatureCollections::getRegisteredFeature(std::string const& name) const return std::nullopt; } +/** Throw a `LogicError` with `logicErrorMessage` unless `condition` is true. + * + * Used exclusively inside `registerFeature` to enforce registration-time + * invariants. Failures here indicate a programming error in the amendment + * macro list and abort the process at startup. + * + * @param condition The invariant that must hold. + * @param logicErrorMessage Message passed to `logicError` on failure. + */ void check(bool condition, char const* logicErrorMessage) { @@ -267,11 +378,9 @@ FeatureCollections::registerFeature(std::string const& name, Supported support, return f; } - // Each feature should only be registered once logicError("Duplicate feature registration"); } -/** Tell FeatureCollections when registration is complete. */ bool FeatureCollections::registrationIsDone() { @@ -320,23 +429,33 @@ allAmendments() return gFeatureCollections.allAmendments(); } -/** Amendments that this server supports. - Whether they are enabled depends on the Rules defined in the validated - ledger */ +/** Amendments that this server supports and their default voting behavior. + * + * Whether any of these amendments is actually active depends on the + * `Rules` object derived from the validated ledger's Amendments object. + */ std::map const& detail::supportedAmendments() { return gFeatureCollections.supportedAmendments(); } -/** Amendments that this server won't vote for by default. */ +/** Number of supported amendments this server will NOT vote for by default. + * + * Includes both `VoteBehavior::DefaultNo` and `VoteBehavior::Obsolete` + * entries. Used in unit tests to verify vote-tally invariants. + */ std::size_t detail::numDownVotedAmendments() { return gFeatureCollections.numDownVotedAmendments(); } -/** Amendments that this server will vote for by default. */ +/** Number of supported amendments this server WILL vote for by default. + * + * Counts only `VoteBehavior::DefaultYes` entries. Used in unit tests to + * verify vote-tally invariants. + */ std::size_t detail::numUpVotedAmendments() { @@ -345,53 +464,108 @@ detail::numUpVotedAmendments() //------------------------------------------------------------------------------ +/** Look up a registered amendment by name and return its `uint256` hash. + * + * @param name The amendment's string name. + * @return The `uint256` identifier if registered, or `std::nullopt`. + */ std::optional getRegisteredFeature(std::string const& name) { return gFeatureCollections.getRegisteredFeature(name); } +/** Register an amendment and return its deterministic `uint256` identifier. + * + * Called during static initialization — once per amendment — via the + * X-macro expansion of `features.macro`. Must not be called after + * `registrationIsDone()` has been called. + * + * @param name The amendment's ASCII name. + * @param support Whether this build supports the amendment. + * @param vote The server's default vote behavior. + * @return The `uint256` hash computed as `sha512Half(name)`. + * @throws LogicError on invariant violation or duplicate name. + */ uint256 registerFeature(std::string const& name, Supported support, VoteBehavior vote) { return gFeatureCollections.registerFeature(name, support, vote); } -// Retired features are in the ledger and have no code controlled by the -// feature. They need to be supported, but do not need to be voted on. +/** Register an amendment whose conditional code has been removed from the codebase. + * + * Retired amendments must remain registered because they may appear in the + * Amendments ledger object. Removing them entirely would cause amendment + * blocking. They are registered as `Supported::Yes, VoteBehavior::Obsolete` + * so the server understands them but does not vote for them. + * + * @param name The amendment's string name. + * @return The `uint256` hash for the retired amendment. + */ uint256 retireFeature(std::string const& name) { return registerFeature(name, Supported::Yes, VoteBehavior::Obsolete); } -/** Tell FeatureCollections when registration is complete. */ +/** Seal the registry against further writes. + * + * Called exactly once by the file-scope `kREAD_ONLY_SET` initializer after + * all amendment globals have been constructed. Subsequent query calls will + * assert this fence. + * + * @return Always `true`. + */ bool registrationIsDone() { return gFeatureCollections.registrationIsDone(); } +/** Translate an amendment `uint256` to its `FeatureBitset` bit position. + * + * @param f A registered amendment identifier. + * @return The zero-based bit index within `FeatureBitset`. + * @throws LogicError if `f` is not registered. + */ size_t featureToBitsetIndex(uint256 const& f) { return gFeatureCollections.featureToBitsetIndex(f); } +/** Translate a `FeatureBitset` bit position to its amendment `uint256`. + * + * @param i Zero-based bit index within `FeatureBitset`. + * @return The `uint256` hash for the amendment at that index. + * @throws LogicError if `i` is out of bounds. + */ uint256 bitsetIndexToFeature(size_t i) { return gFeatureCollections.bitsetIndexToFeature(i); } +/** Return the human-readable name for an amendment, or its hex string if unknown. + * + * @param f The amendment identifier. + * @return The registered name, or `to_string(f)` if `f` is not in the registry. + */ std::string featureToName(uint256 const& f) { return gFeatureCollections.featureToName(f); } -// All known amendments must be registered either here or below with the -// "retired" amendments +// --- Amendment registration via X-macro expansion --- +// +// The macros below expand features.macro to produce one file-scope +// `uint256 const feature` variable per amendment. Each variable's +// initializer calls `registerFeature`, which inserts into `gFeatureCollections` +// and returns the hash. C++ guarantees top-to-bottom initialization within a +// translation unit, so all amendments are registered before `kREAD_ONLY_SET` +// seals the registry. #pragma push_macro("XRPL_FEATURE") #undef XRPL_FEATURE @@ -402,6 +576,18 @@ featureToName(uint256 const& f) #pragma push_macro("XRPL_RETIRE_FIX") #undef XRPL_RETIRE_FIX +/** Validate a feature name at compile time and return it as a C-string. + * + * Wraps `validFeatureName` and `validFeatureNameSize` (both `consteval`) in + * `static_assert` expressions so that an ill-formed name in `features.macro` + * is a build error. Checks: + * - No non-ASCII bytes (high bit set) or control characters (below 0x20). + * - Length ≤ 63 bytes and length ≠ 32 bytes (the 32-byte length is reserved + * for raw `uint256` hash values in WASM / interop contexts). + * + * @param fn A `consteval` lambda returning `const char*` — the name literal. + * @return The same C-string the lambda returns. + */ consteval auto enforceValidFeatureName(auto fn) -> char const* { @@ -442,10 +628,9 @@ enforceValidFeatureName(auto fn) -> char const* #undef XRPL_FEATURE #pragma pop_macro("XRPL_FEATURE") -// All of the features should now be registered, since variables in a cpp file -// are initialized from top to bottom. -// -// Use initialization of one final static variable to set featureCollections::readOnly_. +// All amendments are now registered. This final static variable seals the +// registry by flipping `readOnly_` to true. C++ guarantees that all +// file-scope variables above are fully initialized before this line runs. [[maybe_unused]] static bool const kREAD_ONLY_SET = gFeatureCollections.registrationIsDone(); } // namespace xrpl diff --git a/src/libxrpl/protocol/IOUAmount.cpp b/src/libxrpl/protocol/IOUAmount.cpp index e4326d611e..7db0c96b86 100644 --- a/src/libxrpl/protocol/IOUAmount.cpp +++ b/src/libxrpl/protocol/IOUAmount.cpp @@ -1,3 +1,12 @@ +/** @file + * IOU amount arithmetic and normalization for the XRP Ledger. + * + * Implements the normalization engine, `Number`-based construction, + * addition with exponent alignment, and the `mulRatio` ratio-multiplication + * primitive used by fee calculations, transfer rates, and AMM math. + * All non-XRP balances in the ledger (trust lines, offers, AMM pools) are + * represented as `IOUAmount`. + */ #include #include @@ -19,7 +28,13 @@ namespace xrpl { namespace { -// Use a static inside a function to help prevent order-of-initialization issues +/** Returns the per-coroutine `LocalValue` that backs the STNumber switchover flag. + * + * Using a function-local static avoids C++ static initialization order + * problems that would corrupt the flag if it were a namespace-scope variable. + * The default value of `true` means the `Number`-based arithmetic path is + * active unless explicitly disabled. + */ LocalValue& getStaticSTNumberSwitchover() { @@ -28,44 +43,100 @@ getStaticSTNumberSwitchover() } } // namespace +/** Returns the current STNumber arithmetic switchover state for this coroutine. + * + * When `true`, `IOUAmount` normalization and addition delegate to the + * `Number` class (higher-precision, correct rounding). When `false`, the + * legacy base-10 digit-shifting algorithm is used instead. + * + * The value is stored in a `LocalValue` — the XRPL coroutine-aware + * thread-local mechanism — so concurrent transactions cannot interfere with + * each other's arithmetic mode. + * + * @return `true` if the `Number`-based path is active, `false` for legacy. + * @see setSTNumberSwitchover, NumberSO + */ bool getSTNumberSwitchover() { return *getStaticSTNumberSwitchover(); } +/** Sets the STNumber arithmetic switchover state for this coroutine. + * + * Prefer the `NumberSO` RAII guard over calling this function directly; it + * saves and restores the previous value automatically. + * + * @param v `true` to enable the `Number`-based path; `false` for legacy. + * @see getSTNumberSwitchover, NumberSO + */ void setSTNumberSwitchover(bool v) { *getStaticSTNumberSwitchover() = v; } -/* The range for the mantissa when normalized */ -// log(2^63,10) ~ 18.96 -// -static std::int64_t constexpr kMIN_MANTISSA = STAmount::kMIN_VALUE; -static std::int64_t constexpr kMAX_MANTISSA = STAmount::kMAX_VALUE; -/* The range for the exponent when normalized */ -static int constexpr kMIN_EXPONENT = STAmount::kMIN_OFFSET; -static int constexpr kMAX_EXPONENT = STAmount::kMAX_OFFSET; +// --- Canonical range constants (imported from STAmount to lock IOUAmount +// precision to the on-wire serialization format) --- +// log(2^63,10) ~ 18.96; the 15-digit mantissa range fits comfortably in int64. +static std::int64_t constexpr kMIN_MANTISSA = STAmount::kMIN_VALUE; /**< 10^15 */ +static std::int64_t constexpr kMAX_MANTISSA = STAmount::kMAX_VALUE; /**< 10^16 - 1 */ +static int constexpr kMIN_EXPONENT = STAmount::kMIN_OFFSET; /**< -96 */ +static int constexpr kMAX_EXPONENT = STAmount::kMAX_OFFSET; /**< 80 */ +/** Constructs an `IOUAmount` from a `Number` without invoking `normalize()`. + * + * The `(mantissa, exponent)` constructor always calls `normalize()`, and the + * switchover path inside `normalize()` calls this function — using the public + * constructor here would cause infinite recursion. This factory bypasses + * `normalize()` entirely by writing directly to the private fields, then + * delegates to `Number::normalizeToRange` to enforce the canonical mantissa + * and exponent bounds. + * + * @param number The high-precision value to convert. + * @return An `IOUAmount` whose fields are set by `Number::normalizeToRange`. + * @note Callers are responsible for checking the resulting exponent against + * `kMIN_EXPONENT` / `kMAX_EXPONENT` and handling overflow or underflow. + */ IOUAmount IOUAmount::fromNumber(Number const& number) { - // Need to create a default IOUAmount and assign directly so it doesn't try - // to normalize, which calls fromNumber IOUAmount result{}; std::tie(result.mantissa_, result.exponent_) = number.normalizeToRange(kMIN_MANTISSA, kMAX_MANTISSA); return result; } +/** Returns the smallest representable positive IOU amount (10^15 × 10^-96 = 10^-81). + * + * Used by `mulRatio` as the result when `roundUp` is `true` and the + * computed value is positive but too small to normalize — ensuring a + * non-zero fee is never silently dropped to zero during rounding. + * + * @return `IOUAmount(kMIN_MANTISSA, kMIN_EXPONENT)`. + */ IOUAmount IOUAmount::minPositiveAmount() { return IOUAmount(kMIN_MANTISSA, kMIN_EXPONENT); } +/** Adjusts mantissa and exponent to canonical form. + * + * When the STNumber switchover is active, delegates to `fromNumber()` / + * `Number::normalizeToRange` for a single precision-preserving step. + * Under the legacy path, uses a base-10 digit-shifting loop: multiplies + * the mantissa by 10 (decrementing the exponent) while below `kMIN_MANTISSA`, + * and divides by 10 (incrementing the exponent) while above `kMAX_MANTISSA`. + * + * Overflow policy: throws `std::overflow_error` if the exponent would exceed + * `kMAX_EXPONENT` during scale-down. + * Underflow policy: silently becomes zero when the mantissa cannot be scaled + * to `kMIN_MANTISSA` without pushing the exponent below `kMIN_EXPONENT`. + * + * @throw std::overflow_error if the normalized value exceeds the maximum + * representable IOU amount. + */ void IOUAmount::normalize() { @@ -119,6 +190,16 @@ IOUAmount::normalize() mantissa_ = -mantissa_; } +/** Constructs an `IOUAmount` from a high-precision `Number`. + * + * Delegates to `fromNumber()` to avoid the normalization recursion, then + * validates the resulting exponent: throws on overflow, silently zeroes on + * underflow (sub-minimum amounts round to nothing). + * + * @param other The `Number` value to convert. + * @throw std::overflow_error if `other` exceeds the maximum representable + * IOU amount. + */ IOUAmount::IOUAmount(Number const& other) : IOUAmount(fromNumber(other)) { if (exponent_ > kMAX_EXPONENT) @@ -127,6 +208,23 @@ IOUAmount::IOUAmount(Number const& other) : IOUAmount(fromNumber(other)) *this = beast::kZERO; } +/** Adds `other` to this amount in place. + * + * Under the STNumber switchover path, converts both operands to `Number`, + * adds with full precision, and converts back. Under the legacy path, + * aligns the two operands to a common exponent by truncating digits from + * the smaller-magnitude value, then adds mantissas. A near-cancellation + * guard zeros the result when the raw sum falls in [-10, 10], since values + * that small cannot be re-normalized to the required mantissa range. + * + * @param other The amount to add. + * @return `*this` after addition. + * @throw std::overflow_error (via `normalize()`) if the sum exceeds the + * maximum representable IOU amount. + * @note The legacy mantissa addition cannot overflow `int64_t` because + * both operands are pre-aligned and individually within the mantissa + * range, but the subsequent `normalize()` call can throw. + */ IOUAmount& IOUAmount::operator+=(IOUAmount const& other) { @@ -173,12 +271,54 @@ IOUAmount::operator+=(IOUAmount const& other) return *this; } +/** Returns a decimal string representation of `amount`. + * + * Delegates to `to_string(Number)`, which formats the mantissa and + * exponent as a human-readable decimal (e.g. `"1.5e-10"`). + * + * @param amount The IOU amount to format. + * @return A decimal string representation. + */ std::string to_string(IOUAmount const& amount) { return to_string(Number{amount}); } +/** Multiplies an IOU amount by a rational fraction with controlled rounding. + * + * Computes `amt × num / den` using 128-bit intermediate arithmetic to + * avoid overflow of the 64-bit mantissa. A static lookup table of powers + * of ten (`kPOWER_TABLE`) drives exact integer log₁₀ computations, avoiding + * the rounding ambiguities of `std::log10`. + * + * Precision-maximization: when the integer quotient `mul / den` leaves a + * remainder, the code scales both the quotient and the remainder up by as + * many powers of ten as the mantissa headroom allows, recovering fractional + * digits that would otherwise be lost. + * + * Rounding rules (only applied when a remainder exists after all scaling): + * - Positive result, `roundUp == true`: mantissa incremented by one ULP. + * If the unrounded result is zero, `minPositiveAmount()` is returned so + * that a non-zero fee is never silently dropped. + * - Negative result, `roundUp == false` (round toward −∞): mantissa + * decremented by one ULP. Same zero guard applies. + * - All other combinations: remainder is truncated. + * + * @param amt The IOU amount to scale. + * @param num 32-bit unsigned numerator. + * @param den 32-bit unsigned denominator; must be non-zero. + * @param roundUp When `true`, round away from zero for positive results + * (and toward zero for negative); when `false`, truncate toward zero + * for positive results. + * @return `amt × num / den`, normalized and rounded per the rules above. + * @throw std::runtime_error if `den == 0`. + * @throw std::overflow_error (via `IOUAmount` constructor) if the result + * exceeds the maximum representable IOU amount. + * @note `num` and `den` are typically quality-encoding constants such as + * `QUALITY_ONE` (10^9) in path-finding, or transfer-rate values in + * fee calculations. + */ IOUAmount mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundUp) { @@ -187,9 +327,9 @@ mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU if (den == 0u) Throw("division by zero"); - // A vector with the value 10^index for indexes from 0 to 29 - // The largest intermediate value we expect is 2^96, which - // is less than 10^29 + // Powers of ten from 10^0 to 10^29; covers the largest 128-bit intermediate + // (~2^96 < 10^29). Table lookup is used instead of std::log10 to guarantee + // exact integer boundaries without floating-point rounding error. static auto const kPOWER_TABLE = [] { std::vector result; result.reserve(30); // 2^96 is largest intermediate result size @@ -202,23 +342,17 @@ mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU return result; }(); - // Return floor(log10(v)) - // Note: Returns -1 for v == 0 + // Returns floor(log10(v)); returns -1 for v == 0. static auto kLOG10_FLOOR = [](uint128_t const& v) { - // Find the index of the first element >= the requested element, the - // index is the log of the element in the log table. auto const l = std::ranges::lower_bound(kPOWER_TABLE, v); int index = std::distance(kPOWER_TABLE.begin(), l); - // If we're not equal, subtract to get the floor if (*l != v) --index; return index; }; - // Return ceil(log10(v)) + // Returns ceil(log10(v)). static auto kLOG10_CEIL = [](uint128_t const& v) { - // Find the index of the first element >= the requested element, the - // index is the log of the element in the log table. auto const l = std::ranges::lower_bound(kPOWER_TABLE, v); return int(std::distance(kPOWER_TABLE.begin(), l)); }; @@ -227,8 +361,7 @@ mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU bool const neg = amt.mantissa() < 0; uint128_t const den128(den); - // a 32 value * a 64 bit value and stored in a 128 bit value. This will - // never overflow + // 64-bit mantissa × 32-bit num fits in 128 bits; cannot overflow. uint128_t const mul = uint128_t(neg ? -amt.mantissa() : amt.mantissa()) * uint128_t(num); auto low = mul / den128; @@ -238,12 +371,10 @@ mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU if (rem) { - // Mathematically, the result is low + rem/den128. However, since this - // uses integer division rem/den128 will be zero. Scale the result so - // low does not overflow the largest amount we can store in the mantissa - // and (rem/den128) is as large as possible. Scale by multiplying low - // and rem by 10 and subtracting one from the exponent. We could do this - // with a loop, but it's more efficient to use logarithms. + // `rem / den128` is zero under integer division. Instead, scale both + // `low` and `rem` up by as many powers of ten as the 64-bit mantissa + // headroom allows, then recompute `rem / den128`. This recovers + // fractional digits that would otherwise be discarded. auto const roomToGrow = kFL64 - kLOG10_CEIL(low); if (roomToGrow > 0) { @@ -256,10 +387,8 @@ mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU rem = rem - addRem * den128; } - // The largest result we can have is ~2^95, which overflows the 64 bit - // result we can store in the mantissa. Scale result down by dividing by ten - // and adding one to the exponent until the low will fit in the 64-bit - // mantissa. Use logarithms to avoid looping. + // If `low` still overflows 64 bits (~2^95 worst case), divide down and + // track whether any set bits were lost (needed for rounding). bool hasRem = bool(rem); auto const mustShrink = kLOG10_CEIL(low) - kFL64; if (mustShrink > 0) @@ -273,7 +402,6 @@ mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU std::int64_t mantissa = low.convert_to(); - // normalize before rounding if (neg) mantissa *= -1; @@ -281,25 +409,19 @@ mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU if (hasRem) { - // handle rounding if (roundUp && !neg) { if (!result) - { return IOUAmount::minPositiveAmount(); - } - // This addition cannot overflow because the mantissa is already - // normalized + // Safe: mantissa is already normalized and cannot overflow the range. return IOUAmount(result.mantissa() + 1, result.exponent()); } if (!roundUp && neg) { if (!result) - { return IOUAmount(-kMIN_MANTISSA, kMIN_EXPONENT); - } - // This subtraction cannot underflow because `result` is not zero + // Safe: `result` is non-zero so the mantissa will not underflow. return IOUAmount(result.mantissa() - 1, result.exponent()); } } diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index a8ade0de0f..476419f148 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -1,3 +1,18 @@ +/** @file + * Implements all ledger-index (keylet) factory functions for the XRP Ledger. + * + * Every object stored in the ledger state map is addressed by a 256-bit key. + * This file is the single authoritative source for deriving those keys. + * All derivations use "tagged hashing" via `indexHash`: a `sha512Half` over + * a type-specific `LedgerNameSpace` discriminator followed by the object's + * identifying parameters, preventing cross-type key collisions even when + * two object types happen to share the same parameter values. + * + * The public surface is the `keylet::` namespace, whose functions return + * `Keylet` values pairing a key with its expected `LedgerEntryType`. + * Free functions (`getBookBase`, `getQuality`, etc.) are deprecated + * lower-level utilities retained for compatibility. + */ #include #include @@ -50,47 +65,59 @@ namespace xrpl { and marked as [[deprecated]] to prevent accidental reuse. */ enum class LedgerNameSpace : std::uint16_t { - Account = 'a', - DirNode = 'd', - TrustLine = 'r', - Offer = 'o', - OwnerDir = 'O', - BookDir = 'B', - SkipList = 's', - Escrow = 'u', - Amendments = 'f', - FeeSettings = 'e', - Ticket = 'T', - SignerList = 'S', - XRPPaymentChannel = 'x', - Check = 'C', - DepositPreauth = 'p', - DepositPreauthCredentials = 'P', - NegativeUnl = 'N', - NftokenOffer = 'q', - NftokenBuyOffers = 'h', - NftokenSellOffers = 'i', - Amm = 'A', - Bridge = 'H', - XchainClaimId = 'Q', - XchainCreateAccountClaimId = 'K', - Did = 'I', - Oracle = 'R', - MPTokenIssuance = '~', - MPToken = 't', - Credential = 'D', - PermissionedDomain = 'm', - Delegate = 'E', - Vault = 'V', - LoanBroker = 'l', // lower-case L - Loan = 'L', + Account = 'a', /**< AccountRoot entry. */ + DirNode = 'd', /**< Interior directory page (index > 0). */ + TrustLine = 'r', /**< RippleState (trust line) between two accounts. */ + Offer = 'o', /**< Offer placed by an account. */ + OwnerDir = 'O', /**< Root page of an account's owner directory. */ + BookDir = 'B', /**< Order-book directory for a currency pair. */ + SkipList = 's', /**< Ledger skip list (short or long form). */ + Escrow = 'u', /**< Escrow conditional payment. */ + Amendments = 'f', /**< Singleton amendments table. */ + FeeSettings = 'e', /**< Singleton fee-settings object. */ + Ticket = 'T', /**< Ticket (sequence placeholder). */ + SignerList = 'S', /**< Multi-signature signer list. */ + XRPPaymentChannel = 'x', /**< XRP payment channel. */ + Check = 'C', /**< Check (deferred payment). */ + DepositPreauth = 'p', /**< Single-account deposit pre-authorization. */ + DepositPreauthCredentials = 'P', /**< Credential-set deposit pre-authorization. */ + NegativeUnl = 'N', /**< Singleton negative UNL object. */ + NftokenOffer = 'q', /**< NFToken buy or sell offer. */ + NftokenBuyOffers = 'h', /**< Directory of buy offers for a given NFToken. */ + NftokenSellOffers = 'i', /**< Directory of sell offers for a given NFToken. */ + Amm = 'A', /**< Automated market maker pool. */ + Bridge = 'H', /**< Cross-chain bridge. */ + XchainClaimId = 'Q', /**< Cross-chain claim ID. */ + XchainCreateAccountClaimId = 'K', /**< Cross-chain create-account claim ID. */ + Did = 'I', /**< Decentralized identifier (DID) document. */ + Oracle = 'R', /**< Price oracle. */ + MPTokenIssuance = '~', /**< Multi-purpose token issuance. */ + MPToken = 't', /**< Individual holder's MPToken balance entry. */ + Credential = 'D', /**< Verifiable credential. */ + PermissionedDomain = 'm', /**< Permissioned DEX domain. */ + Delegate = 'E', /**< Account delegation grant. */ + Vault = 'V', /**< Single-asset vault. */ + LoanBroker = 'l', /**< Loan broker (lower-case L to distinguish from Loan). */ + Loan = 'L', /**< Individual loan issued by a loan broker. */ // No longer used or supported. Left here to reserve the space to avoid accidental reuse. - Contract [[deprecated]] = 'c', - Generator [[deprecated]] = 'g', - Nickname [[deprecated]] = 'n', + Contract [[deprecated]] = 'c', /**< @deprecated Legacy contract entry; reserved to prevent reuse. */ + Generator [[deprecated]] = 'g', /**< @deprecated Legacy generator entry; reserved to prevent reuse. */ + Nickname [[deprecated]] = 'n', /**< @deprecated Legacy nickname entry; reserved to prevent reuse. */ }; +/** Derive a ledger-entry key using tagged hashing. + * + * Prepends the `uint16_t` representation of @p space to @p args and returns + * `sha512Half` of the concatenation. The namespace discriminator ensures + * that two different object types that share the same parameters still + * produce distinct keys. + * + * @tparam Args Serialisable argument types forwarded to `sha512Half`. + * @param space Namespace tag identifying the ledger-entry type. + * @param args Type-specific parameters that uniquely identify the object. + * @return 256-bit key for the ledger entry. + */ template static uint256 indexHash(LedgerNameSpace space, Args const&... args) @@ -98,6 +125,18 @@ indexHash(LedgerNameSpace space, Args const&... args) return sha512Half(safeCast(space), args...); } +/** Compute the base key for an order book directory. + * + * Dispatches over all four combinations of `Issue`/`MPTIssue` asset pairs + * using `if constexpr`, hashing the relevant currency and account/MPTID + * fields under the `BookDir` namespace. If `book.domain` is set the domain + * ID is appended to the hash inputs, producing a permissioned-DEX book key. + * The returned key has quality 0 embedded in the last 8 bytes (see + * `keylet::quality`), making it the lowest possible key for that book. + * + * @param book Order book specifying the in/out asset pair and optional domain. + * @return 256-bit base key for the book's directory with quality field = 0. + */ uint256 getBookBase(Book const& book) { @@ -140,6 +179,19 @@ getBookBase(Book const& book) return k.key; } +/** Advance a book-directory key to the next quality level. + * + * Adds a unit to the 64-bit quality field embedded in the last 8 bytes of + * @p uBase. Because the quality is stored in big-endian byte order at the + * tail of the `uint256`, adding `kNEXT_QUALITY` (which is `1 << 64` in the + * 256-bit space) correctly increments just that field without touching the + * book-identifying prefix. The result is the smallest key belonging to the + * next quality tier in the same book. + * + * @param uBase A book-directory key, typically the return value of + * `getBookBase`. + * @return Key with quality incremented by 1. + */ uint256 getQualityNext(uint256 const& uBase) { @@ -148,6 +200,17 @@ getQualityNext(uint256 const& uBase) return uBase + kNEXT_QUALITY; } +/** Extract the 64-bit quality value from a book-directory key. + * + * Reads the last 8 bytes of @p uBase as a big-endian `uint64_t`. This + * relies on `base_uint`'s internal big-endian byte layout, which puts the + * most-significant bytes first so that the tail of the array holds the + * least-significant (quality) bits of the full 256-bit value. + * + * @param uBase A book-directory key produced by `getBookBase` or + * `keylet::quality`. + * @return The 64-bit quality (exchange rate) embedded in the key. + */ std::uint64_t getQuality(uint256 const& uBase) { @@ -155,12 +218,27 @@ getQuality(uint256 const& uBase) return boost::endian::big_to_native(((std::uint64_t*)uBase.end())[-1]); } +/** Compute the 256-bit key for a ticket ledger entry. + * + * @param account Owner of the ticket. + * @param ticketSeq Sequence number that was consumed when the ticket was + * created. + * @return Key derived from account and sequence under the `Ticket` namespace. + */ uint256 getTicketIndex(AccountID const& account, std::uint32_t ticketSeq) { return indexHash(LedgerNameSpace::Ticket, account, ticketSeq); } +/** Compute the 256-bit key for a ticket ledger entry from a `SeqProxy`. + * + * @param account Owner of the ticket. + * @param ticketSeq A `SeqProxy` in ticket mode; asserts if it represents a + * sequence number. + * @return Key derived from account and the proxy's value under the `Ticket` + * namespace. + */ uint256 getTicketIndex(AccountID const& account, SeqProxy ticketSeq) { @@ -168,6 +246,19 @@ getTicketIndex(AccountID const& account, SeqProxy ticketSeq) return getTicketIndex(account, ticketSeq.value()); } +/** Construct a 192-bit MPT issuance identifier from a sequence and account. + * + * Packs a big-endian 32-bit sequence number into the first 4 bytes of the + * MPTID, followed immediately by the 20-byte `AccountID`. The explicit + * `native_to_big` conversion ensures canonical byte order regardless of host + * endianness, which is required because the composite value is stored on-ledger + * and compared byte-by-byte. + * + * @param sequence The issuer's account sequence number at creation time + * (stored as `sfSequence` in the `MPTokenIssuance` SLE). + * @param account The issuing account. + * @return 192-bit identifier uniquely addressing this MPT issuance. + */ MPTID makeMptID(std::uint32_t sequence, AccountID const& account) { @@ -182,18 +273,39 @@ makeMptID(std::uint32_t sequence, AccountID const& account) namespace keylet { +/** Return the keylet for an AccountRoot ledger entry. + * + * @param id The account address. + * @return Keylet typed `ltACCOUNT_ROOT` whose key is the account's ledger index. + */ Keylet account(AccountID const& id) noexcept { return Keylet{ltACCOUNT_ROOT, indexHash(LedgerNameSpace::Account, id)}; } +/** Return a wildcard keylet for any entry that may appear in an owner directory. + * + * Uses `ltCHILD` so that `Keylet::check()` accepts any entry type — useful + * when iterating a directory without knowing the entry type in advance. + * + * @param key Raw 256-bit ledger key. + * @return Keylet typed `ltCHILD` wrapping the provided key. + */ Keylet child(uint256 const& key) noexcept { return {ltCHILD, key}; } +/** Return the keylet for the "short" ledger-hash skip list. + * + * The short skip list is a singleton ledger object that holds the hashes of + * the most recent ledgers since the last flag ledger (at most 256 entries). + * Because it has no parameters its key is computed once and cached. + * + * @return Reference to a static `Keylet` typed `ltLEDGER_HASHES`. + */ Keylet const& skip() noexcept { @@ -201,6 +313,16 @@ skip() noexcept return kRET; } +/** Return the keylet for a "long" ledger-hash skip list page. + * + * Each long skip list page covers a range of 65536 ledgers identified by + * the top 16 bits of the ledger sequence. Together the short and long skip + * lists allow any historical ledger to be located in at most two hops. + * + * @param ledger Any ledger index within the desired 65536-ledger range; + * only the upper 16 bits are used to derive the key. + * @return Keylet typed `ltLEDGER_HASHES` for the corresponding skip-list page. + */ Keylet skip(LedgerIndex ledger) noexcept { @@ -210,6 +332,13 @@ skip(LedgerIndex ledger) noexcept LedgerNameSpace::SkipList, std::uint32_t(static_cast(ledger) >> 16))}; } +/** Return the keylet for the singleton amendments table. + * + * The amendments object is globally unique; its key is computed once and + * cached. The `const&` return type signals that this is a fixed address. + * + * @return Reference to a static `Keylet` typed `ltAMENDMENTS`. + */ Keylet const& amendments() noexcept { @@ -217,6 +346,12 @@ amendments() noexcept return kRET; } +/** Return the keylet for the singleton fee-settings object. + * + * The fee object is globally unique; its key is computed once and cached. + * + * @return Reference to a static `Keylet` typed `ltFEE_SETTINGS`. + */ Keylet const& fees() noexcept { @@ -224,6 +359,12 @@ fees() noexcept return kRET; } +/** Return the keylet for the singleton negative-UNL object. + * + * The negative UNL is globally unique; its key is computed once and cached. + * + * @return Reference to a static `Keylet` typed `ltNEGATIVE_UNL`. + */ Keylet const& negativeUNL() noexcept { @@ -231,28 +372,40 @@ negativeUNL() noexcept return kRET; } +/** Return the keylet for the root directory page of an order book. + * + * Delegates to `getBookBase` and wraps the result in an `ltDIR_NODE` keylet. + * The key encodes quality 0 in its last 8 bytes, placing it before all + * real offer-directory pages in the SHAMap. + * + * @param b The order book (in/out asset pair plus optional domain). + * @return Keylet typed `ltDIR_NODE` for the book's root directory. + */ Keylet BookT::operator()(Book const& b) const { return {ltDIR_NODE, getBookBase(b)}; } +/** Return the keylet for a trust line (RippleState) between two accounts. + * + * A trust line is a bilateral relationship; the same on-ledger object is + * referenced regardless of which account is treated as "issuer" or "holder". + * To achieve this, the two account IDs are sorted in ascending order before + * hashing, producing an order-independent key. + * + * @note TrustSet may call this with `id0 == id1` to locate and delete + * malformed self-trust lines; the expected `id0 != id1` invariant is + * therefore not asserted here. + * + * @param id0 One of the two accounts on the trust line. + * @param id1 The other account on the trust line. + * @param currency The currency of the trust line. + * @return Keylet typed `ltRIPPLE_STATE`. + */ Keylet line(AccountID const& id0, AccountID const& id1, Currency const& currency) noexcept { - // There is code in TrustSet that calls us with id0 == id1, to allow users - // to locate and delete such "weird" trustlines. If we remove that code, we - // could enable this assert: - // XRPL_ASSERT(id0 != id1, "xrpl::keylet::line : accounts must be - // different"); - - // A trust line is shared between two accounts; while we typically think - // of this as an "issuer" and a "holder" the relationship is actually fully - // bidirectional. - // - // So that we can generate a unique ID for a trust line, regardess of which - // side of the line we're looking at, we define a "canonical" order for the - // two accounts (smallest then largest) and hash them in that order: auto const accounts = std::minmax(id0, id1); return { @@ -260,12 +413,32 @@ line(AccountID const& id0, AccountID const& id1, Currency const& currency) noexc indexHash(LedgerNameSpace::TrustLine, accounts.first, accounts.second, currency)}; } +/** Return the keylet for a specific offer placed by an account. + * + * @param id Account that placed the offer. + * @param seq Sequence number of the offer transaction. + * @return Keylet typed `ltOFFER`. + */ Keylet offer(AccountID const& id, std::uint32_t seq) noexcept { return {ltOFFER, indexHash(LedgerNameSpace::Offer, id, seq)}; } +/** Return the keylet for an order-book directory page at a specific quality. + * + * Embeds @p q as a big-endian 64-bit value in the last 8 bytes of the + * book's base key. Because `uint256` values are stored most-significant- + * byte first, incrementing the embedded quality field (via `getQualityNext`) + * advances to the next directory page in natural SHAMap sort order. + * + * @note The raw pointer arithmetic here relies on `base_uint`'s internal + * big-endian byte layout. + * + * @param k Base keylet for the order book (must be `ltDIR_NODE`). + * @param q 64-bit quality value (inverted exchange rate) to embed. + * @return Keylet typed `ltDIR_NODE` with quality encoded in the last 8 bytes. + */ Keylet quality(Keylet const& k, std::uint64_t q) noexcept { @@ -284,6 +457,12 @@ quality(Keylet const& k, std::uint64_t q) noexcept return {ltDIR_NODE, x}; } +/** Return the keylet for the next lower quality tier of an order-book directory. + * + * @param k A directory keylet (must be `ltDIR_NODE`) whose last 8 bytes + * encode a quality value. + * @return Keylet typed `ltDIR_NODE` with quality incremented by 1. + */ Keylet NextT::operator()(Keylet const& k) const { @@ -291,46 +470,102 @@ NextT::operator()(Keylet const& k) const return {ltDIR_NODE, getQualityNext(k.key)}; } +/** Return the keylet for a ticket owned by @p id with sequence @p ticketSeq. + * + * @param id Owner of the ticket. + * @param ticketSeq Sequence number consumed when the ticket was created. + * @return Keylet typed `ltTICKET`. + */ Keylet TicketT::operator()(AccountID const& id, std::uint32_t ticketSeq) const { return {ltTICKET, getTicketIndex(id, ticketSeq)}; } +/** Return the keylet for a ticket owned by @p id, resolved from a `SeqProxy`. + * + * @param id Owner of the ticket. + * @param ticketSeq SeqProxy in ticket mode; asserts if it represents a plain sequence. + * @return Keylet typed `ltTICKET`. + */ Keylet TicketT::operator()(AccountID const& id, SeqProxy ticketSeq) const { return {ltTICKET, getTicketIndex(id, ticketSeq)}; } -// This function is presently static, since it's never accessed from anywhere -// else. If we ever support multiple pages of signer lists, this would be the -// keylet used to locate them. +/** Return the keylet for a specific page of a signer list (internal helper). + * + * Currently only page 0 is ever allocated. This paginated overload is kept + * `static` as an architectural reservation: if signer-list pagination is + * ever supported, the infrastructure to derive per-page keylets is already + * in place, but the interface is intentionally not exposed until then. + * + * @param account Account whose signer list is being addressed. + * @param page Page number (always 0 in current protocol). + * @return Keylet typed `ltSIGNER_LIST`. + */ static Keylet signers(AccountID const& account, std::uint32_t page) noexcept { return {ltSIGNER_LIST, indexHash(LedgerNameSpace::SignerList, account, page)}; } +/** Return the keylet for an account's signer list. + * + * @param account Account whose signer list is being addressed. + * @return Keylet typed `ltSIGNER_LIST` for page 0. + */ Keylet signers(AccountID const& account) noexcept { return signers(account, 0); } +/** Return the keylet for a Check issued by an account. + * + * @param id Account that created the check. + * @param seq Sequence number of the CheckCreate transaction. + * @return Keylet typed `ltCHECK`. + */ Keylet check(AccountID const& id, std::uint32_t seq) noexcept { return {ltCHECK, indexHash(LedgerNameSpace::Check, id, seq)}; } +/** Return the keylet for a single-account deposit pre-authorization. + * + * @param owner Account granting the pre-authorization. + * @param preauthorized Account being pre-authorized to deposit. + * @return Keylet typed `ltDEPOSIT_PREAUTH` under the `DepositPreauth` + * namespace (distinct from the credential-set overload). + */ Keylet depositPreauth(AccountID const& owner, AccountID const& preauthorized) noexcept { return {ltDEPOSIT_PREAUTH, indexHash(LedgerNameSpace::DepositPreauth, owner, preauthorized)}; } -// Credentials should be sorted here, use credentials::makeSorted +/** Return the keylet for a credential-set deposit pre-authorization. + * + * Each credential is hashed individually as `sha512Half(issuer, credentialType)`, + * and the resulting hashes are passed as a vector to the outer `indexHash`. + * Because `authCreds` is a `std::set` the iteration order is deterministic, + * so the final hash is stable regardless of insertion order. + * + * The `DepositPreauthCredentials` namespace is distinct from `DepositPreauth`, + * preventing key collisions with the single-account overload even when the + * `owner` parameter is identical. + * + * @note Pass credentials pre-sorted via `credentials::makeSorted` to satisfy + * the `std::set` ordering requirement. + * + * @param owner Account granting the pre-authorization. + * @param authCreds Sorted set of (issuer AccountID, credentialType) pairs. + * @return Keylet typed `ltDEPOSIT_PREAUTH` under the `DepositPreauthCredentials` + * namespace. + */ Keylet depositPreauth( AccountID const& owner, @@ -347,18 +582,45 @@ depositPreauth( //------------------------------------------------------------------------------ +/** Return a keylet for any ledger entry without type enforcement. + * + * Uses `ltANY` so `Keylet::check()` accepts any entry type. Intended for + * low-level read paths that need to fetch an entry before its type is known. + * + * @param key Raw 256-bit ledger key. + * @return Keylet typed `ltANY`. + */ Keylet unchecked(uint256 const& key) noexcept { return {ltANY, key}; } +/** Return the keylet for the root page of an account's owner directory. + * + * The owner directory lists all ledger objects owned by the account + * (offers, trust lines, escrows, etc.). Page 0 is keyed directly; + * subsequent pages are keyed via `keylet::page`. + * + * @param id Account whose owner directory is being addressed. + * @return Keylet typed `ltDIR_NODE`. + */ Keylet ownerDir(AccountID const& id) noexcept { return {ltDIR_NODE, indexHash(LedgerNameSpace::OwnerDir, id)}; } +/** Return the keylet for a specific page within a directory. + * + * Page 0 is stored at the root key itself; subsequent pages are stored at + * keys derived by hashing the root key with the page index under the + * `DirNode` namespace. + * + * @param key 256-bit key of the directory's root page. + * @param index Zero-based page index; 0 returns the root key unchanged. + * @return Keylet typed `ltDIR_NODE`. + */ Keylet page(uint256 const& key, std::uint64_t index) noexcept { @@ -368,18 +630,45 @@ page(uint256 const& key, std::uint64_t index) noexcept return {ltDIR_NODE, indexHash(LedgerNameSpace::DirNode, key, index)}; } +/** Return the keylet for an escrow created by @p src. + * + * @param src Account that created the escrow. + * @param seq Sequence number of the EscrowCreate transaction. + * @return Keylet typed `ltESCROW`. + */ Keylet escrow(AccountID const& src, std::uint32_t seq) noexcept { return {ltESCROW, indexHash(LedgerNameSpace::Escrow, src, seq)}; } +/** Return the keylet for an XRP payment channel. + * + * @param src Funding (source) account. + * @param dst Receiving (destination) account. + * @param seq Sequence number of the PaymentChannelCreate transaction. + * @return Keylet typed `ltPAYCHAN`. + */ Keylet payChan(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept { return {ltPAYCHAN, indexHash(LedgerNameSpace::XRPPaymentChannel, src, dst, seq)}; } +/** Return the keylet for an owner's lowest possible NFT page. + * + * NFT page keys are composite values: the high 160 bits hold the owner's + * `AccountID`; the low 96 bits are a range tag derived from an NFToken ID. + * The minimum keylet has the low 96 bits set to zero, making it the floor + * of the owner's page range in the SHAMap. + * + * @note NFT pages are NOT located by hashing — the key is constructed + * directly as a composite, enabling bounded range scans over all of an + * owner's pages without a linked-list traversal. + * + * @param owner Account that owns the NFT collection. + * @return Keylet typed `ltNFTOKEN_PAGE` with low 96 bits all zero. + */ Keylet nftpageMin(AccountID const& owner) { @@ -388,6 +677,16 @@ nftpageMin(AccountID const& owner) return {ltNFTOKEN_PAGE, uint256{buf}}; } +/** Return the keylet for an owner's highest possible NFT page. + * + * The maximum keylet has the low 96 bits set to all ones, making it the + * ceiling of the owner's page range. Together with `nftpageMin` this + * defines the half-open interval `[min, max]` that covers every page + * belonging to this owner. + * + * @param owner Account that owns the NFT collection. + * @return Keylet typed `ltNFTOKEN_PAGE` with low 96 bits all one. + */ Keylet nftpageMax(AccountID const& owner) { @@ -396,6 +695,16 @@ nftpageMax(AccountID const& owner) return {ltNFTOKEN_PAGE, id}; } +/** Return the keylet for the NFT page that should contain @p token. + * + * Preserves the owner identity from @p k (high 160 bits) and replaces + * the range tag (low 96 bits) with the corresponding bits of @p token. + * The result is the key of the page whose range covers that token. + * + * @param k An NFT page keylet for the same owner (must be `ltNFTOKEN_PAGE`). + * @param token 256-bit NFToken ID whose low 96 bits determine the page. + * @return Keylet typed `ltNFTOKEN_PAGE` for the page covering @p token. + */ Keylet nftpage(Keylet const& k, uint256 const& token) { @@ -403,24 +712,51 @@ nftpage(Keylet const& k, uint256 const& token) return {ltNFTOKEN_PAGE, (k.key & ~nft::kPAGE_MASK) + (token & nft::kPAGE_MASK)}; } +/** Return the keylet for an NFToken buy or sell offer. + * + * @param owner Account that created the offer. + * @param seq Sequence number of the NFTokenCreateOffer transaction. + * @return Keylet typed `ltNFTOKEN_OFFER`. + */ Keylet nftoffer(AccountID const& owner, std::uint32_t seq) { return {ltNFTOKEN_OFFER, indexHash(LedgerNameSpace::NftokenOffer, owner, seq)}; } +/** Return the keylet for the directory of buy offers for an NFToken. + * + * @param id 256-bit NFToken ID. + * @return Keylet typed `ltDIR_NODE` for the buy-offer directory. + */ Keylet nftBuys(uint256 const& id) noexcept { return {ltDIR_NODE, indexHash(LedgerNameSpace::NftokenBuyOffers, id)}; } +/** Return the keylet for the directory of sell offers for an NFToken. + * + * @param id 256-bit NFToken ID. + * @return Keylet typed `ltDIR_NODE` for the sell-offer directory. + */ Keylet nftSells(uint256 const& id) noexcept { return {ltDIR_NODE, indexHash(LedgerNameSpace::NftokenSellOffers, id)}; } +/** Return the keylet for an AMM pool keyed by its two pooled assets. + * + * The two assets are sorted via `std::minmax` before hashing to produce an + * order-independent key — `amm(A, B)` and `amm(B, A)` yield the same keylet. + * A `std::visit` then dispatches over all four `Issue`/`MPTIssue` combinations + * at compile time to extract the correct identifier fields for each asset type. + * + * @param asset1 One of the two pooled assets. + * @param asset2 The other pooled asset. + * @return Keylet typed `ltAMM`. + */ Keylet amm(Asset const& asset1, Asset const& asset2) noexcept { @@ -455,28 +791,60 @@ amm(Asset const& asset1, Asset const& asset2) noexcept maxA.value()); } +/** Return the keylet for an AMM pool from a pre-computed 256-bit AMM ID. + * + * Use this overload when the AMM ID has already been computed (e.g. it is + * stored in `sfAMMID` on another SLE) to avoid redundant hashing. + * + * @param id Pre-computed 256-bit AMM identifier. + * @return Keylet typed `ltAMM`. + */ Keylet amm(uint256 const& id) noexcept { return {ltAMM, id}; } +/** Return the keylet for a Delegate grant from one account to another. + * + * @param account Account that is granting delegated authority. + * @param authorizedAccount Account receiving the delegated authority. + * @return Keylet typed `ltDELEGATE`. + */ Keylet delegate(AccountID const& account, AccountID const& authorizedAccount) noexcept { return {ltDELEGATE, indexHash(LedgerNameSpace::Delegate, account, authorizedAccount)}; } +/** Return the keylet for a cross-chain bridge object. + * + * A door account may support multiple bridges. On the locking chain, at + * most one bridge is permitted per locking-chain currency; on the issuing + * chain, at most one bridge per issuing-chain currency. The key therefore + * encodes the door account and the currency appropriate to @p chainType. + * + * @param bridge Bridge descriptor containing door accounts and issues. + * @param chainType Selects whether to key on the locking or issuing side. + * @return Keylet typed `ltBRIDGE`. + */ Keylet bridge(STXChainBridge const& bridge, STXChainBridge::ChainType chainType) { - // A door account can support multiple bridges. On the locking chain - // there can only be one bridge per lockingChainCurrency. On the issuing - // chain there can only be one bridge per issuingChainCurrency. auto const& issue = bridge.issue(chainType); return {ltBRIDGE, indexHash(LedgerNameSpace::Bridge, bridge.door(chainType), issue.currency)}; } +/** Return the keylet for a cross-chain claim ID. + * + * The key encodes the full bridge identity (both door accounts and both + * issues) plus the sequential claim ID (`sfXChainClaimID`), ensuring + * uniqueness across all bridges and all claim IDs. + * + * @param bridge Bridge descriptor. + * @param seq Sequential claim ID (`sfXChainClaimID` in the object). + * @return Keylet typed `ltXCHAIN_OWNED_CLAIM_ID`. + */ Keylet xChainClaimID(STXChainBridge const& bridge, std::uint64_t seq) { @@ -491,6 +859,15 @@ xChainClaimID(STXChainBridge const& bridge, std::uint64_t seq) seq)}; } +/** Return the keylet for a cross-chain create-account claim ID. + * + * Analogous to `xChainClaimID` but for the create-account variant. The + * sequential counter is `sfXChainAccountCreateCount` in the object. + * + * @param bridge Bridge descriptor. + * @param seq Sequential create-account claim ID (`sfXChainAccountCreateCount`). + * @return Keylet typed `ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID`. + */ Keylet xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq) { @@ -505,72 +882,160 @@ xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq) seq)}; } +/** Return the keylet for an account's DID (Decentralized Identifier) document. + * + * @param account Account that owns the DID. + * @return Keylet typed `ltDID`. + */ Keylet did(AccountID const& account) noexcept { return {ltDID, indexHash(LedgerNameSpace::Did, account)}; } +/** Return the keylet for a price oracle. + * + * An account may own multiple oracles identified by distinct document IDs. + * + * @param account Account that owns the oracle. + * @param documentID Application-defined identifier distinguishing oracles + * within the same account (`sfOracleDocumentID`). + * @return Keylet typed `ltORACLE`. + */ Keylet oracle(AccountID const& account, std::uint32_t const& documentID) noexcept { return {ltORACLE, indexHash(LedgerNameSpace::Oracle, account, documentID)}; } +/** Return the keylet for an MPT issuance from the issuer's sequence and account. + * + * Constructs the `MPTID` via `makeMptID` then delegates to the `MPTID` + * overload to avoid duplicating the hash logic. + * + * @param seq Issuer's account sequence at the time of issuance creation. + * @param issuer Account that created the issuance. + * @return Keylet typed `ltMPTOKEN_ISSUANCE`. + */ Keylet mptIssuance(std::uint32_t seq, AccountID const& issuer) noexcept { return mptIssuance(makeMptID(seq, issuer)); } +/** Return the keylet for an MPT issuance from a pre-built MPTID. + * + * @param issuanceID 192-bit MPT issuance identifier (see `makeMptID`). + * @return Keylet typed `ltMPTOKEN_ISSUANCE`. + */ Keylet mptIssuance(MPTID const& issuanceID) noexcept { return {ltMPTOKEN_ISSUANCE, indexHash(LedgerNameSpace::MPTokenIssuance, issuanceID)}; } +/** Return the keylet for a holder's MPToken balance entry, identified by MPTID. + * + * Derives the issuance key first via `mptIssuance(issuanceID).key`, then + * delegates to the `uint256`-keyed overload. Use the `uint256` overload + * directly when the issuance key is already available to avoid redundant + * hashing. + * + * @param issuanceID 192-bit MPTID identifying the issuance. + * @param holder Account holding the MPToken balance. + * @return Keylet typed `ltMPTOKEN`. + */ Keylet mptoken(MPTID const& issuanceID, AccountID const& holder) noexcept { return mptoken(mptIssuance(issuanceID).key, holder); } +/** Return the keylet for a holder's MPToken balance entry, identified by issuance key. + * + * @param issuanceKey 256-bit key of the `MPTokenIssuance` SLE. + * @param holder Account holding the MPToken balance. + * @return Keylet typed `ltMPTOKEN`. + */ Keylet mptoken(uint256 const& issuanceKey, AccountID const& holder) noexcept { return {ltMPTOKEN, indexHash(LedgerNameSpace::MPToken, issuanceKey, holder)}; } +/** Return the keylet for a verifiable credential. + * + * @param subject Account the credential was issued to. + * @param issuer Account that issued the credential. + * @param credType Application-defined credential type byte string. + * @return Keylet typed `ltCREDENTIAL`. + */ Keylet credential(AccountID const& subject, AccountID const& issuer, Slice const& credType) noexcept { return {ltCREDENTIAL, indexHash(LedgerNameSpace::Credential, subject, issuer, credType)}; } +/** Return the keylet for a single-asset vault created by @p owner. + * + * Derives the raw key via `indexHash` then delegates to the `uint256` + * overload (which wraps it in the `ltVAULT` keylet), keeping the two-step + * pattern consistent with `loanbroker` and `loan`. + * + * @param owner Account that created the vault. + * @param seq Sequence number of the VaultCreate transaction. + * @return Keylet typed `ltVAULT`. + */ Keylet vault(AccountID const& owner, std::uint32_t seq) noexcept { return vault(indexHash(LedgerNameSpace::Vault, owner, seq)); } +/** Return the keylet for a loan broker created by @p owner. + * + * @param owner Account that created the loan broker. + * @param seq Sequence number of the LoanBrokerCreate transaction. + * @return Keylet typed `ltLOAN_BROKER`. + */ Keylet loanbroker(AccountID const& owner, std::uint32_t seq) noexcept { return loanbroker(indexHash(LedgerNameSpace::LoanBroker, owner, seq)); } +/** Return the keylet for an individual loan within a loan broker. + * + * @param loanBrokerID 256-bit key of the parent `LoanBroker` SLE. + * @param loanSeq Sequential loan number assigned by the broker. + * @return Keylet typed `ltLOAN`. + */ Keylet loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept { return loan(indexHash(LedgerNameSpace::Loan, loanBrokerID, loanSeq)); } +/** Return the keylet for a permissioned domain owned by @p account. + * + * @param account Account that created the permissioned domain. + * @param seq Sequence number of the PermissionedDomainSet transaction. + * @return Keylet typed `ltPERMISSIONED_DOMAIN`. + */ Keylet permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept { return {ltPERMISSIONED_DOMAIN, indexHash(LedgerNameSpace::PermissionedDomain, account, seq)}; } +/** Return the keylet for a permissioned domain from a pre-computed domain ID. + * + * Use this overload when the domain ID is already known (e.g. stored in + * `sfDomainID` on another SLE) to avoid recomputing the hash. + * + * @param domainID Pre-computed 256-bit domain key. + * @return Keylet typed `ltPERMISSIONED_DOMAIN`. + */ Keylet permissionedDomain(uint256 const& domainID) noexcept { diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp index 5c691159d7..e33da94f49 100644 --- a/src/libxrpl/protocol/InnerObjectFormats.cpp +++ b/src/libxrpl/protocol/InnerObjectFormats.cpp @@ -1,3 +1,16 @@ +/** @file + * Defines the `InnerObjectFormats` singleton registry, which declares the + * canonical field schemas (`SOTemplate`) for every structured sub-object that + * can appear inside an XRPL serialized object (`STObject`). + * + * This registry is the inner-object counterpart to `TxFormats` and + * `LedgerFormats`: those govern top-level transaction and ledger-entry shapes, + * while `InnerObjectFormats` governs nested objects embedded within them. + * Callers in `STObject` (`makeInnerObject`, `applyTemplateFromSField`, + * `getFieldObject`) use this registry to enforce field-presence rules during + * construction and deserialization. + */ + #include #include @@ -5,11 +18,21 @@ namespace xrpl { +/** Registers all known inner object schemas. + * + * Each `add()` call binds an `SField`'s JSON name and integer field code to + * an `SOTemplate` that declares which child fields are required, optional, or + * default. Using the field's own code as the registry key means no separate + * enum is needed — the `SField` identity doubles as the lookup key. + * + * @note Any inner object whose `SOTemplate` carries `soeDEFAULT` fields (e.g. + * `sfTradingFee`, `sfDiscountedFee`, `sfScale`) must be constructed via + * `STObject::makeInnerObject()` so that the template is applied before + * field values are set; constructing such objects directly and then + * calling `set()` bypasses the default-omission logic. + */ InnerObjectFormats::InnerObjectFormats() { - // inner objects with the default fields have to be - // constructed with STObject::makeInnerObject() - add(sfSignerEntry.jsonName, sfSignerEntry.getCode(), { @@ -162,6 +185,13 @@ InnerObjectFormats::InnerObjectFormats() }); } +/** Return the process-wide singleton instance. + * + * Initialized on first call via a Meyer's static local; safe for concurrent + * access after that point. The object is immutable after construction. + * + * @return A const reference to the singleton `InnerObjectFormats` registry. + */ InnerObjectFormats const& InnerObjectFormats::getInstance() { @@ -169,6 +199,16 @@ InnerObjectFormats::getInstance() return kINSTANCE; } +/** Look up the field schema for a structured inner object. + * + * Translates an `SField` to its registered `SOTemplate` by matching on the + * field's integer code. The returned pointer is stable for the lifetime of + * the process; callers may cache it. + * + * @param sField The `SField` identifying the inner object type. + * @return A pointer to the `SOTemplate` if the field is a registered inner + * object type, or `nullptr` if it is not. + */ SOTemplate const* InnerObjectFormats::findSOTemplateBySField(SField const& sField) const { diff --git a/src/libxrpl/protocol/Issue.cpp b/src/libxrpl/protocol/Issue.cpp index 33ad3a0835..e118505ec3 100644 --- a/src/libxrpl/protocol/Issue.cpp +++ b/src/libxrpl/protocol/Issue.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements Issue: the (Currency, AccountID) pair that identifies every + * IOU and XRP asset on the XRP Ledger. + * + * XRP is represented as a special-case Issue whose currency and account + * fields are both the zero/XRP sentinel values. All serialisation, string + * rendering, and JSON parsing for non-MPT assets flows through this file. + */ + #include #include @@ -13,6 +22,19 @@ namespace xrpl { +/** Returns a human-readable diagnostic string of the form + * `currency[/account]`. + * + * For XRP (zero currency), only the currency string is returned with no + * slash suffix. For IOU issues the account is rendered after a slash, + * substituting `"0"` for the XRP sentinel account and `"1"` for + * `noAccount()`. + * + * @note This format differs from `to_string(Issue)`, which reverses the + * field order to `account/currency`. Use `getText()` for logging and + * diagnostics; use `setJson()` for canonical wire output. + * @return Diagnostic string; never empty. + */ std::string Issue::getText() const { @@ -42,6 +64,15 @@ Issue::getText() const return ret; } +/** Writes the canonical wire-format JSON representation into @p jv. + * + * Always sets `jv["currency"]`. Sets `jv["issuer"]` as a Base58Check + * account string only for non-XRP issues; XRP omits `"issuer"` entirely, + * which is the authoritative form expected by transaction JSON, RPC + * responses, and the binary codec. + * + * @param jv Output JSON object; existing keys are not cleared. + */ void Issue::setJson(json::Value& jv) const { @@ -50,24 +81,68 @@ Issue::setJson(json::Value& jv) const jv[jss::issuer] = toBase58(account); } +/** Returns `true` if this issue represents XRP (the native asset). + * + * Implemented as a full equality comparison against `xrpIssue()`. + * The underlying `operator==` short-circuits on the currency field alone + * when `isXRP(currency)` is true, so the account field is not consulted + * for XRP values. + * + * @return `true` iff `*this == xrpIssue()`. + */ bool Issue::native() const { return *this == xrpIssue(); } +/** Returns `true` if amounts of this issue are stored as integers. + * + * For `Issue`, only XRP is integral (amounts are denominated in + * indivisible drops). All IOU currencies use floating-point mantissa + * and exponent representation. Delegates entirely to `native()`. + * + * @note `MPTIssue::integral()` always returns `true` because MPT amounts + * are also integers. The shared method name enables generic code + * operating on either asset kind to query precision behaviour without + * a type-dispatch. + * @return `true` iff `native()`. + */ bool Issue::integral() const { return native(); } +/** Returns `true` if the currency and account fields are mutually consistent. + * + * The invariant is that both fields must agree on XRP-ness: either both are + * the XRP/zero sentinel or neither is. A cross-contaminated issue (XRP + * currency with a real account, or a real currency with the XRP account + * sentinel) would silently corrupt amount comparisons and offer-book + * matching. Call this on any newly constructed `Issue` that originates + * from external input. + * + * @param ac The issue to validate. + * @return `true` iff `isXRP(ac.currency) == isXRP(ac.account)`. + */ bool isConsistent(Issue const& ac) { return isXRP(ac.currency) == isXRP(ac.account); } +/** Returns a string representation of the form `account/currency`, or just + * the currency string when the account is the XRP sentinel. + * + * @note The field order (`account/currency`) is the reverse of + * `Issue::getText()` (`currency/account`). This asymmetry is + * historical: offer-book keys and engine log lines have always used + * this order. Both formats are in active use in different parts of + * the codebase. + * @param ac The issue to render. + * @return A non-empty string identifying the issue. + */ std::string to_string(Issue const& ac) { @@ -77,6 +152,14 @@ to_string(Issue const& ac) return to_string(ac.account) + "/" + to_string(ac.currency); } +/** Returns the canonical wire-format JSON representation of an issue. + * + * Convenience wrapper around `Issue::setJson()`. + * + * @param is The issue to serialise. + * @return A JSON object with a `"currency"` field and, for non-XRP issues, + * an `"issuer"` field. + */ json::Value toJson(Issue const& is) { @@ -85,6 +168,33 @@ toJson(Issue const& is) return jv; } +/** Parses and validates an `Issue` from a JSON object. + * + * Performs layered validation in strict order: + * 1. @p v must be a JSON object (not a string or array). + * 2. The `mpt_issuance_id` field must be absent — its presence signals + * that the caller has routed MPT data into the wrong parser. + * 3. `"currency"` must be a JSON string that parses to something other + * than `badCurrency()` (the literal three-letter code `"XRP"`, which + * is reserved) or `noCurrency()` (parse failure sentinel). + * 4. For XRP currency, `"issuer"` must be absent. + * 5. For non-XRP currencies, `"issuer"` must be a string that decodes as + * a valid Base58Check account ID. + * + * Two exception types are used intentionally: `Json::error` for malformed + * JSON values (wrong type, missing or extra fields), and + * `std::runtime_error` for structural misuse (non-object input or + * MPT-typed JSON routed here). Callers that only care about format errors + * may catch the narrower type. + * + * @param v The JSON value to parse; must be an object. + * @return The parsed `Issue`. + * @throws std::runtime_error if @p v is not an object, or if + * `mpt_issuance_id` is present. + * @throws Json::error if any field is missing, wrong type, or carries an + * invalid currency code or account string. + * @see mptIssueFromJson for the parallel MPT parser. + */ Issue issueFromJson(json::Value const& v) { @@ -136,6 +246,12 @@ issueFromJson(json::Value const& v) return Issue{currency, *issuer}; } +/** Writes the issue to a stream using the `to_string(Issue)` format. + * + * @param os The output stream. + * @param x The issue to write. + * @return @p os. + */ std::ostream& operator<<(std::ostream& os, Issue const& x) { diff --git a/src/libxrpl/protocol/Keylet.cpp b/src/libxrpl/protocol/Keylet.cpp index 26bc98c1d6..3c806d8436 100644 --- a/src/libxrpl/protocol/Keylet.cpp +++ b/src/libxrpl/protocol/Keylet.cpp @@ -1,3 +1,8 @@ +/** @file + * Implements Keylet::check(), the runtime guardian that enforces type + * correctness after a SHAMap lookup. + */ + #include #include @@ -6,6 +11,26 @@ namespace xrpl { +/** Validates that a deserialized ledger entry corresponds to this keylet. + * + * Applies a three-tier match, ordered from most-permissive to most-strict: + * + * 1. **`ltANY`** — wildcard; always returns `true`. The caller bears full + * responsibility for type safety (used by the `keylet::unchecked` family). + * 2. **`ltCHILD`** — directory-child pseudo-type; returns `true` for any + * entry whose concrete type is not `ltDIR_NODE`. Directory nodes are + * structural bookkeeping objects; a child of a directory is definitionally + * something other than a directory node. + * 3. **Exact match** — for all concrete types, both the entry's type and its + * key must equal those stored in this keylet. This is the normal case. + * + * @param sle The deserialized ledger entry retrieved from the state map. + * @return `true` if `sle` legitimately corresponds to this keylet. + * @note `sle` must not carry `ltANY` or `ltCHILD` as its own type; these + * are pseudo-types used only for keylet construction and filtering. An + * `XRPL_ASSERT` enforces this precondition — a violation indicates a + * malformed entry read from the state map. + */ bool Keylet::check(STLedgerEntry const& sle) const { diff --git a/src/libxrpl/protocol/LedgerFormats.cpp b/src/libxrpl/protocol/LedgerFormats.cpp index 99c636fbdc..7972e21ccd 100644 --- a/src/libxrpl/protocol/LedgerFormats.cpp +++ b/src/libxrpl/protocol/LedgerFormats.cpp @@ -1,3 +1,17 @@ +/** @file + * Registers every on-ledger object type with its canonical field schema. + * + * This translation unit is the single registration point that populates the + * `LedgerFormats` singleton. The constructor uses an X-macro pass over + * `ledger_entries.macro` so that both the `LedgerEntryType` enum (declared + * in the header) and the `SOTemplate` validation schemas (built here) are + * derived from one authoritative source. + * + * @note `LedgerEntryType` numeric values are serialized into every ledger + * object and are therefore protocol-stable. Reusing or renumbering them + * without explicit amendment logic causes a hard fork. + */ + #include #include @@ -8,6 +22,26 @@ namespace xrpl { +/** Return the three fields present in every ledger entry. + * + * These fields are injected into every `SOTemplate` built by the + * `LedgerFormats` constructor, so a change here automatically propagates to + * all ~30 registered entry types. + * + * The three universal fields are: + * - `sfLedgerIndex` (`SoeOptional`) — position in the ledger; may be + * absent in some serialization contexts. + * - `sfLedgerEntryType` (`SoeRequired`) — numeric type discriminator used + * by `findByType()` for lookup; must always be present. + * - `sfFlags` (`SoeRequired`) — object flag bitmask; must always + * be present, even when zero. + * + * Using a function-local static avoids the static-initialization-order + * problem: the vector is constructed on first call, which occurs inside the + * `LedgerFormats` constructor before any entry type accesses it. + * + * @return A reference to the immutable common-fields vector. + */ std::vector const& LedgerFormats::getCommonFields() { @@ -19,6 +53,27 @@ LedgerFormats::getCommonFields() return kCOMMON_FIELDS; } +/** Populate the registry with all known ledger entry formats. + * + * Uses an X-macro pass over `ledger_entries.macro`: the local `LEDGER_ENTRY` + * macro expands each entry into an `add(jss::name, tag, fields, + * getCommonFields())` call. `UNWRAP(...)` strips the extra layer of + * parentheses that protects field-list commas from the preprocessor. + * + * `#pragma push_macro`/`pop_macro` guards both `LEDGER_ENTRY` and `UNWRAP` + * so any pre-existing definitions in this translation unit are restored after + * the include, preventing hard-to-diagnose build failures. + * + * `LEDGER_ENTRY_DUPLICATE` (defined in the macro file itself) handles the + * `DepositPreauth` name collision between transaction and ledger entry types: + * it expands identically to `LEDGER_ENTRY` but suppresses the `jss.h` + * `JSS()` emission to avoid a duplicate-symbol link error. + * + * @note If `ledger_entries.macro` contains a duplicate numeric type ID, + * `KnownFormats::add()` detects it via `findByType()` and throws + * `LogicError` during static initialization, causing a crash on startup + * rather than silent registry corruption. + */ LedgerFormats::LedgerFormats() { #pragma push_macro("UNWRAP") @@ -38,6 +93,19 @@ LedgerFormats::LedgerFormats() #pragma pop_macro("UNWRAP") } +/** Return the singleton registry of all known ledger entry formats. + * + * Uses a function-local static (Meyer's singleton) for thread-safe, + * once-only initialization guaranteed by C++11. The first call constructs + * the `LedgerFormats` object and registers every entry type; subsequent + * calls return the same instance. + * + * Callers use the returned object to validate and deserialize ledger entries, + * for example via `findByType(type)` to retrieve the `SOTemplate` for a + * given `LedgerEntryType`. + * + * @return A `const` reference to the process-wide `LedgerFormats` instance. + */ LedgerFormats const& LedgerFormats::getInstance() { diff --git a/src/libxrpl/protocol/LedgerHeader.cpp b/src/libxrpl/protocol/LedgerHeader.cpp index 9ddd6e180d..c727393795 100644 --- a/src/libxrpl/protocol/LedgerHeader.cpp +++ b/src/libxrpl/protocol/LedgerHeader.cpp @@ -1,3 +1,15 @@ +/** @file + * Canonical serialization, deserialization, and hash calculation for + * `LedgerHeader` — the fixed-size metadata block that identifies every + * closed XRP Ledger. + * + * Every path a ledger takes through the system (network propagation, + * node-store persistence, validation, and replay) passes through one or + * more of these four functions. The field order and integer widths used + * here are protocol-immutable: changing them without an amendment would + * produce hashes that diverge from the rest of the network. + */ + #include #include @@ -10,6 +22,28 @@ namespace xrpl { +/** Append a ledger header to a serializer in canonical network byte order. + * + * Field order: seq (32-bit), drops (64-bit), parentHash, txHash, + * accountHash (each 256-bit), parentCloseTime, closeTime (each 32-bit + * epoch counts), closeTimeResolution (8-bit), closeFlags (8-bit). The + * `hash` field is appended last only when @p includeHash is `true`. + * + * Omitting the hash by default avoids circularity: the hash is derived + * from all other fields, so it must not be part of the payload that is + * fed into `calculateLedgerHash`. Pass `includeHash = true` when + * persisting to the node store or transmitting over the wire so that + * receivers can skip recomputing it. + * + * @note The field order here must exactly mirror `calculateLedgerHash`. + * They are not mechanically linked; a divergence silently breaks + * consensus-level hash agreement across the network. + * + * @param info The ledger header to serialize. `validated` and + * `accepted` are runtime-only flags and are not written. + * @param s Accumulator that receives the serialized bytes. + * @param includeHash If `true`, append `info.hash` after all other fields. + */ void addRaw(LedgerHeader const& info, Serializer& s, bool includeHash) { @@ -27,6 +61,25 @@ addRaw(LedgerHeader const& info, Serializer& s, bool includeHash) s.addBitString(info.hash); } +/** Deserialize a ledger header from a raw byte buffer. + * + * Reads fields in the same order that `addRaw` writes them. Time fields + * are raw 32-bit epoch counts (seconds since the XRPL epoch, 1 Jan 2000) + * that are wrapped back into typed `NetClock::time_point` values. + * + * No semantic validation is performed beyond what `SerialIter` enforces. + * The caller is responsible for verifying that the deserialized `hash` + * matches `calculateLedgerHash` before trusting the data. + * + * @param data View over the raw bytes to deserialize. + * @param hasHash If `true`, read a trailing 256-bit hash into + * `LedgerHeader::hash`. Pass `false` when the hash was omitted + * during serialization. + * @return A populated `LedgerHeader`; `validated` and `accepted` + * are left at their default values. + * @throws `std::runtime_error` (via `SerialIter`) if @p data is + * shorter than the expected field sequence. + */ LedgerHeader deserializeHeader(Slice data, bool hasHash) { @@ -50,16 +103,48 @@ deserializeHeader(Slice data, bool hasHash) return header; } +/** Deserialize a ledger header that was stored with a leading 4-byte prefix. + * + * Advances past the `HashPrefix` tag that is prepended when a ledger header + * is stored in the node database or transmitted in a peer-protocol message, + * then delegates to `deserializeHeader`. + * + * @param data View over the raw bytes, including the 4-byte prefix. + * @param hasHash Forwarded to `deserializeHeader`; see its documentation. + * @return A populated `LedgerHeader` as returned by `deserializeHeader`. + * @throws `std::runtime_error` (via `SerialIter`) if the remaining + * buffer after skipping the prefix is too short. + */ LedgerHeader deserializePrefixedHeader(Slice data, bool hasHash) { return deserializeHeader(data + 4, hasHash); } +/** Compute the canonical 256-bit identity hash for a ledger header. + * + * Feeds all header fields (except `hash` itself) into `sha512Half` — + * the first 256 bits of a SHA-512 digest — prepended with + * `HashPrefix::LedgerMaster` (`LWR\0`). The four-byte prefix ensures + * hash-domain separation: even identical binary content produces a + * different digest when hashed as a ledger vs. any other XRPL object type. + * + * Explicit casts to `std::uint32_t`, `std::uint64_t`, and `std::uint8_t` + * in the call site pin each field to its protocol-defined wire width, + * preventing silent widening or narrowing from silently diverging from + * the network. + * + * @note The field order and widths here must exactly mirror `addRaw`. + * They are not mechanically linked; a divergence causes this node to + * compute hashes that disagree with the rest of the network. + * + * @param info The ledger header to hash. `hash`, `validated`, and + * `accepted` are not included in the digest. + * @return The 256-bit canonical ledger hash. + */ uint256 calculateLedgerHash(LedgerHeader const& info) { - // VFALCO This has to match addRaw in View.h. return sha512Half( HashPrefix::LedgerMaster, std::uint32_t(info.seq), diff --git a/src/libxrpl/protocol/MPTAmount.cpp b/src/libxrpl/protocol/MPTAmount.cpp index 1950407ab5..6b36b244d9 100644 --- a/src/libxrpl/protocol/MPTAmount.cpp +++ b/src/libxrpl/protocol/MPTAmount.cpp @@ -1,7 +1,25 @@ +/** @file + * Out-of-line arithmetic and comparison operators for MPTAmount. + * + * MPTAmount wraps a plain `int64_t` balance for Multi-Purpose Tokens. + * The operators here are intentionally branch-free: no overflow detection + * is performed. Callers are expected to validate bounds through the ledger + * constraint machinery before mutating balances. The safe multiplication + * path (`mulRatio`, defined inline in MPTAmount.h) uses 128-bit + * intermediates and checks for overflow before converting. + */ #include namespace xrpl { +/** Add `other` to this amount in place. + * + * No overflow detection is performed; callers must ensure the result + * remains within `int64_t` range. + * + * @param other The amount to add. + * @return Reference to this object after the addition. + */ MPTAmount& MPTAmount::operator+=(MPTAmount const& other) { @@ -9,6 +27,14 @@ MPTAmount::operator+=(MPTAmount const& other) return *this; } +/** Subtract `other` from this amount in place. + * + * No overflow detection is performed; callers must ensure the result + * remains within `int64_t` range. + * + * @param other The amount to subtract. + * @return Reference to this object after the subtraction. + */ MPTAmount& MPTAmount::operator-=(MPTAmount const& other) { @@ -16,30 +42,75 @@ MPTAmount::operator-=(MPTAmount const& other) return *this; } +/** Return the arithmetic negation of this amount. + * + * Used in transaction arithmetic where a credit and a debit are expressed + * as equal-magnitude amounts of opposite sign before being applied to the + * ledger. Callers must ensure the magnitude is representable in `int64_t` + * (negating `INT64_MIN` is undefined behavior). + * + * @return A new MPTAmount whose value is `-value_`. + */ MPTAmount MPTAmount::operator-() const { return MPTAmount{-value_}; } +/** Test equality with another MPTAmount. + * + * Together with `operator<`, this satisfies the requirements of + * `boost::totally_ordered`, from which `!=`, `>`, `<=`, and `>=` are + * synthesized. + * + * @param other The amount to compare against. + * @return `true` if both amounts represent the same integer value. + */ bool MPTAmount::operator==(MPTAmount const& other) const { return value_ == other.value_; } +/** Test equality with a raw `int64_t` value. + * + * Allows comparisons such as `amt == 0` without constructing a temporary + * MPTAmount. `boost::equality_comparable` + * synthesizes the mixed-type `!=` from this overload. + * + * @param other The raw integer value to compare against. + * @return `true` if `value_` equals `other`. + */ bool MPTAmount::operator==(value_type other) const { return value_ == other; } +/** Return `true` if this amount is less than `other`. + * + * The single total-order primitive from which `boost::totally_ordered` + * synthesizes `>`, `<=`, and `>=`. Signed comparison gives correct + * semantics for negative balances: a negative MPT amount is less than zero. + * + * @param other The amount to compare against. + * @return `true` if `value_` is strictly less than `other.value_`. + */ bool MPTAmount::operator<(MPTAmount const& other) const { return value_ < other.value_; } +/** Return the smallest positive MPT amount (one indivisible unit). + * + * Equivalent to one drop of XRP for MPT balances. This named factory + * exists so generic code that operates across `XRPAmount`, `IOUAmount`, + * and `MPTAmount` can call a uniform interface — for `IOUAmount` the + * smallest representable positive value requires more complex construction. + * + * @return `MPTAmount{1}`. + */ MPTAmount MPTAmount::minPositiveAmount() { diff --git a/src/libxrpl/protocol/MPTIssue.cpp b/src/libxrpl/protocol/MPTIssue.cpp index e9ec852d1d..f66a3dfb40 100644 --- a/src/libxrpl/protocol/MPTIssue.cpp +++ b/src/libxrpl/protocol/MPTIssue.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements MPTIssue: the identity type for Multi-Purpose Token issuances. + * + * MPTIssue adapts a raw 192-bit MPTID (sequence ‖ AccountID) to provide the + * same interface as Issue, enabling participation in the Asset variant and + * static-polymorphism patterns throughout the ledger engine without rewriting + * amount-handling code. + */ + #include #include @@ -16,39 +25,85 @@ namespace xrpl { +/** Construct from a pre-formed 192-bit issuance identifier. + * + * @param issuanceID The packed MPTID (32-bit sequence ‖ 160-bit AccountID). + */ MPTIssue::MPTIssue(MPTID const& issuanceID) : mptID_(issuanceID) { } +/** Construct from the raw components of an MPTID. + * + * Delegates to `xrpl::makeMptID(sequence, account)` to assemble the packed + * MPTID, saving callers from invoking that helper explicitly. + * + * @param sequence The issuer's account sequence number at the time of + * issuance. + * @param account The AccountID of the issuer. + */ MPTIssue::MPTIssue(std::uint32_t sequence, AccountID const& account) : MPTIssue(xrpl::makeMptID(sequence, account)) { } +/** Extract the issuer's AccountID from the packed MPTID. + * + * MPTID encodes sequence (4 bytes) followed immediately by AccountID (20 + * bytes). This method recovers the AccountID by pointer-casting past the + * leading 4 bytes. A `static_assert` on the total size of MPTID guards + * against padding being silently introduced between the two fields; if the + * layout ever changes the build fails rather than reading the wrong bytes. + * + * @return A reference to the AccountID embedded in the MPTID. The reference + * is valid for the lifetime of this MPTIssue object. + * @note The free function `getMPTIssuer()` in the header achieves the same + * extraction via `std::copy_n` + `std::bit_cast`, which is constexpr- + * eligible. This member uses `reinterpret_cast` for a direct reference + * return but is equally correct given the same `static_assert` guard. + */ AccountID const& MPTIssue::getIssuer() const { - // MPTID is concatenation of sequence + account static_assert(sizeof(MPTID) == (sizeof(std::uint32_t) + sizeof(AccountID))); - // copy from id skipping the sequence AccountID const* account = reinterpret_cast(mptID_.data() + sizeof(std::uint32_t)); return *account; } +/** Return the hex string representation of the underlying MPTID. + * + * @return Uppercase hex encoding of the 192-bit issuance identifier. + */ std::string MPTIssue::getText() const { return to_string(mptID_); } +/** Write the issuance identifier into a JSON object under the key + * `mpt_issuance_id`. + * + * Serializes the MPTID as a hex string. Intended for merging into a larger + * JSON structure; use `toJson()` when a standalone object is needed. + * + * @param jv The JSON object to write into; must be of object type. + */ void MPTIssue::setJson(json::Value& jv) const { jv[jss::mpt_issuance_id] = to_string(mptID_); } +/** Produce a JSON object representing this MPT issuance. + * + * Constructs a fresh `Json::Value` object and populates it via + * `MPTIssue::setJson()`, yielding `{"mpt_issuance_id": ""}`. + * + * @param mptIssue The issuance to serialize. + * @return A JSON object with a single `mpt_issuance_id` field. + */ json::Value toJson(MPTIssue const& mptIssue) { @@ -57,12 +112,37 @@ toJson(MPTIssue const& mptIssue) return jv; } +/** Return the hex string representation of an MPT issuance. + * + * @param mptIssue The issuance to convert. + * @return Uppercase hex encoding of the underlying 192-bit MPTID. + */ std::string to_string(MPTIssue const& mptIssue) { return to_string(mptIssue.getMptID()); } +/** Parse an MPT issuance from a JSON object. + * + * Expects a JSON object with exactly a `mpt_issuance_id` string field + * containing a valid 192-bit hex-encoded MPTID. The presence of `currency` + * or `issuer` fields is explicitly rejected to guard against callers + * accidentally passing an IOU-style amount object. + * + * Two exception types are used deliberately: `std::runtime_error` covers + * structural misuse (wrong JSON type, forbidden fields), while `json::Error` + * is reserved for field-level format failures (wrong value type, invalid + * hex). Callers that need to distinguish protocol misuse from parse errors + * can catch at the appropriate level. + * + * @param v The JSON value to parse; must be an object. + * @return The parsed MPTIssue. + * @throws std::runtime_error if `v` is not a JSON object, or if `currency` + * or `issuer` fields are present. + * @throws json::Error if `mpt_issuance_id` is absent, not a string, or not + * a valid 192-bit hex value. + */ MPTIssue mptIssueFromJson(json::Value const& v) { @@ -94,6 +174,12 @@ mptIssueFromJson(json::Value const& v) return MPTIssue{id}; } +/** Stream the hex representation of an MPT issuance. + * + * @param os The output stream. + * @param x The issuance to write. + * @return `os`, to allow chaining. + */ std::ostream& operator<<(std::ostream& os, MPTIssue const& x) { diff --git a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp b/src/libxrpl/protocol/NFTSyntheticSerializer.cpp index 4f0a2d5071..7c5ab38909 100644 --- a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp +++ b/src/libxrpl/protocol/NFTSyntheticSerializer.cpp @@ -1,3 +1,14 @@ +/** @file + * Composition point for NFT synthetic field injection into RPC responses. + * + * Provides `xrpl::RPC::insertNFTSyntheticInJson`, the single entry point + * that RPC handlers call after writing raw transaction metadata to enrich + * a response with NFT-derived fields. The underlying extraction logic lives + * in `NFTokenID.h` and `NFTokenOfferID.h` under the broader `xrpl::` + * namespace so that Clio (the XRPL History API server) can call those + * helpers directly without the RPC coupling. + */ + #include #include @@ -11,6 +22,36 @@ namespace xrpl::RPC { +/** Enrich a transaction JSON response with NFT-derived synthetic fields. + * + * Writes into `response[jss::meta]`, calling two independent delegates: + * + * - `insertNFTokenID` — adds `nftoken_id` (mint/accept-offer) or + * `nftoken_ids` (cancel-offer) by comparing pre- and post-transaction + * NFToken arrays across all affected ledger nodes in the metadata. + * - `insertNFTokenOfferID` — adds `offer_id` for `NFTokenCreateOffer` + * (and mints with an immediate sell offer) by locating the newly + * created `NFTokenOffer` node and extracting its `sfLedgerIndex`. + * + * Both delegates perform their own eligibility checks gated on transaction + * type and `tesSUCCESS`, so this function is safe to call for any + * transaction type: non-NFT transactions produce no output. + * + * @param response The top-level RPC response object; synthetic + * fields are written into its `meta` sub-object, which is created on + * demand if absent. + * @param transaction The executed transaction. A null pointer is + * handled gracefully by the delegates (no-op). + * @param transactionMeta Read-only view of the transaction's metadata; + * used to diff ledger node states and locate created objects. + * + * @note The `meta` sub-object of `response` must already be populated by + * the caller (e.g., via `TxMeta::getJson`) before this function is + * invoked, matching the call-site pattern in `Tx.cpp`, `Simulate.cpp`, + * `AccountTx.cpp`, and `NetworkOPs.cpp`. + * + * @see insertNFTokenID, insertNFTokenOfferID + */ void insertNFTSyntheticInJson( json::Value& response, diff --git a/src/libxrpl/protocol/NFTokenID.cpp b/src/libxrpl/protocol/NFTokenID.cpp index cd2c46b66b..cc09817a29 100644 --- a/src/libxrpl/protocol/NFTokenID.cpp +++ b/src/libxrpl/protocol/NFTokenID.cpp @@ -1,3 +1,19 @@ +/** @file + * Reconstructs NFToken identities from transaction metadata and injects + * them into RPC JSON responses as synthetic fields. + * + * Raw transaction metadata records ledger state changes (what + * `NFTokenPage` objects looked like before and after) but does not + * annotate which token was added, traded, or involved in a cancelled + * offer. The functions here bridge that gap so API consumers don't have + * to perform the same inference themselves. + * + * These helpers are also consumed directly by Clio (the XRPL History + * API server), which performs the same enrichment independently of + * rippled. That is why the helpers are free functions rather than + * file-scope statics. + */ + #include #include @@ -20,6 +36,21 @@ namespace xrpl { +/** Returns true if this transaction could have produced or consumed an NFToken. + * + * Guards all downstream extraction logic. A transaction qualifies only when + * it is one of the three NFT transaction types + * (`ttNFTOKEN_MINT`, `ttNFTOKEN_ACCEPT_OFFER`, `ttNFTOKEN_CANCEL_OFFER`) + * and its result code is `tesSUCCESS`. A failed transaction cannot have + * mutated any NFToken page, so metadata diffing would be meaningless. + * + * @param serializedTx The executed transaction; a null pointer yields + * false immediately. + * @param transactionMeta Metadata from the same transaction, used to + * check the result code. + * @return True only when `serializedTx` is non-null, its type is an NFT + * transaction type, and the result is `tesSUCCESS`. + */ bool canHaveNFTokenID(std::shared_ptr const& serializedTx, TxMeta const& transactionMeta) { @@ -37,12 +68,35 @@ canHaveNFTokenID(std::shared_ptr const& serializedTx, TxMeta const& return true; } +/** Recovers the ID of the NFToken added by a mint transaction. + * + * `ttNFTOKEN_MINT` metadata records the full token arrays of every + * affected `NFTokenPage` in `sfPreviousFields` and `sfFinalFields`, but + * does not mark the newly inserted entry. This function recovers it via + * set-difference: it accumulates all token IDs from previous states into + * `prevIDs` and all token IDs from final states into `finalIDs`, then + * uses `std::mismatch` to locate the first entry present in `finalIDs` + * but absent from `prevIDs`. + * + * The invariant `finalIDs.size() == prevIDs.size() + 1` must hold exactly + * (tokens are minted one at a time). If it is violated the function + * returns `std::nullopt` rather than guessing. + * + * @note When a mint causes an existing page to split, the linked-list + * rewiring may produce a `sfModifiedNode` for a third page whose + * `sfPreviousFields` contain only pointer updates (`NextPageMin` / + * `PreviousPageMin`) with no `sfNFTokens` array. Such nodes are + * skipped silently; without this guard the size invariant would + * incorrectly fail for legitimate mints. + * + * @param transactionMeta Metadata from a `ttNFTOKEN_MINT` transaction. + * @return The `uint256` ID of the newly minted token, or `std::nullopt` + * if the size invariant is violated or `std::mismatch` unexpectedly + * reaches the end of `finalIDs`. + */ std::optional getNFTokenIDFromPage(TxMeta const& transactionMeta) { - // The metadata does not make it obvious which NFT was added. To figure - // that out we gather up all of the previous NFT IDs and all of the final - // NFT IDs and compare them to find what changed. std::vector prevIDs; std::vector finalIDs; @@ -97,8 +151,6 @@ getNFTokenIDFromPage(TxMeta const& transactionMeta) if (finalIDs.size() != prevIDs.size() + 1) return std::nullopt; - // Find the first NFT ID that doesn't match. We're looking for an - // added NFT, so the one we want will be the mismatch in finalIDs. auto const diff = std::ranges::mismatch(finalIDs, prevIDs); // There should always be a difference so the returned finalIDs @@ -109,6 +161,23 @@ getNFTokenIDFromPage(TxMeta const& transactionMeta) return *diff.in1; } +/** Collects the NFToken IDs referenced by deleted `NFTokenOffer` objects. + * + * Both `ttNFTOKEN_ACCEPT_OFFER` and `ttNFTOKEN_CANCEL_OFFER` delete one + * or more `ltNFTOKEN_OFFER` ledger entries. Each deleted offer's + * `sfFinalFields` carries the `sfNFTokenID` it was created for, so the + * token identity is recoverable without any set-difference arithmetic. + * + * The result is sorted and deduplicated because `ttNFTOKEN_CANCEL_OFFER` + * can cancel multiple offers simultaneously, and several offers may + * reference the same NFT. + * + * @param transactionMeta Metadata from a `ttNFTOKEN_ACCEPT_OFFER` or + * `ttNFTOKEN_CANCEL_OFFER` transaction. + * @return Sorted, deduplicated vector of `uint256` NFToken IDs recovered + * from all deleted offer nodes. Empty if no qualifying deletions are + * found. + */ std::vector getNFTokenIDFromDeletedOffer(TxMeta const& transactionMeta) { @@ -124,14 +193,38 @@ getNFTokenIDFromDeletedOffer(TxMeta const& transactionMeta) tokenIDResult.push_back(toAddNFT); } - // Deduplicate the NFT IDs because multiple offers could affect the same NFT - // and hence we would get duplicate NFT IDs std::ranges::sort(tokenIDResult); auto const uniq = std::ranges::unique(tokenIDResult); tokenIDResult.erase(uniq.begin(), uniq.end()); return tokenIDResult; } +/** Injects synthetic NFToken ID field(s) into an RPC transaction response. + * + * Dispatches to the appropriate extraction helper based on transaction type + * and writes into `response`: + * + * - `ttNFTOKEN_MINT` — writes `jss::nftoken_id` (single string) via + * `getNFTokenIDFromPage`. + * - `ttNFTOKEN_ACCEPT_OFFER` — writes `jss::nftoken_id` (single string, + * first element) via `getNFTokenIDFromDeletedOffer`. + * - `ttNFTOKEN_CANCEL_OFFER` — writes `jss::nftoken_ids` (JSON array of + * all deduplicated token IDs) via `getNFTokenIDFromDeletedOffer`. + * + * All failure modes are silent: if `canHaveNFTokenID` returns false, or + * if extraction yields no result, the function returns without adding any + * field. No exceptions are thrown. + * + * In practice this is called from `insertNFTSyntheticInJson` in + * `NFTSyntheticSerializer.cpp`, which targets `response[jss::meta]`. + * + * @param response The JSON object to enrich; fields are written + * directly into it (caller is responsible for scoping to `jss::meta`). + * @param transaction The executed transaction. Null is handled + * gracefully via `canHaveNFTokenID`. + * @param transactionMeta Read-only metadata used for both eligibility + * checking and token ID extraction. + */ void insertNFTokenID( json::Value& response, @@ -141,7 +234,6 @@ insertNFTokenID( if (!canHaveNFTokenID(transaction, transactionMeta)) return; - // We extract the NFTokenID from metadata by comparing affected nodes if (auto const type = transaction->getTxnType(); type == ttNFTOKEN_MINT) { std::optional result = getNFTokenIDFromPage(transactionMeta); diff --git a/src/libxrpl/protocol/NFTokenOfferID.cpp b/src/libxrpl/protocol/NFTokenOfferID.cpp index ee39222bfa..8efa6cf9fe 100644 --- a/src/libxrpl/protocol/NFTokenOfferID.cpp +++ b/src/libxrpl/protocol/NFTokenOfferID.cpp @@ -1,3 +1,18 @@ +/** @file + * Synthetic `offer_id` injection for NFToken offer transactions. + * + * The XRPL transaction format records only inputs; the ledger index of a + * newly created offer object is determined during consensus and stored only + * in transaction metadata. The three functions in this file extract that + * index from `TxMeta` and expose it as `jss::offer_id` in the RPC JSON + * response, sparing every API consumer from walking `AffectedNodes` manually. + * + * These functions are non-static so that Clio (the XRPL History API server) + * can call `canHaveNFTokenOfferID` and `getOfferIDFromCreatedOffer` + * independently. The primary call site inside rippled is + * `xrpl::RPC::insertNFTSyntheticInJson` in `NFTSyntheticSerializer.cpp`. + */ + #include #include @@ -16,6 +31,24 @@ namespace xrpl { +/** Determine whether a transaction can have an NFToken offer ID. + * + * Acts as a fast pre-filter before the more expensive metadata scan in + * `getOfferIDFromCreatedOffer`. Three conditions must all hold: + * + * 1. `serializedTx` is non-null. + * 2. The transaction type is `ttNFTOKEN_CREATE_OFFER`, **or** it is + * `ttNFTOKEN_MINT` with `sfAmount` present (a mint that simultaneously + * creates an immediate-sale buy offer). + * 3. The transaction succeeded (`tesSUCCESS`). Failed transactions never + * modify the ledger, so no offer object can exist in the metadata. + * + * @param serializedTx The transaction to inspect. A null `shared_ptr` + * is handled safely and causes the function to return `false`. + * @param transactionMeta Metadata whose result code is checked for success. + * @return `true` only when all three conditions are satisfied, meaning a + * subsequent call to `getOfferIDFromCreatedOffer` may yield a value. + */ bool canHaveNFTokenOfferID( std::shared_ptr const& serializedTx, @@ -36,6 +69,26 @@ canHaveNFTokenOfferID( return true; } +/** Extract the ledger index of the NFToken offer created by a transaction. + * + * Scans the `AffectedNodes` array in `transactionMeta` for the unique + * `CreatedNode` whose `sfLedgerEntryType` is `ltNFTOKEN_OFFER`. When found, + * returns `sfLedgerIndex` of that node — the globally unique offer ID. + * + * Because at most one `NFTokenOffer` object can be created per transaction, + * the loop exits on the first match. Returns `std::nullopt` if no qualifying + * node is found, which can occur on edge cases where metadata is absent or + * the offer creation path was not actually taken despite an eligible type. + * + * @param transactionMeta Read-only transaction metadata to scan. + * @return The `uint256` ledger index of the newly created offer, or + * `std::nullopt` if no `CreatedNode` of type `ltNFTOKEN_OFFER` is + * present in the metadata. + * + * @note Callers that have already performed their own eligibility checks + * (e.g., Clio) may call this function directly without first calling + * `canHaveNFTokenOfferID`. + */ std::optional getOfferIDFromCreatedOffer(TxMeta const& transactionMeta) { @@ -50,6 +103,25 @@ getOfferIDFromCreatedOffer(TxMeta const& transactionMeta) return std::nullopt; } +/** Inject the NFToken offer ID into a JSON response as `jss::offer_id`. + * + * Composes `canHaveNFTokenOfferID` and `getOfferIDFromCreatedOffer`: + * returns immediately (no-op) if the transaction is ineligible or the + * metadata contains no created offer node; otherwise writes the offer's + * ledger index as a hex string into `response[jss::offer_id]`. + * + * Never throws and never modifies `response` when the offer ID cannot be + * determined. The primary call site is + * `xrpl::RPC::insertNFTSyntheticInJson`, which passes `response[jss::meta]` + * as the target so that `offer_id` appears inside the `meta` sub-object. + * + * @param response The JSON object to enrich; `jss::offer_id` is + * written here when a valid offer ID is found. + * @param transaction The executed transaction. A null pointer results in + * a no-op (handled by `canHaveNFTokenOfferID`). + * @param transactionMeta Read-only transaction metadata used to locate the + * created `NFTokenOffer` node. + */ void insertNFTokenOfferID( json::Value& response, diff --git a/src/libxrpl/protocol/PathAsset.cpp b/src/libxrpl/protocol/PathAsset.cpp index 97011129e5..4b443c0480 100644 --- a/src/libxrpl/protocol/PathAsset.cpp +++ b/src/libxrpl/protocol/PathAsset.cpp @@ -1,3 +1,12 @@ +/** @file + * Non-inline serialization interface for PathAsset. + * + * All other PathAsset operations (holds, get, isXRP, operator==, + * hash_append, constructors) are implemented inline in PathAsset.h. + * to_string() and operator<< are defined here to give them a single + * translation-unit home and avoid ODR concerns from repeated inclusion. + */ + #include #include @@ -6,12 +15,30 @@ namespace xrpl { +/** Produce a human-readable description of a PathAsset. + * + * Dispatches via std::visit to either to_string(Currency const&) or + * to_string(MPTID const&) depending on the active alternative. For a + * Currency this yields the ISO 4217 ticker or "XRP"; for an MPTID it + * yields the base-58 encoded token identifier. + * + * @param asset The path asset to stringify. + * @return A descriptive string identifying the currency or MPT issuance. + */ std::string to_string(PathAsset const& asset) { return std::visit([&](auto const& issue) { return to_string(issue); }, asset.value()); } +/** Stream-insert a human-readable description of a PathAsset. + * + * Equivalent to `os << to_string(x)`. Intended for logging and diagnostics. + * + * @param os The output stream. + * @param x The path asset to write. + * @return `os`, for chaining. + */ std::ostream& operator<<(std::ostream& os, PathAsset const& x) { diff --git a/src/libxrpl/protocol/Permissions.cpp b/src/libxrpl/protocol/Permissions.cpp index 4222c63fea..37e5c00f0c 100644 --- a/src/libxrpl/protocol/Permissions.cpp +++ b/src/libxrpl/protocol/Permissions.cpp @@ -1,3 +1,15 @@ +/** @file + * Implements the `Permission` singleton — the central authority for + * XRPL's account-delegation permission system. + * + * The constructor uses X-macro expansions of `transactions.macro` and + * `permissions.macro` to populate five lookup maps that cover every + * known transaction type and granular sub-operation permission. No + * other translation unit defines or mutates these maps, so this file + * is the single source of truth for the permission number-space at + * runtime. + */ + #include #include @@ -13,6 +25,30 @@ namespace xrpl { +/** Constructs the singleton by populating all five permission lookup maps. + * + * Each map is populated by expanding the same macro files + * (`transactions.macro` / `permissions.macro`) with a purpose-specific + * `#define TRANSACTION` or `#define PERMISSION` binding. `#pragma + * push_macro` / `pop_macro` guards prevent the temporary definitions from + * escaping into surrounding translation units. + * + * Maps built: + * - `txFeatureMap_` — TxType → required amendment (`uint256{}` = none) + * - `delegableTx_` — TxType → `Delegable` / `NotDelegable` + * - `granularPermissionMap_` — name string → `GranularPermissionType` + * - `granularNameMap_` — `GranularPermissionType` → name string + * - `granularTxTypeMap_` — `GranularPermissionType` → parent `TxType` + * + * Two startup assertions enforce invariants that cannot be expressed in the + * type system: + * 1. `txFeatureMap_` and `delegableTx_` must have the same number of entries + * (a mismatch would mean a transaction appears in one macro expansion but + * not the other — a programming error in the macro file). + * 2. Every granular permission value must be `> UINT16_MAX`, preserving the + * numeric partition between transaction-level permissions (≤ UINT16_MAX) + * and granular permissions (> UINT16_MAX). + */ Permission::Permission() { txFeatureMap_ = { @@ -89,6 +125,15 @@ Permission::Permission() } } +/** Returns the process-wide singleton instance. + * + * Initialized on first call via a function-local `static`; C++11 + * guarantees the initialization is thread-safe. The instance is + * never mutated after construction, so concurrent read access + * requires no synchronization. + * + * @return A `const` reference to the singleton `Permission` object. + */ Permission const& Permission::getInstance() { @@ -96,6 +141,17 @@ Permission::getInstance() return kINSTANCE; } +/** Resolves a raw permission value to its human-readable name. + * + * Tries the granular permission table first (values > `UINT16_MAX`). + * If that fails, decodes the value as a transaction-level permission + * (`value - 1` = `TxType`) and delegates to `TxFormats` for the name, + * keeping transaction names canonical in a single place. + * + * @param value Raw `sfPermissionValue` from the ledger. + * @return The permission name, or `std::nullopt` if `value` is + * unrecognized as either a granular or transaction-level permission. + */ std::optional Permission::getPermissionName(std::uint32_t const value) const { @@ -103,7 +159,6 @@ Permission::getPermissionName(std::uint32_t const value) const if (auto const granular = getGranularName(permissionValue)) return granular; - // not a granular permission, check if it maps to a transaction type auto const txType = permissionToTxType(value); if (auto const* item = TxFormats::getInstance().findByType(txType); item != nullptr) return item->getName(); @@ -111,6 +166,16 @@ Permission::getPermissionName(std::uint32_t const value) const return std::nullopt; } +/** Looks up the numeric value of a granular permission by name. + * + * Used when deserializing permission names from JSON (e.g., during + * `DelegateSet` preflight or RPC input handling). + * + * @param name Case-sensitive granular permission name (e.g., + * `"TrustlineAuthorize"`). + * @return The corresponding `uint32_t` wire value, or `std::nullopt` if + * `name` is not a known granular permission. + */ std::optional Permission::getGranularValue(std::string const& name) const { @@ -121,6 +186,15 @@ Permission::getGranularValue(std::string const& name) const return std::nullopt; } +/** Looks up the name of a granular permission by its enum value. + * + * Inverse of `getGranularValue`; used when serializing permission values + * to human-readable output. + * + * @param value A `GranularPermissionType` enum value. + * @return The permission name string, or `std::nullopt` if `value` is + * not a known granular permission. + */ std::optional Permission::getGranularName(GranularPermissionType const& value) const { @@ -131,6 +205,18 @@ Permission::getGranularName(GranularPermissionType const& value) const return std::nullopt; } +/** Returns the parent transaction type for a granular permission. + * + * Multiple granular permissions can share the same parent `TxType` + * (e.g., `TrustlineAuthorize`, `TrustlineFreeze`, and + * `TrustlineUnfreeze` all map to `ttTRUST_SET`). This relationship is + * used by `isDelegable()` to locate the required amendment for any + * granular sub-operation. + * + * @param gpType A `GranularPermissionType` enum value. + * @return The parent `TxType`, or `std::nullopt` if `gpType` is not + * a known granular permission. + */ std::optional Permission::getGranularTxType(GranularPermissionType const& gpType) const { @@ -141,6 +227,15 @@ Permission::getGranularTxType(GranularPermissionType const& gpType) const return std::nullopt; } +/** Returns the amendment required to delegate a transaction type, if any. + * + * @param txType A recognized transaction type. + * @return A reference to the required amendment hash, or `std::nullopt` + * if the transaction type needs no enabling amendment. + * @note Asserts (debug builds) that `txType` is present in + * `txFeatureMap_`. Passing an unregistered `TxType` is a + * programming error. + */ std::optional> Permission::getTxFeature(TxType txType) const { @@ -154,16 +249,34 @@ Permission::getTxFeature(TxType txType) const return txFeaturesIt->second; } +/** Determines whether a permission value may appear in a `DelegateSet` + * transaction under the current ledger rules. + * + * Called from `DelegateSet::preflight()` for every entry in + * `sfPermissions`. The check differs by permission kind: + * + * - **Granular permission** (value > `UINT16_MAX`): accepted whenever the + * value resolves to a known `GranularPermissionType`. Granular + * permissions are intentionally narrow, so no additional gate is needed. + * - **Transaction-level permission** (value ≤ `UINT16_MAX`): accepted only + * when the decoded `TxType` is recognized, the transaction's required + * amendment is currently enabled in `rules` (or no amendment is + * required), and the `Delegation` flag for that type is `Delegable`. + * + * @param permissionValue Raw `sfPermissionValue` from the ledger object. + * @param rules Active amendment rules for the current ledger. + * @return `true` if the permission may be granted, `false` otherwise. + * @note The amendment check prevents a transaction type from being + * delegated before the ledger feature that introduces it is live, + * even if the macro table already includes it. + */ bool Permission::isDelegable(std::uint32_t const& permissionValue, Rules const& rules) const { auto const granularPermission = getGranularName(static_cast(permissionValue)); if (granularPermission) - { - // granular permissions are always allowed to be delegated return true; - } auto const txType = permissionToTxType(permissionValue); auto const it = delegableTx_.find(txType); @@ -176,9 +289,6 @@ Permission::isDelegable(std::uint32_t const& permissionValue, Rules const& rules txFeaturesIt != txFeatureMap_.end(), "xrpl::Permissions::isDelegable : tx exists in txFeatureMap_"); - // Delegation is only allowed if the required amendment for the transaction - // is enabled. For transactions that do not require an amendment, delegation - // is always allowed. if (txFeaturesIt->second != uint256{} && !rules.enabled(txFeaturesIt->second)) return false; @@ -188,12 +298,30 @@ Permission::isDelegable(std::uint32_t const& permissionValue, Rules const& rules return true; } +/** Converts a `TxType` to its transaction-level permission value. + * + * Transaction-level permissions are encoded as `TxType + 1`. The +1 + * offset shifts zero-based `TxType` values to start at 1, keeping the + * entire range within `uint16` and reserving 0 as an invalid sentinel. + * + * @param type A transaction type. + * @return The corresponding permission value (`static_cast(type) + 1`). + */ uint32_t Permission::txToPermissionType(TxType const& type) { return static_cast(type) + 1; } +/** Converts a transaction-level permission value back to its `TxType`. + * + * Inverse of `txToPermissionType`; callers are responsible for verifying + * that `value` is in the transaction-level range (≤ `UINT16_MAX`) before + * calling this function. + * + * @param value A transaction-level permission value. + * @return The decoded `TxType` (`static_cast(value - 1)`). + */ TxType Permission::permissionToTxType(uint32_t const& value) { diff --git a/src/libxrpl/protocol/Protocol.cpp b/src/libxrpl/protocol/Protocol.cpp index 14230e78bd..9ab345f522 100644 --- a/src/libxrpl/protocol/Protocol.cpp +++ b/src/libxrpl/protocol/Protocol.cpp @@ -1,12 +1,68 @@ +/** @file + * Predicates that identify the two special ledger milestones built into + * the XRPL consensus heartbeat: the **flag ledger** and the **voting + * ledger**. + * + * Every `kFLAG_LEDGER_INTERVAL` (256) ledgers the network reaches a + * boundary where accumulated validator votes are tallied and network-wide + * parameters — fees, reserve requirements, amendment activation, and + * Negative UNL reliability scores — are updated. These two predicates + * locate that boundary from different vantage points; the distinction is + * resolved entirely at the call site (see `Ledger::isFlagLedger()` and + * `Ledger::isVotingLedger()` for the canonical example of the `+1` offset). + */ #include namespace xrpl { + +/** Returns true if @p seq falls exactly on a flag-ledger boundary. + * + * A flag ledger is any ledger whose sequence number is an exact multiple of + * `kFLAG_LEDGER_INTERVAL` (256). It is the ledger in which fee and + * amendment votes — collected as validator vote bits in the preceding + * validation messages — are applied via pseudo-transactions, and in which + * the Negative UNL reliability update takes effect. + * + * Callers pass the ledger's **own** sequence number when asking "has this + * ledger already crossed the boundary?" Compare with `isVotingLedger`, + * which callers invoke with `seq + 1` to ask "will the ledger built *on + * top of* this one be a flag ledger?". + * + * @param seq The ledger index to test. + * @return `true` if `seq % kFLAG_LEDGER_INTERVAL == 0`. + * + * @note `Change::doApply` and `FeeVoteImpl` gate their parameter-update + * logic on this predicate. Because `kFLAG_LEDGER_INTERVAL` is an + * implicit part of the wire protocol, changing it without an amendment + * mechanism would cause a hard fork. + */ bool isVotingLedger(LedgerIndex seq) { return seq % kFLAG_LEDGER_INTERVAL == 0; } +/** Returns true if @p seq falls exactly on a flag-ledger boundary. + * + * Semantically distinct from `isVotingLedger` even though the arithmetic is + * identical: callers pass `seq + 1` (the sequence of the ledger about to be + * built) to ask "is the current consensus session producing a flag ledger?" + * That `+1` offset lives at the call site — most visibly in + * `Ledger::isVotingLedger()` — so the two names remain meaningful at their + * respective call sites without embedding the offset here. + * + * `RCLConsensus` uses both predicates in sequence when assembling + * pseudo-transactions for a new consensus round: + * - If the previous ledger `isFlagLedger()`, fee-vote and amendment + * pseudo-transactions are injected (votes collected during that flag + * ledger's validations are applied now). + * - If the previous ledger `isVotingLedger()`, Negative UNL + * pseudo-transactions are injected (the consensus session is building + * the flag ledger itself). + * + * @param seq The ledger index to test. + * @return `true` if `seq % kFLAG_LEDGER_INTERVAL == 0`. + */ bool isFlagLedger(LedgerIndex seq) { diff --git a/src/libxrpl/protocol/PublicKey.cpp b/src/libxrpl/protocol/PublicKey.cpp index ad88e60fe7..dfce652c6e 100644 --- a/src/libxrpl/protocol/PublicKey.cpp +++ b/src/libxrpl/protocol/PublicKey.cpp @@ -1,3 +1,14 @@ +/** @file + * Implementation of XRPL public key construction, type detection, + * signature canonicality enforcement, signature verification, and + * node identity derivation. + * + * Both supported elliptic curve systems — secp256k1 and Ed25519 — are + * handled here. The 33-byte inline buffer in `PublicKey` unifies them: + * secp256k1 compressed keys are natively 33 bytes (0x02/0x03 prefix), + * while Ed25519 keys are 32 bytes prefixed with the XRPL-specific 0xED + * marker. The lead byte therefore acts as a self-describing type tag. + */ #include #include @@ -31,6 +42,19 @@ operator<<(std::ostream& os, PublicKey const& pk) return os; } +/** Decode a Base58Check-encoded public key. + * + * Validates both the Base58 token-type framing and the decoded key bytes + * via `publicKeyType`. Safe to call on untrusted input: any malformed or + * unrecognized encoding returns `std::nullopt` rather than throwing. + * + * @param type The expected `TokenType` prefix (e.g. `NodePublic` or + * `AccountPublic`). + * @param s The Base58Check-encoded string to decode. + * @return A `PublicKey` on success, or `std::nullopt` if the string is + * not valid Base58Check for the given type or the decoded bytes do + * not represent a recognized key type. + */ template <> std::optional parseBase58(TokenType type, std::string const& s) @@ -44,8 +68,18 @@ parseBase58(TokenType type, std::string const& s) //------------------------------------------------------------------------------ -// Parse a length-prefixed number -// Format: 0x02 +/** Parse and consume one DER integer component from a signature buffer. + * + * Expects the encoding `0x02 ` at the start of `buf`. + * Advances `buf` past the consumed bytes on success. Rejects values + * that are negative (high bit set), zero, or carry unnecessary zero + * padding — all real DER malformations observed in practice. + * + * @param buf Buffer positioned at the start of the integer tag; advanced + * in place past the consumed component on success. + * @return A `Slice` over the integer bytes, or `std::nullopt` if the + * encoding is invalid. + */ static std::optional sigPart(Slice& buf) { @@ -55,15 +89,15 @@ sigPart(Slice& buf) buf += 2; if (len > buf.size() || len < 1 || len > 33) return std::nullopt; - // Can't be negative if ((buf[0] & 0x80) != 0) return std::nullopt; if (buf[0] == 0) { - // Can't be zero + // A single zero byte is not a valid integer encoding. if (len == 1) return std::nullopt; - // Can't be padded + // A leading zero is only valid when it prevents the high bit + // from being interpreted as a sign bit. if ((buf[1] & 0x80) == 0) return std::nullopt; } @@ -72,6 +106,17 @@ sigPart(Slice& buf) return number; } +/** Convert a raw byte slice to a non-negative hex literal for big-integer parsing. + * + * Produces a `"0x..."` string suitable for constructing a + * `boost::multiprecision::number`. When the high bit of the first byte is + * set the output is prefixed with `"0x00"` to ensure the value is treated + * as a non-negative integer by the multiprecision parser. + * + * @param slice The big-endian byte sequence to encode. + * @return A hex string starting with `"0x"` representing the unsigned + * integer value of `slice`. + */ static std::string sliceToHex(Slice const& slice) { @@ -95,18 +140,34 @@ sliceToHex(Slice const& slice) return s; } -/** Determine whether a signature is canonical. - Canonical signatures are important to protect against signature morphing - attacks. - @param vSig the signature data - @param sigLen the length of the signature - @param strict_param whether to enforce strictly canonical semantics - - @note For more details please see: - https://xrpl.org/transaction-malleability.html - https://bitcointalk.org/index.php?topic=8392.msg127623#msg127623 - https://github.com/sipa/bitcoin/commit/58bc86e37fda1aec270bccb3df6c20fbd2a6591c -*/ +/** Classify the canonicality of a DER-encoded secp256k1 ECDSA signature. + * + * For any signed message, `(R, S)` and `(R, G-S)` are both mathematically + * valid ECDSA signatures. Allowing both forms enables transaction + * malleability: an attacker can flip S to produce a different serialization + * — and thus a different transaction ID — without invalidating the + * signature. XRPL addresses this by defining a *fully canonical* signature + * as one where `S <= G/2` (equivalently `S <= G-S`). Signatures where + * `S > G/2` are *canonical* but not fully canonical; callers may choose + * whether to accept them. + * + * The DER structure checked is: + * `0x30 0x02 0x02 ` + * + * Only the structure and canonicality of the encoding are checked; no + * cryptographic verification is performed. + * + * @param sig DER-encoded ECDSA signature to examine. + * @return `ECDSACanonicality::FullyCanonical` if `S <= G-S`, + * `ECDSACanonicality::Canonical` if `S > G-S` but the signature is + * otherwise structurally valid, or `std::nullopt` if the encoding is + * malformed (wrong header bytes, invalid integer components, R or S + * outside the curve order, trailing bytes, etc.). + * + * @note See https://xrpl.org/transaction-malleability.html for the + * broader context on malleability and the rationale for requiring + * fully canonical signatures. + */ std::optional ecdsaCanonicality(Slice const& sig) { @@ -117,11 +178,11 @@ ecdsaCanonicality(Slice const& sig) boost::multiprecision::unchecked, void>>; + // secp256k1 curve order G. static uint264 const kG( "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"); // NOLINT(readability-identifier-naming) - // The format of a signature should be: - // <30> [ <02> ] [ <02> ] + // DER structure: 0x30 [ 0x02 ] [ 0x02 ] if ((sig.size() < 8) || (sig.size() > 72)) return std::nullopt; if ((sig[0] != 0x30) || (sig[1] != (sig.size() - 2))) @@ -140,37 +201,58 @@ ecdsaCanonicality(Slice const& sig) if (sNum >= kG) return std::nullopt; - // (R,S) and (R,G-S) are canonical, - // but is fully canonical when S <= G-S + // Both (R,S) and (R,G-S) are canonical; only S <= G-S is fully canonical. auto const Sp = kG - sNum; // NOLINT(readability-identifier-naming) if (sNum > Sp) return ECDSACanonicality::Canonical; return ECDSACanonicality::FullyCanonical; } +/** Check whether an Ed25519 signature's scalar S is in canonical form. + * + * Per the Ed25519 spec, the second 32 bytes of a signature encode the + * scalar S in little-endian order. S must be strictly less than the + * Ed25519 subgroup order `l` to be canonical; signatures with `S >= l` + * are rejected to prevent malleability analogous to ECDSA. + * + * @param sig The 64-byte Ed25519 signature to check. + * @return `true` if the signature is exactly 64 bytes and `S < l`, + * `false` otherwise. + */ static bool ed25519Canonical(Slice const& sig) { if (sig.size() != 64) return false; - // Big-endian Order, the Ed25519 subgroup order + // Big-endian representation of the Ed25519 subgroup order l. // NOLINTNEXTLINE(readability-identifier-naming) std::uint8_t const Order[] = { 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0xDE, 0xF9, 0xDE, 0xA2, 0xF7, 0x9C, 0xD6, 0x58, 0x12, 0x63, 0x1A, 0x5C, 0xF5, 0xD3, 0xED, }; - // Take the second half of signature - // and byte-reverse it to big-endian. + // S is the second 32 bytes of the signature, stored little-endian; + // byte-reverse to big-endian for lexicographic comparison. auto const le = sig.data() + 32; std::uint8_t S[32]; // NOLINT(readability-identifier-naming) std::reverse_copy(le, le + 32, S); - // Must be less than Order return std::lexicographical_compare(S, S + 32, Order, Order + 32); } //------------------------------------------------------------------------------ +/** Construct a `PublicKey` from a raw byte slice. + * + * Copies exactly `kSIZE` (33) bytes from `slice` after validating that + * the bytes represent a recognised key type (secp256k1 or Ed25519). + * Calls `logicError` — which terminates the process — rather than + * throwing, because receiving an invalid key here indicates a + * programming error (e.g., bypassed deserialization), not a recoverable + * runtime condition. + * + * @param slice Byte sequence to construct from; must be at least 33 bytes + * long and satisfy `publicKeyType(slice) != std::nullopt`. + */ PublicKey::PublicKey(Slice const& slice) { if (slice.size() < kSIZE) @@ -185,11 +267,13 @@ PublicKey::PublicKey(Slice const& slice) std::memcpy(buf_, slice.data(), kSIZE); } +/** Copy-construct a `PublicKey` using a direct 33-byte `memcpy`. */ PublicKey::PublicKey(PublicKey const& other) { std::memcpy(buf_, other.buf_, kSIZE); } +/** Copy-assign a `PublicKey` using a direct 33-byte `memcpy`. */ PublicKey& PublicKey::operator=(PublicKey const& other) { @@ -203,6 +287,17 @@ PublicKey::operator=(PublicKey const& other) //------------------------------------------------------------------------------ +/** Determine the key type encoded in a raw 33-byte key slice. + * + * Uses the lead byte as a self-describing type tag: `0xED` signals an + * Ed25519 key (XRPL's prefix convention); `0x02` or `0x03` signal a + * secp256k1 compressed public key. Any other lead byte, or a slice that + * is not exactly 33 bytes, is unrecognised. + * + * @param slice Raw bytes to inspect. + * @return The detected `KeyType`, or `std::nullopt` if the slice does + * not represent a known key type. + */ std::optional publicKeyType(Slice const& slice) { diff --git a/src/libxrpl/rdb/DatabaseCon.cpp b/src/libxrpl/rdb/DatabaseCon.cpp index ac27a080c5..a904424002 100644 --- a/src/libxrpl/rdb/DatabaseCon.cpp +++ b/src/libxrpl/rdb/DatabaseCon.cpp @@ -16,17 +16,34 @@ namespace xrpl { +/** Process-wide registry mapping integer IDs to live `Checkpointer` instances. + * + * Each `DatabaseCon` that uses WAL checkpointing registers its `Checkpointer` + * here on construction and removes it on destruction. The numeric ID assigned + * by `create()` is cast to `void*` and passed to SQLite's `sqlite3_wal_hook`, + * so the C callback never holds a raw pointer to a C++ object — only an integer + * key that it resolves through this collection. If the collection entry has + * been erased (because the owning `DatabaseCon` was torn down), `fromId()` + * returns `nullptr` and the hook deregisters itself. + * + * All public methods are thread-safe; access is serialised by `mutex_`. + */ class CheckpointersCollection { std::uintptr_t nextId_{0}; - // Mutex protects the CheckpointersCollection std::mutex mutex_; - // Each checkpointer is given a unique id. All the checkpointers that are - // part of a DatabaseCon are part of this collection. When the DatabaseCon - // is destroyed, its checkpointer is removed from the collection std::unordered_map> checkpointers_; public: + /** Look up a live checkpointer by its integer ID. + * + * Called from the SQLite WAL hook (on the writer thread) via the free + * function `checkpointerFromId()`. Returns `nullptr` when the entry has + * been erased, signalling the hook to deregister itself. + * + * @param id The integer key assigned by `create()`. + * @return A `shared_ptr` to the checkpointer, or `nullptr` if not found. + */ std::shared_ptr fromId(std::uintptr_t id) { @@ -37,6 +54,14 @@ public: return nullptr; } + /** Remove the checkpointer with the given ID from the registry. + * + * Called by `DatabaseCon::~DatabaseCon()` as the first step of teardown, + * ensuring that any subsequent WAL hook invocations find nothing and + * self-deregister rather than scheduling a job on a dying connection. + * + * @param id The integer key assigned by `create()`. + */ void erase(std::uintptr_t id) { @@ -44,6 +69,19 @@ public: checkpointers_.erase(id); } + /** Create and register a new checkpointer, returning its `shared_ptr`. + * + * Assigns a fresh monotonically-increasing ID, calls `makeCheckpointer()` + * (which arms the SQLite WAL hook), and stores the result in the map — + * all under the same lock. Storing before returning is required because + * the WAL hook is live as soon as `makeCheckpointer()` returns, and the + * first hook invocation may arrive before the caller saves the pointer. + * + * @param session The SOCI session whose WAL activity will be checkpointed. + * @param jobQueue The job queue on which checkpoint work is dispatched. + * @param registry The service registry forwarded to `makeCheckpointer()`. + * @return The newly created, already-registered `Checkpointer`. + */ std::shared_ptr create( std::shared_ptr const& session, @@ -58,14 +96,51 @@ public: } }; +/** Process-wide singleton registry of all live WAL checkpointers. + * + * Shared by every `DatabaseCon` instance in the process. The SQLite WAL hook + * callback resolves its integer cookie through this registry so it never + * holds a raw pointer to a potentially-destroyed C++ object. + */ CheckpointersCollection gCheckpointers; +/** Resolve a WAL checkpointer by its integer ID. + * + * This free function is the bridge between SQLite's C callback and the C++ + * `Checkpointer` object. The WAL hook passes the integer ID (registered as a + * `void*`) to this function. If the `DatabaseCon` that owns the checkpointer + * has already been destroyed and its entry erased, `nullptr` is returned and + * the hook should call `sqlite3_wal_hook(conn, nullptr, nullptr)` to + * deregister itself. + * + * @param id The integer ID originally assigned by `CheckpointersCollection::create()`. + * @return A `shared_ptr` to the checkpointer, or `nullptr` if it no longer exists. + */ std::shared_ptr checkpointerFromId(std::uintptr_t id) { return gCheckpointers.fromId(id); } +/** Destroy the connection, waiting for any in-flight checkpoint job to finish. + * + * Teardown follows a strict sequence to avoid use-after-free and file-lock + * races: + * + * 1. Erase the checkpointer from `gCheckpointers` — future WAL hook + * invocations will see `nullptr` and self-deregister. + * 2. Release `DatabaseCon`'s own `shared_ptr` so only + * in-flight `JobQueue` lambdas retain references. + * 3. Busy-poll the `weak_ptr` use-count (100 ms intervals) until all + * lambda captures have been released, i.e., the checkpoint job has + * finished executing. + * + * The poll is deliberate — a condvar would require additional plumbing in + * `WALCheckpointer`, and database teardown is rare. Without the wait, a + * new `DatabaseCon` opened to the same SQLite file immediately after + * destruction could fail because the old checkpoint job might still hold + * the WAL lock. + */ DatabaseCon::~DatabaseCon() { if (checkpointer_) @@ -75,11 +150,10 @@ DatabaseCon::~DatabaseCon() std::weak_ptr const wk(checkpointer_); checkpointer_.reset(); - // The references to our Checkpointer held by 'checkpointer_' and - // 'checkpointers' have been removed, so if the use count is nonzero, a - // checkpoint is currently in progress. Wait for it to end, otherwise - // creating a new DatabaseCon to the same database may fail due to the - // database being locked by our (now old) Checkpointer. + // Both `checkpointer_` and `gCheckpointers` have released their + // references. A nonzero use_count means a checkpoint job is still + // running; wait for it to drain so the WAL lock is not held when + // the caller opens a new connection to the same file. while (wk.use_count() != 0) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); @@ -87,8 +161,31 @@ DatabaseCon::~DatabaseCon() } } +/** Process-wide SQLite PRAGMA statements applied to every connection. + * + * Set once at startup (e.g., `PRAGMA journal_mode=WAL`) and shared across all + * `DatabaseCon` instances that opt in via `Setup::useGlobalPragma`. Null when + * no global pragmas have been configured. + */ std::unique_ptr const> DatabaseCon::Setup::globalPragma; +/** Arm WAL checkpointing on this connection. + * + * Creates a `WALCheckpointer` via `makeCheckpointer()`, registers it in the + * process-wide `gCheckpointers` collection, and stores it as `checkpointer_`. + * Registration happens inside `gCheckpointers.create()` — under its mutex — + * before the pointer is returned, because the SQLite WAL hook is armed inside + * `makeCheckpointer()` and can fire on the next write commit. + * + * Separated from the constructors so that checkpointing is opt-in; constructors + * accepting `CheckpointerSetup` call this after the base constructor has opened + * and initialised the database. + * + * @param q The job queue on which checkpoint work is dispatched. + * Must not be null — passing null is a programming error. + * @param registry The service registry forwarded to `makeCheckpointer()`. + * @throws std::logic_error if `q` is null. + */ void DatabaseCon::setupCheckpointing(JobQueue* q, ServiceRegistry& registry) { diff --git a/src/libxrpl/rdb/SociDB.cpp b/src/libxrpl/rdb/SociDB.cpp index 541933b3b0..639452afd1 100644 --- a/src/libxrpl/rdb/SociDB.cpp +++ b/src/libxrpl/rdb/SociDB.cpp @@ -1,3 +1,14 @@ +/** @file + * SOCI/SQLite adapter: session lifecycle, WAL checkpointing, memory + * diagnostics, and blob conversion helpers. + * + * This translation unit is wrapped in a Clang `-Wdeprecated` suppression + * pragma because SOCI's own headers use deprecated constructs. The pragma + * scope is kept as narrow as possible (this file only). + * + * Only `"sqlite"` is a valid backend; any other value in the `[sqdb]` + * config section causes an immediate `std::runtime_error`. + */ #include #include #include @@ -32,10 +43,27 @@ namespace xrpl { +/** WAL frame threshold that triggers an off-thread checkpoint. + * + * Mirrors SQLite's own auto-checkpoint default so behaviour is consistent + * whether or not the WAL hook fires first. Tune here to change the + * trade-off between WAL file growth and checkpoint frequency. + */ static auto gCheckpointPageCount = 1000; namespace detail { +/** Build the SQLite connection string for a named database. + * + * Appends @p name + @p ext to @p dir to produce the full filesystem path + * that SOCI uses as its connection string. + * + * @param name Database filename stem (e.g. `"ledger"`). + * @param dir Directory that holds the database files. + * @param ext File extension, either `".db"` or `".sqlite"`. + * @return The absolute file path as a string. + * @throws std::runtime_error if @p name is empty. + */ std::string getSociSqliteInit(std::string const& name, std::string const& dir, std::string const& ext) { @@ -50,6 +78,19 @@ getSociSqliteInit(std::string const& name, std::string const& dir, std::string c return file.string(); } +/** Resolve the SOCI connection string from the node configuration. + * + * Reads `[sqdb] backend` (defaulting to `"sqlite"`). Any value other than + * `"sqlite"` throws immediately — no other backends are supported. The + * `validators` and `peerfinder` databases receive the `.sqlite` extension; + * all other databases receive `.db`. This extension asymmetry is a + * historical artifact. + * + * @param config Node configuration supplying `[sqdb]` and `database_path`. + * @param dbName Logical database name (e.g. `"ledger"`, `"peerfinder"`). + * @return Filesystem path suitable for passing to `soci::session::open`. + * @throws std::runtime_error if the backend is not `"sqlite"`. + */ std::string getSociInit(BasicConfig const& config, std::string const& dbName) { @@ -106,6 +147,18 @@ open(soci::session& s, std::string const& beName, std::string const& connectionS } } +/** Recover the raw SQLite connection pointer from a SOCI session. + * + * This is the only intentional breach of the SOCI abstraction in this file. + * A `dynamic_cast` to `soci::sqlite3_session_backend` extracts the native + * `sqlite3*` handle required by WAL and memory-statistics APIs that SOCI + * does not expose. + * + * @param s An open SOCI session backed by SQLite. + * @return The raw `sqlite3*` connection pointer. + * @throws std::logic_error if @p s is not a SQLite session or the connection + * pointer is null. + */ static sqlite_api::sqlite3* getConnection(soci::session& s) { @@ -120,6 +173,15 @@ getConnection(soci::session& s) return result; } +/** Return total SQLite memory in use across the entire process, in kilobytes. + * + * Delegates to `sqlite3_memory_used()`, which is a process-global counter + * independent of any individual connection. + * + * @param s Any open SOCI/SQLite session (used only to validate the backend). + * @return Process-wide SQLite heap usage in kilobytes. + * @throws std::logic_error if @p s is not a SQLite session. + */ std::uint32_t getKBUsedAll(soci::session& s) { @@ -128,10 +190,20 @@ getKBUsedAll(soci::session& s) return static_cast(sqlite_api::sqlite3_memory_used() / kilobytes(1)); } +/** Return the page-cache memory used by a specific database connection, in kilobytes. + * + * Calls `sqlite3_db_status(..., SQLITE_DBSTATUS_CACHE_USED, ...)` on the + * connection underlying @p s. + * + * @param s An open SOCI/SQLite session. + * @return Page-cache footprint of @p s in kilobytes. + * @throws std::logic_error if @p s is not a SQLite session. + * @note This function will need to be extended if additional SOCI backends + * are ever supported. + */ std::uint32_t getKBUsedDB(soci::session& s) { - // This function will have to be customized when other backends are added if (auto conn = getConnection(s)) { int cur = 0, hiw = 0; @@ -142,6 +214,15 @@ getKBUsedDB(soci::session& s) return 0; // Silence compiler warning. } +/** Copy a SOCI blob into a byte vector. + * + * Resizes @p to to match the blob length and reads all bytes via SOCI's + * `blob::read` API, centralising the `reinterpret_cast` from `char*` to + * `uint8_t*`. + * + * @param from Source SOCI blob. + * @param to Destination vector; resized and overwritten on return. + */ void convert(soci::blob& from, std::vector& to) { @@ -151,6 +232,14 @@ convert(soci::blob& from, std::vector& to) from.read(0, reinterpret_cast(&to[0]), from.get_len()); } +/** Copy a SOCI blob into a string. + * + * Delegates to the vector overload then constructs the string from that + * buffer. + * + * @param from Source SOCI blob. + * @param to Destination string; overwritten on return. + */ void convert(soci::blob& from, std::string& to) { @@ -159,6 +248,15 @@ convert(soci::blob& from, std::string& to) to.assign(tmp.begin(), tmp.end()); } +/** Write a byte vector into a SOCI blob. + * + * Uses `blob::write` for non-empty input and `blob::trim(0)` for an empty + * vector. Calling `blob::write` with a null pointer on an empty vector is + * undefined behaviour in SOCI, hence the explicit branch. + * + * @param from Source byte vector. + * @param to Destination SOCI blob; overwritten on return. + */ void convert(std::vector const& from, soci::blob& to) { @@ -172,6 +270,14 @@ convert(std::vector const& from, soci::blob& to) } } +/** Write a string into a SOCI blob. + * + * Mirrors the vector overload: `blob::write` for non-empty strings, + * `blob::trim(0)` for empty strings. + * + * @param from Source string. + * @param to Destination SOCI blob; overwritten on return. + */ void convert(std::string const& from, soci::blob& to) { @@ -187,18 +293,47 @@ convert(std::string const& from, soci::blob& to) namespace { -/** Run a thread to checkpoint the write ahead log (wal) for - the given soci::session every 1000 pages. This is only implemented - for sqlite databases. - - Note: According to: https://www.sqlite.org/wal.html#ckpt this - is the default behavior of sqlite. We may be able to remove this - class. -*/ - +/** SQLite WAL checkpointer that offloads checkpoint work to the XRPL JobQueue. + * + * Installs a `sqlite3_wal_hook` that fires after every WAL write. When the + * accumulated WAL size reaches `gCheckpointPageCount` frames the hook enqueues + * a `jtWAL` job rather than running `sqlite3_wal_checkpoint_v2` on the + * writer's thread. This prevents database writers from stalling on I/O. + * + * The hook cookie is the checkpointer's integer `id_`, not a raw `this` + * pointer. `checkpointerFromId()` performs the lookup against the + * process-wide `CheckpointersCollection`; if lookup returns null (because the + * owning `DatabaseCon` has been destroyed) the hook unregisters itself and + * returns without touching freed memory. + * + * At most one checkpoint job is in-flight at any time, enforced by the + * `running_` flag under `mutex_`. The job lambda captures a `weak_ptr` to + * this object so a destroyed `DatabaseCon` causes it to exit silently. + * + * @note SQLite already auto-checkpoints at 1000 pages; the primary benefit of + * this class is routing that work onto the XRPL job queue rather than the + * calling thread. + * @see DatabaseCon, checkpointerFromId, makeCheckpointer + */ class WALCheckpointer : public Checkpointer { public: + /** Construct and arm the WAL hook on the underlying SQLite connection. + * + * Registers `sqliteWALHook` via `sqlite3_wal_hook` using @p id cast to + * `void*` as the cookie. The hook fires immediately upon the next WAL + * write, so the checkpointer must already be registered in + * `CheckpointersCollection` before this constructor returns. + * + * @param id Unique integer identifier for this checkpointer; must + * match the ID stored in `CheckpointersCollection`. + * @param session Weak reference to the owning session; the checkpointer + * must not outlive the object that holds the corresponding + * `shared_ptr`. + * @param q Job queue on which checkpoint jobs are scheduled. + * @param registry Service registry used to obtain the WALCheckpointer + * journal. + */ WALCheckpointer( std::uintptr_t id, std::weak_ptr session, @@ -216,6 +351,15 @@ public: } } + /** Attempt to lock the session and retrieve the underlying connection. + * + * Returns `{nullptr, {}}` if the session `weak_ptr` has expired (i.e., + * the owning `DatabaseCon` has been destroyed). The caller must keep the + * returned `shared_ptr` alive for as long as it uses the `sqlite3*`. + * + * @return A pair of the raw connection pointer (or null) and the + * locked session `shared_ptr` that keeps the connection valid. + */ std::pair> getConnection() const { @@ -234,6 +378,14 @@ public: ~WALCheckpointer() override = default; + /** Enqueue a checkpoint job on the `JobQueue`, if none is already running. + * + * Guards against concurrent submissions with `running_` under `mutex_`. + * If the `JobQueue` rejects the job (e.g., during shutdown) `running_` + * is reset so the next WAL hook invocation can retry. The job lambda + * holds only a `weak_ptr` to this checkpointer so a destroyed + * `DatabaseCon` causes it to exit without touching freed memory. + */ void schedule() override { @@ -263,6 +415,17 @@ public: } } + /** Perform a passive WAL checkpoint and reset the `running_` flag. + * + * Calls `sqlite3_wal_checkpoint_v2` with `SQLITE_CHECKPOINT_PASSIVE`, + * which checkpoints only WAL frames that are not currently held by any + * reader. `SQLITE_LOCKED` is expected under reader contention and is + * logged at trace level; any other non-OK result is logged as a warning. + * `running_` is always reset under `mutex_` before returning so future + * hook invocations can schedule new jobs. + * + * Exits immediately if the session `weak_ptr` has expired. + */ void checkpoint() override { @@ -291,17 +454,43 @@ public: } protected: + /** Unique integer identifier used as the WAL hook cookie. */ std::uintptr_t const id_; - // session is owned by the DatabaseCon parent that holds the checkpointer. - // It is possible (though rare) for the DatabaseCon class to be destroyed - // before the checkpointer. + + /** Weak reference to the owning session. + * + * `DatabaseCon` holds the `shared_ptr`; this weak reference lets + * in-flight checkpoint jobs detect that the connection has been torn + * down without preventing the destructor from completing. + */ std::weak_ptr session_; + + /** Guards `running_` against concurrent WAL hook and job-completion access. */ std::mutex mutex_; + + /** Job queue on which checkpoint jobs are submitted. */ JobQueue& jobQueue_; + /** True while a checkpoint job is enqueued or executing. */ bool running_ = false; + + /** Journal for WAL checkpoint trace and warning messages. */ beast::Journal const j_; + /** SQLite WAL hook callback. + * + * SQLite invokes this function on the writing thread after each WAL + * write. The cookie @p cpId is the checkpointer's integer ID (not a + * pointer); `checkpointerFromId` resolves it to the live + * `shared_ptr`. If lookup fails the hook unregisters + * itself to prevent future calls after the `DatabaseCon` is gone. + * + * @param cpId Checkpointer ID cast to `void*` by the constructor. + * @param conn SQLite connection that triggered the hook. + * @param dbName Name of the attached database (unused). + * @param walSize Current WAL size in pages. + * @return Always `SQLITE_OK`. + */ static int sqliteWALHook(void* cpId, sqlite_api::sqlite3* conn, char const* dbName, int walSize) { @@ -322,6 +511,22 @@ protected: } // namespace +/** Create and arm a WAL checkpointer for a SQLite session. + * + * Constructs a `WALCheckpointer` and registers `sqlite3_wal_hook` on the + * underlying connection. The caller (typically `DatabaseCon::setupCheckpointing`) + * must insert the returned pointer into `CheckpointersCollection` under @p id + * **before** this call returns, because WAL writes can trigger the hook + * immediately. + * + * @param id Integer ID that will be passed as the WAL hook cookie and + * used for lookup in `checkpointerFromId`. + * @param session Weak reference to the session to checkpoint; must not + * outlive the `shared_ptr` held by the owning `DatabaseCon`. + * @param queue Job queue on which checkpoint jobs will be scheduled. + * @param registry Service registry used to obtain the WALCheckpointer journal. + * @return A `shared_ptr` to the new `Checkpointer`. + */ std::shared_ptr makeCheckpointer( std::uintptr_t id, diff --git a/src/libxrpl/shamap/SHAMap.cpp b/src/libxrpl/shamap/SHAMap.cpp index dafca12c22..44df82b3fe 100644 --- a/src/libxrpl/shamap/SHAMap.cpp +++ b/src/libxrpl/shamap/SHAMap.cpp @@ -1,3 +1,20 @@ +/** @file + * Implements SHAMap: construction, mutation, traversal, copy-on-write + * snapshotting, lazy node fetching, and persistence flush. + * + * Every XRP Ledger snapshot (account state or transaction set) is stored as a + * SHAMap — a 16-way radix trie whose inner nodes hash their children, forming + * a Merkle tree. The root hash cryptographically commits to the full content, + * so two nodes agree on ledger state iff their root hashes match. + * + * Key design invariants maintained here: + * - Copy-on-write via `cowid_`: nodes are cloned before mutation only when + * shared with another map generation. + * - Lazy fetch: nodes absent from the in-process cache are pulled from the + * NodeStore or a `SHAMapSyncFilter` on demand. + * - Merge property: inner nodes exist only when two or more items share a + * common key prefix; single-child inner nodes collapse on deletion. + */ #include #include // IWYU pragma: keep @@ -40,6 +57,19 @@ namespace xrpl { +/** Construct a concrete leaf node of the appropriate subtype. + * + * Maps `SHAMapNodeType` to one of `SHAMapTxLeafNode`, + * `SHAMapTxPlusMetaLeafNode`, or `SHAMapAccountStateLeafNode`. The three + * subtypes differ in hash prefix and whether the item key is included in the + * hash, so callers must always supply the correct type. + * + * @param type The logical node type; must not be `TnInner`. + * @param item The slab-allocated payload to store in the leaf. + * @param owner The `cowid_` of the map that will own this leaf. + * @return A freshly constructed leaf with `cowid == owner`. + * @throws LogicError if `type` is not one of the three recognised leaf types. + */ [[nodiscard]] intr_ptr::SharedPtr makeTypedLeaf(SHAMapNodeType type, boost::intrusive_ptr item, std::uint32_t owner) { @@ -57,22 +87,47 @@ makeTypedLeaf(SHAMapNodeType type, boost::intrusive_ptr item, std::to_string(static_cast>(type))); } +/** Construct a new, empty map in `Modifying` state. + * + * @param t Whether this map holds transactions or account state. + * @param f The `Family` providing the NodeStore and caches. + */ SHAMap::SHAMap(SHAMapType t, Family& f) : f_(f), journal_(f.journal()), state_(SHAMapState::Modifying), type_(t) { root_ = intr_ptr::makeShared(cowid_); } -// The `hash` parameter is unused. It is part of the interface so it's clear -// from the parameters that this is the constructor to use when the hash is -// known. The fact that the parameter is unused is an implementation detail that -// should not change the interface. +/** Construct a map in `Synching` state for a ledger whose root hash is known. + * + * The `hash` parameter is not stored or used internally — it exists solely to + * signal caller intent and select this overload over the two-argument form. + * Once the root node is fetched via `fetchRoot()`, the map's `root_->getHash()` + * will equal `hash`. + * + * @param t Whether this map holds transactions or account state. + * @param hash The expected root hash (used as a documentation signal only). + * @param f The `Family` providing the NodeStore and caches. + */ SHAMap::SHAMap(SHAMapType t, uint256 const& hash, Family& f) : f_(f), journal_(f.journal()), state_(SHAMapState::Synching), type_(t) { root_ = intr_ptr::makeShared(cowid_); } +/** Copy constructor used by `snapShot()` to create a CoW snapshot. + * + * Shares `root_` with `other` and increments `cowid_` so that any subsequent + * mutation on either map will clone nodes before modifying them. If either + * map is mutable, `unshare()` is called immediately to break node sharing and + * prevent concurrent mutations from corrupting the other map's tree. An + * immutable snapshot of an immutable map skips `unshare()` entirely, making + * the operation O(1) with zero node copies. + * + * @param other The source map to snapshot. + * @param isMutable If true, the new map enters `Modifying` state; otherwise + * `Immutable`. + */ SHAMap::SHAMap(SHAMap const& other, bool isMutable) : f_(other.f_) , journal_(other.f_.journal()) @@ -90,23 +145,40 @@ SHAMap::SHAMap(SHAMap const& other, bool isMutable) } } +/** Return a heap-allocated CoW snapshot of this map. + * + * @param isMutable If true, the snapshot is in `Modifying` state and may be + * mutated independently of this map. If false, the snapshot is + * `Immutable` and shares all nodes with this map at zero copy cost + * (provided this map is also immutable). + * @return A `shared_ptr` to the new snapshot. + */ std::shared_ptr SHAMap::snapShot(bool isMutable) const { return std::make_shared(*this, isMutable); } +/** Propagate a structural change up to the root, updating CoW ownership. + * + * Consumes `stack` bottom-up. For each inner node on the path, the node is + * CoW-unshared (cloned if its `cowid` differs from the map's), then `child` + * is linked into the appropriate branch via `setChild`. On return, a chain of + * freshly owned inner nodes runs from the modification point to `root_`. + * + * @param stack Path of inner nodes from root down to (but not including) + * `child`; consumed on return. + * @param target Key of the item being inserted, deleted, or updated; used to + * determine which branch to follow at each level. + * @param child The new subtree to attach (leaf or inner node); must already + * carry this map's `cowid_`. + */ void SHAMap::dirtyUp( SharedPtrNodeStack& stack, uint256 const& target, intr_ptr::SharedPtr child) { - // walk the tree up from through the inner nodes to the root_ - // update hashes and links - // stack is a path of inner nodes up to, but not including, child - // child can be an inner node or a leaf - XRPL_ASSERT( (state_ != SHAMapState::Synching) && (state_ != SHAMapState::Immutable), "xrpl::SHAMap::dirtyUp : valid state"); @@ -129,6 +201,22 @@ SHAMap::dirtyUp( } } +/** Descend towards the leaf position for `id`, optionally recording the path. + * + * At each level the 4-bit nibble of `id` at that depth is used to select the + * next branch. Descent stops when a leaf is reached or an empty branch is + * encountered. The returned leaf may hold a different key than `id` — callers + * that need an exact match must compare `leaf->peekItem()->key() == id`. + * + * @param id The 256-bit key to navigate towards. + * @param stack If non-null, each visited node (including the terminal leaf or + * the last inner node before an empty branch) is pushed here. Must be + * empty on entry. + * @return Pointer to the leaf at or nearest to `id`, or `nullptr` if the + * branch leading to `id` is empty. + * @throws SHAMapMissingNode if a non-empty branch references a node that + * cannot be fetched. + */ SHAMapLeafNode* SHAMap::walkTowardsKey(uint256 const& id, SharedPtrNodeStack* stack) const { @@ -156,6 +244,15 @@ SHAMap::walkTowardsKey(uint256 const& id, SharedPtrNodeStack* stack) const return safeDowncast(inNode.get()); } +/** Find the leaf whose key is exactly `id`, or return `nullptr`. + * + * Delegates to `walkTowardsKey()` and performs an exact key comparison on + * the result. The radix trie can terminate at a leaf whose prefix matches + * `id` but whose stored key diverges; this function filters that case out. + * + * @param id Key to look up. + * @return Pointer to the matching leaf, or `nullptr` if not found. + */ SHAMapLeafNode* SHAMap::findKey(uint256 const& id) const { @@ -165,6 +262,16 @@ SHAMap::findKey(uint256 const& id) const return leaf; } +/** Fetch a node from the NodeStore by hash, bypassing the in-process cache. + * + * Calls `f_.db().fetchNodeObject()` then delegates to `finishFetch()` to + * deserialize and canonicalize the result. + * + * @param hash Hash of the node to retrieve. + * @return The deserialized node, or an empty pointer if not found or if + * deserialization fails. + * @note Only valid for backed maps (`backed_ == true`). + */ intr_ptr::SharedPtr SHAMap::fetchNodeFromDB(SHAMapHash const& hash) const { @@ -173,6 +280,23 @@ SHAMap::fetchNodeFromDB(SHAMapHash const& hash) const return finishFetch(hash, obj); } +/** Deserialize a raw NodeObject into a canonicalized tree node. + * + * If `object` is null, clears `full_` and notifies the missing-node + * acquisition pipeline via `f_.missingNodeAcquireBySeq()`, then returns an + * empty pointer. Otherwise, deserializes via `SHAMapTreeNode::makeFromPrefix`, + * calls `canonicalize()`, and returns the result. + * + * Deserialization failures (`std::runtime_error` or any other exception) are + * caught, logged at warn level, and suppressed — callers receive an empty + * pointer rather than a crash. + * + * @param hash Expected hash of the node (used for deserialization and + * cache registration). + * @param object Raw NodeObject from the database, or null on a miss. + * @return The deserialized and canonicalized node, or an empty pointer on any + * failure. + */ intr_ptr::SharedPtr SHAMap::finishFetch(SHAMapHash const& hash, std::shared_ptr const& object) const { @@ -207,7 +331,19 @@ SHAMap::finishFetch(SHAMapHash const& hash, std::shared_ptr const& o return {}; } -// See if a sync filter has a node +/** Attempt to supply a missing node from a `SHAMapSyncFilter`. + * + * Calls `filter->getNode(hash)`. If the filter provides data, the blob is + * deserialized and, for backed maps, canonicalized. On success, notifies the + * filter via `gotNode(true, ...)` so it can persist or account for the node. + * Deserialization exceptions are caught and logged; an empty pointer is + * returned on any failure. + * + * @param hash Hash of the node to retrieve. + * @param filter The sync filter to consult; must not be null. + * @return The deserialized node, or an empty pointer if the filter has no data + * or if deserialization fails. + */ intr_ptr::SharedPtr SHAMap::checkFilter(SHAMapHash const& hash, SHAMapSyncFilter* filter) const { @@ -232,8 +368,17 @@ SHAMap::checkFilter(SHAMapHash const& hash, SHAMapSyncFilter* filter) const return {}; } -// Get a node without throwing -// Used on maps where missing nodes are expected +/** Retrieve a node without throwing, consulting the filter as a fallback. + * + * Tiered lookup: (1) in-process `TreeNodeCache`, (2) NodeStore (backed maps + * only), (3) the provided `SHAMapSyncFilter`. Returns an empty pointer on a + * complete miss; never throws `SHAMapMissingNode`. + * + * @param hash Hash of the node to retrieve. + * @param filter Optional sync filter consulted after a database miss; may be + * null to skip filter lookup. + * @return The node if found, or an empty pointer on miss. + */ intr_ptr::SharedPtr SHAMap::fetchNodeNT(SHAMapHash const& hash, SHAMapSyncFilter* filter) const { @@ -257,6 +402,14 @@ SHAMap::fetchNodeNT(SHAMapHash const& hash, SHAMapSyncFilter* filter) const return node; } +/** Retrieve a node without throwing, using only the cache and NodeStore. + * + * Two-tier lookup: (1) in-process `TreeNodeCache`, (2) NodeStore (backed maps + * only). No sync filter is consulted. Returns an empty pointer on miss. + * + * @param hash Hash of the node to retrieve. + * @return The node if found, or an empty pointer on miss. + */ intr_ptr::SharedPtr SHAMap::fetchNodeNT(SHAMapHash const& hash) const { @@ -268,7 +421,16 @@ SHAMap::fetchNodeNT(SHAMapHash const& hash) const return node; } -// Throw if the node is missing +/** Retrieve a node, throwing if it is missing. + * + * Delegates to `fetchNodeNT(hash)` and throws `SHAMapMissingNode` if the + * result is an empty pointer. Use this on paths that assume the tree is + * locally complete. + * + * @param hash Hash of the required node. + * @return The node. + * @throws SHAMapMissingNode if the node cannot be found. + */ intr_ptr::SharedPtr SHAMap::fetchNode(SHAMapHash const& hash) const { @@ -280,6 +442,16 @@ SHAMap::fetchNode(SHAMapHash const& hash) const return node; } +/** Descend into `branch` of `parent`, throwing on a non-empty missing node. + * + * Returns `nullptr` only when the branch is empty; throws `SHAMapMissingNode` + * when the branch is non-empty but the child cannot be fetched. + * + * @param parent The inner node to descend from (raw pointer variant). + * @param branch Branch index (0–15) to follow. + * @return Raw pointer to the child, or `nullptr` for an empty branch. + * @throws SHAMapMissingNode if the branch is non-empty but unfetchable. + */ SHAMapTreeNode* SHAMap::descendThrow(SHAMapInnerNode* parent, int branch) const { @@ -291,6 +463,16 @@ SHAMap::descendThrow(SHAMapInnerNode* parent, int branch) const return ret; } +/** Descend into `branch` of `parent`, throwing on a non-empty missing node. + * + * Shared-pointer variant of the same operation. Returns an empty pointer only + * when the branch is empty; throws `SHAMapMissingNode` otherwise. + * + * @param parent The inner node to descend from (reference variant). + * @param branch Branch index (0–15) to follow. + * @return Shared pointer to the child, or empty for an empty branch. + * @throws SHAMapMissingNode if the branch is non-empty but unfetchable. + */ intr_ptr::SharedPtr SHAMap::descendThrow(SHAMapInnerNode& parent, int branch) const { @@ -302,6 +484,17 @@ SHAMap::descendThrow(SHAMapInnerNode& parent, int branch) const return ret; } +/** Fetch and link a child node into its parent, returning a raw pointer. + * + * If the child is already in memory (`getChildPointer` non-null) it is + * returned immediately. Otherwise, for backed maps, the node is fetched from + * the cache or NodeStore and installed via `canonicalizeChild`. Returns + * `nullptr` on miss. + * + * @param parent The inner node whose child to retrieve. + * @param branch Branch index (0–15). + * @return Raw pointer to the child, or `nullptr` if unavailable. + */ SHAMapTreeNode* SHAMap::descend(SHAMapInnerNode* parent, int branch) const { @@ -317,6 +510,18 @@ SHAMap::descend(SHAMapInnerNode* parent, int branch) const return node.get(); } +/** Fetch and link a child node into its parent, returning a shared pointer. + * + * Shared-pointer variant. For backed maps, uses the throwing `fetchNode()` + * so a non-empty missing child raises `SHAMapMissingNode`. Installs the + * result via `canonicalizeChild`. + * + * @param parent The inner node whose child to retrieve (reference variant). + * @param branch Branch index (0–15). + * @return Shared pointer to the child, or empty if the branch is empty or + * the map is unbacked. + * @throws SHAMapMissingNode if backed and the child cannot be fetched. + */ intr_ptr::SharedPtr SHAMap::descend(SHAMapInnerNode& parent, int branch) const { @@ -332,8 +537,18 @@ SHAMap::descend(SHAMapInnerNode& parent, int branch) const return node; } -// Gets the node that would be hooked to this branch, -// but doesn't hook it up. +/** Fetch a child node without installing it into the parent. + * + * Returns the in-memory child if present, or fetches from the NodeStore for + * backed maps, but does NOT call `canonicalizeChild` — the returned node is + * not hooked into the tree. Used by `walkMap` / `walkMapParallel` to probe + * node availability without side effects. + * + * @param parent The inner node whose child to probe. + * @param branch Branch index (0–15). + * @return The child node if available, or an empty pointer on miss. + * @throws SHAMapMissingNode if backed and the child is non-empty but absent. + */ intr_ptr::SharedPtr SHAMap::descendNoStore(SHAMapInnerNode& parent, int branch) const { @@ -343,6 +558,18 @@ SHAMap::descendNoStore(SHAMapInnerNode& parent, int branch) const return ret; } +/** Descend to `branch` using a sync filter, returning the child and its ID. + * + * Used during missing-node discovery. If the child is not in memory, consults + * `filter` (via `fetchNodeNT`) then installs via `canonicalizeChild`. The + * returned `SHAMapNodeID` is derived from `parentID.getChildNodeID(branch)`. + * + * @param parent Inner node to descend from. + * @param parentID Trie address of `parent`. + * @param branch Branch index (0–15); must be non-empty. + * @param filter Sync filter supplying peer-sourced nodes; may be null. + * @return Pair of (child pointer or null, child node ID). + */ std::pair SHAMap::descend( SHAMapInnerNode* parent, @@ -373,6 +600,22 @@ SHAMap::descend( return std::make_pair(child, parentID.getChildNodeID(branch)); } +/** Descend asynchronously, posting an I/O request when the node is absent. + * + * Returns the child immediately if it is already in memory or in the filter. + * If the child must be loaded from the NodeStore, posts an async fetch via + * `f_.db().asyncFetch()`, sets `pending = true`, and returns `nullptr`. When + * the fetch completes, `callback` is invoked with the deserialized node and its + * hash. Used by `getMissingNodes` to maximize I/O concurrency. + * + * @param parent Inner node to descend from. + * @param branch Branch index (0–15); callers must ensure non-empty. + * @param filter Optional sync filter consulted before the async path. + * @param pending Set to `true` if an async I/O was posted; `false` + * otherwise. + * @param callback Invoked on async completion with `(node, hash)`. + * @return The child if synchronously available, or `nullptr` if async. + */ SHAMapTreeNode* SHAMap::descendAsync( SHAMapInnerNode* parent, @@ -415,11 +658,22 @@ SHAMap::descendAsync( return ptr.get(); } +/** Ensure exclusive ownership of `node` before mutation (copy-on-write). + * + * If `node->cowid() != cowid_`, the node is shared with another map + * generation and must be cloned before it can be modified. The clone receives + * this map's `cowid_` and, if the node is the root, `root_` is updated to + * point at the clone. + * + * @tparam Node Either `SHAMapInnerNode` or `SHAMapLeafNode`. + * @param node The node to potentially clone. + * @param nodeID Trie address of `node`; used to detect the root. + * @return The original node if already exclusively owned, otherwise the clone. + */ template intr_ptr::SharedPtr SHAMap::unshareNode(intr_ptr::SharedPtr node, SHAMapNodeID const& nodeID) { - // make sure the node is suitable for the intended operation (copy on write) XRPL_ASSERT(node->cowid() <= cowid_, "xrpl::SHAMap::unshareNode : node valid for cowid"); if (node->cowid() != cowid_) { @@ -432,6 +686,23 @@ SHAMap::unshareNode(intr_ptr::SharedPtr node, SHAMapNodeID const& nodeID) return node; } +/** Directional traversal helper shared by `firstBelow` and `lastBelow`. + * + * Descends from `node` following the first non-empty branch in the direction + * specified by `loopParams` at each level. Pushes every visited inner node + * (and ultimately the leaf) onto `stack`. Returns `nullptr` if the subtree + * is empty. + * + * @param node Starting node; may be a leaf (returns it immediately) or + * an inner node. + * @param stack Accumulates visited nodes for subsequent iterator steps. + * @param branch Branch index used to compute the child `SHAMapNodeID` + * relative to `stack.top()`. + * @param loopParams Tuple of `{init, cmp, incr}` lambdas controlling scan + * direction: `init` is the starting branch index, `cmp` is the loop + * condition, and `incr` advances the index. + * @return Pointer to the first/last leaf in the subtree, or `nullptr` if none. + */ SHAMapLeafNode* SHAMap::belowHelper( intr_ptr::SharedPtr node, @@ -478,6 +749,17 @@ SHAMap::belowHelper( } return nullptr; } +/** Return the lexicographically last leaf at or below `node`. + * + * Scans branches from 15 down to 0 at each level, descending into the first + * non-empty one. Pushes visited nodes onto `stack`. + * + * @param node Subtree root to search. + * @param stack Accumulates the path for iterator support. + * @param branch Branch index of `node` within its parent; used to compute + * `SHAMapNodeID` for stack entries. + * @return The last leaf, or `nullptr` if the subtree is empty. + */ SHAMapLeafNode* SHAMap::lastBelow(intr_ptr::SharedPtr node, SharedPtrNodeStack& stack, int branch) const @@ -488,6 +770,17 @@ SHAMap::lastBelow(intr_ptr::SharedPtr node, SharedPtrNodeStack& return belowHelper(node, stack, branch, {init, cmp, incr}); } +/** Return the lexicographically first leaf at or below `node`. + * + * Scans branches from 0 up to 15 at each level, descending into the first + * non-empty one. Pushes visited nodes onto `stack`. + * + * @param node Subtree root to search. + * @param stack Accumulates the path for iterator support. + * @param branch Branch index of `node` within its parent; used to compute + * `SHAMapNodeID` for stack entries. + * @return The first leaf, or `nullptr` if the subtree is empty. + */ SHAMapLeafNode* SHAMap::firstBelow(intr_ptr::SharedPtr node, SharedPtrNodeStack& stack, int branch) const @@ -500,11 +793,19 @@ SHAMap::firstBelow(intr_ptr::SharedPtr node, SharedPtrNodeStack& } static boost::intrusive_ptr const kNO_ITEM; +/** Return the sole item below `node`, or a null reference if there are many. + * + * Traverses downward following the single occupied branch at each level. If + * at any level more than one branch is occupied, returns `kNO_ITEM`. Used + * during deletion to determine whether an inner node can be collapsed. + * + * @param node Subtree root to examine. + * @return Reference to the unique `SHAMapItem` if exactly one leaf exists + * below `node`, or `kNO_ITEM` if there are zero or more than one. + */ boost::intrusive_ptr const& SHAMap::onlyBelow(SHAMapTreeNode* node) const { - // If there is only one item below this node, return it - while (!node->isLeaf()) { SHAMapTreeNode* nextNode = nullptr; @@ -539,6 +840,15 @@ SHAMap::onlyBelow(SHAMapTreeNode* node) const return leaf->peekItem(); } +/** Return a pointer to the first leaf and initialise the iterator stack. + * + * Calls `firstBelow(root_, stack)`. On an empty map, clears `stack` and + * returns `nullptr`. Used as the entry point for forward iteration. + * + * @param stack Must be empty on entry; populated with the path to the first + * leaf on return. + * @return Pointer to the first leaf, or `nullptr` if the map is empty. + */ SHAMapLeafNode const* SHAMap::peekFirstItem(SharedPtrNodeStack& stack) const { @@ -553,6 +863,20 @@ SHAMap::peekFirstItem(SharedPtrNodeStack& stack) const return node; } +/** Advance the iterator to the next leaf in ascending key order. + * + * Pops the current leaf from `stack`, then walks up the stack popping inner + * nodes until a node with a non-empty branch after the branch taken to reach + * `id` is found. Descends into that branch via `firstBelow`. Returns + * `nullptr` when `id` was the last item. + * + * @param id Key of the current leaf (used to identify which branch was + * last taken at each level). + * @param stack Path from the previous `peekFirstItem` / `peekNextItem` call; + * updated in place. + * @return Pointer to the next leaf, or `nullptr` if iteration is exhausted. + * @throws SHAMapMissingNode if a required node cannot be fetched. + */ SHAMapLeafNode const* SHAMap::peekNextItem(uint256 const& id, SharedPtrNodeStack& stack) const { @@ -578,10 +902,18 @@ SHAMap::peekNextItem(uint256 const& id, SharedPtrNodeStack& stack) const } stack.pop(); } - // must be last item return nullptr; } +/** Look up an item by key without transferring ownership. + * + * Returns a reference to the intrusive pointer stored in the leaf. The + * reference is valid as long as the map is not mutated. Returns `kNO_ITEM` + * (a null intrusive pointer) if `id` is not present. + * + * @param id Key to look up. + * @return Reference to the item pointer, or a reference to `kNO_ITEM`. + */ boost::intrusive_ptr const& SHAMap::peekItem(uint256 const& id) const { @@ -593,6 +925,13 @@ SHAMap::peekItem(uint256 const& id) const return leaf->peekItem(); } +/** Look up an item by key and also retrieve its leaf hash. + * + * @param id Key to look up. + * @param hash Populated with the leaf node's hash on success; unchanged on + * miss. + * @return Reference to the item pointer, or a reference to `kNO_ITEM`. + */ boost::intrusive_ptr const& SHAMap::peekItem(uint256 const& id, SHAMapHash& hash) const { @@ -605,6 +944,16 @@ SHAMap::peekItem(uint256 const& id, SHAMapHash& hash) const return leaf->peekItem(); } +/** Return an iterator to the first item whose key is strictly greater than `id`. + * + * Walks towards `id` to build a path stack, then unwinds upward scanning for + * the next occupied branch after the one that leads to `id`. Descends into + * that branch via `firstBelow`. Returns `end()` if no such item exists. + * + * @param id Pivot key; does not need to be present in the map. + * @return Iterator to the first item with `key > id`, or `end()`. + * @throws SHAMapMissingNode if a required node cannot be fetched. + */ SHAMap::ConstIterator SHAMap::upperBound(uint256 const& id) const { @@ -638,6 +987,18 @@ SHAMap::upperBound(uint256 const& id) const } return end(); } +/** Return an iterator to the last item whose key is strictly less than `id`. + * + * Walks towards `id`, then unwinds upward scanning for the next occupied + * branch *before* the one leading to `id`, descending via `lastBelow`. + * Returns `end()` if no such item exists. + * + * @param id Pivot key; does not need to be present in the map. + * @return Iterator to the greatest item with `key < id`, or `end()`. + * @throws SHAMapMissingNode if a required node cannot be fetched. + * @note This is a reverse lower-bound, not the STL convention; it finds the + * item just *below* `id`, not just at-or-above it. + */ SHAMap::ConstIterator SHAMap::lowerBound(uint256 const& id) const { @@ -679,10 +1040,22 @@ SHAMap::hasItem(uint256 const& id) const return (findKey(id) != nullptr); } +/** Remove the item with key `id` from the map. + * + * Walks to the target leaf, then unwinds the path stack reducing inner-node + * child counts. Nodes that drop to zero children are nulled out; nodes that + * drop to one child are collapsed — the surviving leaf is hoisted upward to + * enforce the radix-trie merge property. Finally, `dirtyUp` links the + * modified subtree back to the root. + * + * @param id Key of the item to remove. + * @return `true` if the item was found and removed; `false` if not present. + * @throws SHAMapMissingNode if the tree is incomplete and a required node + * cannot be fetched. + */ bool SHAMap::delItem(uint256 const& id) { - // delete the item with this ID XRPL_ASSERT(state_ != SHAMapState::Immutable, "xrpl::SHAMap::delItem : not immutable"); SharedPtrNodeStack stack; @@ -701,7 +1074,7 @@ SHAMap::delItem(uint256 const& id) using TreeNodeType = intr_ptr::SharedPtr; - // What gets attached to the end of the chain (For now, nothing, since we deleted the leaf) + // prevNode starts null: the deleted leaf produces nothing to attach upward. TreeNodeType prevNode; while (!stack.empty()) @@ -720,19 +1093,14 @@ SHAMap::delItem(uint256 const& id) if (!nodeID.isRoot()) { - // we may have made this a node with 1 or 0 children - // And, if so, we need to remove this branch int const bc = node->getBranchCount(); if (bc == 0) { - // no children below this branch - // // Note: This is unnecessary due to the std::move above but left here for safety prevNode = TreeNodeType{}; } else if (bc == 1) { - // If there's only one item, pull up on the thread auto item = onlyBelow(node.get()); if (item) @@ -755,7 +1123,6 @@ SHAMap::delItem(uint256 const& id) } else { - // This node is now the end of the branch prevNode = std::move(node); } } @@ -764,13 +1131,25 @@ SHAMap::delItem(uint256 const& id) return true; } +/** Insert a new item into the map; does not update an existing item. + * + * Two cases: (1) the walk terminates at an empty inner-node branch — the item + * is placed there directly; (2) the walk terminates at an existing leaf whose + * key prefix-collides with the new key — a chain of new inner nodes is created + * descending until the two keys diverge into separate branches. This + * preserves the radix-trie merge property (inner nodes only where ≥2 items + * share a prefix). + * + * @param type Leaf type to create; must not be `TnInner`. + * @param item The item to insert; its `key()` must not already be present. + * @return `true` if inserted; `false` if the key already exists. + * @throws SHAMapMissingNode if the tree is incomplete during traversal. + */ bool SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr item) { XRPL_ASSERT(state_ != SHAMapState::Immutable, "xrpl::SHAMap::addGiveItem : not immutable"); XRPL_ASSERT(type != SHAMapNodeType::TnInner, "xrpl::SHAMap::addGiveItem : valid type input"); - - // add the specified item, does not update uint256 const tag = item->key(); SharedPtrNodeStack stack; @@ -791,7 +1170,6 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr node = unshareNode(std::move(node), nodeID); if (node->isInner()) { - // easy case, we end on an inner node auto inner = intr_ptr::staticPointerCast(node); int const branch = selectBranch(nodeID, tag); XRPL_ASSERT( @@ -800,8 +1178,6 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr } else { - // this is a leaf node that has to be made an inner node holding two - // items auto leaf = intr_ptr::staticPointerCast(node); auto otherItem = leaf->peekItem(); XRPL_ASSERT( @@ -814,14 +1190,10 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr while ((b1 = selectBranch(nodeID, tag)) == (b2 = selectBranch(nodeID, otherItem->key()))) { stack.emplace(node, nodeID); - - // we need a new inner node, since both go on same branch at this - // level nodeID = nodeID.getChildNodeID(b1); node = intr_ptr::makeShared(cowid_); } - // we can add the two leaf nodes here XRPL_ASSERT(node->isInner(), "xrpl::SHAMap::addGiveItem : node is inner"); auto inner = safeDowncast(node.get()); @@ -833,12 +1205,27 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr return true; } +/** Insert a new item into the map (forwarding wrapper for `addGiveItem`). + * + * @param type Leaf type to create. + * @param item The item to insert. + * @return `true` if inserted; `false` if the key already exists. + */ bool SHAMap::addItem(SHAMapNodeType type, boost::intrusive_ptr item) { return addGiveItem(type, std::move(item)); } +/** Return the Merkle root hash, computing it if necessary. + * + * If the root's stored hash is zero (indicating pending mutations), calls + * `unshare()` to perform a full hash recompute. This requires a + * `const_cast` because hash propagation is logically a read (the content does + * not change) but physically mutates inner nodes. + * + * @return The current root hash. + */ SHAMapHash SHAMap::getHash() const { @@ -851,10 +1238,21 @@ SHAMap::getHash() const return hash; } +/** Replace the payload of an existing item, keeping its key unchanged. + * + * Locates the leaf for `item->key()`, CoW-unshares it, and calls `setItem()`. + * `dirtyUp()` is invoked only when `setItem()` signals the hash actually + * changed, preventing spurious rehashing for no-op updates. Cross-type + * changes (e.g., updating a transaction leaf as an account-state leaf) are + * rejected with a fatal log and a `false` return. + * + * @param type Must match the existing leaf's type. + * @param item New payload; `item->key()` must already be present. + * @return `true` on success; `false` if the key is absent or the type differs. + */ bool SHAMap::updateGiveItem(SHAMapNodeType type, boost::intrusive_ptr item) { - // can't change the tag but can change the hash uint256 const tag = item->key(); XRPL_ASSERT(state_ != SHAMapState::Immutable, "xrpl::SHAMap::updateGiveItem : not immutable"); @@ -891,6 +1289,16 @@ SHAMap::updateGiveItem(SHAMapNodeType type, boost::intrusive_ptr SHAMap::writeNode(NodeObjectType t, intr_ptr::SharedPtr node) const @@ -951,40 +1359,74 @@ SHAMap::writeNode(NodeObjectType t, intr_ptr::SharedPtr node) co return node; } -// We can't modify an inner node someone else might have a -// pointer to because flushing modifies inner nodes -- it -// makes them point to canonical/shared nodes. +/** Clone a node if needed before modifying it during a flush. + * + * Flushing converts inner nodes to point at canonical/shared children, + * which physically mutates them. If `node->cowid() != cowid_`, the node is + * shared with another map generation and must be cloned before this map can + * safely modify it — otherwise the modification would corrupt other maps. + * + * @tparam Node `SHAMapInnerNode` or `SHAMapLeafNode`. + * @param node Node to prepare for flushing; must have non-zero `cowid()` + * (a zero cowid would mean it is already shared/canonical, which is a + * logic error since canonical nodes are never dirty). + * @return The original node if exclusively owned, otherwise its clone. + */ template intr_ptr::SharedPtr SHAMap::preFlushNode(intr_ptr::SharedPtr node) const { - // A shared node should never need to be flushed - // because that would imply someone modified it XRPL_ASSERT(node->cowid(), "xrpl::SHAMap::preFlushNode : valid input node"); if (node->cowid() != cowid_) { - // Node is not uniquely ours, so unshare it before - // possibly modifying it node = intr_ptr::staticPointerCast(node->clone(cowid_)); } return node; } +/** Traverse the entire tree making every owned node shareable (cowid → 0). + * + * Calls `walkSubTree(false, ...)`. No data is written to the NodeStore. + * After this call all nodes are safe to share with other maps (e.g., after + * creating a snapshot whose parent must not later corrupt the shared nodes). + * + * @return Number of nodes processed. + */ int SHAMap::unshare() { - // Don't share nodes with parent map return walkSubTree(false, NodeObjectType::Unknown); } +/** Flush all dirty nodes to the NodeStore and make them shareable. + * + * Calls `walkSubTree(backed_, t)`. For database-backed maps this writes + * every owned node; for unbacked (in-memory) maps it only performs the + * `unshare` step without I/O. + * + * @param t NodeStore object type to use when persisting nodes. + * @return Number of nodes processed. + */ int SHAMap::flushDirty(NodeObjectType t) { - // We only write back if this map is backed. return walkSubTree(backed_, t); } +/** Post-order DFS flush: update hashes and optionally persist all dirty nodes. + * + * Uses an explicit stack to avoid recursion on a tree up to 64 levels deep. + * For each node encountered: `preFlushNode()` clones if shared, `updateHash()` + * / `updateHashDeep()` recomputes the hash, and `unshare()` sets `cowid` to 0. + * If `doWrite` is true, `writeNode()` serializes and stores each node. The + * last inner node processed becomes the new `root_`. + * + * @param doWrite If true, persist each node to the NodeStore. Must be false + * for unbacked maps. + * @param t NodeStore object type passed to `writeNode`. + * @return Total number of nodes flushed (leaves + inner nodes). + */ int SHAMap::walkSubTree(bool doWrite, NodeObjectType t) { @@ -1024,7 +1466,7 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t) int pos = 0; - // We can't flush an inner node until we flush its children + // We can't flush an inner node until we flush its children (post-order). while (true) { while (pos < kBRANCH_FACTOR) @@ -1036,27 +1478,22 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t) else { // No need to do I/O. If the node isn't linked, - // it can't need to be flushed + // it can't need to be flushed. int const branch = pos; auto child = node->getChild(pos++); if (child && (child->cowid() != 0)) { - // This is a node that needs to be flushed - child = preFlushNode(std::move(child)); if (child->isInner()) { - // save our place and work on this node - stack.emplace(std::move(node), branch); node = intr_ptr::staticPointerCast(child); pos = 0; } else { - // flush this leaf ++flushed; XRPL_ASSERT( @@ -1075,10 +1512,7 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t) } } - // update the hash of this inner node node->updateHashDeep(); - - // This inner node can now be shared node->unshare(); if (doWrite) @@ -1093,21 +1527,27 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t) pos = stack.top().second; stack.pop(); - // Hook this inner node to its parent XRPL_ASSERT(parent->cowid() == cowid_, "xrpl::SHAMap::walkSubTree : parent cowid do match"); parent->shareChild(pos, node); - // Continue with parent's next child, if any node = std::move(parent); ++pos; } - // Last inner node is the new root_ + // Last inner node processed becomes the new root_. root_ = std::move(node); return flushed; } +/** Log the full tree structure to the journal at INFO level. + * + * Performs an iterative DFS, printing each node's string representation. + * Only in-memory (already-linked) children are visited — nodes that exist + * only in the NodeStore are not fetched. Intended for debugging only. + * + * @param hash If true, also log each node's hash alongside its description. + */ void SHAMap::dump(bool hash) const { @@ -1155,6 +1595,14 @@ SHAMap::dump(bool hash) const JLOG(journal_.info()) << leafCount << " resident leaves"; } +/** Look up a node in the family-wide `TreeNodeCache`. + * + * Any node returned from the cache has `cowid() == 0` — it is canonical and + * shared, and must not be mutated without first calling `unshareNode()`. + * + * @param hash Hash of the node to look up. + * @return The cached node, or an empty pointer on a cache miss. + */ intr_ptr::SharedPtr SHAMap::cacheLookup(SHAMapHash const& hash) const { @@ -1163,6 +1611,16 @@ SHAMap::cacheLookup(SHAMapHash const& hash) const return ret; } +/** Register a node in the family-wide `TreeNodeCache`, deduplicating by hash. + * + * If the cache already holds a node for `hash`, `node` is replaced with that + * cached instance so all maps in the same `Family` share one object per hash. + * If not, `node` is inserted. The node must have `cowid() == 0`; placing a + * CoW-owned node in the shared cache would allow other maps to mutate it. + * + * @param hash Hash of `node`; must equal `node->getHash()`. + * @param node Node to canonicalize; updated in place if replaced by cache. + */ void SHAMap::canonicalize(SHAMapHash const& hash, intr_ptr::SharedPtr& node) const { @@ -1173,6 +1631,14 @@ SHAMap::canonicalize(SHAMapHash const& hash, intr_ptr::SharedPtr f_.getTreeNodeCache()->canonicalizeReplaceClient(hash.asUInt256(), node); } +/** Verify internal consistency of the entire tree. + * + * Forces a full hash recompute via `getHash()`, asserts the root is a + * non-leaf inner node, iterates every leaf via `peekFirstItem` / + * `peekNextItem` to exercise all descent paths, and then delegates to + * `SHAMapTreeNode::invariants(true)` to verify each node's structural + * invariants. Intended for use in tests and debug builds. + */ void SHAMap::invariants() const { diff --git a/src/libxrpl/shamap/SHAMapDelta.cpp b/src/libxrpl/shamap/SHAMapDelta.cpp index b1aeac18e8..8aeb2911ab 100644 --- a/src/libxrpl/shamap/SHAMapDelta.cpp +++ b/src/libxrpl/shamap/SHAMapDelta.cpp @@ -22,14 +22,54 @@ namespace xrpl { -// This code is used to compare another node's transaction tree -// to our own. It returns a map containing all items that are different -// between two SHA maps. It is optimized not to descend down tree -// branches with the same branch hash. A limit can be passed so -// that we will abort early if a node sends a map to us that -// makes no sense at all. (And our sync algorithm will avoid -// synchronizing matching branches too.) +/** @file + * Implements SHAMap comparison (delta) and completeness-check (walkMap) + * operations. These are logically separate from the core map mechanics + * (insert, fetch, hash) and answer two questions: how do two trees differ, + * and what nodes are missing from this tree? + * + * The delta algorithm short-circuits at matching subtree hashes, giving + * O(d) complexity in the number of differences rather than O(n) in total + * items. Missing-node detection uses iterative DFS (sequential) or + * depth-1-partitioned parallel traversal. + */ +/** Walk a subtree that is paired with an empty branch or single item from + * the other map, collecting all differences into `differences`. + * + * This is the asymmetric-case helper for `compare`. One side of the + * traversal has a full subtree rooted at `node`; the other side has either + * nothing (`otherMapItem == nullptr`) or a single leaf item. + * + * For each leaf found in the subtree three outcomes are possible: + * - No counterpart (`emptyBranch` is true or keys differ): the leaf is + * recorded as unmatched (one half of the `DeltaItem` pair is null). + * - Same key, different payload: recorded as a modification; `emptyBranch` + * is set to suppress a trailing entry for `otherMapItem`. + * - Exact match (same key and payload): silently consumed; `emptyBranch` + * is set. + * + * After the walk, if `otherMapItem` was never matched it is inserted as its + * own unmatched entry. + * + * `isFirstMap` determines which half of the `DeltaItem` pair receives `this` + * map's item versus the other map's item, so the semantic ordering + * (first-map version, second-map version) is preserved regardless of which + * direction the asymmetry runs. + * + * @param node Root of the subtree to walk. Must not be null. + * @param otherMapItem The single item from the opposite side, or null if + * that side has no branch here. + * @param isFirstMap `true` if `this` map is the first operand of the + * enclosing `compare` call; controls `DeltaItem` pair ordering. + * @param differences Accumulator; entries are appended. + * @param maxCount Shared budget counter; decremented per insertion. + * Passed by reference so the budget is shared across all delegations + * from `compare`. + * @return `true` if the walk completed within budget; `false` if `maxCount` + * was exhausted (diff is truncated). + * @throws SHAMapMissingNode if a referenced node cannot be fetched. + */ bool SHAMap::walkBranch( SHAMapTreeNode* node, @@ -38,8 +78,6 @@ SHAMap::walkBranch( Delta& differences, int& maxCount) const { - // Walk a branch of a SHAMap that's matched by an empty branch or single - // item in the other map std::stack> nodeStack; nodeStack.push(node); @@ -52,7 +90,6 @@ SHAMap::walkBranch( if (node->isInner()) { - // This is an inner node, add all non-empty branches auto inner = safeDowncast(node); for (int i = 0; i < 16; ++i) { @@ -62,12 +99,10 @@ SHAMap::walkBranch( } else { - // This is a leaf node, process its item auto item = safeDowncast(node)->peekItem(); if (emptyBranch || (item->key() != otherMapItem->key())) { - // unmatched if (isFirstMap) { differences.insert(std::make_pair(item->key(), DeltaRef(item, nullptr))); @@ -82,7 +117,6 @@ SHAMap::walkBranch( } else if (item->slice() != otherMapItem->slice()) { - // non-matching items with same tag if (isFirstMap) { differences.insert(std::make_pair(item->key(), DeltaRef(item, otherMapItem))); @@ -99,7 +133,6 @@ SHAMap::walkBranch( } else { - // exact match emptyBranch = true; } } @@ -107,9 +140,9 @@ SHAMap::walkBranch( if (!emptyBranch) { - // otherMapItem was unmatched, must add + // otherMapItem's key did not appear anywhere in the subtree — record it as unmatched if (isFirstMap) - { // this is first map, so other item is from second + { differences.insert( std::make_pair(otherMapItem->key(), DeltaRef(nullptr, otherMapItem))); } @@ -129,11 +162,6 @@ SHAMap::walkBranch( bool SHAMap::compare(SHAMap const& otherMap, Delta& differences, int maxCount) const { - // compare two hash trees, add up to maxCount differences to the difference - // table return value: true=complete table of differences given, false=too - // many differences throws on corrupt tables or missing nodes CAUTION: - // otherMap is not locked and must be immutable - XRPL_ASSERT( isValid() && otherMap.isValid(), "xrpl::SHAMap::compare : valid state and valid input"); @@ -141,7 +169,7 @@ SHAMap::compare(SHAMap const& otherMap, Delta& differences, int maxCount) const return true; using StackEntry = std::pair; - std::stack> nodeStack; // track nodes we've pushed + std::stack> nodeStack; nodeStack.emplace(root_.get(), otherMap.root_.get()); while (!nodeStack.empty()) @@ -159,7 +187,6 @@ SHAMap::compare(SHAMap const& otherMap, Delta& differences, int maxCount) const if (ourNode->isLeaf() && otherNode->isLeaf()) { - // two leaves auto ours = safeDowncast(ourNode); auto other = safeDowncast(otherNode); if (ours->peekItem()->key() == other->peekItem()->key()) diff --git a/src/libxrpl/shamap/SHAMapInnerNode.cpp b/src/libxrpl/shamap/SHAMapInnerNode.cpp index ef38819599..862d91208c 100644 --- a/src/libxrpl/shamap/SHAMapInnerNode.cpp +++ b/src/libxrpl/shamap/SHAMapInnerNode.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements SHAMapInnerNode — the 16-way branching (non-leaf) node of the + * SHAMap Merkle radix trie. + * + * Each inner node fans out on 4 bits of a 256-bit key (branchFactor = 16). + * Child hashes and child pointers are stored together in a single sparse + * TaggedPointer allocation whose capacity is encoded in the pointer's two + * low bits. All mutations require exclusive CoW ownership (cowid_ != 0). + * Concurrent reads of different children are serialised with per-child bit + * spinlocks packed into a single 16-bit atomic. + */ #include #include // IWYU pragma: keep @@ -27,6 +38,15 @@ namespace xrpl { +/** Construct an inner node with a given CoW owner and initial child-array capacity. + * + * The TaggedPointer is allocated with room for @p numAllocatedChildren pairs, + * rounded up to the next supported capacity tier (2, 4, 8, or 16). Starting + * small (the default of 2) minimises RAM for nodes that stay sparse. + * + * @param cowid Copy-on-write owner ID; 0 means shared/immutable. + * @param numAllocatedChildren Initial capacity of the hashes and children arrays. + */ SHAMapInnerNode::SHAMapInnerNode(std::uint32_t cowid, std::uint8_t numAllocatedChildren) : SHAMapTreeNode(cowid), hashesAndChildren_(numAllocatedChildren) { @@ -34,6 +54,16 @@ SHAMapInnerNode::SHAMapInnerNode(std::uint32_t cowid, std::uint8_t numAllocatedC SHAMapInnerNode::~SHAMapInnerNode() = default; +/** Release all child SharedPtrs before the object's memory is reclaimed. + * + * Called by the intrusive reference-count infrastructure when the strong + * count reaches zero while weak references are still live. Explicitly + * resetting every child pointer breaks reference cycles and ensures timely + * resource cleanup without waiting for weak references to expire. + * + * @note This runs before the destructor; do not access @c hash_ or + * @c isBranch_ after this point. + */ void SHAMapInnerNode::partialDestructor() { @@ -69,6 +99,22 @@ SHAMapInnerNode::getChildIndex(int i) const return hashesAndChildren_.getChildIndex(isBranch_, i); } +/** Produce a CoW-owned deep copy of this node for a new owner. + * + * Allocates a new SHAMapInnerNode sized exactly for the current branch + * count and copies all hashes and child pointers. Hashes are copied + * outside the spinlock (they are immutable on shared nodes); child + * pointers are copied under the full-node Spinlock so that a concurrent + * call to canonicalizeChild() cannot race the copy. + * + * Sparse and dense layouts are handled separately: a sparse source packs + * entries sequentially in the clone; a dense source maps branch number + * directly to array index so the clone is also dense. + * + * @param cowid Copy-on-write owner ID assigned to the new node. + * @return A freshly allocated node sharing no mutable state with the + * original. + */ intr_ptr::SharedPtr SHAMapInnerNode::clone(std::uint32_t cowid) const { @@ -118,6 +164,20 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const return p; } +/** Deserialize a full-format inner node from wire data. + * + * The full format encodes all 16 child hashes in branch order (512 bytes). + * After parsing, the child arrays are right-sized via resizeChildArrays() + * to match actual occupancy, and the node hash is either adopted from + * @p hash (trusted path, e.g. retrieved by known hash) or recomputed. + * + * @param data Raw wire bytes; must be exactly 512 bytes (16 × 32). + * @param hash Expected node hash, used only when @p hashValid is true. + * @param hashValid If true, adopt @p hash without recomputing; if false, + * call updateHash() to derive the hash from child data. + * @return A freshly allocated, shareable (cowid = 0) inner node. + * @throws std::runtime_error if @p data is not exactly 512 bytes. + */ intr_ptr::SharedPtr SHAMapInnerNode::makeFullInner(Slice data, SHAMapHash const& hash, bool hashValid) { @@ -153,6 +213,20 @@ SHAMapInnerNode::makeFullInner(Slice data, SHAMapHash const& hash, bool hashVali return ret; } +/** Deserialize a compressed-format inner node from wire data. + * + * The compressed format encodes only non-empty branches. Each entry is + * 33 bytes: a 32-byte hash followed by a 1-byte branch index (0–15). + * Entries may appear in any order. The node hash is always recomputed + * from the parsed child hashes (no trusted-hash variant exists for this + * format). + * + * @param data Raw wire bytes; must be a multiple of 33 and at most 495 bytes + * (15 entries — 16 entries would use the full format instead). + * @return A freshly allocated, shareable (cowid = 0) inner node. + * @throws std::runtime_error if @p data length is not a multiple of 33, + * exceeds 495 bytes, or contains a branch index >= 16. + */ intr_ptr::SharedPtr SHAMapInnerNode::makeCompressedInner(Slice data) { @@ -188,6 +262,17 @@ SHAMapInnerNode::makeCompressedInner(Slice data) return ret; } +/** Recompute this node's Merkle hash from the current child-hash array. + * + * Feeds HashPrefix::InnerNode followed by all 16 child hashes (zero-filled + * for absent branches) into SHA-512/2. All 16 slots are always consumed + * regardless of sparsity, so the hash is layout-independent. An empty + * node (isBranch_ == 0) produces a zero hash. + * + * @note Callers are responsible for ensuring the @c hashes array is up to + * date before calling this. Use updateHashDeep() when child hashes + * may have been modified in memory but not yet synced to the array. + */ void SHAMapInnerNode::updateHash() { @@ -203,6 +288,18 @@ SHAMapInnerNode::updateHash() hash_ = SHAMapHash{nh}; } +/** Pull hashes from in-memory children, then recompute this node's hash. + * + * setChild() zeroes the hash slot for a newly installed child so that the + * hash is not stale if the child is later modified. After a batch of + * mutations where child hashes have been updated in memory but the local + * hashes array was not synchronised, this method propagates those values + * upward before delegating to updateHash(). + * + * Only non-empty branches that carry a live in-memory pointer are updated; + * branches backed only by a known hash (fetched from DB but not yet + * instantiated as objects) are left unchanged. + */ void SHAMapInnerNode::updateHashDeep() { @@ -217,6 +314,22 @@ SHAMapInnerNode::updateHashDeep() updateHash(); } +/** Serialize this node for peer-to-peer wire transmission. + * + * Chooses between two formats based on occupancy: + * - **Compressed** (< 12 branches): emits each non-empty branch as a + * 32-byte hash followed by a 1-byte position, then appends + * kWIRE_TYPE_COMPRESSED_INNER. Wire size: 33 × n + 1 bytes. + * - **Full** (≥ 12 branches): emits all 16 hashes in order, then appends + * kWIRE_TYPE_INNER. Wire size: 513 bytes. + * + * The trailing type byte allows the receiver to select the correct + * deserialization factory (makeFullInner or makeCompressedInner). + * + * @param s Serializer to append the encoded bytes to. + * @note Asserts that the node is not empty; serializing an empty inner + * node is a caller bug. + */ void SHAMapInnerNode::serializeForWire(Serializer& s) const { @@ -240,6 +353,16 @@ SHAMapInnerNode::serializeForWire(Serializer& s) const } } +/** Serialize this node in canonical hash-input form. + * + * Always emits HashPrefix::InnerNode followed by all 16 child hashes in + * branch order (zero-filled for absent branches), matching exactly what + * updateHash() feeds to the SHA-512/2 hasher. Unlike serializeForWire(), + * this form is always full (512 + 4 bytes) and carries no wire-type suffix. + * + * @param s Serializer to append the encoded bytes to. + * @note Asserts that the node is not empty. + */ void SHAMapInnerNode::serializeWithPrefix(Serializer& s) const { @@ -249,6 +372,14 @@ SHAMapInnerNode::serializeWithPrefix(Serializer& s) const iterChildren([&](SHAMapHash const& hh) { s.addBitString(hh.asUInt256()); }); } +/** Return a human-readable representation of this node for debugging. + * + * Extends the base-class string with one line per non-empty branch, + * formatted as "b = ". + * + * @param id Tree address of this node, forwarded to the base implementation. + * @return Multi-line diagnostic string. + */ std::string SHAMapInnerNode::getString(SHAMapNodeID const& id) const { @@ -263,6 +394,20 @@ SHAMapInnerNode::getString(SHAMapNodeID const& id) const return ret; } +/** Install or remove a child at branch @p m, resizing the arrays if needed. + * + * Computes the destination isBranch_ mask (adding or clearing bit @p m), + * then reconstructs hashesAndChildren_ via the TaggedPointer move constructor + * that handles simultaneous resize and entry migration. Installing a child + * zeroes its hash slot (marking this node dirty); removing one shrinks the + * allocation. Clears hash_ unconditionally so that the next getHash() + * call triggers a recompute. + * + * @param m Branch index to modify (0–15). + * @param child New child pointer; pass nullptr to remove the branch. + * @note Requires CoW ownership (cowid_ != 0). Does not acquire spinlocks — + * callers must hold exclusive ownership by construction. + */ // We are modifying an inner node void SHAMapInnerNode::setChild(int m, intr_ptr::SharedPtr child) @@ -305,6 +450,17 @@ SHAMapInnerNode::setChild(int m, intr_ptr::SharedPtr child) "xrpl::SHAMapInnerNode::setChild : maximum branch count"); } +/** Store a child pointer into an already-occupied branch without resizing. + * + * Used after the branch has been set (and sized) by setChild(), when the + * child object is being made shareable. Unlike setChild(), this does not + * touch isBranch_ or hash_ and does not acquire spinlocks — it is valid + * only while the node still has CoW ownership. + * + * @param m Branch index (0–15); must already be non-empty. + * @param child Non-null pointer to the child being shared. + * @note Requires CoW ownership (cowid_ != 0) and a pre-existing branch. + */ // finished modifying, now make shareable void SHAMapInnerNode::shareChild(int m, intr_ptr::SharedPtr const& child) @@ -320,6 +476,18 @@ SHAMapInnerNode::shareChild(int m, intr_ptr::SharedPtr const& ch hashesAndChildren_.getChildren()[*getChildIndex(m)] = child; } +/** Return a raw (non-owning) pointer to the child at @p branch. + * + * Acquires the per-child packed spinlock for the child's physical array + * index, allowing concurrent access to different children of the same node. + * Returns a raw pointer rather than a SharedPtr to avoid bumping the + * reference count on hot traversal paths; the caller must not store this + * pointer beyond the node's lifetime or release the owning SharedPtr. + * + * @param branch Branch index (0–15); must be non-empty. + * @return Raw pointer to the in-memory child, or nullptr if the child has + * not yet been loaded into memory (only its hash is known). + */ SHAMapTreeNode* SHAMapInnerNode::getChildPointer(int branch) { @@ -337,6 +505,16 @@ SHAMapInnerNode::getChildPointer(int branch) return hashesAndChildren_.getChildren()[index].get(); } +/** Return a reference-counted pointer to the child at @p branch. + * + * Acquires the per-child packed spinlock to safely copy the SharedPtr while + * another thread may be installing a pointer via canonicalizeChild(). + * Prefer getChildPointer() on hot paths where the caller can ensure the + * node outlives the pointer use. + * + * @param branch Branch index (0–15); must be non-empty. + * @return SharedPtr to the in-memory child, empty if not yet loaded. + */ intr_ptr::SharedPtr SHAMapInnerNode::getChild(int branch) { @@ -353,6 +531,17 @@ SHAMapInnerNode::getChild(int branch) return hashesAndChildren_.getChildren()[index]; } +/** Return the known hash for child branch @p m. + * + * For branches present in the tree (isBranch_ bit set), returns a reference + * into the internal hashes array. For absent branches, returns a reference + * to the shared zero-hash sentinel kZERO_SHA_MAP_HASH. + * + * @param m Branch index (0–15). + * @return Reference to the child hash, or kZERO_SHA_MAP_HASH if empty. + * @note The reference is valid for the lifetime of this node. Do not store + * it across mutations of this node. + */ SHAMapHash const& SHAMapInnerNode::getChildHash(int m) const { @@ -365,6 +554,23 @@ SHAMapInnerNode::getChildHash(int m) const return kZERO_SHA_MAP_HASH; } +/** Deduplicate a freshly loaded child using first-writer-wins under a spinlock. + * + * When multiple threads simultaneously fetch the same child from backing + * storage, each constructs its own SHAMapTreeNode. This method serialises + * installation under the per-child packed spinlock: if the slot is already + * occupied (another thread won the race), the caller's @p node is discarded + * and the incumbent is returned. If the slot is empty, @p node is installed + * and returned. Either way the caller receives the single canonical in-memory + * instance for this child. + * + * @param branch Branch index (0–15); must be non-empty (hash already known). + * @param node Freshly constructed child node whose hash matches the stored + * child hash. + * @return The canonical in-memory child pointer (may differ from @p node). + * @note Asserts that @p node's hash matches the stored child hash before + * acquiring the lock. + */ intr_ptr::SharedPtr SHAMapInnerNode::canonicalizeChild(int branch, intr_ptr::SharedPtr node) { @@ -399,6 +605,21 @@ SHAMapInnerNode::canonicalizeChild(int branch, intr_ptr::SharedPtr #include @@ -15,6 +26,18 @@ namespace xrpl { +/** Construct a new leaf whose hash must be computed by the subclass. + * + * Used when creating a leaf for the first time. The concrete subclass is + * expected to call `updateHash()` immediately after delegating to this + * constructor so that `hash_` reflects the stored item. + * + * @param item The ledger object to store; must carry at least 12 bytes of + * payload (protocol-level minimum for any meaningful serialized object). + * @param cowid Copy-on-Write owner ID. Non-zero indicates exclusive ownership + * by a particular `SHAMap` instance; zero means the node is shared and + * must not be mutated. + */ SHAMapLeafNode::SHAMapLeafNode(boost::intrusive_ptr item, std::uint32_t cowid) : SHAMapTreeNode(cowid), item_(std::move(item)) { @@ -24,6 +47,18 @@ SHAMapLeafNode::SHAMapLeafNode(boost::intrusive_ptr item, std: "SHAMapItem const>, std::uint32_t) : minimum input size"); } +/** Construct a leaf with a pre-computed hash, skipping hash recomputation. + * + * Used during deserialization (`makeFromWire`, `makeFromPrefix`) and during + * `clone()` operations where the hash is already known. Passing the hash + * directly avoids a SHA-512 half computation. + * + * @param item The ledger object to store; must carry at least 12 bytes of + * payload. + * @param cowid Copy-on-Write owner ID (see the two-argument overload). + * @param hash The pre-computed hash for this leaf. The caller is responsible + * for ensuring this matches `item`; no verification is performed here. + */ SHAMapLeafNode::SHAMapLeafNode( boost::intrusive_ptr item, std::uint32_t cowid, @@ -56,6 +91,15 @@ SHAMapLeafNode::setItem(boost::intrusive_ptr item) return (oldHash != hash_); } +/** Produce a human-readable diagnostic string for this leaf node. + * + * Prepends position context from `SHAMapTreeNode::getString(id)`, then + * appends the concrete node type (resolved via the virtual `getType()`), + * the item's 256-bit key, the node hash, and the item payload size in bytes. + * + * @param id The tree position (depth + masked path prefix) of this node. + * @return A multi-line string suitable for logging and debug output. + */ std::string SHAMapLeafNode::getString(SHAMapNodeID const& id) const { @@ -89,6 +133,12 @@ SHAMapLeafNode::getString(SHAMapNodeID const& id) const return ret; } +/** Assert that this leaf is in a valid, fully-initialized state. + * + * Checks that `hash_` is non-zero and `item_` is non-null. Sealed `final` + * here so that no concrete subclass can inadvertently weaken or bypass + * these universal leaf invariants. + */ void SHAMapLeafNode::invariants(bool) const { diff --git a/src/libxrpl/shamap/SHAMapNodeID.cpp b/src/libxrpl/shamap/SHAMapNodeID.cpp index 4eb6742aac..831b44bd08 100644 --- a/src/libxrpl/shamap/SHAMapNodeID.cpp +++ b/src/libxrpl/shamap/SHAMapNodeID.cpp @@ -13,6 +13,26 @@ namespace xrpl { +/** Return the path-prefix bitmask for a given tree depth. + * + * The SHAMap tree consumes one 4-bit nibble of a 256-bit key per level, + * so the meaningful prefix at depth @p depth is exactly the top `4 * depth` + * bits of a `uint256`. This function returns a precomputed mask with those + * bits set to 1 and all lower bits set to 0, indexed by depth [0, 64]. + * + * The table is built once at program start inside a function-local `static`: + * - depth 0 (root): all-zero mask — the root carries no path prefix. + * - depth 1: high nibble of byte 0 set (`0xF0______...`). + * - depth 2: full first byte set (`0xFF______...`). + * - Each subsequent pair of depths adds one nibble more. + * - depth 64 (leaf): all 32 bytes fully set. + * + * @param depth Tree depth in [0, 64]. Caller is responsible for range + * validity; out-of-range access is undefined behaviour. + * @return Reference to the precomputed mask for @p depth. + * @note Thread-safe: the `static` local is initialised exactly once by the + * C++11 guarantee; no explicit synchronisation is required. + */ static uint256 const& depthMask(unsigned int depth) { @@ -40,7 +60,6 @@ depthMask(unsigned int depth) return kMASKS.entry[depth]; } -// canonicalize the hash to a node ID for this depth SHAMapNodeID::SHAMapNodeID(unsigned int depth, uint256 const& hash) : id_(hash), depth_(depth) { XRPL_ASSERT( @@ -64,15 +83,6 @@ SHAMapNodeID::getChildNodeID(unsigned int m) const { XRPL_ASSERT( m < SHAMap::kBRANCH_FACTOR, "xrpl::SHAMapNodeID::getChildNodeID : valid branch input"); - - // A SHAMap has exactly 65 levels, so nodes must not exceed that - // depth; if they do, this breaks the invariant of never allowing - // the construction of a SHAMapNodeID at an invalid depth. We assert - // to catch this in debug builds. - // - // We throw (but never assert) if the node is at level 64, since - // entries at that depth are leaf nodes and have no children and even - // constructing a child node from them would break the above invariant. XRPL_ASSERT( depth_ <= SHAMap::kLEAF_DEPTH, "xrpl::SHAMapNodeID::getChildNodeID : maximum leaf depth"); diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp index 935f5b4e4a..4260b1057f 100644 --- a/src/libxrpl/shamap/SHAMapSync.cpp +++ b/src/libxrpl/shamap/SHAMapSync.cpp @@ -76,24 +76,23 @@ SHAMap::visitNodes(std::function const& function) const } else { - // If there are no more children, don't push this node + // Avoid pushing if no further siblings remain; saves a + // pointless push/pop cycle for trailing empty branches. while ((pos != 15) && (node->isEmptyBranch(pos + 1))) ++pos; if (pos != 15) { - // save next position to resume at stack.emplace(pos + 1, std::move(node)); } - // descend to the child's first position node = intr_ptr::staticPointerCast(child); pos = 0; } } else { - ++pos; // move to next position + ++pos; } } @@ -110,8 +109,6 @@ SHAMap::visitDifferences( SHAMap const* have, std::function const& function) const { - // Visit every node in this SHAMap that is not present - // in the specified SHAMap if (!root_) return; @@ -128,7 +125,6 @@ SHAMap::visitDifferences( function(*root_); return; } - // contains unexplored non-matching inner node entries using StackEntry = std::pair; std::stack> stack; @@ -139,11 +135,9 @@ SHAMap::visitDifferences( auto const [node, nodeID] = stack.top(); stack.pop(); - // 1) Add this node to the pack if (!function(*node)) return; - // 2) push non-matching child inner nodes for (int i = 0; i < 16; ++i) { if (!node->isEmptyBranch(i)) @@ -170,10 +164,23 @@ SHAMap::visitDifferences( } } -// Starting at the position referred to by the specfied -// StackEntry, process that node and its first resident -// children, descending the SHAMap until we complete the -// processing of a node. +/** Process one inner node and its children during missing-node discovery. + * + * Starting at the position described by `se`, iterates over children of the + * current inner node in a randomised order. For each non-empty branch it + * either (a) skips via the FullBelowCache, (b) records a confirmed missing + * hash, (c) posts an async read via `descendAsync` (incrementing + * `mn.deferred`), or (d) pushes the current position and descends into a + * child inner node. Returns when the 16 branches are exhausted, when + * `mn.max` reaches zero, or when `mn.deferred` would exceed `mn.maxDefer`. + * + * On normal completion, marks the node full-below in both the in-memory + * field and the `FullBelowCache` if no missing children were found below it. + * + * @param mn Mutable traversal context for the current `getMissingNodes` call. + * @param se Current stack entry; `node` is set to `nullptr` on normal + * completion to signal to the caller that this node is done. + */ void SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se) { @@ -206,7 +213,6 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se) pending, [node, nodeID, branch, &mn]( intr_ptr::SharedPtr found, SHAMapHash const&) { - // a read completed asynchronously std::unique_lock const lock{mn.deferLock}; mn.finishedReads.emplace_back(node, nodeID, branch, std::move(found)); mn.deferCondVar.notify_one(); @@ -219,8 +225,6 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se) } else if (d == nullptr) { - // node is not in database - fullBelow = false; // for now, not known full below mn.missingHashes.insert(childHash); mn.missingNodes.emplace_back(nodeID.getChildNodeID(branch), childHash.asUInt256()); @@ -231,8 +235,6 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se) else if (d->isInner() && !safeDowncast(d)->isFullBelow(mn.generation)) { mn.stack.push(se); - - // Switch to processing the child node node = safeDowncast(d); nodeID = nodeID.getChildNodeID(branch); firstChild = randInt(255); @@ -242,11 +244,8 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se) } } - // We have finished processing an inner node - // and thus (for now) all its children - if (fullBelow) - { // No partial node encountered below this node + { node->setFullBelowGen(mn.generation); if (backed_) { @@ -257,12 +256,23 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se) node = nullptr; } -// Wait for deferred reads to finish and -// process their results +/** Drain all pending async reads and integrate their results. + * + * Blocks on `mn.deferCondVar` until all `mn.deferred` reads have called + * back. Successfully fetched nodes are canonicalized into their parent via + * `canonicalizeChild` and the parent is added to `mn.resumes` so that + * `getMissingNodes` will revisit it after this batch. Nodes that resolve as + * null (genuinely missing) are recorded in `mn.missingNodes` subject to + * `mn.max`, with deduplication via `mn.missingHashes`. + * + * @param mn Mutable traversal context for the current `getMissingNodes` call. + * @note Must only be called from the thread running `getMissingNodes`; + * async callbacks write to `mn.finishedReads` under `mn.deferLock` and + * signal `mn.deferCondVar`. + */ void SHAMap::gmnProcessDeferredReads(MissingNodes& mn) { - // Process all deferred reads int complete = 0; while (complete != mn.deferred) { @@ -283,11 +293,8 @@ SHAMap::gmnProcessDeferredReads(MissingNodes& mn) auto const& nodeHash = parent->getChildHash(branch); if (nodePtr) - { // Got the node + { nodePtr = parent->canonicalizeChild(branch, std::move(nodePtr)); - - // When we finish this stack, we need to restart - // with the parent of this node mn.resumes[parent] = parentID; } else if ((mn.max > 0) && (mn.missingHashes.insert(nodeHash).second)) @@ -302,10 +309,26 @@ SHAMap::gmnProcessDeferredReads(MissingNodes& mn) mn.deferred = 0; } -/** Get a list of node IDs and hashes for nodes that are part of this SHAMap - but not available locally. The filter can hold alternate sources of - nodes that are not permanently stored locally -*/ +/** Discover nodes that are referenced in this map but absent locally. + * + * Performs a depth-first traversal using the `MissingNodes` engine, issuing + * async reads (up to 512 in flight) via `descendAsync` and draining them in + * batches through `gmnProcessDeferredReads`. Subtrees confirmed complete by + * the `FullBelowCache` are skipped entirely. + * + * Each inner-node entry is visited with a randomly chosen starting child so + * that concurrent callers on the same map are likely to request different + * missing nodes — maximizing coverage per sync round rather than sending + * redundant requests. + * + * If the result vector is empty when the traversal finishes, the map is + * transitioned out of `Synching` state via `clearSynching()`. + * + * @param max Stop after collecting this many missing-node entries. + * @param filter Optional sync filter; may be `nullptr`. + * @return Vector of `(SHAMapNodeID, hash)` pairs for each unresolvable node, + * ordered by traversal encounter. + */ std::vector> SHAMap::getMissingNodes(int max, SHAMapSyncFilter* filter) { @@ -325,19 +348,12 @@ SHAMap::getMissingNodes(int max, SHAMapSyncFilter* filter) return std::move(mn.missingNodes); } - // Start at the root. - // The firstChild value is selected randomly so if multiple threads - // are traversing the map, each thread will start at a different - // (randomly selected) inner node. This increases the likelihood - // that the two threads will produce different request sets (which is - // more efficient than sending identical requests). MissingNodes::StackEntry pos{ safeDowncast(root_.get()), SHAMapNodeID(), randInt(255), 0, true}; auto& node = std::get<0>(pos); auto& nextChild = std::get<3>(pos); auto& fullBelow = std::get<4>(pos); - // Traverse the map without blocking do { while ((node != nullptr) && (mn.deferred <= mn.maxDefer)) @@ -349,27 +365,22 @@ SHAMap::getMissingNodes(int max, SHAMapSyncFilter* filter) if ((node == nullptr) && !mn.stack.empty()) { - // Pick up where we left off with this node's parent - bool const was = fullBelow; // was full below + bool const was = fullBelow; pos = mn.stack.top(); mn.stack.pop(); if (nextChild == 0) { - // This is a node we are processing for the first time fullBelow = true; } else { - // This is a node we are continuing to process - fullBelow = fullBelow && was; // was and still is + fullBelow = fullBelow && was; } XRPL_ASSERT(node, "xrpl::SHAMap::getMissingNodes : first non-null node"); } } - // We have either emptied the stack or - // posted as many deferred reads as we can if (mn.deferred != 0) gmnProcessDeferredReads(mn); @@ -377,11 +388,9 @@ SHAMap::getMissingNodes(int max, SHAMapSyncFilter* filter) return std::move(mn.missingNodes); if (node == nullptr) - { // We weren't in the middle of processing a node - + { if (mn.stack.empty() && !mn.resumes.empty()) { - // Recheck nodes we could not finish before for (auto const& [innerNode, nodeId] : mn.resumes) { if (!innerNode->isFullBelow(mn.generation)) @@ -393,17 +402,12 @@ SHAMap::getMissingNodes(int max, SHAMapSyncFilter* filter) if (!mn.stack.empty()) { - // Resume at the top of the stack pos = mn.stack.top(); mn.stack.pop(); XRPL_ASSERT(node, "xrpl::SHAMap::getMissingNodes : second non-null node"); } } - // node will only still be nullptr if - // we finished the current node, the stack is empty - // and we have no nodes to resume - } while (node != nullptr); if (mn.missingNodes.empty()) @@ -419,9 +423,6 @@ SHAMap::getNodeFat( bool fatLeaves, std::uint32_t depth) const { - // Gets a node and some of its children - // to a specified depth - auto node = root_.get(); SHAMapNodeID nodeID; @@ -458,21 +459,17 @@ SHAMap::getNodeFat( std::tie(node, nodeID, depth) = stack.top(); stack.pop(); - // Add this node to the reply s.erase(); node->serializeForWire(s); data.emplace_back(nodeID, s.getData()); if (node->isInner()) { - // We descend inner nodes with only a single child - // without decrementing the depth auto inner = safeDowncast(node); int const bc = inner->getBranchCount(); if ((depth > 0) || (bc == 1)) { - // We need to process this node's children for (int i = 0; i < 16; ++i) { if (!inner->isEmptyBranch(i)) @@ -482,13 +479,13 @@ SHAMap::getNodeFat( if (childNode->isInner() && ((depth > 1) || (bc == 1))) { - // If there's more than one child, reduce the depth - // If only one child, follow the chain + // Single-child chains traverse without decrementing + // the depth budget; multi-child nodes consume one + // level of depth per branch. stack.emplace(childNode, childID, (bc > 1) ? (depth - 1) : depth); } else if (childNode->isInner() || fatLeaves) { - // Just include this node s.erase(); childNode->serializeForWire(s); data.emplace_back(childID, s.getData()); @@ -511,7 +508,6 @@ SHAMap::serializeRoot(Serializer& s) const SHAMapAddNode SHAMap::addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter* filter) { - // we already have a root_ node if (root_->getHash().isNonZero()) { JLOG(journal_.trace()) << "got root node, already have one"; @@ -601,7 +597,6 @@ SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncF auto const& actualKey = safeDowncast(newNode.get())->peekItem()->key(); - // Validate that this leaf belongs at the target position auto const expectedNodeID = SHAMapNodeID::createID(node.getDepth(), actualKey); if (expectedNodeID.getNodeID() != node.getNodeID()) { @@ -618,7 +613,6 @@ SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncF if ((currNodeID.getDepth() > kLEAF_DEPTH) || (newNode->isInner() && currNodeID.getDepth() == kLEAF_DEPTH)) { - // Map is provably invalid state_ = SHAMapState::Invalid; return SHAMapAddNode::useful(); } @@ -656,7 +650,6 @@ SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncF bool SHAMap::deepCompare(SHAMap& other) const { - // Intended for debug/test only std::stack> stack; stack.emplace(root_.get(), other.root_.get()); @@ -722,7 +715,16 @@ SHAMap::deepCompare(SHAMap& other) const return true; } -/** Does this map have this inner node? +/** Walk the tree to `targetNodeID` and confirm it carries `targetNodeHash`. + * + * Used by `visitDifferences` to short-circuit descending into subtrees that + * are already in agreement between two maps. + * + * @param targetNodeID Tree address of the inner node to locate. + * @param targetNodeHash Expected hash of that node. + * @return `true` if the walk reaches an inner node at `targetNodeID` whose + * hash equals `targetNodeHash`; `false` if the path is empty, diverges, + * or the hashes differ. */ bool SHAMap::hasInnerNode(SHAMapNodeID const& targetNodeID, SHAMapHash const& targetNodeHash) const @@ -744,7 +746,16 @@ SHAMap::hasInnerNode(SHAMapNodeID const& targetNodeID, SHAMapHash const& targetN return (node->isInner()) && (node->getHash() == targetNodeHash); } -/** Does this map have this leaf node? +/** Walk toward `tag` and confirm a leaf at that position carries `targetNodeHash`. + * + * Traverses inner nodes following `tag`'s nibbles, stopping as soon as the + * branch hash matches `targetNodeHash` (avoiding a full descent to the leaf) + * or a dead-end branch is reached. + * + * @param tag 256-bit key identifying the target leaf. + * @param targetNodeHash Expected hash of the leaf node. + * @return `true` if a branch hash equal to `targetNodeHash` is found on the + * path toward `tag`; `false` if the path is empty or the key is absent. */ bool SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const @@ -760,17 +771,16 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const int const branch = selectBranch(nodeID, tag); auto inner = safeDowncast(node); if (inner->isEmptyBranch(branch)) - return false; // Dead end, node must not be here + return false; - if (inner->getChildHash(branch) == targetNodeHash) // Matching leaf, no need to retrieve it + if (inner->getChildHash(branch) == targetNodeHash) return true; node = descendThrow(inner, branch); nodeID = nodeID.getChildNodeID(branch); } while (node->isInner()); - return false; // If this was a matching leaf, we would have caught it - // already + return false; } std::optional> diff --git a/src/libxrpl/shamap/SHAMapTreeNode.cpp b/src/libxrpl/shamap/SHAMapTreeNode.cpp index ac405f3286..ac716ce2a8 100644 --- a/src/libxrpl/shamap/SHAMapTreeNode.cpp +++ b/src/libxrpl/shamap/SHAMapTreeNode.cpp @@ -1,3 +1,20 @@ +/** @file + * Static factory methods for deserializing `SHAMapTreeNode` subclass objects + * from raw bytes. + * + * Two serialization formats are handled here: + * + * - **Wire format** (`makeFromWire`): the type discriminant is a single byte + * appended to the *end* of the buffer. No pre-computed hash is available, + * so leaf constructors call `updateHash()` internally (`hashValid = false`). + * + * - **Prefixed format** (`makeFromPrefix`): a 4-byte big-endian `HashPrefix` + * constant leads the buffer. The caller supplies the already-verified hash, + * so leaf constructors skip recomputation (`hashValid = true`). + * + * All nodes are constructed with `cowid = 0`, marking them as unowned and + * immediately shareable across multiple `SHAMap` instances. + */ #include #include // IWYU pragma: keep @@ -28,6 +45,9 @@ namespace xrpl { intr_ptr::SharedPtr SHAMapTreeNode::makeTransaction(Slice data, SHAMapHash const& hash, bool hashValid) { + // The item key IS the transaction ID: sha512Half(prefix, payload). + // It is derived from the content, not stored in the payload, so no tail + // extraction is needed here (unlike makeTransactionWithMeta/makeAccountState). auto item = makeShamapitem(sha512Half(HashPrefix::TransactionId, data), data); if (hashValid) @@ -43,6 +63,9 @@ SHAMapTreeNode::makeTransactionWithMeta(Slice data, SHAMapHash const& hash, bool uint256 tag; + // The 32-byte item key is appended to the *tail* of the serialized payload + // by serializeForWire(). Extract it, then chop it off before creating the + // SHAMapItem so that item->slice() contains only the tx+meta blob. if (s.size() < tag.kBYTES) Throw("Short TXN+MD node"); @@ -67,6 +90,8 @@ SHAMapTreeNode::makeAccountState(Slice data, SHAMapHash const& hash, bool hashVa uint256 tag; + // The 32-byte ledger-object key is appended to the tail of the payload by + // serializeForWire(). Extract and chop it, leaving only the state blob. if (s.size() < tag.kBYTES) Throw("short AS node"); @@ -76,6 +101,7 @@ SHAMapTreeNode::makeAccountState(Slice data, SHAMapHash const& hash, bool hashVa s.chop(tag.kBYTES); + // A zero key is not a valid ledger-object identity; reject it as corrupt. if (tag.isZero()) Throw("Invalid AS node"); @@ -93,10 +119,13 @@ SHAMapTreeNode::makeFromWire(Slice rawNode) if (rawNode.empty()) return {}; + // The wire format appends the kWIRE_TYPE_* discriminant as the final byte. auto const type = rawNode[rawNode.size() - 1]; rawNode.removeSuffix(1); + // The wire format carries no pre-computed hash, so every concrete node + // constructor must call updateHash() to derive it from the payload. bool const hashValid = false; SHAMapHash const hash; @@ -125,13 +154,16 @@ SHAMapTreeNode::makeFromPrefix(Slice rawNode, SHAMapHash const& hash) Throw("prefix: short node"); // FIXME: Use SerialIter::get32? - // Extract the prefix + // Extract the 4-byte big-endian HashPrefix that leads the buffer. auto const type = safeCast( (safeCast(rawNode[0]) << 24) + (safeCast(rawNode[1]) << 16) + (safeCast(rawNode[2]) << 8) + (safeCast(rawNode[3]))); rawNode.removePrefix(4); + // The caller has already verified the hash (e.g., matched against a parent + // branch entry or a trusted node store record), so leaf constructors can + // skip updateHash() and use the supplied hash directly. bool const hashValid = true; if (type == HashPrefix::TransactionId) diff --git a/src/libxrpl/tx/ApplyContext.cpp b/src/libxrpl/tx/ApplyContext.cpp index 88e37baf00..a2806fa57e 100644 --- a/src/libxrpl/tx/ApplyContext.cpp +++ b/src/libxrpl/tx/ApplyContext.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements `ApplyContext`, the sandboxed ledger view and invariant + * enforcement layer for transaction execution. + * + * `ApplyContext` owns the `ApplyViewImpl` sandbox that a `Transactor` writes + * into during `doApply`. No mutation escapes to the live `OpenView` until + * `apply()` is called. When `doApply` returns, `checkInvariants` runs every + * registered checker against the pending changes; a failure triggers rollback + * and escalates the result to `tec`/`tef INVARIANT_FAILED` depending on + * whether a prior retry has already been attempted. + */ #include #include @@ -25,6 +36,26 @@ namespace xrpl { +/** Construct an `ApplyContext` for a batch-inner or standalone transaction. + * + * Emplaces a fresh `ApplyViewImpl` sandbox on top of `base`. All transactor + * reads and writes go through this sandbox; `base` is not touched until + * `apply()` is called. + * + * @param registry Service registry available to transactors. + * @param base The live open ledger view to sandbox against. + * @param parentBatchId ID of the enclosing batch transaction, or `nullopt` + * for non-batch transactions. Must be present if and only if `TapBatch` + * is set in `flags`. + * @param tx The pre-validated transaction to apply. + * @param preclaimResult TER returned by the preclaim phase. + * @param baseFee The base fee for this transaction type. + * @param flags Apply-phase flags (e.g., `TapDryRun`, `TapBatch`). + * @param journal Logging sink. + * @note Asserts that `parentBatchId.has_value() == (flags & TapBatch)`. + * Constructing with a mismatched batch ID / flag combination is a + * programming error and will fire in debug builds. + */ ApplyContext::ApplyContext( ServiceRegistry& registry, OpenView& base, @@ -49,12 +80,37 @@ ApplyContext::ApplyContext( view_.emplace(&base_, flags_); } +/** Discard all pending sandbox changes and reset to a clean view over `base_`. + * + * Re-emplaces a fresh `ApplyViewImpl` on top of `base_`, atomically + * discarding every ledger entry modification accumulated since construction + * or the previous `discard()`. Because the sandbox has never written through + * to `base_`, this rollback costs only object construction. + * + * Called by `Transactor::reset()` when re-applying a fee-only path after an + * invariant failure, and by `Transactor::operator()` on the `tapFAIL_HARD` + * branch to suppress even fee collection. + */ void ApplyContext::discard() { view_.emplace(&base_, flags_); } +/** Commit the sandbox to the live ledger view and generate transaction metadata. + * + * Flushes all pending `ApplyViewImpl` deltas into `base_` by delegating to + * `view_->apply()`. Passes `parentBatchId_` and the `TapDryRun` flag so the + * commit step remains aware of batch context and simulation mode without + * requiring the transactor to track those concerns. + * + * @param ter The final transaction result code determined by the transactor. + * @return Transaction metadata describing ledger changes, or `nullopt` if + * the transaction was a dry run (`TapDryRun`) and no metadata is + * produced. + * @note Must not be called after `discard()` without re-running the + * transaction; calling on a fresh sandbox produces empty metadata. + */ std::optional ApplyContext::apply(TER ter) { @@ -62,12 +118,32 @@ ApplyContext::apply(TER ter) return view_->apply(base_, tx, ter, parentBatchId_, (flags_ & TapDryRun) != 0u, journal); } +/** Return the number of ledger entry modifications pending in the sandbox. + * + * Delegates to `ApplyViewImpl::size()`. Used by `Transactor::operator()` to + * check whether the transaction metadata would exceed `oversizeMetaDataCap` + * before committing. + * + * @return Count of modified ledger entries not yet committed to `base_`. + */ std::size_t ApplyContext::size() { return view_->size(); // NOLINT(bugprone-unchecked-optional-access) } +/** Enumerate every pending ledger entry modification in the sandbox. + * + * Calls `func` once for each modified entry, providing the entry's key, a + * deletion flag, and shared pointers to the before- and after-states. + * Used internally by `checkInvariantsHelper` to feed every registered + * invariant checker via `visitEntry`. + * + * @param func Visitor invoked as + * `func(key, isDelete, before, after)` for each modified entry. + * `before` is null for newly created entries; `after` is null for + * deleted entries. + */ void ApplyContext::visit( std::functionvisit(base_, func); // NOLINT(bugprone-unchecked-optional-access) } +/** Escalate an invariant failure to the appropriate TER code. + * + * On a first-time invariant failure the caller receives `tecINVARIANT_FAILED`, + * which is included in the ledger so the sender is still charged a fee. + * If the incoming `result` is already `tecINVARIANT_FAILED` or + * `tefINVARIANT_FAILED` — meaning the caller is on a fee-only retry and even + * that minimal path broke an invariant — this function escalates to + * `tefINVARIANT_FAILED`, which is NOT included in any ledger. Nothing is + * committed when the result is `tef`. + * + * @param result The TER that was in effect when the invariant failure occurred. + * @return `tefINVARIANT_FAILED` if `result` signals a prior invariant failure; + * `tecINVARIANT_FAILED` otherwise. + */ TER ApplyContext::failInvariantCheck(TER const result) { @@ -92,6 +182,34 @@ ApplyContext::failInvariantCheck(TER const result) : TER{tecINVARIANT_FAILED}; } +/** Drive all registered invariant checkers against the current sandbox state. + * + * Uses compile-time index unpacking to avoid virtual dispatch. Execution has + * two phases for each checker in `InvariantChecks`: + * + * 1. **visitEntry** — called once per modified ledger entry via `visit()`. + * Each checker accumulates per-entry state (e.g., running XRP drop totals, + * account deletion counts). + * 2. **finalize** — called after all entries have been visited. Results are + * collected into a `std::array` (NOT a `...&&` fold) so every + * failing checker logs its own diagnostic before the verdict is rendered. + * Short-circuiting with `&&` would silence all but the first failure, + * which is unacceptable during incident diagnosis. + * + * Checkers run even when `result` is a `tec*` failure code; a bug or exploit + * could mutate ledger state on a failed transaction, and invariants are the + * last line of defense against that. + * + * Any exception thrown by a checker is treated as an invariant failure and + * routed through `failInvariantCheck`. + * + * @tparam Is Index pack over `InvariantChecks` tuple positions. + * @param result The transaction result code to validate against. + * @param fee The fee actually charged for the transaction. + * @param Index sequence used to unpack the checker tuple at compile time. + * @return `result` unchanged if all checkers pass; `failInvariantCheck(result)` + * if any checker fails or throws. + */ template TER ApplyContext::checkInvariantsHelper( @@ -103,7 +221,6 @@ ApplyContext::checkInvariantsHelper( { auto checkers = getInvariantChecks(); - // call each check's per-entry method visit([&checkers]( uint256 const& index, bool isDelete, @@ -112,15 +229,9 @@ ApplyContext::checkInvariantsHelper( (..., std::get(checkers).visitEntry(isDelete, before, after)); }); - // Note: do not replace this logic with a `...&&` fold expression. - // The fold expression will only run until the first check fails (it - // short-circuits). While the logic is still correct, the log - // message won't be. Every failed invariant should write to the log, - // not just the first one. std::array const finalizers{{std::get(checkers).finalize( tx, result, fee, *view_, journal)...}}; // NOLINT(bugprone-unchecked-optional-access) - // call each check's finalizer to see that it passes if (!std::all_of(finalizers.cbegin(), finalizers.cend(), [](auto const& b) { return b; })) { JLOG(journal.fatal()) << "Transaction has failed one or more global invariants: " diff --git a/src/libxrpl/tx/SignerEntries.cpp b/src/libxrpl/tx/SignerEntries.cpp index d425c86ca6..e97533e7d9 100644 --- a/src/libxrpl/tx/SignerEntries.cpp +++ b/src/libxrpl/tx/SignerEntries.cpp @@ -1,3 +1,10 @@ +/** @file + * Implementation of `SignerEntries::deserialize()` — the single extraction + * point for multi-signer co-signer lists from both transaction and ledger + * data. All business-logic validation (duplicate detection, quorum + * reachability, self-reference prohibition) is intentionally left to callers + * (`SignerListSet`, `XChainBridge`, `Transactor::checkMultiSign()`). + */ #include #include @@ -33,14 +40,12 @@ SignerEntries::deserialize(STObject const& obj, beast::Journal journal, std::str STArray const& sEntries(obj.getFieldArray(sfSignerEntries)); for (STObject const& sEntry : sEntries) { - // Validate the SignerEntry. if (sEntry.getFName() != sfSignerEntry) { JLOG(journal.trace()) << "Malformed " << annotation << ": Expected SignerEntry."; return Unexpected(temMALFORMED); } - // Extract SignerEntry fields. AccountID const account = sEntry.getAccountID(sfAccount); std::uint16_t const weight = sEntry.getFieldU16(sfSignerWeight); std::optional const tag = sEntry.at(~sfWalletLocator); diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index 6af781145c..5eacf31e20 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -52,7 +52,20 @@ namespace xrpl { -/** Performs early sanity checks on the txid */ +/** Gate every transaction on a minimal set of structural invariants. + * + * Checks that apply to all transaction types before any type-specific + * validation: pseudo-transaction / batch-inner exclusivity, network-ID + * presence rules for legacy vs. modern networks, a non-zero transaction + * ID, and the absence of unrecognised flag bits. + * + * @param ctx Preflight context carrying the transaction and network state. + * @param flagMask Bitmask of bits that must NOT be set in the transaction + * flags; bits set here are treated as invalid for this transaction type. + * @return `tesSUCCESS` if all checks pass; a `tel*` or `tem*` code otherwise. + * @note Pseudo-transactions are exempt from NetworkID checks unless they + * explicitly carry `sfNetworkID`. + */ NotTEC preflight0(PreflightContext const& ctx, std::uint32_t flagMask) { @@ -70,15 +83,15 @@ preflight0(PreflightContext const& ctx, std::uint32_t flagMask) if (nodeNID <= 1024) { - // legacy networks have ids less than 1024, these networks cannot - // specify NetworkID in txn + // Legacy networks (ID ≤ 1024) never carry sfNetworkID — its + // presence would make the transaction non-canonical on those nets. if (txNID) return telNETWORK_ID_MAKES_TX_NON_CANONICAL; } else { - // new networks both require the field to be present and require it - // to match + // Modern networks require sfNetworkID to be present and to match + // the local node's network ID, preventing cross-network replay. if (!txNID) return telREQUIRES_NETWORK_ID; @@ -107,9 +120,17 @@ preflight0(PreflightContext const& ctx, std::uint32_t flagMask) namespace detail { -/** Checks the validity of the transactor signing key. +/** Validate that `sfSigningPubKey`, when present, is a recognised key type. * - * Normally called from preflight1. + * An empty `sfSigningPubKey` is valid (multi-sign or simulation); a + * non-empty key that is not a recognised curve type (secp256k1 / Ed25519) + * is rejected immediately. + * + * @param sigObject The STObject that carries `sfSigningPubKey` (typically + * the transaction itself). + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` if the key is absent or has a valid type; + * `temBAD_SIGNATURE` otherwise. */ NotTEC preflightCheckSigningKey(STObject const& sigObject, beast::Journal j) @@ -123,6 +144,23 @@ preflightCheckSigningKey(STObject const& sigObject, beast::Journal j) return tesSUCCESS; } +/** Validate signing-key state for dry-run (simulation) transactions. + * + * Returns a result only when `TapDryRun` is set; returns `std::nullopt` + * for normal (non-simulated) transactions so the caller continues with + * regular validation. In simulation mode a transaction must carry no + * real signature: `sfTxnSignature` must be absent or empty, all entries + * in `sfSigners` must have no signature, and `sfSigningPubKey` must be + * empty (to avoid ambiguous single-sign + multi-sign state). + * + * @param flags Apply flags for this invocation; checked for `TapDryRun`. + * @param sigObject The STObject holding signing fields (typically the tx). + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` if simulation constraints are satisfied; `temINVALID` + * if they are violated; `std::nullopt` if `TapDryRun` is not set. + * @note The `temINVALID` branches are marked `LCOV_EXCL_LINE` because the + * `simulate` RPC validates these constraints before reaching preflight. + */ std::optional preflightCheckSimulateKeys(ApplyFlags flags, STObject const& sigObject, beast::Journal j) { @@ -165,7 +203,21 @@ preflightCheckSimulateKeys(ApplyFlags flags, STObject const& sigObject, beast::J } // namespace detail -/** Performs early sanity checks on the account and fee fields */ +/** Validate account identity, fee format, signing-key syntax, and ordering + * constraints common to all transaction types. + * + * Calls `preflight0` for transaction-ID / NetworkID / flag-mask checks, + * then adds: non-zero `sfAccount`, non-negative native `sfFee`, valid + * `sfSigningPubKey` format, and the mutual-exclusion rule between Tickets + * and `sfAccountTxnID`. Also validates `sfDelegate` field presence against + * the `featurePermissionDelegationV1_1` amendment. + * + * @param ctx Preflight context carrying the transaction and rules. + * @param flagMask Bitmask of disallowed flag bits, forwarded to `preflight0`. + * @return `tesSUCCESS` if all checks pass; a `tem*` or `tel*` error otherwise. + * @note Do not call this directly from a derived transactor's `preflight`. + * It is invoked automatically by `invokePreflight`. + */ NotTEC Transactor::preflight1(PreflightContext const& ctx, std::uint32_t flagMask) { @@ -188,7 +240,6 @@ Transactor::preflight1(PreflightContext const& ctx, std::uint32_t flagMask) return temBAD_SRC_ACCOUNT; } - // No point in going any further if the transaction fee is malformed. auto const fee = ctx.tx.getFieldAmount(sfFee); if (!fee.native() || fee.negative() || !isLegalAmount(fee.xrp())) { @@ -219,7 +270,22 @@ Transactor::preflight1(PreflightContext const& ctx, std::uint32_t flagMask) return tesSUCCESS; } -/** Checks whether the signature appears valid */ +/** Verify cryptographic signature validity using the hash-router cache. + * + * In simulation mode (`TapDryRun`), delegates to + * `detail::preflightCheckSimulateKeys` and returns early, skipping all + * subsequent checks. For `tfInnerBatchTxn` transactions the signature + * check is skipped entirely — the outer batch already provides + * authorization. For all other transactions, consults the hash router's + * cached `Validity` state; `SigBad` returns `temINVALID`. + * + * @param ctx Preflight context carrying the transaction, rules, and flags. + * @return `tesSUCCESS` if the signature is valid (or skipped legitimately); + * `temINVALID` if the cached validity is `SigBad` or simulation + * constraints are violated. + * @note Do not call this directly from a derived transactor's `preflight`. + * It is invoked automatically by `invokePreflight`. + */ NotTEC Transactor::preflight2(PreflightContext const& ctx) { @@ -286,6 +352,20 @@ Transactor::preflightSigValidated(PreflightContext const& ctx) return tesSUCCESS; } +/** Verify that the optional `sfDelegate` field authorises this transaction. + * + * If `sfDelegate` is absent the transaction is self-signed and no + * delegation check is needed. If present, a `DelegateObject` at + * `keylet::delegate(account, delegate)` must exist and must grant the + * delegate permission to submit this specific transaction type via + * `checkTxPermission`. + * + * @param view Read-only ledger view used to look up the delegate object. + * @param tx The transaction carrying the optional `sfDelegate` field. + * @return `tesSUCCESS` if delegation is not in use or the delegate is + * authorised; `terNO_DELEGATE_PERMISSION` if the delegate object is + * absent or the permission is not granted. + */ NotTEC Transactor::checkPermission(ReadView const& view, STTx const& tx) { @@ -302,38 +382,48 @@ Transactor::checkPermission(ReadView const& view, STTx const& tx) return checkTxPermission(sle, tx); } +/** Compute the base fee for a transaction before load scaling. + * + * The fee is `baseFee * (1 + signerCount)`: the network's base fee unit + * plus one additional unit for each entry in `sfSigners` (multi-sign). + * Derived transactors may override this for types that have a different + * cost model (e.g., `AccountDelete` charges an owner-reserve fee). + * + * @param view Read-only ledger view supplying the current `fees().base`. + * @param tx The transaction whose `sfSigners` array is inspected. + * @return The unscaled base fee in drops. + */ XRPAmount Transactor::calculateBaseFee(ReadView const& view, STTx const& tx) { - // Returns the fee in fee units. - - // The computation has two parts: - // * The base fee, which is the same for most transactions. - // * The additional cost of each multisignature on the transaction. XRPAmount const baseFee = view.fees().base; - // Each signer adds one more baseFee to the minimum required fee - // for the transaction. std::size_t const signerCount = tx.isFieldPresent(sfSigners) ? tx.getFieldArray(sfSigners).size() : 0; return baseFee + (signerCount * baseFee); } -// Returns the fee in fee units, not scaled for load. +/** Return the owner-reserve increment as a transaction fee (unscaled). + * + * Used by transaction types whose cost is proportional to the reserve + * rather than the base fee (e.g., `AccountDelete`, `AMMCreate`, + * `LedgerStateFix`). The current ledger's `fees().increment` is returned + * directly; load scaling is applied separately by `minimumFee`. + * + * @param view Read-only ledger view supplying `fees().increment`. + * @param tx The transaction (unused; present for the static interface + * convention shared with `calculateBaseFee`). + * @return The owner-reserve increment in drops. + * @note An assertion fires if `increment ≤ base * 100`, because the + * reserve-as-fee model breaks down when the reserve is not + * substantially larger than the base fee. + */ XRPAmount Transactor::calculateOwnerReserveFee(ReadView const& view, STTx const& tx) { - // Assumption: One reserve increment is typically much greater than one base - // fee. - // This check is in an assert so that it will come to the attention of - // developers if that assumption is not correct. If the owner reserve is not - // significantly larger than the base fee (or even worse, smaller), we will - // need to rethink charging an owner reserve as a transaction fee. - // TODO: This function is static, and I don't want to add more parameters. - // When it is finally refactored to be in a context that has access to the - // Application, include "app().getOverlay().networkID() > 2 ||" in the - // condition. + // The reserve must be significantly larger than the base fee; if it ever + // is not, charging an owner reserve as a tx fee needs to be reconsidered. XRPL_ASSERT( view.fees().increment > view.fees().base * 100, "xrpl::Transactor::calculateOwnerReserveFee : Owner reserve is " @@ -351,6 +441,26 @@ Transactor::minimumFee( return scaleFeeLoad(baseFee, registry.getFeeTrack(), fees, (flags & TapUnlimited) != 0u); } +/** Validate the transaction fee and the fee-payer's ability to cover it. + * + * For open-ledger transactions, confirms the fee meets the load-adjusted + * minimum (`minimumFee`). For all transactions, confirms the fee-payer's + * account balance is sufficient. + * + * Batch inner transactions (`TapBatch`) must carry a zero fee; any + * non-zero fee is rejected with `temBAD_FEE`. + * + * @param ctx Preclaim context with read-only ledger view and flags. + * @param baseFee The unscaled base fee returned by `calculateBaseFee`. + * @return `tesSUCCESS` if the fee is valid and the account can cover it; + * a `tel*`, `tec*`, or `ter*` code otherwise. + * @note Because preclaim evaluates against a static `ReadView`, it does not + * reflect fee deductions from other in-flight transactions from the same + * account within the current ledger. The balance check here may + * therefore pass optimistically; the `Transactor::reset` mechanism + * corrects any shortfall by clamping the actual fee to the remaining + * balance when the transaction is applied. + */ TER Transactor::checkFee(PreclaimContext const& ctx, XRPAmount baseFee) { @@ -394,13 +504,6 @@ Transactor::checkFee(PreclaimContext const& ctx, XRPAmount baseFee) auto const balance = (*sle)[sfBalance].xrp(); - // NOTE: Because preclaim evaluates against a static readview, it - // does not reflect fee deductions from other transactions paid by - // the same account within the current ledger. - // As a result, if an account's balance is over-committed across multiple - // transactions, this check may pass optimistically. - // The fee shortfall will be handled by the Transactor::reset mechanism, - // which caps the fee to the remaining actual balance. if (balance < feePaid) { JLOG(ctx.j.trace()) << "Insufficient balance:" << " balance=" << to_string(balance) @@ -418,6 +521,16 @@ Transactor::checkFee(PreclaimContext const& ctx, XRPAmount baseFee) return tesSUCCESS; } +/** Deduct the transaction fee from the fee-payer's account balance. + * + * Decrements `sfBalance` on the fee-payer's `AccountRoot` in the mutable + * view. If the fee-payer is the transaction account (`account_`), the SLE + * update is deferred to `apply()` to avoid a redundant write; otherwise + * `view().update` is called immediately. + * + * @return `tesSUCCESS` on success; `tefINTERNAL` (unreachable in practice) + * if the fee-payer account root is missing from the ledger. + */ TER Transactor::payFee() { @@ -428,16 +541,35 @@ Transactor::payFee() if (!sle) return tefINTERNAL; // LCOV_EXCL_LINE - // Deduct the fee, so it's not available during the transaction. - // Will only write the account back if the transaction succeeds. + // Deduct the fee so it is unavailable during the transaction body. + // The account SLE is written back in apply() when feePayer == account_. sle->setFieldAmount(sfBalance, sle->getFieldAmount(sfBalance) - feePaid); if (feePayer != account_) - view().update(sle); // done in `apply()` for the account + view().update(sle); // VFALCO Should we call view().rawDestroyXRP() here as well? return tesSUCCESS; } +/** Verify that the transaction's sequence or ticket is valid for the account. + * + * For sequence-based transactions, enforces strict monotonic ordering: + * the transaction sequence must equal the account's current sequence. + * Future sequences return `terPRE_SEQ` (retryable); past sequences + * return `tefPAST_SEQ` (permanently invalid). + * + * For ticket-based transactions, the ticket's numeric value must be + * strictly less than the account's current sequence (to rule out tickets + * that have not yet been created), and the ticket SLE must actually exist. + * + * @param view Read-only ledger view for account and ticket lookups. + * @param tx The transaction carrying `sfSequence` or `sfTicketSequence`. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` if the sequence or ticket is valid; + * `terNO_ACCOUNT` if the source account does not exist; + * `terPRE_SEQ` / `terPRE_TICKET` if retryable; + * `tefPAST_SEQ` / `tefNO_TICKET` / `temSEQ_AND_TICKET` otherwise. + */ NotTEC Transactor::checkSeqProxy(ReadView const& view, STTx const& tx, beast::Journal j) { @@ -503,6 +635,22 @@ Transactor::checkSeqProxy(ReadView const& view, STTx const& tx, beast::Journal j return tesSUCCESS; } +/** Verify ordering constraints: `sfAccountTxnID`, `sfLastLedgerSequence`, + * and duplicate-transaction detection. + * + * Checks three conditions in order: + * 1. If `sfAccountTxnID` is present, the account's recorded last-tx hash + * must match (enforcing a strict prior-transaction chain). + * 2. If `sfLastLedgerSequence` is present, the current ledger index must + * not exceed it (expiry check). + * 3. The transaction hash must not already exist in the current ledger + * (replay / duplicate prevention). + * + * @param ctx Preclaim context with read-only ledger view and transaction. + * @return `tesSUCCESS` if all ordering checks pass; + * `terNO_ACCOUNT` if the source account is missing; + * `tefWRONG_PRIOR` / `tefMAX_LEDGER` / `tefALREADY` otherwise. + */ NotTEC Transactor::checkPriorTxAndLastLedger(PreclaimContext const& ctx) { @@ -531,6 +679,18 @@ Transactor::checkPriorTxAndLastLedger(PreclaimContext const& ctx) return tesSUCCESS; } +/** Advance the account sequence or consume the referenced ticket. + * + * For sequence-based transactions, increments `sfSequence` to `seqProxy + 1`. + * For ticket-based transactions, calls `ticketDelete` to erase the ticket SLE, + * remove it from the owner directory, and adjust the owner count. + * + * @param sleAccount Mutable account root SLE; must not be null. + * @return `tesSUCCESS` on success; a `tef*` error if ticket deletion fails + * (which indicates ledger corruption — see `ticketDelete`). + * @note For `TicketCreate` transactions, `sfSequence` will be advanced a + * second time by the transactor body to allocate the ticket IDs. + */ TER Transactor::consumeSeqProxy(SLE::pointer const& sleAccount) { @@ -538,16 +698,30 @@ Transactor::consumeSeqProxy(SLE::pointer const& sleAccount) SeqProxy const seqProx = ctx_.tx.getSeqProxy(); if (seqProx.isSeq()) { - // Note that if this transaction is a TicketCreate, then - // the transaction will modify the account root sfSequence - // yet again. + // TicketCreate will advance sfSequence again during doApply to + // allocate the requested ticket IDs. sleAccount->setFieldU32(sfSequence, seqProx.value() + 1); return tesSUCCESS; } return ticketDelete(view(), account_, getTicketIndex(account_, seqProx), j_); } -// Remove a single Ticket from the ledger. +/** Remove a single Ticket from the ledger and release its owner reserve. + * + * Performs four coordinated mutations: + * 1. Removes the ticket SLE from the ledger. + * 2. Removes the ticket from the account's owner directory. + * 3. Decrements `sfTicketCount` on the account root, removing the field + * entirely when it reaches zero. + * 4. Calls `adjustOwnerCount` to release the ticket's reserve unit. + * + * @param view Mutable ledger view to apply changes against. + * @param account The account that owns the ticket. + * @param ticketIndex The ledger index of the ticket SLE to delete. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` on success; `tefBAD_LEDGER` if any expected SLE is + * missing (indicates ledger corruption; branches are `LCOV_EXCL`). + */ TER Transactor::ticketDelete( ApplyView& view, @@ -555,8 +729,6 @@ Transactor::ticketDelete( uint256 const& ticketIndex, beast::Journal j) { - // Delete the Ticket, adjust the account root ticket count, and - // reduce the owner count. SLE::pointer const sleTicket = view.peek(keylet::kTICKET(ticketIndex)); if (!sleTicket) { @@ -575,8 +747,6 @@ Transactor::ticketDelete( // LCOV_EXCL_STOP } - // Update the account root's TicketCount. If the ticket count drops to - // zero remove the (optional) field. auto sleAccount = view.peek(keylet::account(account)); if (!sleAccount) { @@ -605,32 +775,44 @@ Transactor::ticketDelete( // LCOV_EXCL_STOP } - // Update the Ticket owner's reserve. adjustOwnerCount(view, sleAccount, -1, j); - // Remove Ticket from ledger. view.erase(sleTicket); return tesSUCCESS; } -// check stuff before you bother to lock the ledger +/** Perform inexpensive pre-apply setup before the ledger is mutated. + * + * The base implementation just asserts that `account_` is non-zero. + * Derived transactors override this to cache fields or compute derived + * values they will need in `doApply`. + */ void Transactor::preCompute() { XRPL_ASSERT(account_ != beast::kZERO, "xrpl::Transactor::preCompute : nonzero account"); } +/** Orchestrate the mutable phase of transaction processing. + * + * Calls `preCompute()`, snapshots `preFeeBalance_` from the account's + * current balance, then in order: `consumeSeqProxy`, `payFee`, updates + * `sfAccountTxnID` if present, and finally calls `doApply`. + * + * @return The `TER` from `doApply`, or an earlier error from + * `consumeSeqProxy` / `payFee` if those fail. + * @note Reserve checks inside `doApply` should compare against + * `preFeeBalance_` (pre-fee snapshot), not the post-fee balance, + * to correctly allow an account to dip into its reserve to pay the fee. + */ TER Transactor::apply() { preCompute(); - // If the transactor requires a valid account and the transaction doesn't - // list one, preflight will have already a flagged a failure. auto const sle = view().peek(keylet::account(account_)); - // sle must exist except for transactions - // that allow zero account. + // sle must exist except for transactions that allow zero account. XRPL_ASSERT( sle != nullptr || account_ == beast::kZERO, "xrpl::Transactor::apply : non-null SLE or zero account"); @@ -656,6 +838,33 @@ Transactor::apply() return doApply(); } +/** Verify the signing authority for a single transaction or signer object. + * + * Dispatches to one of four paths: + * 1. **Pseudo-account guard** — rejects with `tefBAD_AUTH` when + * `featureLendingProtocol` is active and `idAccount` is a pseudo-account. + * 2. **Batch inner** — if `parentBatchId` is set and `featureBatch` is + * active, asserts that no key/signature/signer-list is present (the + * outer batch already authorised the inner transactions) and returns. + * 3. **Dry-run** — skips validation entirely when `TapDryRun` is set and + * no signing key or signer list is present. + * 4. **Multi-sign** — if `sfSigners` is present, delegates to + * `checkMultiSign`. + * 5. **Single-sign** — derives the signer account from the public key and + * delegates to `checkSingleSign`. + * + * @param view Read-only ledger view for account lookups. + * @param flags Apply flags (checked for `TapDryRun`). + * @param parentBatchId Set when this is an inner batch transaction. + * @param idAccount The account whose signing authority is being checked + * (may be the delegate account when `sfDelegate` is present). + * @param sigObject The STObject carrying signing fields (`sfSigningPubKey`, + * `sfTxnSignature`, `sfSigners`). + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` if the signing authority is valid; + * `tefBAD_AUTH`, `tefMASTER_DISABLED`, `tefNOT_MULTI_SIGNING`, + * `tefBAD_QUORUM`, `temINVALID_FLAG`, or `terNO_ACCOUNT` otherwise. + */ NotTEC Transactor::checkSign( ReadView const& view, @@ -678,10 +887,9 @@ Transactor::checkSign( } auto const pkSigner = sigObject.getFieldVL(sfSigningPubKey); - // Ignore signature check on batch inner transactions if (parentBatchId && view.rules().enabled(featureBatch)) { - // Defensive Check: These values are also checked in Batch::preflight + // Defensive: also checked in Batch::preflight, but guard here too. if (sigObject.isFieldPresent(sfTxnSignature) || !pkSigner.empty() || sigObject.isFieldPresent(sfSigners)) { @@ -692,19 +900,14 @@ Transactor::checkSign( if (((flags & TapDryRun) != 0u) && pkSigner.empty() && !sigObject.isFieldPresent(sfSigners)) { - // simulate: skip signature validation when neither SigningPubKey nor - // Signers are provided return tesSUCCESS; } - // If the pk is empty and not simulate or simulate and signers, - // then we must be multi-signing. if (sigObject.isFieldPresent(sfSigners)) { return checkMultiSign(view, flags, idAccount, sigObject, j); } - // Check Single Sign XRPL_ASSERT(!pkSigner.empty(), "xrpl::Transactor::checkSign : non-empty signer"); if (!publicKeyType(makeSlice(pkSigner))) @@ -722,6 +925,14 @@ Transactor::checkSign( return checkSingleSign(view, idSigner, idAccount, sleAccount, j); } +/** Convenience overload that extracts signing context from a `PreclaimContext`. + * + * When `sfDelegate` is present, the signing identity is the delegate account; + * otherwise it is `sfAccount`. Forwards to the full `checkSign` overload. + * + * @param ctx Preclaim context carrying the view, flags, and transaction. + * @return The result of the underlying `checkSign` call. + */ NotTEC Transactor::checkSign(PreclaimContext const& ctx) { @@ -730,6 +941,20 @@ Transactor::checkSign(PreclaimContext const& ctx) return checkSign(ctx.view, ctx.flags, ctx.parentBatchId, idAccount, ctx.tx, ctx.j); } +/** Verify the `sfBatchSigners` array on the outer Batch transaction. + * + * Iterates each entry in `sfBatchSigners`. An entry with an empty + * `sfSigningPubKey` is validated as multi-sign; an entry with a non-empty + * key is validated as single-sign. A special case allows a signer whose + * account does not yet exist in the ledger, provided the signing key + * derives to that account (master key) — this permits a Batch to fund an + * account creation as part of the same bundle. + * + * @param ctx Preclaim context with read-only view, flags, and transaction. + * @return `tesSUCCESS` if every batch signer is valid; + * `tefBAD_AUTH`, `tefMASTER_DISABLED`, `tefNOT_MULTI_SIGNING`, or + * `tefBAD_QUORUM` if any signer fails validation. + */ NotTEC Transactor::checkBatchSign(PreclaimContext const& ctx) { @@ -774,6 +999,22 @@ Transactor::checkBatchSign(PreclaimContext const& ctx) return ret; } +/** Verify that a single-signature key is authorised for `idAccount`. + * + * Applies three precedence rules in order: + * 1. Regular key: if `sfRegularKey` is set and equals `idSigner`, accept. + * 2. Enabled master key: if master is not disabled and `idAccount == idSigner`, accept. + * 3. Disabled master key: if master is disabled and `idAccount == idSigner`, reject with + * `tefMASTER_DISABLED`. + * Any other key returns `tefBAD_AUTH`. + * + * @param view Read-only ledger view (unused after caller reads `sleAccount`). + * @param idSigner AccountID derived from the transaction's signing public key. + * @param idAccount The account whose authority is being checked. + * @param sleAccount The account root SLE for `idAccount`. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS`, `tefMASTER_DISABLED`, or `tefBAD_AUTH`. + */ NotTEC Transactor::checkSingleSign( ReadView const& view, @@ -784,28 +1025,43 @@ Transactor::checkSingleSign( { bool const isMasterDisabled = sleAccount->isFlag(lsfDisableMaster); - // Signed with regular key. if ((*sleAccount)[~sfRegularKey] == idSigner) - { return tesSUCCESS; - } - // Signed with enabled master key. if (!isMasterDisabled && idAccount == idSigner) - { return tesSUCCESS; - } - // Signed with disabled master key. if (isMasterDisabled && idAccount == idSigner) - { return tefMASTER_DISABLED; - } - // Signed with any other key. return tefBAD_AUTH; } +/** Verify a multi-signature against the account's registered signer list. + * + * Performs a linear merge of the sorted `sfSigners` array from `sigObject` + * against the account's sorted `SignerEntry` list, validating each signer + * under three rules (established January 2015): + * - **Phantom account**: `idSigner == txSignerAcctID` and no account root + * in the ledger — always accepted. + * - **Master key**: `idSigner == txSignerAcctID` and account root present + * — accepted unless `lsfDisableMaster` is set. + * - **Regular key**: `idSigner != txSignerAcctID` and account root present + * — accepted only if `idSigner == sfRegularKey` on the account root. + * + * Accumulates weights; fails with `tefBAD_QUORUM` if the total falls below + * `sfSignerQuorum`. All signers must be valid — the first invalid entry + * short-circuits with a `tef*` error. + * + * @param view Read-only ledger view for account and signer-list lookups. + * @param flags Apply flags (checked for `TapDryRun` simulation mode). + * @param id The account whose `SignerList` is consulted. + * @param sigObject The STObject carrying the `sfSigners` array. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` if quorum is met and all signers are valid; + * `tefNOT_MULTI_SIGNING` if no signer list exists; + * `tefBAD_SIGNATURE`, `tefMASTER_DISABLED`, or `tefBAD_QUORUM` otherwise. + */ NotTEC Transactor::checkMultiSign( ReadView const& view, @@ -814,17 +1070,14 @@ Transactor::checkMultiSign( STObject const& sigObject, beast::Journal const j) { - // Get id's SignerList and Quorum. std::shared_ptr const sleAccountSigners = view.read(keylet::signers(id)); - // If the signer list doesn't exist the account is not multi-signing. if (!sleAccountSigners) { JLOG(j.trace()) << "applyTransaction: Invalid: Not a multi-signing account."; return tefNOT_MULTI_SIGNING; } - // We have plans to support multiple SignerLists in the future. The - // presence and defaulted value of the SignerListID field will enable that. + // SignerListID == 0 is reserved for future multi-list support. XRPL_ASSERT( sleAccountSigners->isFieldPresent(sfSignerListID), "xrpl::Transactor::checkMultiSign : has signer list ID"); @@ -836,22 +1089,14 @@ Transactor::checkMultiSign( if (!accountSigners) return accountSigners.error(); - // Get the array of transaction signers. STArray const& txSigners(sigObject.getFieldArray(sfSigners)); - // Walk the accountSigners performing a variety of checks and see if - // the quorum is met. - - // Both the multiSigners and accountSigners are sorted by account. So - // matching multi-signers to account signers should be a simple - // linear walk. *All* signers must be valid or the transaction fails. std::uint32_t weightSum = 0; auto iter = accountSigners->begin(); for (auto const& txSigner : txSigners) { AccountID const txSignerAcctID = txSigner.getAccountID(sfAccount); - // Attempt to match the SignerEntry with a Signer; while (iter->account < txSignerAcctID) { if (++iter == accountSigners->end()) @@ -862,18 +1107,13 @@ Transactor::checkMultiSign( } if (iter->account != txSignerAcctID) { - // The SigningAccount is not in the SignerEntries. JLOG(j.trace()) << "applyTransaction: Invalid SigningAccount.Account."; return tefBAD_SIGNATURE; } - // We found the SigningAccount in the list of valid signers. Now we - // need to compute the accountID that is associated with the signer's - // public key. auto const spk = txSigner.getFieldVL(sfSigningPubKey); - // spk being non-empty in non-simulate is checked in - // STTx::checkMultiSign + // spk non-empty in non-simulate mode is enforced by STTx::checkMultiSign. if (!spk.empty() && !publicKeyType(makeSlice(spk))) { JLOG(j.trace()) << "checkMultiSign: signing public key type is unknown"; @@ -887,39 +1127,13 @@ Transactor::checkMultiSign( AccountID const signingAcctIDFromPubKey = spk.empty() ? txSignerAcctID : calcAccountID(PublicKey(makeSlice(spk))); - // Verify that the signingAcctID and the signingAcctIDFromPubKey - // belong together. Here are the rules: - // - // 1. "Phantom account": an account that is not in the ledger - // A. If signingAcctID == signingAcctIDFromPubKey and the - // signingAcctID is not in the ledger then we have a phantom - // account. - // B. Phantom accounts are always allowed as multi-signers. - // - // 2. "Master Key" - // A. signingAcctID == signingAcctIDFromPubKey, and signingAcctID - // is in the ledger. - // B. If the signingAcctID in the ledger does not have the - // asfDisableMaster flag set, then the signature is allowed. - // - // 3. "Regular Key" - // A. signingAcctID != signingAcctIDFromPubKey, and signingAcctID - // is in the ledger. - // B. If signingAcctIDFromPubKey == signingAcctID.RegularKey (from - // ledger) then the signature is allowed. - // - // No other signatures are allowed. (January 2015) - - // In any of these cases we need to know whether the account is in - // the ledger. Determine that now. auto const sleTxSignerRoot = view.read(keylet::account(txSignerAcctID)); if (signingAcctIDFromPubKey == txSignerAcctID) { - // Either Phantom or Master. Phantoms automatically pass. + // Phantom (no account root) or Master Key. Phantoms pass automatically. if (sleTxSignerRoot) { - // Master Key. Account may not have asfDisableMaster set. std::uint32_t const signerAccountFlags = sleTxSignerRoot->getFieldU32(sfFlags); if ((signerAccountFlags & lsfDisableMaster) != 0u) @@ -931,8 +1145,7 @@ Transactor::checkMultiSign( } else { - // May be a Regular Key. Let's find out. - // Public key must hash to the account's regular key. + // Regular Key: public key must hash to the account's sfRegularKey. if (!sleTxSignerRoot) { JLOG(j.trace()) << "applyTransaction: Non-phantom signer " @@ -951,23 +1164,31 @@ Transactor::checkMultiSign( return tefBAD_SIGNATURE; } } - // The signer is legitimate. Add their weight toward the quorum. weightSum += iter->weight; } - // Cannot perform transaction if quorum is not met. if (weightSum < sleAccountSigners->getFieldU32(sfSignerQuorum)) { JLOG(j.trace()) << "applyTransaction: Signers failed to meet quorum."; return tefBAD_QUORUM; } - // Met the quorum. Continue. return tesSUCCESS; } //------------------------------------------------------------------------------ +/** Delete up to `kUNFUNDED_OFFER_REMOVE_LIMIT` unfunded offers from the ledger. + * + * Called after a `tecOVERSIZE` or `tecKILLED` failure to clean up offers + * that were identified as unfunded during the failed transaction's execution. + * Offer deletion is capped to avoid unbounded ledger growth from a single + * failed transaction. + * + * @param view Mutable ledger view to apply deletions against. + * @param offers Indices of offers to attempt to delete. + * @param viewJ Journal for logging inside `offerDelete`. + */ static void removeUnfundedOffers(ApplyView& view, std::vector const& offers, beast::Journal viewJ) { @@ -985,6 +1206,15 @@ removeUnfundedOffers(ApplyView& view, std::vector const& offers, beast: } } +/** Delete up to `kEXPIRED_OFFER_REMOVE_LIMIT` expired NFToken offers. + * + * Called after a `tecEXPIRED` failure to remove NFToken offers that were + * found to have expired during the failed transaction's execution. + * + * @param view Mutable ledger view to apply deletions against. + * @param offers Indices of NFToken offer SLEs to attempt to delete. + * @param viewJ Journal for logging inside `nft::deleteTokenOffer`. + */ static void removeExpiredNFTokenOffers( ApplyView& view, @@ -1004,6 +1234,16 @@ removeExpiredNFTokenOffers( } } +/** Delete all expired credential SLEs collected during a failed transaction. + * + * Called after a `tecEXPIRED` failure. Unlike the offer-removal helpers + * there is no cap on the number of credentials removed; any deletion + * failure is logged but does not abort the loop. + * + * @param view Mutable ledger view to apply deletions against. + * @param creds Indices of credential SLEs to attempt to delete. + * @param viewJ Journal for error logging on deletion failures. + */ static void removeExpiredCredentials(ApplyView& view, std::vector const& creds, beast::Journal viewJ) { @@ -1021,6 +1261,16 @@ removeExpiredCredentials(ApplyView& view, std::vector const& creds, bea } } +/** Delete obsolete AMM trust lines collected during a `tecINCOMPLETE` failure. + * + * Called to clean up `ltRIPPLE_STATE` entries that were deleted during the + * failed transaction's execution but must still be removed from the ledger. + * Aborts without deletion if the count exceeds `kMAX_DELETABLE_AMM_TRUST_LINES`. + * + * @param view Mutable ledger view to apply deletions against. + * @param trustLines Indices of trust-line SLEs to attempt to delete. + * @param viewJ Journal for error logging on deletion failures. + */ static void removeDeletedTrustLines( ApplyView& view, @@ -1044,10 +1294,21 @@ removeDeletedTrustLines( } } +/** Delete obsolete AMM MPToken holders collected during a `tecINCOMPLETE` failure. + * + * Called alongside `removeDeletedTrustLines` to clean up `ltMPTOKEN` entries + * for each side of an AMM pool. At most two MPTs can be present (one per + * pool asset); a count exceeding two indicates an unexpected state and + * is logged as an error without applying any deletions. + * + * @param view Mutable ledger view to apply deletions against. + * @param mpts Indices of MPToken SLEs to attempt to delete. + * @param viewJ Journal for error logging on deletion failures. + */ static void removeDeletedMPTs(ApplyView& view, std::vector const& mpts, beast::Journal viewJ) { - // There could be at most two MPTs - one for each side of AMM pool + // At most two MPTs — one per side of the AMM pool. if (mpts.size() > 2) { JLOG(viewJ.error()) << "removeDeletedMPTs: deleted mpts exceed 2 " << mpts.size(); @@ -1064,10 +1325,24 @@ removeDeletedMPTs(ApplyView& view, std::vector const& mpts, beast::Jour } } -/** Reset the context, discarding any changes made and adjust the fee. - - @param fee The transaction fee to be charged. - @return A pair containing the transaction result and the actual fee charged. +/** Discard all ledger mutations and re-apply only the fee and sequence. + * + * Calls `ctx_.discard()` to roll back every change made during `doApply`, + * then re-deducts the fee from the fee-payer and re-consumes the sequence + * or ticket. If the fee exceeds the payer's current balance (possible + * when multiple in-flight transactions over-commit the same account), the + * fee is clamped to the remaining balance. + * + * This is the mechanism that ensures a failing `tec*` transaction always + * charges its fee even when the account's balance was over-committed by + * other transactions evaluated against the same static `ReadView`. + * + * @param fee The fee that should be charged; may be clamped downward. + * @return A pair `{TER, actualFee}`. `TER` is `tesSUCCESS` on success or + * `tefINTERNAL` if the account or fee-payer SLE is unexpectedly absent. + * `actualFee` is the fee after any clamping. + * @note The fee is clamped only downward; it is never raised above what the + * transaction declared in `sfFee`. */ std::pair Transactor::reset(XRPAmount fee) @@ -1076,8 +1351,6 @@ Transactor::reset(XRPAmount fee) auto const txnAcct = view().peek(keylet::account(ctx_.tx.getAccountID(sfAccount))); - // The account should never be missing from the ledger. But if it - // is missing then we can't very well charge it a fee, can we? if (!txnAcct) return {tefINTERNAL, beast::kZERO}; @@ -1087,23 +1360,16 @@ Transactor::reset(XRPAmount fee) auto const balance = payerSle->getFieldAmount(sfBalance).xrp(); - // balance should have already been checked in checkFee / preFlight. + // balance should have already been checked in checkFee / preflight. XRPL_ASSERT( balance != beast::kZERO && (!view().open() || balance >= fee), "xrpl::Transactor::reset : valid balance"); - // We retry/reject the transaction if the account balance is zero or - // we're applying against an open ledger and the balance is less than - // the fee if (fee > balance) fee = balance; - // Since we reset the context, we need to charge the fee and update - // the account's sequence number (or consume the Ticket) again. - // - // If for some reason we are unable to consume the ticket or sequence - // then the ledger is corrupted. Rather than make things worse we - // reject the transaction. + // Re-deduct the fee and re-consume the sequence/ticket after discard. + // Failure here means ledger corruption — reject rather than making it worse. payerSle->setFieldAmount(sfBalance, balance - fee); TER const ter{consumeSeqProxy(txnAcct)}; XRPL_ASSERT(isTesSuccess(ter), "xrpl::Transactor::reset : result is tesSUCCESS"); @@ -1118,26 +1384,45 @@ Transactor::reset(XRPAmount fee) return {ter, fee}; } -// The sole purpose of this function is to provide a convenient, named -// location to set a breakpoint, to be used when replaying transactions. +/** Named breakpoint location for replaying specific transactions in a debugger. + * + * Does nothing except log the hash at debug level. To replay a specific + * transaction, set a breakpoint here and configure the node's trap-tx hash + * via the `ServiceRegistry`. + * + * @param txHash The hash of the trapped transaction, for logging. + */ void Transactor::trapTransaction(uint256 txHash) const { JLOG(j_.debug()) << "Transaction trapped: " << txHash; } +/** Run transaction-specific invariants over every modified ledger entry. + * + * Two-phase execution: + * 1. Visits every SLE modified by this transaction via `visitInvariantEntry`. + * 2. Calls `finalizeInvariants` on the derived transactor to evaluate + * accumulated state. + * + * Any exception during either phase is caught and converted to + * `tecINVARIANT_FAILED` to maintain determinism across validators. + * + * @param result Tentative TER from transaction processing. + * @param fee Fee consumed by the transaction. + * @return The original `result` if all invariants pass; + * `tecINVARIANT_FAILED` otherwise. + */ [[nodiscard]] TER Transactor::checkTransactionInvariants(TER result, XRPAmount fee) { try { - // Phase 1: visit modified entries ctx_.visit( [this](uint256 const&, bool isDelete, SLE::const_ref before, SLE::const_ref after) { this->visitInvariantEntry(isDelete, before, after); }); - // Phase 2: finalize if (!this->finalizeInvariants(ctx_.tx, result, fee, ctx_.view(), ctx_.journal)) { JLOG(ctx_.journal.fatal()) << // @@ -1160,18 +1445,28 @@ Transactor::checkTransactionInvariants(TER result, XRPAmount fee) return result; } +/** Run both transaction-specific and protocol-level invariants. + * + * Calls `checkTransactionInvariants` first (more specific — if these fail, + * the transaction's core logic is wrong), then `ctx_.checkInvariants` + * (broader protocol properties that must hold for any transaction). + * + * Both layers always run; neither short-circuits the other. If both + * fail, `tefINVARIANT_FAILED` (the more severe code) is returned. + * + * @param result Tentative TER from transaction processing. + * @param fee Fee consumed by the transaction. + * @return The original `result` if all invariants pass; + * `tecINVARIANT_FAILED` if either layer fails; + * `tefINVARIANT_FAILED` if the protocol layer returns that code. + */ [[nodiscard]] TER Transactor::checkInvariants(TER result, XRPAmount fee) { - // Transaction invariants first (more specific). These check post-conditions of the specific - // transaction. If these fail, the transaction's core logic is wrong. auto const txResult = checkTransactionInvariants(result, fee); - - // Protocol invariants second (broader). These check properties that must hold regardless of - // transaction type. auto const protoResult = ctx_.checkInvariants(result, fee); - // Fail if either check failed. tef (fatal) takes priority over tec. + // tefINVARIANT_FAILED is more severe than tec; it prevents ledger inclusion. if (protoResult == tefINVARIANT_FAILED) return tefINVARIANT_FAILED; if (txResult == tecINVARIANT_FAILED || protoResult == tecINVARIANT_FAILED) @@ -1179,18 +1474,39 @@ Transactor::checkInvariants(TER result, XRPAmount fee) return result; } + //------------------------------------------------------------------------------ + +/** Execute the full apply phase and return a result with optional metadata. + * + * Entry point for phase 3 of the transaction pipeline. Orchestrates: + * 1. RAII guards for numeric arithmetic rules (`NumberSO`, + * `CurrentTransactionRulesGuard`). + * 2. Debug-only serdes round-trip check to catch serialisation mismatches. + * 3. Optional `trapTransaction` breakpoint for replay debugging. + * 4. Calls `apply()` when `ctx_.preclaimResult` is `tesSUCCESS`. + * 5. Enforces `tecOVERSIZE` when metadata exceeds `kOVERSIZE_META_DATA_CAP`. + * 6. For `tapFAIL_HARD` + `tec*`: discards immediately, nothing is applied. + * 7. For `tecOVERSIZE`, `tecKILLED`, `tecINCOMPLETE`, `tecEXPIRED`: visits + * the context diff to collect deleted objects, calls `reset()`, then runs + * the appropriate targeted cleanup helpers. + * 8. Runs `checkInvariants`; on failure, resets to fee-only and re-checks + * protocol invariants. + * 9. For `tapDRY_RUN`, forces `applied = false` unconditionally. + * + * @return An `ApplyResult` containing the final `TER`, whether the + * transaction was applied, and optional `TxMeta`. + * @note `applied = true` means the context was committed via `ctx_.apply()`. + * Dry-run mode computes all mutations but never commits them. + */ ApplyResult Transactor::operator()() { JLOG(j_.trace()) << "apply: " << ctx_.tx.getTransactionID(); - // These global updates really should have been for every Transaction - // step: preflight, preclaim, and doApply. And even calculateBaseFee. See - // with_txn_type(). - // - // raii classes for the current ledger rules. - // fixUniversalNumber predate the rulesGuard and should be replaced. + // NumberSO and CurrentTransactionRulesGuard ideally should apply for every + // pipeline phase (preflight/preclaim/doApply); they were retrofitted here. + // See with_txn_type() for the full story. NumberSO const stNumberSO{view().rules().enabled(fixUniversalNumber)}; CurrentTransactionRulesGuard const currentTransactionRulesGuard(view().rules()); @@ -1238,8 +1554,7 @@ Transactor::operator()() if (isTecClaim(result) && ((view().flags() & TapFailHard) != 0u)) { - // If the tapFAIL_HARD flag is set, a tec result - // must not do anything + // tapFAIL_HARD: discard all mutations; fee is not charged. ctx_.discard(); applied = false; } @@ -1249,10 +1564,6 @@ Transactor::operator()() { JLOG(j_.trace()) << "reapplying because of " << transToken(result); - // FIXME: This mechanism for doing work while returning a `tec` is - // awkward and very limiting. A more general purpose approach - // should be used, making it possible to do more useful work - // when transactions fail with a `tec` code. std::vector removedOffers; std::vector removedTrustLines; std::vector removedMPTs; @@ -1314,7 +1625,6 @@ Transactor::operator()() }); } - // Reset the context, potentially adjusting the fee. { auto const resetResult = reset(fee); if (!isTesSuccess(resetResult.first)) @@ -1323,7 +1633,6 @@ Transactor::operator()() fee = resetResult.second; } - // If necessary, remove any offers found unfunded during processing if ((result == tecOVERSIZE) || (result == tecKILLED)) { removeUnfundedOffers(view(), removedOffers, ctx_.registry.get().getJournal("View")); @@ -1353,28 +1662,24 @@ Transactor::operator()() if (applied) { - // Check invariants: if `tecINVARIANT_FAILED` is not returned, we can - // proceed to apply the tx result = checkInvariants(result, fee); if (result == tecINVARIANT_FAILED) { - // Reset to fee-claim only + // Invariants failed: reset to fee-only claim and re-check protocol invariants. auto const resetResult = reset(fee); if (!isTesSuccess(resetResult.first)) result = resetResult.first; fee = resetResult.second; - // Check invariants again to ensure the fee claiming doesn't violate - // invariants. After reset, only protocol invariants are re-checked. - // Transaction invariants are not meaningful here — the transaction's - // effects have been rolled back. + // After reset, only protocol invariants are re-checked; transaction + // invariants are not meaningful once effects have been rolled back. if (isTesSuccess(result) || isTecClaim(result)) result = ctx_.checkInvariants(result, fee); } - // We ran through the invariant checker, which can, in some cases, - // return a tef error code. Don't apply the transaction in that case. + // A tef* from the invariant checker means the transaction cannot be + // applied at all — not even as a fee claim. if (!isTecClaim(result) && !isTesSuccess(result)) applied = false; } @@ -1382,23 +1687,17 @@ Transactor::operator()() std::optional metadata; if (applied) { - // Transaction succeeded fully or (retries are not allowed and the - // transaction could claim a fee) - - // The transactor and invariant checkers guarantee that this will - // *never* trigger but if it, somehow, happens, don't allow a tx - // that charges a negative fee. + // The transactor and invariant checkers guarantee this never triggers, + // but guard against it anyway — a negative fee must never be committed. if (fee < beast::kZERO) Throw("fee charged is negative!"); - // Charge whatever fee they specified. The fee has already been - // deducted from the balance of the account that issued the - // transaction. We just need to account for it in the ledger - // header. + // Burn the fee in the closed ledger header (already deducted from the + // account balance above; this records it as destroyed XRP). if (!view().open() && fee != beast::kZERO) ctx_.destroyXRP(fee); - // Once we call apply, we will no longer be able to look at view() + // ctx_.apply() commits the view to base_; view() is invalid after this. metadata = ctx_.apply(result); } diff --git a/src/libxrpl/tx/apply.cpp b/src/libxrpl/tx/apply.cpp index 228d15778f..58d3729032 100644 --- a/src/libxrpl/tx/apply.cpp +++ b/src/libxrpl/tx/apply.cpp @@ -1,3 +1,13 @@ +/** @file + * Top-level transaction application coordinator for the XRP Ledger. + * + * Bridges the network-level validity cache (`HashRouter`) with the + * stateless-then-stateful application pipeline defined in `applySteps.cpp`. + * Where `applySteps.cpp` contains the mechanics of each pipeline stage + * (`preflight → preclaim → doApply`), this file decides *when* to run them, + * how to short-circuit redundant work via the hash-router cache, and how to + * handle the multi-transaction semantics of the Batch feature. + */ #include #include @@ -25,12 +35,16 @@ namespace xrpl { -// These are the same flags defined as HashRouterFlags::PRIVATE1-4 in -// HashRouter.h -constexpr HashRouterFlags kSF_SIGBAD = HashRouterFlags::PRIVATE1; // Signature is bad -constexpr HashRouterFlags kSF_SIGGOOD = HashRouterFlags::PRIVATE2; // Signature is good -constexpr HashRouterFlags kSF_LOCALBAD = HashRouterFlags::PRIVATE3; // Local checks failed -constexpr HashRouterFlags kSF_LOCALGOOD = HashRouterFlags::PRIVATE4; // Local checks passed +// --- HashRouter private flag aliases (PRIVATE1–PRIVATE4) --- + +/** Cached flag: transaction signature failed verification. */ +constexpr HashRouterFlags kSF_SIGBAD = HashRouterFlags::PRIVATE1; +/** Cached flag: transaction signature passed verification. */ +constexpr HashRouterFlags kSF_SIGGOOD = HashRouterFlags::PRIVATE2; +/** Cached flag: local well-formedness checks failed (`passesLocalChecks`). */ +constexpr HashRouterFlags kSF_LOCALBAD = HashRouterFlags::PRIVATE3; +/** Cached flag: local well-formedness checks passed (`passesLocalChecks`). */ +constexpr HashRouterFlags kSF_LOCALGOOD = HashRouterFlags::PRIVATE4; //------------------------------------------------------------------------------ @@ -67,10 +81,7 @@ checkValidity(HashRouter& router, STTx const& tx, Rules const& rules) } if (any(flags & kSF_SIGBAD)) - { - // Signature is known bad return {Validity::SigBad, "Transaction has bad signature."}; - } if (!any(flags & kSF_SIGGOOD)) { @@ -83,22 +94,13 @@ checkValidity(HashRouter& router, STTx const& tx, Rules const& rules) router.setFlags(id, kSF_SIGGOOD); } - // Signature is now known good + // Signature is now known good. if (any(flags & kSF_LOCALBAD)) - { - // ...but the local checks - // are known bad. return {Validity::SigGoodOnly, "Local checks failed."}; - } if (any(flags & kSF_LOCALGOOD)) - { - // ...and the local checks - // are known good. return {Validity::Valid, ""}; - } - // Do the local checks std::string reason; if (!passesLocalChecks(tx, reason)) { @@ -122,13 +124,28 @@ forceValidity(HashRouter& router, uint256 const& txid, Validity validity) flags |= kSF_SIGGOOD; [[fallthrough]]; case Validity::SigBad: - // would be silly to call directly + // No flag to set: calling forceValidity with SigBad is intentionally a no-op. break; } if (any(flags)) router.setFlags(txid, flags); } +/** Core apply implementation: invoke a preflight callable, then run preclaim and doApply. + * + * Accepting a callable rather than a `PreflightResult` keeps the sequencing + * enforced: preflight results can be produced on a different thread (they hold + * no ledger references), but preclaim and doApply must run together against + * the same view. The `NumberSO` RAII guard is installed here — before preclaim + * — so the correct fixed-point arithmetic mode is active for the entire + * post-preflight pipeline. + * + * @tparam PreflightChecks Callable returning a `PreflightResult`. + * @param registry The service registry providing transactor implementations. + * @param view The open ledger view for preclaim and doApply. + * @param preflightChecks Callable that produces the `PreflightResult`. + * @return An `ApplyResult` with the TER code and whether mutations were committed. + */ template ApplyResult apply(ServiceRegistry& registry, OpenView& view, PreflightChecks&& preflightChecks) @@ -144,6 +161,20 @@ apply(ServiceRegistry& registry, OpenView& view, STTx const& tx, ApplyFlags flag registry, view, [&]() mutable { return preflight(registry, view.rules(), tx, flags, j); }); } +/** Apply a batch inner transaction, supplying the enclosing batch's ID. + * + * The `parentBatchId` is threaded into the preflight context so the transactor + * can enforce inner-batch signing rules (no `sfTxnSignature`, empty + * `sfSigningPubKey`). The `TapBatch` flag should be set in `flags`. + * + * @param registry The service registry providing transactor implementations. + * @param view The per-transaction batch sub-view created by `applyBatchTransactions`. + * @param parentBatchId Transaction ID of the enclosing `ttBATCH` transaction. + * @param tx The inner transaction to apply. + * @param flags Apply flags; must include `TapBatch`. + * @param j Logging sink. + * @return An `ApplyResult` with the TER code and whether mutations were committed. + */ ApplyResult apply( ServiceRegistry& registry, @@ -158,6 +189,29 @@ apply( }); } +/** Execute the inner transactions of a committed `ttBATCH` transaction. + * + * The outer batch transaction must already be applied before this is called; + * its fee and sequence have been consumed. Inner transactions run against a + * two-level view stack: `batchView` (all-or-nothing relative to the caller's + * outer view) wraps a per-transaction `perTxBatchView`. If an inner transaction + * is applied, its changes are promoted from `perTxBatchView` to `batchView`. + * + * Execution policy is read from the batch transaction's flags: + * - `tfAllOrNothing`: any failure causes immediate return of `false`. + * - `tfUntilFailure`: iteration stops at the first failure; prior successes kept. + * - `tfOnlyOne`: stops after the first success; subsequent transactions skipped. + * - `tfIndependent` (no special flag): all inner transactions run independently. + * + * @param registry The service registry providing transactor implementations. + * @param batchView The whole-batch `OpenView` wrapping the outer ledger view. + * The caller promotes this to the outer view only if the function returns + * `true`. + * @param batchTxn The outer `ttBATCH` transaction containing `sfRawTransactions`. + * @param j Logging sink; each inner result is logged with a `BatchTrace` prefix. + * @return `true` if at least one inner transaction was applied and the batch + * execution policy allows the aggregate to be committed; `false` otherwise. + */ static bool applyBatchTransactions( ServiceRegistry& registry, @@ -183,8 +237,6 @@ applyBatchTransactions( JLOG(j.debug()) << "BatchTrace[" << parentBatchId << "]: " << tx.getTransactionID() << " " << (ret.applied ? "applied" : "failure") << ": " << transToken(ret.ter); - // If the transaction should be applied push its changes to the - // whole-batch view. if (ret.applied && (isTesSuccess(ret.ter) || isTecClaim(ret.ter))) perTxBatchView.apply(batchView); @@ -229,7 +281,6 @@ applyTransaction( ApplyFlags flags, beast::Journal j) { - // Returns false if the transaction has need not be retried. if (retryAssured) flags = flags | TapRetry; @@ -243,8 +294,6 @@ applyTransaction( { JLOG(j.debug()) << "Transaction applied: " << transToken(result.ter); - // The batch transaction was just applied; now we need to apply - // its inner transactions as necessary. if (isTesSuccess(result.ter) && txn.getTxnType() == ttBATCH) { OpenView wholeBatchView(kBATCH_VIEW, view); @@ -258,7 +307,6 @@ applyTransaction( if (isTefFailure(result.ter) || isTemMalformed(result.ter) || isTelLocal(result.ter)) { - // failure JLOG(j.debug()) << "Transaction failure: " << transHuman(result.ter); return ApplyTransactionResult::Fail; } diff --git a/src/libxrpl/tx/applySteps.cpp b/src/libxrpl/tx/applySteps.cpp index 99b3425275..ad33f67547 100644 --- a/src/libxrpl/tx/applySteps.cpp +++ b/src/libxrpl/tx/applySteps.cpp @@ -1,3 +1,19 @@ +/** @file + * Orchestration hub for the XRPL transaction processing pipeline. + * + * Every transaction passes through four entry points in order: `preflight`, + * `preclaim`, `calculateBaseFee`, and `doApply`. This file contains no + * transaction-specific business logic; its sole responsibility is the + * compile-time type-dispatch machinery (`withTxnType`) that routes each + * transaction to the correct `Transactor` subclass, and the assembly of the + * structured `PreflightResult`, `PreclaimResult`, and `ApplyResult` tokens + * that higher layers consume. + * + * @note Transactor headers must not be included directly here. All + * type-specific behavior is accessed through the X-macro dispatch in + * `transactions.macro`. See the comment at line 37 and the macro file + * itself for the rationale. + */ #include #include @@ -44,6 +60,13 @@ namespace xrpl { namespace { +/** Internal sentinel thrown when a runtime `TxType` has no entry in + * `transactions.macro`. + * + * All four public entry points catch this; the catch blocks are marked + * `LCOV_EXCL` because an unrecognised type should have been rejected well + * before reaching this layer. + */ struct UnknownTxnType : std::exception { TxType txnType; @@ -52,8 +75,35 @@ struct UnknownTxnType : std::exception } }; -// Call a lambda with the concrete transaction type as a template parameter -// throw an "UnknownTxnType" exception on error +/** Invoke a generic callable with the concrete `Transactor` subclass as a + * compile-time template parameter, selected by runtime `txnType`. + * + * The switch statement is generated at compile time by re-including + * `transactions.macro` with `TRANSACTION` defined to emit one `case` label + * per known transaction type. Each case calls + * `f.template operator()()`, resolving all type-specific + * behavior without virtual dispatch and without requiring this translation + * unit to include any transactor headers. + * + * Before dispatch, thread-local RAII guards configure numeric precision for + * the entire processing step: + * - When `featureSingleAssetVault` or `featureLendingProtocol` is active: + * `CurrentTransactionRulesGuard` installs `rules` into a thread-local slot, + * and `NumberSO` configures floating-point-style arithmetic (or legacy mode + * when `fixUniversalNumber` is absent). + * - Otherwise: `NumberMantissaScaleGuard` forces the legacy small-mantissa + * behavior to preserve historical correctness across all three pipeline + * phases. + * + * @tparam F A generic callable whose `operator()()` will be invoked with + * `T` bound to the concrete transactor for `txnType`. + * @param rules Amendment rules for the current ledger, used to select the + * numeric precision mode. + * @param txnType The runtime transaction type to dispatch on. + * @param f The callable to invoke. + * @return The return value of `f.template operator()()`. + * @throws UnknownTxnType if `txnType` does not appear in `transactions.macro`. + */ template auto withTxnType(Rules const& rules, TxType txnType, F&& f) @@ -76,14 +126,12 @@ withTxnType(Rules const& rules, TxType txnType, F&& f) std::optional mantissaScaleGuard; if (rules.enabled(featureSingleAssetVault) || rules.enabled(featureLendingProtocol)) { - // raii classes for the current ledger rules. // fixUniversalNumber predates the rulesGuard and should be replaced. stNumberSO.emplace(rules.enabled(fixUniversalNumber)); rulesGuard.emplace(rules); } else { - // Without those features enabled, always use the old number rules. mantissaScaleGuard.emplace(MantissaRange::MantissaScale::Small); } @@ -106,16 +154,17 @@ withTxnType(Rules const& rules, TxType txnType, F&& f) } } // namespace -// Templates so preflight does the right thing with T::kCONSEQUENCES_FACTORY. -// -// This could be done more easily using if constexpr, but Visual Studio -// 2017 doesn't handle if constexpr correctly. So once we're no longer -// building with Visual Studio 2017 we can consider replacing the four -// templates with a single template function that uses if constexpr. -// -// For ConsequencesFactoryType::Normal -// - +/** Build a `TxConsequences` for a transaction whose transactor declares + * `ConsequencesFactory = Normal`. + * + * Constructs standard fee-and-sequence consequences from the raw `STTx`. + * Selected at compile time by the C++20 `requires` clause on + * `T::kCONSEQUENCES_FACTORY`. + * + * @tparam T The concrete `Transactor` subclass. + * @param ctx The preflight context carrying the transaction. + * @return A default `TxConsequences` summarising the fee and sequence. + */ template requires(T::kCONSEQUENCES_FACTORY == Transactor::ConsequencesFactoryType::Normal) TxConsequences @@ -124,7 +173,17 @@ consequencesHelper(PreflightContext const& ctx) return TxConsequences(ctx.tx); }; -// For ConsequencesFactoryType::Blocker +/** Build a `TxConsequences` for a transaction whose transactor declares + * `ConsequencesFactory = Blocker`. + * + * Sets `Category::Blocker`, signalling to the TxQ that this transaction + * (e.g. `SetRegularKey`, `AccountDelete`, `SignerListSet`) may invalidate + * the signatures on subsequent queued transactions from the same account. + * + * @tparam T The concrete `Transactor` subclass. + * @param ctx The preflight context carrying the transaction. + * @return A `TxConsequences` marked as a blocker. + */ template requires(T::kCONSEQUENCES_FACTORY == Transactor::ConsequencesFactoryType::Blocker) TxConsequences @@ -133,7 +192,19 @@ consequencesHelper(PreflightContext const& ctx) return TxConsequences(ctx.tx, TxConsequences::Category::Blocker); }; -// For ConsequencesFactoryType::Custom +/** Build a `TxConsequences` for a transaction whose transactor declares + * `ConsequencesFactory = Custom`. + * + * Delegates entirely to `T::makeTxConsequences(ctx)`, which the transactor + * must implement. Used by `Payment` (`sfSendMax` XRP spend), `OfferCreate` + * (XRP `TakerGets`), `TicketCreate` (multi-sequence), `AccountSet` + * (conditional blocker), and `LoanSet` (counterparty signers). + * + * @tparam T The concrete `Transactor` subclass; must provide a static + * `makeTxConsequences(PreflightContext const&)`. + * @param ctx The preflight context carrying the transaction. + * @return The `TxConsequences` produced by `T::makeTxConsequences`. + */ template requires(T::kCONSEQUENCES_FACTORY == Transactor::ConsequencesFactoryType::Custom) TxConsequences @@ -142,6 +213,16 @@ consequencesHelper(PreflightContext const& ctx) return T::makeTxConsequences(ctx); }; +/** Run the preflight phase for the transaction in `ctx`. + * + * Dispatches to `Transactor::invokePreflight` for the concrete transactor + * type, then builds `TxConsequences` via `consequencesHelper` on success, + * or from the error code on failure. + * + * @param ctx The preflight context. + * @return A pair of `(NotTEC, TxConsequences)`: the validation result and the + * worst-case queue cost estimate. + */ static std::pair invokePreflight(PreflightContext const& ctx) { @@ -164,26 +245,30 @@ invokePreflight(PreflightContext const& ctx) } } +/** Run the preclaim phase for the transaction in `ctx`. + * + * Executes a fixed sequence of static checks via compile-time name hiding + * (not virtual dispatch): `checkSeqProxy`, `checkPriorTxAndLastLedger`, + * `checkPermission`, `checkSign`, then `checkFee`, and finally + * `T::preclaim(ctx)` for transactor-specific ledger validation. + * + * @param ctx The preclaim context, carrying a read-only ledger view. + * @return A `TER` result: `tesSUCCESS`, a `tec*` fee-claiming failure, or a + * non-claiming `tem*`/`tef*`/`ter*` code. + * + * @note **Security invariant:** every check up to and including `checkSign` + * must return `NotTEC` (never a `tec*` code). A `tec*` result from a + * pre-signature check would cause a fee to be charged before the sender's + * identity is authenticated, enabling theft or destruction of funds. + * Only `checkFee` and the transactor-specific `preclaim` may return the + * full `TER` range. + */ static TER invokePreclaim(PreclaimContext const& ctx) { try { - // use name hiding to accomplish compile-time polymorphism of static - // class functions for Transactor and derived classes. return withTxnType(ctx.view.rules(), ctx.tx.getTxnType(), [&]() -> TER { - // preclaim functionality is divided into two sections: - // 1. Up to and including the signature check: returns NotTEC. - // All transaction checks before and including checkSign - // MUST return NotTEC, or something more restrictive. - // Allowing tec results in these steps risks theft or - // destruction of funds, as a fee will be charged before the - // signature is checked. - // 2. After the signature check: returns TER. - - // If the transactor requires a valid account and the - // transaction doesn't list one, preflight will have already - // a flagged a failure. auto const id = ctx.tx.getAccountID(sfAccount); if (id != beast::kZERO) @@ -223,21 +308,14 @@ invokePreclaim(PreclaimContext const& ctx) } } -/** - * @brief Calculates the base fee for a given transaction. +/** Compute the base fee for `tx` by dispatching to the concrete transactor's + * `calculateBaseFee` static method. * - * This function determines the base fee required for the specified transaction - * by invoking the appropriate fee calculation logic based on the transaction - * type. It uses a type-dispatch mechanism to select the correct calculation - * method. - * - * @param view The ledger view to use for fee calculation. - * @param tx The transaction for which the base fee is to be calculated. - * @return The calculated base fee as an XRPAmount. - * - * @throws std::exception If an error occurs during fee calculation, including - * but not limited to unknown transaction types or internal errors, the function - * logs an error and returns an XRPAmount of zero. + * @param view Read-only ledger view used by fee overrides (e.g. multi-signer + * count from `SignerListSet`). + * @param tx The transaction whose fee is being calculated. + * @return The base fee in drops. Returns zero if the transaction type is + * unrecognised (unreachable in production). */ static XRPAmount invokeCalculateBaseFee(ReadView const& view, STTx const& tx) @@ -257,6 +335,7 @@ invokeCalculateBaseFee(ReadView const& view, STTx const& tx) } } +/** @see TxConsequences::TxConsequences(NotTEC) */ TxConsequences::TxConsequences(NotTEC pfResult) : isBlocker_(false) , fee_(beast::kZERO) @@ -268,6 +347,12 @@ TxConsequences::TxConsequences(NotTEC pfResult) !isTesSuccess(pfResult), "xrpl::TxConsequences::TxConsequences : is not tesSUCCESS"); } +/** @see TxConsequences::TxConsequences(STTx const&) + * + * @note `fee_` is extracted from `sfFee` only when the amount is native XRP + * and non-negative — a defensive guard against malformed or exotic fee + * fields that would otherwise corrupt queue accounting. + */ TxConsequences::TxConsequences(STTx const& tx) : isBlocker_(false) , fee_(tx[sfFee].native() && !tx[sfFee].negative() ? tx[sfFee].xrp() : beast::kZERO) @@ -277,21 +362,36 @@ TxConsequences::TxConsequences(STTx const& tx) { } +/** @see TxConsequences::TxConsequences(STTx const&, Category) */ TxConsequences::TxConsequences(STTx const& tx, Category category) : TxConsequences(tx) { isBlocker_ = (category == TxConsequences::Category::Blocker); } +/** @see TxConsequences::TxConsequences(STTx const&, XRPAmount) */ TxConsequences::TxConsequences(STTx const& tx, XRPAmount potentialSpend) : TxConsequences(tx) { potentialSpend_ = potentialSpend; } +/** @see TxConsequences::TxConsequences(STTx const&, std::uint32_t) */ TxConsequences::TxConsequences(STTx const& tx, std::uint32_t sequencesConsumed) : TxConsequences(tx) { sequencesConsumed_ = sequencesConsumed; } +/** Instantiate the concrete `Transactor` for the transaction in `ctx` and + * execute it. + * + * Dispatches to `withTxnType`, which constructs `T p(ctx)` and calls `p()`. + * The call operator in `Transactor::operator()()` runs `preCompute`, + * `consumeSeqProxy`, `payFee`, `doApply`, and invariant checking in sequence, + * returning an `ApplyResult` that reflects whether the transaction was + * committed to the ledger. + * + * @param ctx The mutable apply context wrapping the open ledger view. + * @return An `ApplyResult` with the `TER` and `applied` flag. + */ static ApplyResult invokeApply(ApplyContext& ctx) { @@ -313,9 +413,16 @@ invokeApply(ApplyContext& ctx) } } -// Test-only factory — not part of the public API. -// The returned Transactor holds a raw reference to ctx; the caller must ensure -// the ApplyContext outlives the Transactor. +/** Construct a concrete `Transactor` for the transaction in `ctx`. + * + * Test-only factory — not part of the public API. Allows unit tests to + * instantiate and inspect a transactor without running the full apply loop. + * + * @param ctx The apply context; must outlive the returned object because the + * transactor holds a raw reference to it. + * @return A heap-allocated `Transactor` subclass for the transaction type. + * @throws UnknownTxnType if the transaction type is not in `transactions.macro`. + */ std::unique_ptr makeTransactor(ApplyContext& ctx) { @@ -366,6 +473,14 @@ preflight( } } +/** @copydoc preclaim(PreflightResult const&, ServiceRegistry&, OpenView const&) + * + * @note If the amendment rules recorded in `preflightResult` differ from + * `view.rules()` (the ledger advanced between preflight and preclaim), + * this function silently re-runs `preflight` with the new rules before + * constructing the `PreclaimContext`. Callers do not need to handle + * this race; the result always reflects the current ledger's rules. + */ PreclaimResult preclaim(PreflightResult const& preflightResult, ServiceRegistry& registry, OpenView const& view) { @@ -438,6 +553,14 @@ calculateDefaultBaseFee(ReadView const& view, STTx const& tx) return Transactor::calculateBaseFee(view, tx); } +/** @copydoc doApply(PreclaimResult const&, ServiceRegistry&, OpenView&) + * + * @note Returns `{tefEXCEPTION, false}` immediately — without applying — if + * the ledger sequence recorded in `preclaimResult.view` does not match + * `view.seq()`. This represents a caller logic error (preclaim and + * doApply were called against different ledger generations) from which + * there is insufficient context to recover. + */ ApplyResult doApply(PreclaimResult const& preclaimResult, ServiceRegistry& registry, OpenView& view) { diff --git a/src/libxrpl/tx/invariants/AMMInvariant.cpp b/src/libxrpl/tx/invariants/AMMInvariant.cpp index 6469799eb6..a89656e28f 100644 --- a/src/libxrpl/tx/invariants/AMMInvariant.cpp +++ b/src/libxrpl/tx/invariants/AMMInvariant.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements `ValidAMM`, the post-transaction invariant checker for AMM ledger + * state consistency. + * + * `visitEntry` accumulates AMM-relevant state from every modified SLE during + * a transaction; `finalize` dispatches to per-transaction-type helpers that + * verify the constant-product and structural invariants. Enforcement is gated + * on `fixAMMv1_3`: violations are always logged but only cause transaction + * rejection once that amendment is active. + */ #include #include @@ -36,13 +46,11 @@ ValidAMM::visitEntry( if (after) { auto const type = after->getType(); - // AMM object changed if (type == ltAMM) { ammAccount_ = after->getAccountID(sfAccount); lptAMMBalanceAfter_ = after->getFieldAmount(sfLPTokenBalance); } - // AMM pool changed else if ( (type == ltRIPPLE_STATE && ((after->getFlags() & lsfAMMNode) != 0u)) || (type == ltACCOUNT_ROOT && after->isFieldPresent(sfAMMID))) @@ -53,7 +61,6 @@ ValidAMM::visitEntry( if (before) { - // AMM object changed if (before->getType() == ltAMM) { lptAMMBalanceBefore_ = before->getFieldAmount(sfLPTokenBalance); @@ -61,6 +68,19 @@ ValidAMM::visitEntry( } } +/** Return true if the three AMM balances satisfy the sign constraint. + * + * When `zeroAllowed` is `No`, all three values must be strictly positive. + * When `zeroAllowed` is `Yes`, the simultaneous all-zeros state is also + * accepted — this is the legitimate terminal state after the final LP + * withdrawal drains the pool completely. + * + * @param amount First pool asset balance. + * @param amount2 Second pool asset balance. + * @param lptAMMBalance LP token supply. + * @param zeroAllowed Whether an all-zeros pool state is valid. + * @return `true` if the balances satisfy the constraint. + */ static bool validBalances( STAmount const& amount, @@ -83,7 +103,6 @@ ValidAMM::finalizeVote(bool enforce, beast::Journal const& j) const { if (lptAMMBalanceAfter_ != lptAMMBalanceBefore_ || ammPoolChanged_) { - // LPTokens and the pool can not change on vote // LCOV_EXCL_START JLOG(j.error()) << "AMMVote invariant failed: " << lptAMMBalanceBefore_.value_or(STAmount{}) << " " << lptAMMBalanceAfter_.value_or(STAmount{}) << " " @@ -101,14 +120,12 @@ ValidAMM::finalizeBid(bool enforce, beast::Journal const& j) const { if (ammPoolChanged_) { - // The pool can not change on bid // LCOV_EXCL_START JLOG(j.error()) << "AMMBid invariant failed: pool changed"; if (enforce) return false; // LCOV_EXCL_STOP } - // LPTokens are burnt, therefore there should be fewer LPTokens else if ( lptAMMBalanceBefore_ && lptAMMBalanceAfter_ && (*lptAMMBalanceAfter_ > *lptAMMBalanceBefore_ || *lptAMMBalanceAfter_ <= beast::kZERO)) @@ -149,9 +166,6 @@ ValidAMM::finalizeCreate( FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j); - // Create invariant: - // sqrt(amount * amount2) == LPTokens - // all balances are greater than zero // NOLINTBEGIN(bugprone-unchecked-optional-access) lptAMMBalanceAfter_ set with ammAccount_ // in visitEntry if (!validBalances(amount, amount2, *lptAMMBalanceAfter_, ZeroAllowed::No) || @@ -217,10 +231,6 @@ ValidAMM::generalInvariant( FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j); - // Deposit and Withdrawal invariant: - // sqrt(amount * amount2) >= LPTokens - // all balances are greater than zero - // unless on last withdrawal auto const poolProductMean = root2(amount * amount2); bool const nonNegativeBalances = validBalances(amount, amount2, *lptAMMBalanceAfter_, zeroAllowed); diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp index eaaeb7fc6f..0b950ba4cf 100644 --- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp +++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp @@ -1,3 +1,30 @@ +/** @file + * Implements `TransfersNotFrozen`, the post-transaction invariant checker + * that prevents token balances from moving across frozen trust lines. + * + * The checker operates in two phases mandated by the `InvariantChecker_PROTOTYPE` + * contract. During `visitEntry()` every modified `ltRIPPLE_STATE` and + * `ltACCOUNT_ROOT` entry is processed to accumulate balance-change records + * keyed by issuer (`balanceChanges_`) and a lightweight issuer cache + * (`possibleIssuers_`). During `finalize()` those records are validated against + * the three freeze tiers — global freeze, deep freeze, and directional freeze — + * and the `AMMClawback` privilege exemption is applied where applicable. + * + * Enforcement is gated on the `featureDeepFreeze` amendment via the `enforce` + * flag. Before the amendment activates, violations are logged at `fatal` + * severity and fire `XRPL_ASSERT` in debug builds (providing early warning to + * operators and developers) but do not invalidate the transaction in release + * builds. When the amendment — or any future fix amendment — is added, only + * the single `enforce =` line in `finalize()` needs to change. + * + * @note The `XRPL_ASSERT(enforce, ...)` calls throughout this file use + * the counterintuitive pattern where the assert fires when `enforce` + * is *false* and a violation is detected. This is intentional: in debug + * builds it crashes the process to catch developer mistakes in tests that + * exercise the invariant without activating the amendment. In release + * builds the assert is a no-op and the `!enforce` return value lets the + * transaction through. + */ #include #include @@ -21,22 +48,25 @@ namespace xrpl { +/** Accumulate one modified ledger entry into the freeze-check state. + * + * A trust line's freeze flags alone cannot determine whether a transfer is + * forbidden — the check must span all affected trust lines because both + * sides of a transfer can carry different freeze states and directionality + * matters. Balance changes are therefore accumulated here and all freeze + * policy decisions are deferred to `finalize()` / `validateIssuerChanges()`. + * + * As a side effect, `ltACCOUNT_ROOT` entries are cached in `possibleIssuers_` + * so that `findIssuer()` can avoid an extra ledger lookup for issuers that + * were already touched by the transaction. Non-trust-line, non-account-root + * entries are silently ignored. + */ void TransfersNotFrozen::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { - /* - * A trust line freeze state alone doesn't determine if a transfer is - * frozen. The transfer must be examined "end-to-end" because both sides of - * the transfer may have different freeze states and freeze impact depends - * on the transfer direction. This is why first we need to track the - * transfers using IssuerChanges senders/receivers. - * - * Only in validateIssuerChanges, after we collected all changes can we - * determine if the transfer is valid. - */ if (!isValidEntry(before, after)) { return; @@ -51,6 +81,23 @@ TransfersNotFrozen::visitEntry( recordBalanceChanges(after, balanceChange); } +/** Validate all collected trust-line balance changes against freeze rules. + * + * Iterates `balanceChanges_` and calls `validateIssuerChanges()` for each + * issuer. The `enforce` flag — controlled by `featureDeepFreeze` — decides + * whether a detected violation causes this method to return `false` (hard + * enforcement) or merely logs and asserts (monitoring-only mode). To add a + * fix amendment in the future, append `|| view.rules().enabled(fixFreezeExploit)` + * to the single line that sets `enforce`; no other code needs to change. + * + * It is considered impossible for an issuer account that owns a trust line + * to be absent from the ledger, but the missing-issuer path is guarded + * defensively to prevent a crash in release builds. + * + * @return `true` if no frozen-fund movement is detected, or if enforcement + * is disabled (`featureDeepFreeze` not yet active). `false` if a freeze + * violation is found and the amendment is active. + */ bool TransfersNotFrozen::finalize( STTx const& tx, @@ -59,22 +106,6 @@ TransfersNotFrozen::finalize( ReadView const& view, beast::Journal const& j) { - /* - * We check this invariant regardless of deep freeze amendment status, - * allowing for detection and logging of potential issues even when the - * amendment is disabled. - * - * If an exploit that allows moving frozen assets is discovered, - * we can alert operators who monitor fatal messages and trigger assert in - * debug builds for an early warning. - * - * In an unlikely event that an exploit is found, this early detection - * enables encouraging the UNL to expedite deep freeze amendment activation - * or deploy hotfixes via new amendments. In case of a new amendment, we'd - * only have to change this line setting 'enforce' variable. - * enforce = view.rules().enabled(featureDeepFreeze) || - * view.rules().enabled(fixFreezeExploit); - */ [[maybe_unused]] bool const enforce = view.rules().enabled(featureDeepFreeze); for (auto const& [issue, changes] : balanceChanges_) @@ -84,8 +115,6 @@ TransfersNotFrozen::finalize( // just in case so xrpld doesn't crash in release. if (!issuerSle) { - // The comment above starting with "assert(enforce)" explains this - // assert. XRPL_ASSERT( enforce, "xrpl::TransfersNotFrozen::finalize : enforce " @@ -106,6 +135,21 @@ TransfersNotFrozen::finalize( return true; } +/** Return true if the entry is a trust line eligible for balance-change recording. + * + * `ltACCOUNT_ROOT` entries are silently cached in `possibleIssuers_` and + * excluded from further processing (returns `false`). All other entry types + * return `false` and are ignored. + * + * The explicit type guard against `ltRIPPLE_STATE` is necessary even though + * the `LedgerEntryTypesMatch` invariant also checks types, because all + * invariants run independently regardless of previous failures and a type + * mismatch here could cause undefined behaviour in subsequent processing. + * + * @param before Pre-transaction SLE; null for newly-created entries. + * @param after Post-transaction SLE; must not be null. + * @return `true` only for `ltRIPPLE_STATE` entries whose type is unchanged. + */ bool TransfersNotFrozen::isValidEntry( std::shared_ptr const& before, @@ -133,6 +177,25 @@ TransfersNotFrozen::isValidEntry( return after->getType() == ltRIPPLE_STATE && (!before || before->getType() == ltRIPPLE_STATE); } +/** Compute the net balance change for a trust line, handling creation and deletion. + * + * Two edge cases require special treatment to close freeze-bypass loopholes: + * + * - **Created mid-transaction** (`before` is null): a `Payment` or + * `OfferCreate` that crosses offers can create a trust line on the fly. + * Such a line is not created frozen, but the sender's line may be. + * Treating the pre-existing balance as zero ensures the full post-transaction + * balance counts as the change, so the sender's freeze state is still checked. + * + * - **Deleted mid-transaction** (`isDelete` is true): the final balance is + * treated as zero so that deleting a trust line cannot be used to transfer + * frozen funds to a third party while appearing to "clear" a balance. + * + * @param before Pre-transaction SLE; null if the trust line was just created. + * @param after Post-transaction SLE. + * @param isDelete True when the entry is being deleted. + * @return Signed `STAmount` representing `balanceAfter - balanceBefore`. + */ STAmount TransfersNotFrozen::calculateBalanceChange( std::shared_ptr const& before, @@ -161,6 +224,16 @@ TransfersNotFrozen::calculateBalanceChange( return balanceAfter - balanceBefore; } +/** Insert a `BalanceChange` into `balanceChanges_` under the given issue. + * + * Routes the change to `IssuerChanges::senders` when `balanceChangeSign < 0` + * (balance decreased from this issuer's perspective) or to + * `IssuerChanges::receivers` when positive. A zero sign is invalid and + * triggers an assertion — callers must filter out zero-change entries first. + * + * @param issue Currency and issuer account identifying the `balanceChanges_` bucket. + * @param change Trust line SLE reference and directional sign; must be non-zero. + */ void TransfersNotFrozen::recordBalance(Issue const& issue, BalanceChange change) { @@ -179,6 +252,19 @@ TransfersNotFrozen::recordBalance(Issue const& issue, BalanceChange change) } } +/** Record a trust line balance change from both sides' issuer perspectives. + * + * XRPL stores trust line balances from the low account's perspective + * (the account with the numerically lower `AccountID`). The same physical + * balance movement therefore looks like opposite-signed changes when viewed + * from the high account's side. To give `validateIssuerChanges()` consistent + * issuer-relative directionality, the change is inserted twice: once for the + * high-limit account's issuer using the raw sign, and once for the low-limit + * account's issuer with the sign inverted. + * + * @param after Post-transaction trust line SLE. + * @param balanceChange Net signed balance change; must be non-zero. + */ void TransfersNotFrozen::recordBalanceChanges( std::shared_ptr const& after, @@ -198,6 +284,17 @@ TransfersNotFrozen::recordBalanceChanges( {.line = after, .balanceChangeSign = -balanceChangeSign}); } +/** Look up an issuer's `AccountRoot` SLE, using the transaction-local cache first. + * + * Checks `possibleIssuers_` (populated during `visitEntry()`) before + * falling back to `view.read()`. This avoids a redundant ledger lookup in + * the common case where the issuer account was already modified by the + * transaction being validated. + * + * @param issuerID Account to look up. + * @param view Post-transaction read-only ledger view used as the fallback. + * @return The issuer's `AccountRoot` SLE, or nullptr if not found. + */ std::shared_ptr TransfersNotFrozen::findIssuer(AccountID const& issuerID, ReadView const& view) { @@ -209,6 +306,29 @@ TransfersNotFrozen::findIssuer(AccountID const& issuerID, ReadView const& view) return view.read(keylet::account(issuerID)); } +/** Validate all balance changes for one issuer's token against freeze rules. + * + * Issuance (no senders) and redemption (no receivers) are unconditionally + * allowed regardless of freeze flags — freeze restrictions apply only to + * holder-to-holder transfers, where both `changes.senders` and + * `changes.receivers` are non-empty. If either collection is empty, + * tokens are flowing directly to or from the issuer. The holder may still + * carry contradicting freeze flags for peer-to-peer transfers, but those + * are validated when the holder is processed as an issuer in its own + * `balanceChanges_` entry. + * + * For holder-to-holder transfers, every sender and receiver trust line is + * checked by `validateFrozenState()` against the three freeze tiers. + * + * @param issuer The issuer's `AccountRoot` SLE; must not be null. + * @param changes All senders and receivers for this issuer's token. + * @param tx The transaction being applied. + * @param j Journal for diagnostic logging. + * @param enforce When `false`, violations log and assert but do not cause + * this method to return `false` (pre-`featureDeepFreeze` mode). + * @return `true` if all changes are permitted; `false` on a freeze violation + * when `enforce` is `true`. + */ bool TransfersNotFrozen::validateIssuerChanges( std::shared_ptr const& issuer, @@ -225,13 +345,6 @@ TransfersNotFrozen::validateIssuerChanges( bool const globalFreeze = issuer->isFlag(lsfGlobalFreeze); if (changes.receivers.empty() || changes.senders.empty()) { - /* If there are no receivers, then the holder(s) are returning - * their tokens to the issuer. Likewise, if there are no - * senders, then the issuer is issuing tokens to the holder(s). - * This is allowed regardless of the issuer's freeze flags. (The - * holder may have contradicting freeze flags, but that will be - * checked when the holder is treated as issuer.) - */ return true; } @@ -250,6 +363,41 @@ TransfersNotFrozen::validateIssuerChanges( return true; } +/** Check whether a single trust line balance change violates freeze rules. + * + * Evaluates three layered freeze conditions, any of which is sufficient to + * block the transfer: + * + * 1. **Global freeze** (`lsfGlobalFreeze` on the issuer): all trust lines with + * that issuer are frozen; no override is possible. + * 2. **Deep freeze** (`lsfLowDeepFreeze`/`lsfHighDeepFreeze`): blocks both + * inbound and outbound movement regardless of directionality. + * 3. **Standard freeze** (`lsfLowFreeze`/`lsfHighFreeze`): direction-sensitive — + * only blocks outgoing transfers (`balanceChangeSign < 0`). + * + * The `high` parameter indicates whether the issuer under scrutiny is the + * high-limit account on this trust line (numerically larger `AccountID`), + * which determines which freeze-flag bits to inspect on the SLE. + * + * **`AMMClawback` exception**: when `hasPrivilege(tx, OverrideFreeze)` is true, + * the invariant permits movement across individually frozen or deep-frozen AMM + * pool trust lines (`lsfAMMNode`). A global freeze is never overrideable, and + * regular (non-AMM) trust lines cannot be clawed back even with the privilege. + * + * When `enforce` is `false` (amendment not yet active), a detected violation + * logs at `fatal` severity and fires `XRPL_ASSERT` in debug builds, but + * returns `true` to allow the transaction through. See the `@file` docstring + * for the rationale behind this pattern. + * + * @param change Trust line SLE and direction of the balance change. + * @param high `true` if the issuer is the high-limit account on this trust line. + * @param tx The transaction being applied. + * @param j Journal for diagnostic logging. + * @param enforce When `false`, violations are logged but do not fail the check. + * @param globalFreeze `true` if the issuer's `lsfGlobalFreeze` flag is set. + * @return `true` if the transfer is permitted; `false` if it violates a freeze + * rule and `enforce` is `true`. + */ bool TransfersNotFrozen::validateFrozenState( BalanceChange const& change, @@ -282,7 +430,6 @@ TransfersNotFrozen::validateFrozenState( JLOG(j.fatal()) << "Invariant failed: Attempting to move frozen funds for " << tx.getTransactionID(); - // The comment above starting with "assert(enforce)" explains this assert. XRPL_ASSERT( enforce, "xrpl::TransfersNotFrozen::validateFrozenState : enforce " diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 4f25a91bdf..7469767bcd 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1,3 +1,17 @@ +/** @file + * Post-transaction invariant-checking machinery for the XRP Ledger. + * + * Every time a transaction is applied, the framework scans all modified + * ledger entries via the two-phase visitor pattern: `visitEntry` accumulates + * per-entry state, and `finalize` renders a pass/fail verdict. If any + * checker fails, the transaction is overridden with `tecINVARIANT_FAILED` + * (fee charged, all other mutations reverted) or `tefINVARIANT_FAILED` if + * even that minimal commit breaks an invariant. + * + * This file implements the checkers declared in `InvariantCheck.h` and the + * `hasPrivilege` helper. The dispatch loop lives in + * `ApplyContext::checkInvariantsHelper`. + */ #include #include @@ -47,6 +61,18 @@ namespace xrpl { return (privileges) & priv; \ } +/** Test whether a transaction type holds a given `Privilege` bit. + * + * Privilege assignments are co-located with transaction type definitions in + * `transactions.macro`. The X-macro expansion above generates one switch + * case per transaction type; each case performs a bitwise AND against the + * compile-time `privileges` bitmask embedded in the macro row. Deprecated + * or unknown transaction types return `false`. + * + * @param tx The transaction whose type is tested. + * @param priv The privilege bit (or OR-combination of bits) to test for. + * @return `true` if every bit in `priv` is set in `tx`'s privilege mask. + */ bool hasPrivilege(STTx const& tx, Privilege priv) { @@ -203,12 +229,9 @@ XRPBalanceChecks::visitEntry( auto const drops = balance.xrp(); - // Can't have more than the number of drops instantiated - // in the genesis ledger. if (drops > kINITIAL_XRP) return true; - // Can't have a negative balance (0 is OK) if (drops < XRPAmount{0}) return true; @@ -248,14 +271,12 @@ NoBadOffers::visitEntry( std::shared_ptr const& after) { auto isBad = [](STAmount const& pays, STAmount const& gets) { - // An offer should never be negative if (pays < beast::kZERO) return true; if (gets < beast::kZERO) return true; - // Can't have an XRP to XRP offer: return pays.native() && gets.native(); }; diff --git a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp index b98d769393..36c1076270 100644 --- a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements the `ValidLoanBroker` post-transaction invariant checker for + * the Lending Protocol (XLS-66). Verifies that every `LoanBroker` ledger + * object touched (directly or via its pseudo-account) by a transaction + * remains internally consistent: non-negative accounting fields, monotonic + * loan sequence, valid vault reference, and cover/balance agreement. + */ + #include #include @@ -19,6 +27,25 @@ namespace xrpl { +/** Collect ledger entries that may involve a LoanBroker. + * + * Classifies each modified SLE into one of four buckets for deferred + * validation in `finalize`: + * + * - `ltLOAN_BROKER` — stored in `brokers_` with before/after snapshots. + * - `ltACCOUNT_ROOT` carrying `sfLoanBrokerID` — the broker's pseudo-account; + * creates a placeholder `BrokerInfo{}` entry in `brokers_` if none exists, + * so the broker is checked even when its own SLE was not modified. + * - `ltRIPPLE_STATE` — appended to `lines_` for issuer lookup in `finalize`. + * - `ltMPTOKEN` — appended to `mpts_` for account lookup in `finalize`. + * + * The `isDelete` flag is not used; the post-state `after` drives all checks + * except the sequence-monotonicity comparison, which uses both snapshots. + * + * @param isDelete True if the entry is being deleted (unused in this checker). + * @param before Pre-transaction SLE snapshot; may be null for new entries. + * @param after Post-transaction SLE snapshot; may be null for deletions. + */ void ValidLoanBroker::visitEntry( bool isDelete, @@ -50,6 +77,18 @@ ValidLoanBroker::visitEntry( } } +/** Validate the owner directory of a broker whose `sfOwnerCount` is zero. + * + * Per XLS-66 §3.12.3, a broker with no outstanding obligations must have a + * single-page owner directory containing at most one entry, and that entry + * may only be an `ltRIPPLE_STATE` or `ltMPTOKEN` object — the trust line or + * MPToken through which the cover collateral is held. + * + * @param view The current read-only ledger view. + * @param dir The owner directory root SLE for the broker's pseudo-account. + * @param j Journal for fatal-level diagnostics on failure. + * @return True if the directory satisfies the zero-owner-count constraint. + */ bool ValidLoanBroker::goodZeroDirectory( ReadView const& view, @@ -91,6 +130,36 @@ ValidLoanBroker::goodZeroDirectory( return true; } +/** Validate all LoanBroker objects touched by the transaction. + * + * First performs indirect broker discovery: iterates `lines_` and `mpts_` + * collected during `visitEntry`, reads each issuer/holder account root, and + * registers any account carrying `sfLoanBrokerID` in `brokers_`. This catches + * transactions that modify broker collateral without touching the + * `ltLOAN_BROKER` SLE directly. + * + * For each discovered broker the following invariants are enforced: + * - `sfLoanSequence` must be monotonically non-decreasing (prevents loan-ID + * replay). + * - `sfDebtTotal` and `sfCoverAvailable` must be ≥ 0 (`STNumber` fields can + * represent negative values, which would indicate a bookkeeping bug). + * - `sfVaultID` must reference an existing `Vault` object. + * - `sfCoverAvailable` must not exceed the pseudo-account's on-ledger asset + * balance (`accountHolds` with freeze and auth both ignored, since + * pseudo-accounts are exempt from those controls). + * - Under `fixSecurity3_1_3`, `sfCoverAvailable` must equal the + * pseudo-account balance exactly, except during `ttLOAN_BROKER_DELETE` + * where the field is not zeroed before removal. + * + * @note No amendment gate is needed here: `ltLOAN_BROKER` objects can only + * exist after the Lending Protocol amendment is enabled, so reaching this + * loop with live broker state implicitly confirms the amendment is active. + * + * @param tx The transaction that was applied. + * @param view The post-transaction ledger view. + * @param j Journal for fatal-level diagnostics on failure. + * @return True if every tracked broker satisfies all invariants. + */ bool ValidLoanBroker::finalize( STTx const& tx, @@ -99,8 +168,8 @@ ValidLoanBroker::finalize( ReadView const& view, beast::Journal const& j) { - // Loan Brokers will not exist on ledger if the Lending Protocol amendment - // is not enabled, so there's no need to check it. + // LoanBroker objects cannot exist unless the Lending Protocol amendment is + // enabled, so there is no need to gate on it explicitly. for (auto const& line : lines_) { diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index e3dff3dde9..5be842304d 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements the `ValidLoan` invariant checker for `ltLOAN` ledger objects + * introduced by the XLS-66d Lending Protocol. + * + * This checker runs after every transaction — including failing ones — and + * enforces five numeric and state-consistency invariants on every `ltLOAN` + * entry touched by the transaction. A `false` return from `finalize` causes + * the transaction to be rolled back and escalated to `tecINVARIANT_FAILED`. + * See `LoanInvariant.h` for the full invariant specification. + */ #include #include @@ -41,8 +51,9 @@ ValidLoan::finalize( for (auto const& [before, after] : loans_) { + // XLS-66d §3.2.2.3: sfPaymentRemaining == 0 iff all outstanding + // balance fields are zero (payment schedule and balances must agree). // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants - // If `Loan.PaymentRemaining = 0` then the loan MUST be fully paid off if (after->at(sfPaymentRemaining) == 0 && (after->at(sfTotalValueOutstanding) != beast::kZERO || after->at(sfPrincipalOutstanding) != beast::kZERO || @@ -52,8 +63,6 @@ ValidLoan::finalize( "remaining has not been paid off"; return false; } - // If `Loan.PaymentRemaining != 0` then the loan MUST NOT be fully paid - // off if (after->at(sfPaymentRemaining) != 0 && after->at(sfTotalValueOutstanding) == beast::kZERO && after->at(sfPrincipalOutstanding) == beast::kZERO && diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 9b8838f145..d97ee02a96 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -1,3 +1,13 @@ +/** @file + * Invariant checker implementations for Multi-Purpose Tokens (MPT). + * + * Provides `ValidMPTIssuance` and `ValidMPTPayment`, which together form + * the post-apply safety net for all MPT-related ledger state changes. + * Both classes follow the standard two-phase checker contract — `visitEntry()` + * accumulates per-SLE data, `finalize()` renders a pass/fail verdict — and are + * registered in the `InvariantChecks` tuple in `InvariantCheck.h`. + */ + #include #include @@ -24,6 +34,19 @@ namespace xrpl { +/** Accumulate MPT object counts from a single modified ledger entry. + * + * Called once per SLE that the transaction touched. Updates the four + * creation/deletion counters and sets `mptCreatedByIssuer_` when a new + * `ltMPTOKEN` entry belongs to the issuance's own issuer account — + * a condition that is always a protocol violation. + * + * @param isDelete `true` when the entry is being removed from the ledger. + * @param before Snapshot of the SLE before the transaction; null if the + * entry did not exist prior to this transaction. + * @param after Snapshot of the SLE after the transaction; null when + * `isDelete` is `true` and the entry has been erased. + */ void ValidMPTIssuance::visitEntry( bool isDelete, @@ -58,6 +81,41 @@ ValidMPTIssuance::visitEntry( } } +/** Verify that the transaction's MPT object lifecycle changes were authorized. + * + * Dispatches on the transaction's privilege mask (see `InvariantCheckPrivilege.h`) + * rather than its type, keeping the logic independent of the growing set of + * MPT-capable transaction types. The hierarchy is: + * + * - **`CreateMptIssuance`**: exactly one `ltMPTOKEN_ISSUANCE` created, none deleted. + * - **`DestroyMptIssuance`**: exactly one deleted, none created. + * - **`MustAuthorizeMpt | MayAuthorizeMpt`**: no issuance changes; `ltMPTOKEN` + * changes bounded by authorization semantics. `ttAMM_WITHDRAW` and + * `ttAMM_CLAWBACK` get a wider allowance (≤1 created, ≤2 deleted) to + * cover both token sides of an AMM pool dissolution. + * - **`MayCreateMpt`**: no issuances and no deletions; auto-creation of + * `ltMPTOKEN` entries allowed for non-issuers (up to 2 for `ttAMM_CREATE`, + * up to 1 for `ttCHECK_CASH`). + * - **`MayDeleteMpt`**: only deletions (≤2 for `ttAMM_DELETE`), no creations. + * - **No privilege**: all counters must be zero on a successful result. + * + * The `mptCreatedByIssuer_` check uses the `assert(enforce)` soft-rollout + * pattern: the fatal log fires unconditionally; the hard `false` return and + * the `XRPL_ASSERT_PARTS` fire only when `featureSingleAssetVault` or + * `featureLendingProtocol` is active, preserving backward compatibility for + * older amendment environments while still catching mistakes in dev builds. + * + * @note With `featureMPTokensV2` enabled, `tecINCOMPLETE` results are also + * subject to full invariant checking (partial progress is still valid + * ledger state). + * + * @param tx The transaction that was applied. + * @param result The `TER` returned by `doApply()` (or the post-reset result). + * @param fee The fee deducted (unused by this checker). + * @param view Read-only view of the post-apply ledger state. + * @param j Journal for fatal-level diagnostics on violation. + * @return `true` if all MPT lifecycle constraints are satisfied. + */ bool ValidMPTIssuance::finalize( STTx const& tx, @@ -76,8 +134,6 @@ ValidMPTIssuance::finalize( if (mptCreatedByIssuer_) { JLOG(j.fatal()) << "Invariant failed: MPToken created for the MPT issuer"; - // The comment above starting with "assert(enforce)" explains this - // assert. XRPL_ASSERT_PARTS( enforceCreatedByIssuer, "xrpl::ValidMPTIssuance::finalize", "no issuer MPToken"); if (enforceCreatedByIssuer) @@ -279,6 +335,30 @@ ValidMPTIssuance::finalize( mptokensDeleted_ == 0; } +/** Accumulate outstanding-amount snapshots and holder-balance deltas for a + * single modified ledger entry. + * + * Two SLE types are relevant: + * - **`ltMPTOKEN_ISSUANCE`**: records the `sfOutstandingAmount` for the + * `Order::Before` and `Order::After` slots of the corresponding `MPTData` + * entry. Also performs an `sfMaximumAmount`-bounded overflow check on the + * post-apply value to catch issuances where outstanding exceeds the cap. + * - **`ltMPTOKEN`**: accumulates the signed net delta (`mptAmount`) across all + * holder entries. The contribution of each token is + * `sfMPTAmount + sfLockedAmount`; locked amounts remain part of the + * outstanding supply. Before-entries are subtracted, after-entries added. + * + * If any individual value exceeds `kMAX_MP_TOKEN_AMOUNT`, or if + * `mptAmt + lockedAmt` would overflow 64-bit arithmetic, `overflow_` is set + * and all further processing for this transaction is skipped. + * + * @note The `isDelete` parameter is unnamed/unused because deletion is already + * signalled by `after == nullptr`; presence of `before` is sufficient to + * handle the Before contribution. + * + * @param before Snapshot of the SLE before the transaction; null for inserts. + * @param after Snapshot of the SLE after the transaction; null for deletes. + */ void ValidMPTPayment::visitEntry( bool, @@ -344,6 +424,39 @@ ValidMPTPayment::visitEntry( } } +/** Verify the `OutstandingAmount` conservation invariant for all touched MPTs. + * + * For each MPT ID recorded during `visitEntry()`, checks: + * + * ``` + * OutstandingAmount[After] == OutstandingAmount[Before] + Σ(MPTAmount[After] − MPTAmount[Before]) + * ``` + * + * where the sum includes `sfLockedAmount` in each holder's contribution, since + * locked amounts remain part of the outstanding supply. + * + * Two layers of overflow protection are applied: + * 1. If `overflow_` was set during `visitEntry()` (individual value exceeded + * `kMAX_MP_TOKEN_AMOUNT`), the check fails immediately. + * 2. The signed delta arithmetic is checked for wrap-around before the final + * equality comparison, because the accumulated `mptAmount` can be large + * enough to overflow a 64-bit signed integer. + * + * Enforcement is amendment-gated: the invariant hard-fails (returns `false`) + * only when `featureMPTokensV2` is active. Before activation, a violation + * logs at fatal severity but returns `true` — preserving backward compatibility + * while surfacing problems to operators. + * + * @note Unlike `ValidMPTIssuance::finalize()`, this method is non-`const` + * because the `data_` hash-map accumulator can in principle be lazily + * mutated; `finalize()` is the single point where all data is final. + * + * @param tx The transaction that was applied (unused by this checker). + * @param result The `TER` result; only `tesSUCCESS` triggers the check. + * @param view Read-only view used to query active amendment rules. + * @param j Journal for fatal-level diagnostics on violation. + * @return `true` if outstanding-amount conservation holds for all touched MPTs. + */ bool ValidMPTPayment::finalize( STTx const& tx, diff --git a/src/libxrpl/tx/invariants/NFTInvariant.cpp b/src/libxrpl/tx/invariants/NFTInvariant.cpp index da415d2ad8..ed51496771 100644 --- a/src/libxrpl/tx/invariants/NFTInvariant.cpp +++ b/src/libxrpl/tx/invariants/NFTInvariant.cpp @@ -1,3 +1,17 @@ +/** + * @file NFTInvariant.cpp + * @brief Post-transaction invariant checkers for the NFT subsystem. + * + * Implements `ValidNFTokenPage` (structural integrity of `ltNFTOKEN_PAGE` + * entries) and `NFTokenCountTracking` (global mint/burn tally consistency on + * `ltACCOUNT_ROOT` entries). Both follow the two-phase invariant protocol: + * `visitEntry` accumulates state across every ledger entry touched by the + * transaction; `finalize` renders a pass/fail verdict once, after all entries + * have been visited. + * + * These classes are driven exclusively by the invariant-check harness in + * `ApplyContext`; they are not called directly by application code. + */ #include #include @@ -25,6 +39,37 @@ namespace xrpl { +/** + * @brief Inspect one touched `ltNFTOKEN_PAGE` entry for structural integrity. + * + * Non-NFT-page SLEs are silently ignored. For each snapshot (`before` and + * `after`) that is present, the inner `check` lambda verifies: + * - Link ownership: `sfPreviousPageMin` / `sfNextPageMin` high 160 bits + * match the current page's owning account; low 96 bits are strictly ordered. + * - Size: 1–`kDIR_MAX_TOKENS_PER_PAGE` (32) tokens, unless the page is being + * deleted (empty is then permitted). + * - Membership: each token's page-bits fall in `[loLimit, hiLimit)`. + * - Sort: tokens are strictly ascending by `nft::compareTokens`. + * - URI validity: an `sfURI` field, if present, must be non-empty. + * + * Two additional checks operate across the `before`→`after` transition rather + * than on a single snapshot: + * - Deletion of the final page (all 96 page-bits set to 1) while + * `sfPreviousPageMin` is still present would orphan the rest of the + * directory, so `deletedFinalPage_` is set. Enforced only after + * `fixNFTokenPageLinks` activates. + * - Loss of `sfNextPageMin` on a non-final page between `before` and `after` + * indicates a broken forward chain, so `deletedLink_` is set. Also gated on + * `fixNFTokenPageLinks`. + * + * Failures are recorded in boolean flags; `finalize` reports them. + * + * @param isDelete True if the SLE is being removed from the ledger. + * @param before Snapshot of the entry before the transaction, or nullptr if + * the entry is being created. + * @param after Snapshot of the entry after the transaction, or nullptr if + * the entry is being deleted. + */ void ValidNFTokenPage::visitEntry( bool isDelete, @@ -43,9 +88,6 @@ ValidNFTokenPage::visitEntry( uint256 const hiLimit = sle->key() & kPAGE_BITS; std::optional const prev = (*sle)[~sfPreviousPageMin]; - // Make sure that any page links... - // 1. Are properly associated with the owning account and - // 2. The page is correctly ordered between links. if (prev) { if (account != (*prev & kACCOUNT_BITS)) @@ -67,16 +109,14 @@ ValidNFTokenPage::visitEntry( { auto const& nftokens = sle->getFieldArray(sfNFTokens); - // An NFTokenPage should never contain too many tokens or be empty. if (std::size_t const nftokenCount = nftokens.size(); (!isDelete && nftokenCount == 0) || nftokenCount > kDIR_MAX_TOKENS_PER_PAGE) invalidSize_ = true; - // If prev is valid, use it to establish a lower bound for - // page entries. If prev is not valid the lower bound is zero. + // Lower bound for token IDs on this page. Derived from the + // page-bits of sfPreviousPageMin; zero if no previous page exists. uint256 const loLimit = prev ? *prev & kPAGE_BITS : uint256(beast::kZERO); - // Also verify that all NFTokenIDs in the page are sorted. uint256 loCmp = loLimit; for (auto const& obj : nftokens) { @@ -85,8 +125,6 @@ ValidNFTokenPage::visitEntry( badSort_ = true; loCmp = tokenID; - // None of the NFTs on this page should belong on lower or - // higher pages. if (uint256 const tokenPageBits = tokenID & kPAGE_BITS; tokenPageBits < loLimit || tokenPageBits >= hiLimit) badEntry_ = true; @@ -101,9 +139,8 @@ ValidNFTokenPage::visitEntry( { check(before); - // While an account's NFToken directory contains any NFTokens, the last - // NFTokenPage (with 96 bits of 1 in the low part of the index) should - // never be deleted. + // Deleting the final page (all 96 page-bits == 1) while + // sfPreviousPageMin is present would orphan the preceding pages. if (isDelete && (before->key() & nft::kPAGE_MASK) == nft::kPAGE_MASK && before->isFieldPresent(sfPreviousPageMin)) { @@ -116,11 +153,7 @@ ValidNFTokenPage::visitEntry( if (!isDelete && before && after) { - // If the NFTokenPage - // 1. Has a NextMinPage field in before, but loses it in after, and - // 2. This is not the last page in the directory - // Then we have identified a corruption in the links between the - // NFToken pages in the NFToken directory. + // A non-final page that loses sfNextPageMin breaks the forward chain. if ((before->key() & nft::kPAGE_MASK) != nft::kPAGE_MASK && before->isFieldPresent(sfNextPageMin) && !after->isFieldPresent(sfNextPageMin)) { @@ -129,6 +162,22 @@ ValidNFTokenPage::visitEntry( } } +/** + * @brief Render a pass/fail verdict for `ValidNFTokenPage`. + * + * Reports the first accumulated failure flag and returns `false` (causing + * `tecINVARIANT_FAILED`). The `deletedFinalPage_` and `deletedLink_` checks + * are only enforced when the `fixNFTokenPageLinks` amendment is active, + * allowing pre-amendment historical replay to proceed without triggering + * these conditions. + * + * @param tx The transaction being applied (unused here). + * @param result The transaction result code (unused here). + * @param view The post-transaction ledger view; used to test amendment + * activation for `fixNFTokenPageLinks`. + * @param j Journal for fatal-level diagnostics on failure. + * @return True if all NFT page invariants pass; false otherwise. + */ bool ValidNFTokenPage::finalize( STTx const& tx, @@ -186,6 +235,22 @@ ValidNFTokenPage::finalize( } //------------------------------------------------------------------------------ + +/** + * @brief Accumulate `sfMintedNFTokens` / `sfBurnedNFTokens` totals. + * + * Sums the pre- and post-transaction values of both NFT count fields across + * every `ltACCOUNT_ROOT` entry touched by the transaction. Non-account-root + * SLEs are silently skipped. `value_or(0)` handles accounts that have never + * minted or burned any NFTs (field absent). + * + * Tracking global totals across all touched roots, rather than per-account + * deltas, is intentional: an NFT mint or burn legitimately affects exactly one + * account's counters, so global sum equality is both necessary and sufficient. + * + * @param before Pre-transaction snapshot, or nullptr for new entries. + * @param after Post-transaction snapshot, or nullptr for deleted entries. + */ void NFTokenCountTracking::visitEntry( bool, @@ -205,6 +270,32 @@ NFTokenCountTracking::visitEntry( } } +/** + * @brief Validate NFT mint/burn count invariants for the completed transaction. + * + * Branches on whether the transaction holds the `ChangeNftCounts` privilege: + * + * **Without privilege**: neither `sfMintedNFTokens` nor `sfBurnedNFTokens` + * may change — any modification indicates a bug in a non-NFT transaction. + * + * **With privilege** (only `ttNFTOKEN_MINT` and `ttNFTOKEN_BURN` carry this): + * - Successful `ttNFTOKEN_MINT`: minted total must strictly increase; burned + * total must be unchanged. + * - Failed `ttNFTOKEN_MINT`: both totals must be unchanged. + * - Successful `ttNFTOKEN_BURN`: burned total must strictly increase; minted + * total must be unchanged. + * - Failed `ttNFTOKEN_BURN`: both totals must be unchanged. + * + * Strict inequality (`>=` rather than `==`) on success is intentional: it + * catches counter wrap-around and incorrect field rewrites, not just the + * case where nothing changed at all. + * + * @param tx The transaction; used for type and privilege checks. + * @param result The transaction result code; distinguishes success from + * failure for mint/burn. + * @param j Journal for fatal-level diagnostics on failure. + * @return True if all count invariants pass; false otherwise. + */ bool NFTokenCountTracking::finalize( STTx const& tx, diff --git a/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp b/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp index 6466812743..d9c060e968 100644 --- a/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp +++ b/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements ValidPermissionedDEX, the invariant checker for the + * Permissioned DEX amendment. + * + * Enforces domain isolation for successful ttPAYMENT and ttOFFER_CREATE + * transactions: every ltDIR_NODE and ltOFFER touched must carry the same + * sfDomainID as the transaction, no regular offers may be affected, the + * referenced ltPERMISSIONED_DOMAIN must exist, and all hybrid offers must be + * structurally well-formed (exactly one sfAdditionalBooks entry). + */ #include #include @@ -41,14 +51,11 @@ ValidPermissionedDEX::visitEntry( regularOffers_ = true; } - // pre-fixSecurity3_1_3: hybrid offer missing domain, missing - // sfAdditionalBooks, or sfAdditionalBooks has more than one entry if (after->isFlag(lsfHybrid) && (!after->isFieldPresent(sfDomainID) || !after->isFieldPresent(sfAdditionalBooks) || after->getFieldArray(sfAdditionalBooks).size() > 1)) badHybridsOld_ = true; - // post-fixSecurity3_1_3: same as above but also catches size == 0 if (after->isFlag(lsfHybrid) && (!after->isFieldPresent(sfDomainID) || !after->isFieldPresent(sfAdditionalBooks) || after->getFieldArray(sfAdditionalBooks).size() != 1)) @@ -68,8 +75,6 @@ ValidPermissionedDEX::finalize( if ((txType != ttPAYMENT && txType != ttOFFER_CREATE) || !isTesSuccess(result)) return true; - // For each offercreate transaction, check if - // permissioned offers are valid bool const isMalformed = view.rules().enabled(fixSecurity3_1_3) ? badHybrids_ : badHybridsOld_; if (txType == ttOFFER_CREATE && isMalformed) { @@ -88,8 +93,6 @@ ValidPermissionedDEX::finalize( return false; } - // for both payment and offercreate, there shouldn't be another domain - // that's different from the domain specified for (auto const& d : domains_) { if (d != domain) diff --git a/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp b/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp index 784a2503ca..5b537bd4e6 100644 --- a/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp +++ b/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements the `ValidPermissionedDomain` post-transaction invariant checker. + * + * Every transaction that creates or modifies a `ltPERMISSIONED_DOMAIN` ledger + * entry must leave its `sfAcceptedCredentials` array in a structurally valid + * state: non-empty, within the size cap, with no duplicate entries, and in + * canonical sort order. Violations are caught here — after `doApply` — as a + * hard safety net that rolls back the transaction if the transactor failed to + * enforce the rules itself. + */ + #include #include @@ -20,6 +31,26 @@ namespace xrpl { +/** Record credential-array facts for a single modified `ltPERMISSIONED_DOMAIN` + * entry, to be evaluated in `finalize`. + * + * Non-domain entries are silently ignored. Only the post-transaction `after` + * state is examined; the pre-transaction state is irrelevant to whether the + * resulting ledger is valid. + * + * Uniqueness is detected via `credentials::makeSorted`: that function returns + * an empty set when any duplicate `(sfIssuer, sfCredentialType)` pair is + * encountered, so `isUnique = !sorted.empty()`. Sort order is then verified + * by walking the canonical set and the original array in lockstep — duplicates + * short-circuit this check because they render the sorted comparison + * meaningless. + * + * @param isDel True when the entry is being deleted by this transaction. + * @param before The ledger entry state before the transaction (may be null for + * newly created entries). + * @param after The ledger entry state after the transaction (null for deleted + * entries; if null this call is a no-op). + */ void ValidPermissionedDomain::visitEntry( bool isDel, @@ -38,6 +69,7 @@ ValidPermissionedDomain::visitEntry( SleStatus ss{ .credentialsSize = credentials.size(), .isSorted = false, + // makeSorted returns empty on duplicates; non-empty means unique. .isUnique = !sorted.empty(), .isDelete = isDel}; @@ -61,6 +93,36 @@ ValidPermissionedDomain::visitEntry( check(sleStatus_, after); } +/** Apply the `ValidPermissionedDomain` invariant policy using the entry facts + * collected by `visitEntry`. + * + * The strictness of the check is gated on the `fixPermissionedDomainInvariant` + * amendment: + * + * - **Pre-amendment**: only validates after a successful + * `ttPERMISSIONED_DOMAIN_SET`; all other transaction types and all failures + * are passed unconditionally. + * + * - **Post-amendment**: enforces a comprehensive policy — + * - A failed transaction must not have touched any domain entry at all. + * - At most one domain entry may be affected per transaction. + * - `ttPERMISSIONED_DOMAIN_SET`: must have modified (not deleted) exactly + * one entry, and its `sfAcceptedCredentials` array must be non-empty, + * within `kMAX_PERMISSIONED_DOMAIN_CREDENTIALS_ARRAY_SIZE` (10), unique, + * and sorted. + * - `ttPERMISSIONED_DOMAIN_DELETE`: must have deleted exactly one entry. + * - Any other transaction type: must not have affected any domain entry. + * + * All failures are logged at `fatal` severity and return `false`, causing + * `ApplyContext` to roll back the transaction entirely. + * + * @param tx The transaction being finalized. + * @param result The TER code produced by `doApply`. + * @param view The post-transaction read view, used to query active + * amendments. + * @param j Journal for fatal-level diagnostic logging on failure. + * @return `true` if the invariant holds; `false` to veto the transaction. + */ bool ValidPermissionedDomain::finalize( STTx const& tx, @@ -104,10 +166,9 @@ ValidPermissionedDomain::finalize( if (view.rules().enabled(fixPermissionedDomainInvariant)) { - // No permissioned domains should be affected if the transaction failed if (!isTesSuccess(result)) { - // If nothing changed, all is good. If there were changes, that's bad. + // A failed transaction must leave all domain entries untouched. return sleStatus_.empty(); } diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 0dad8c18a0..0edc12c219 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -1,3 +1,23 @@ +/** @file + * Post-transaction invariant checker for the SingleAssetVault feature. + * + * Implements `ValidVault`, the 24th checker in the `InvariantChecks` tuple. + * It runs after every successful or fee-claiming transaction and verifies + * that the web of vault-related ledger objects (`ltVAULT`, the share + * `ltMPTOKEN_ISSUANCE`, per-depositor `ltMPTOKEN` entries, and the vault + * pseudo-account `ltACCOUNT_ROOT`) remains mutually consistent. + * + * The checker follows the standard two-phase invariant pattern: + * - `visitEntry` accumulates balance deltas per ledger entry key into the + * `deltas_` map, and snapshots vault/MPT state into `beforeVault_` etc. + * - `finalize` evaluates per-transaction invariants against those snapshots. + * + * Every fatal invariant failure is guarded by + * `XRPL_ASSERT(enforce, ...)` where `enforce = featureSingleAssetVault` + * is active. In debug/test builds the assert fires regardless of amendment + * state, surfacing violations early. In production the transaction is + * rejected (`!enforce == false`) only once the amendment is live. + */ #include #include @@ -30,6 +50,15 @@ namespace xrpl { +/** Construct a `Vault` snapshot from a live `ltVAULT` ledger entry. + * + * Captures every field that invariant checks compare across before/after + * states: asset type, pseudo-account, owner, share MPT ID, and all numeric + * balance fields. + * + * @param from A ledger entry whose type must be `ltVAULT`. + * @return A fully-populated `Vault` value object. + */ ValidVault::Vault ValidVault::Vault::make(SLE const& from) { @@ -48,6 +77,15 @@ ValidVault::Vault::make(SLE const& from) return self; } +/** Construct a `Shares` snapshot from a live `ltMPTOKEN_ISSUANCE` entry. + * + * Captures the fully-qualified `MPTIssue` identity, current outstanding + * amount, and effective maximum (defaulting to `kMAX_MP_TOKEN_AMOUNT` when + * `sfMaximumAmount` is absent) so `finalize` can check ceiling constraints. + * + * @param from A ledger entry whose type must be `ltMPTOKEN_ISSUANCE`. + * @return A fully-populated `Shares` value object. + */ ValidVault::Shares ValidVault::Shares::make(SLE const& from) { @@ -62,6 +100,33 @@ ValidVault::Shares::make(SLE const& from) return self; } +/** Phase-1 entry visitor: accumulate per-key balance deltas. + * + * Called once per modified ledger entry during transaction processing. + * For each entry whose type is relevant to vault accounting + * (`ltVAULT`, `ltMPTOKEN_ISSUANCE`, `ltMPTOKEN`, `ltACCOUNT_ROOT`, + * `ltRIPPLE_STATE`), this method: + * - Snapshots `ltVAULT` state into `beforeVault_` / `afterVault_`. + * - Snapshots `ltMPTOKEN_ISSUANCE` state into `beforeMPTs_` / `afterMPTs_` + * (vault-share issuances are identified later in `finalize`). + * - Computes a signed balance delta `(balanceBefore - balanceAfter) * sign` + * and stores it in `deltas_` keyed by ledger-entry key. + * + * Sign convention: positive delta means assets/shares *flowed into* the + * relevant account. For entries whose balance increase represents more + * assets held (account roots, trust lines, MPTokens) the sign is `-1` + * (balance decreased ⇒ assets left the account ⇒ delta is positive for the + * vault direction). For `ltMPTOKEN_ISSUANCE` the outstanding amount grows + * as shares are minted, so sign is `+1`. + * + * A delta entry is recorded even when the net balance change is zero (e.g., + * a fee exactly offsets an incoming transfer), to avoid accidentally + * treating a coincidental zero as "no activity". + * + * @param isDelete `true` when `before` is being deleted (no surviving SLE). + * @param before Pre-transaction SLE, or `nullptr` for newly created entries. + * @param after Post-transaction SLE; always non-null. + */ void ValidVault::visitEntry( bool isDelete, @@ -186,6 +251,41 @@ ValidVault::visitEntry( } } +/** Phase-2 invariant evaluation: verify vault consistency after transaction. + * + * Called once after all `visitEntry` calls complete. Returns `true` if + * all relevant invariants pass (or the transaction is not vault-related), + * `false` if a violation is detected. + * + * Short-circuits immediately on non-`tesSUCCESS` results — failed + * transactions are allowed to leave vault state unchanged, and any vault + * mutations on a failed transaction are caught by other invariants. + * + * Enforcement is gated on `featureSingleAssetVault`: when the amendment is + * inactive, violations are logged and asserted (crashing debug builds) but + * the function returns `true` so consensus is not broken before the feature + * goes live. + * + * Invariant checks performed (all non-delete operations): + * - Exactly one vault is created or modified per transaction. + * - Immutable fields (`sfAsset`, `sfAccount`, `sfShareMPTID`) do not change. + * - `assetsAvailable` ≤ `assetsTotal` ≥ 0; `assetsMaximum` ≥ 0. + * - `lossUnrealized` ≤ `assetsTotal − assetsAvailable`. + * - `lossUnrealized` only changes for loan transactions (`ttLOAN_MANAGE`, + * `ttLOAN_PAY`). + * - Per-transaction asset and share conservation (see `ttVAULT_DEPOSIT`, + * `ttVAULT_WITHDRAW`, `ttVAULT_CLAWBACK` case blocks). + * - Deletion leaves zero assets, zero shares, and co-deletes the issuance. + * - Creation produces an empty vault whose pseudo-account back-links + * correctly via `sfVaultID`. + * + * @param tx The applied transaction. + * @param ret Final TER result of the transaction. + * @param fee Transaction fee in drops (used to compensate XRP deltas). + * @param view Current ledger view (for read-only fallback lookups). + * @param j Journal for fatal-level invariant-failure diagnostics. + * @return `true` if invariants pass or are not applicable, `false` on violation. + */ bool ValidVault::finalize( STTx const& tx, @@ -1049,6 +1149,19 @@ ValidVault::finalize( return true; } +/** Construct a `DeltaInfo` representing the change from `before` to `after`. + * + * The `scale` field is set to the coarser (larger) of the two values' + * asset-specific scales so that subsequent rounding via `roundToAsset` uses + * a precision no finer than either operand. + * + * @param before Pre-transaction numeric value. + * @param after Post-transaction numeric value. + * @param asset Asset type, used to determine the appropriate scale for + * each value via `xrpl::scale()`. + * @return `DeltaInfo` where `delta = after - before` and `scale` is + * the maximum of the two operand scales. + */ [[nodiscard]] ValidVault::DeltaInfo ValidVault::DeltaInfo::makeDelta(Number const& before, Number const& after, Asset const& asset) { @@ -1057,6 +1170,20 @@ ValidVault::DeltaInfo::makeDelta(Number const& before, Number const& after, Asse .scale = std::max(xrpl::scale(after, asset), xrpl::scale(before, asset))}; } +/** Return the coarsest (largest) scale across a set of `DeltaInfo` values. + * + * Invariant comparisons mix values that may have been computed at different + * precisions (e.g., XRP drops vs. IOU mantissa-exponent pairs). Rounding + * all operands to the coarsest scale ensures comparisons do not spuriously + * fail due to sub-precision differences. + * + * @param numbers Collection of `DeltaInfo` values, each carrying an + * optional scale. An absent scale indicates the value has not yet been + * assigned a precision context. + * @return The maximum `scale` value found, or 0 if `numbers` is empty. + * Falls back to `STAmount::kMAX_OFFSET` if the winning entry's scale is + * `std::nullopt` (which should not occur in well-formed inputs). + */ [[nodiscard]] std::int32_t ValidVault::computeCoarsestScale(std::vector const& numbers) { diff --git a/src/libxrpl/tx/paths/AMMLiquidity.cpp b/src/libxrpl/tx/paths/AMMLiquidity.cpp index 87eb29f104..6c14f6971e 100644 --- a/src/libxrpl/tx/paths/AMMLiquidity.cpp +++ b/src/libxrpl/tx/paths/AMMLiquidity.cpp @@ -1,3 +1,25 @@ +/** @file + * Implements `AMMLiquidity`, the adapter between an on-ledger Automated + * Market Maker pool and the XRPL payment engine's `BookStep` traversal + * layer. + * + * `AMMLiquidity` produces `AMMOffer` objects — synthetic offers + * sized from live pool state — so that `BookStep` can treat AMM liquidity + * identically to CLOB offers during payment path execution. + * + * Two offer-generation strategies are used: + * - **Single-path**: `changeSpotPriceQuality` computes the exact + * constant-product swap that moves the AMM's spot price to the competing + * CLOB offer's quality. If no CLOB is present, `maxOffer` is returned + * instead and `BookStep` trims it to actual limits. + * - **Multi-path**: `generateFibSeqOffer` emits exponentially growing + * synthetic offers keyed to `AMMContext::curIters()`, preventing any + * single path from consuming the entire quality differential. + * + * All offer computation is wrapped in exception handlers so a problematic + * pool cannot abort an in-flight payment; it simply stops contributing + * liquidity for that path. + */ #include #include @@ -53,7 +75,8 @@ AMMLiquidity::fetchBalances(ReadView const& view) const { auto const amountIn = ammAccountHolds(view, ammAccountID_, assetIn_); auto const amountOut = ammAccountHolds(view, ammAccountID_, assetOut_); - // This should not happen. + // Negative balances indicate ledger corruption; the AMM invariant + // checker ensures pool balances are always non-negative. if (amountIn < beast::kZERO || amountOut < beast::kZERO) Throw("AMMLiquidity: invalid balances"); @@ -100,6 +123,19 @@ AMMLiquidity::generateFibSeqOffer(TAmounts const& balances } namespace { + +/** Return the protocol ceiling value for a given amount type. + * + * Used by the pre-`fixAMMOverflowOffer` path of `maxOffer` to construct an + * unbounded input amount. The ceiling is type-specific: native XRP uses + * `STAmount::kMAX_NATIVE`; IOU and STAmount use half of `kMAX_VALUE` at + * `kMAX_OFFSET`; MPT uses `kMAX_MP_TOKEN_AMOUNT`. + * + * @tparam T One of `XRPAmount`, `IOUAmount`, `STAmount`, or `MPTAmount`. + * @return The maximum representable value for the type. + * @note Half of `kMAX_VALUE` is used for IOU/STAmount to leave headroom for + * intermediate arithmetic without overflowing into the sign bit. + */ template constexpr T maxAmount() @@ -122,6 +158,16 @@ maxAmount() } } +/** Compute 99% of a pool's output balance, rounded down to asset precision. + * + * Used by the post-`fixAMMOverflowOffer` path of `maxOffer` to produce a + * bounded takerGets amount. Rounding down ensures the result stays strictly + * below `out` so the caller's `>= balances.out` guard cannot trigger. + * + * @param out Current output-side pool balance. + * @param asset The asset type, used to select rounding precision. + * @return 99% of `out`, rounded down. + */ template T maxOut(T const& out, Asset const& asset) @@ -129,6 +175,7 @@ maxOut(T const& out, Asset const& asset) Number const res = out * Number{99, -2}; return toAmount(asset, res, Number::RoundingMode::Downward); } + } // namespace template @@ -156,7 +203,8 @@ std::optional> AMMLiquidity::getOffer(ReadView const& view, std::optional const& clobQuality) const { - // Can't generate more offers if multi-path. + // AMM offers are not counted by BookStep's own offer counter, so the + // iteration budget is enforced here instead to prevent unbounded loop. if (ammContext_.maxItersReached()) return std::nullopt; diff --git a/src/libxrpl/tx/paths/AMMOffer.cpp b/src/libxrpl/tx/paths/AMMOffer.cpp index 3a7bd8f1df..f23480854e 100644 --- a/src/libxrpl/tx/paths/AMMOffer.cpp +++ b/src/libxrpl/tx/paths/AMMOffer.cpp @@ -1,3 +1,15 @@ +/** @file + * Implements the `AMMOffer` adapter that presents an AMM pool as a + * synthetic offer to `BookStep`'s generic payment-engine inner loop. + * + * `AMMOffer` mirrors the interface of `TOffer` so that + * `BookStep` can consume AMM and CLOB liquidity through identical + * code paths. Eight explicit instantiations cover every valid pairing + * of `XRPAmount`, `IOUAmount`, and `MPTAmount`, keeping the + * implementation in `.cpp` rather than headers. + * + * @see AMMLiquidity, BookStep, QualityFunction + */ #include #include @@ -22,6 +34,21 @@ namespace xrpl { +/** Construct an `AMMOffer` with the sizing and pool state decided by + * `AMMLiquidity::getOffer`. + * + * @param ammLiquidity The liquidity manager that owns this offer's + * `AMMContext` and provides pool metadata (account, assets, fee). + * @param amounts Offer size as presented to `BookStep`. In multi-path + * mode this is a Fibonacci-sequence-scaled amount; in single-path + * mode it is either quality-matched or pool-draining. + * @param balances Live pool token balances at the moment the offer was + * generated. Used by `limitOut`/`limitIn` in single-path mode to + * apply the constant-product swap formula. + * @param quality Spot-price quality when `balances != amounts`; otherwise + * the quality implied by `amounts`. Fixed for multi-path, varying for + * single-path as the pool is consumed. + */ template AMMOffer::AMMOffer( AMMLiquidity const& ammLiquidity, @@ -61,11 +88,31 @@ AMMOffer::amount() const return amounts_; } +/** Mark this offer as consumed and notify the AMM execution context. + * + * Validates that `consumed` does not exceed the initial offer size, sets + * the `consumed_` flag, and calls `AMMContext::setAMMUsed()` so the outer + * payment engine knows AMM liquidity was touched this iteration. + * + * @note The `view` parameter is accepted for interface compatibility with + * `TOffer::consume` but is not used here. Actual pool balance updates + * are performed in `BookStep::consumeOffer()` via `accountSend`, which + * keeps all ledger mutations in one place. + * @note An AMM offer can only be consumed once per payment engine iteration; + * subsequent calls with the same offer will throw because `consumed.in` + * or `consumed.out` would exceed `amounts_`. + * + * @param view Mutable ledger view (unused; present for interface parity + * with `TOffer::consume`). + * @param consumed The `in`/`out` amounts actually transferred. Must not + * exceed the initial `amounts_` in either dimension. + * @throws std::logic_error if `consumed.in > amounts_.in` or + * `consumed.out > amounts_.out`. + */ template void AMMOffer::consume(ApplyView& view, TAmounts const& consumed) { - // Consumed offer must be less or equal to the original if (consumed.in > amounts_.in || consumed.out > amounts_.out) Throw("Invalid consumed AMM offer."); // AMM pool is updated when the amounts are transferred @@ -73,10 +120,29 @@ AMMOffer::consume(ApplyView& view, TAmounts const& consume consumed_ = true; - // Let the context know AMM offer is consumed ammLiquidity_.context().setAMMUsed(); } +/** Resize the offer to deliver exactly `limit` units of the output asset. + * + * **Multi-path mode**: resizes proportionally to the original quality via + * `Quality::ceilOutStrict`, keeping strand quality ordering stable. The + * taker overpays slightly relative to the raw AMM formula, ensuring the + * post-trade pool product `(poolPays − assetOut)(poolGets + assetIn)` + * exceeds the original `poolPays × poolGets`. + * + * **Single-path mode**: applies the constant-product swap formula + * `swapAssetOut(balances_, limit, tradingFee())` for an exact result. + * Quality may shift but does not affect strand ordering since there is + * only one path. + * + * @param offerAmount Current offer size (used in multi-path proportional + * scaling; ignored in single-path mode). + * @param limit Maximum output amount that can be delivered. + * @param roundUp Whether to round the input side up (passed to + * `ceilOutStrict` in multi-path mode). + * @return Resized `{in, out}` pair where `out <= limit`. + */ template TAmounts AMMOffer::limitOut( @@ -84,30 +150,43 @@ AMMOffer::limitOut( TOut const& limit, bool roundUp) const { - // Change the offer size proportionally to the original offer quality - // to keep the strands quality order unchanged. The taker pays slightly - // more for the offer in this case, which results in a slightly higher - // pool product than the original pool product. I.e. if the original - // pool is poolPays, poolGets and the offer is assetIn, assetOut then - // poolPays * poolGets < (poolPays - assetOut) * (poolGets + assetIn) + // Multi-path: resize proportionally to original quality so that strand + // quality ordering is preserved. The slight overpayment keeps the pool + // product non-decreasing even under rounding. if (ammLiquidity_.multiPath()) { - // It turns out that the ceil_out implementation has some slop in - // it, which ceil_out_strict removes. return quality().ceilOutStrict(offerAmount, limit, roundUp); } - // Change the offer size according to the conservation function. The offer - // quality is increased in this case, but it doesn't matter since there is - // only one path. + // Single-path: use the conservation function directly. Quality may + // increase, but that is acceptable with only one path. return {swapAssetOut(balances_, limit, ammLiquidity_.tradingFee()), limit}; } +/** Resize the offer to consume exactly `limit` units of the input asset. + * + * Mirrors `limitOut` but caps on the input side. + * + * **Multi-path mode**: resizes proportionally to the original quality, + * preserving strand ordering. When `fixReducedOffersV2` is active, uses + * `Quality::ceilInStrict` (removes a small rounding slop present in the + * older `ceilIn`). The older path is preserved for replay of historical + * ledgers where that amendment was inactive. + * + * **Single-path mode**: applies `swapAssetIn(balances_, limit, + * tradingFee())` for an exact constant-product result. + * + * @param offerAmount Current offer size (used for proportional scaling in + * multi-path mode). + * @param limit Maximum input amount the taker will supply. + * @param roundUp Whether to round the output side up (forwarded to + * `ceilInStrict` when `fixReducedOffersV2` is active). + * @return Resized `{in, out}` pair where `in <= limit`. + */ template TAmounts AMMOffer::limitIn(TAmounts const& offerAmount, TIn const& limit, bool roundUp) const { - // See the comments above in limitOut(). if (ammLiquidity_.multiPath()) { if (auto const& rules = getCurrentTransactionRules(); @@ -119,6 +198,21 @@ AMMOffer::limitIn(TAmounts const& offerAmount, TIn const& return {limit, swapAssetIn(balances_, limit, ammLiquidity_.tradingFee())}; } +/** Return the quality function used by the path optimizer for this offer. + * + * **Multi-path mode**: returns a constant `QualityFunction` (slope = 0, + * intercept = `quality_`), identical to a CLOB offer. Using a fixed rate + * prevents the AMM's varying spot price from disturbing the relative + * quality ordering of competing strands. + * + * **Single-path mode**: returns an AMM quality function with a negative + * slope derived from the pool depth (`balances_` and `tradingFee()`). + * The optimizer uses this to solve in closed form for the output amount + * that meets the payment's requested quality limit. + * + * @return A `QualityFunction` encoding the effective exchange rate for + * this offer as a linear function of output amount. + */ template QualityFunction AMMOffer::getQualityFunc() const @@ -128,6 +222,25 @@ AMMOffer::getQualityFunc() const return QualityFunction{balances_, ammLiquidity_.tradingFee(), QualityFunction::AMMTag{}}; } +/** Verify the constant-product invariant after offer execution. + * + * First checks that `consumed` does not exceed the original offer size. + * Then recomputes the pre-trade pool product `k = balances_.in * + * balances_.out` and post-trade product `k' = (balances_.in + + * consumed.in) * (balances_.out - consumed.out)`. + * + * The check passes when `k' >= k` (exact conservation) or when the + * relative deviation is within `1e-7` — a tolerance for finite-precision + * arithmetic that can cause `k'` to land just below `k`. Violations are + * logged at error level with full balance and product details for + * post-mortem analysis; the ledger is not aborted. + * + * @param consumed Amounts actually consumed in the trade. Must not exceed + * `amounts_` in either dimension. + * @param j Journal for error-level diagnostics on invariant failure. + * @return `true` if the invariant holds (within tolerance); `false` + * otherwise. + */ template bool AMMOffer::checkInvariant(TAmounts const& consumed, beast::Journal j) const diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index 113155e530..e483252e7f 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -51,20 +51,48 @@ namespace xrpl { +/** CRTP base class for an order-book exchange step in the payment/offer-crossing engine. + * + * A `BookStep` converts one asset to another by consuming offers from the + * Central Limit Order Book (CLOB) and/or an AMM pool whose in/out assets + * match `book_`. It sits between endpoint steps and is responsible for + * price discovery and execution at the ledger level. + * + * `TIn` and `TOut` encode the amount types (`XRPAmount`, `IOUAmount`, + * `MPTAmount`), eliminating virtual dispatch on the hot iteration path. + * `TDerived` supplies policy hooks for transfer-fee handling, self-cross + * logic, and quality-threshold enforcement; two concrete sub-classes exist: + * `BookPaymentStep` (regular payments) and `BookOfferCrossingStep`. + * + * All CRTP dispatch uses `static_cast(this)->method()`. + * + * @tparam TIn Amount type flowing into the book (taker-pays side). + * @tparam TOut Amount type flowing out of the book (taker-gets side). + * @tparam TDerived Concrete sub-class providing payment/crossing policy. + */ template class BookStep : public StepImp> { protected: + /** Discriminates whether the best available offer comes from the AMM or CLOB. */ enum class OfferType { Amm, Clob }; + /** Maximum number of CLOB offers (funded or not) consumed per pass before + * the step is marked inactive to prevent DoS via a dense order book. + */ static constexpr uint32_t kMAX_OFFERS_TO_CONSUME{1000}; Book book_; AccountID strandSrc_; AccountID strandDst_; - // Charge transfer fees when the prev step redeems + /** Pointer to the immediately preceding step; used to determine debt + * direction and to compute transfer rates for offer crossing. + * Null when this is the first step in the strand. + */ Step const* const prevStep_ = nullptr; bool const ownerPaysTransferFee_; - // Mark as inactive (dry) if too many offers are consumed + /** True once `kMAX_OFFERS_TO_CONSUME` offers have been visited in a single + * pass; signals the strand solver to abandon this strand rather than loop. + */ bool inactive_ = false; /** Number of offers consumed or partially consumed the last time the step ran, including expired and unfunded offers. @@ -74,13 +102,18 @@ protected: be partially consumed multiple times during a payment. */ std::uint32_t offersUsed_ = 0; - // If set, AMM liquidity might be available - // if AMM offer quality is better than CLOB offer - // quality or there is no CLOB offer. + /** AMM liquidity for `book_`, if an AMM with nonzero LP-token balance + * exists for this asset pair. When set, each offer-iteration pass + * checks whether the AMM offers better quality than the CLOB tip. + */ std::optional> ammLiquidity_; beast::Journal const j_; Asset const strandDeliver_; + /** Holds the (in, out) amounts produced by the most recent `revImp` or + * `fwdImp` call. `fwdImp` asserts this is set before running, so + * reverse must always precede forward. + */ struct Cache { TIn in; @@ -94,6 +127,16 @@ protected: std::optional cache_; private: + /** Construct a BookStep for the given strand context and asset pair. + * + * If an AMM SLE exists for `(in, out)` and its LP-token balance is + * nonzero, `ammLiquidity_` is emplaced so subsequent passes can + * compare AMM quality against the CLOB tip. + * + * @param ctx Strand construction context (view, src/dst, flags, etc.). + * @param in Asset on the taker-pays side of the book. + * @param out Asset on the taker-gets side of the book. + */ BookStep(StrandContext const& ctx, Asset const& in, Asset const& out) : book_(in, out, ctx.domainID) , strandSrc_(ctx.strandSrc) @@ -118,12 +161,16 @@ private: } public: + /** Returns the order book this step operates on. */ [[nodiscard]] Book const& book() const { return book_; } + /** Returns the cached input amount from the last pass, or `nullopt` if no + * pass has run yet. + */ [[nodiscard]] std::optional cachedIn() const override { @@ -132,6 +179,9 @@ public: return EitherAmount(cache_->in); } + /** Returns the cached output amount from the last pass, or `nullopt` if no + * pass has run yet. + */ [[nodiscard]] std::optional cachedOut() const override { @@ -140,27 +190,64 @@ public: return EitherAmount(cache_->out); } + /** Returns `Issues` when the offer owner pays the transfer fee (i.e. + * downstream steps receive the book-out asset without a trust-line + * redemption); `Redeems` otherwise. + */ [[nodiscard]] DebtDirection debtDirection(ReadView const& sb, StrandDirection dir) const override { return ownerPaysTransferFee_ ? DebtDirection::Issues : DebtDirection::Redeems; } + /** Returns the book for this step, allowing callers to identify it as a + * book step (vs. a direct or endpoint step). + */ [[nodiscard]] std::optional bookStepBook() const override { return book_; } + /** Returns a conservative upper bound on the quality deliverable by this + * step, taking transfer fees into account, plus the resulting debt + * direction. Returns `nullopt` quality when the book and AMM are both + * empty. + * + * @param v Read-only view of the current ledger state. + * @param prevStepDir Debt direction reported by the preceding step. + */ [[nodiscard]] std::pair, DebtDirection> qualityUpperBound(ReadView const& v, DebtDirection prevStepDir) const override; + /** Returns the quality function for the best available offer (AMM or + * CLOB), adjusted for transfer fees. AMM quality functions are + * non-constant (price moves with size); CLOB functions are constant. + * + * @param v Read-only view of the current ledger state. + * @param prevStepDir Debt direction reported by the preceding step. + */ [[nodiscard]] std::pair, DebtDirection> getQualityFunc(ReadView const& v, DebtDirection prevStepDir) const override; + /** Returns the number of offers (funded, unfunded, or expired) touched + * during the most recent `revImp` or `fwdImp` call. + */ [[nodiscard]] std::uint32_t offersUsed() const override; + /** Backward (reverse) simulation pass. + * + * Iterates CLOB/AMM offers starting from the desired output `out`, + * accumulates consumed amounts into a cache, and returns the total + * `(in, out)` pair. Offer IDs to remove are appended to `ofrsToRm`. + * + * @param sb Payment sandbox (mutable layered view). + * @param afView Apply view used for the offer stream. + * @param ofrsToRm Accumulates keys of unfunded/bad offers to erase. + * @param out Desired output amount this step should deliver. + * @return Actual `(in, out)` amounts consumed/produced. + */ std::pair revImp( PaymentSandbox& sb, @@ -168,6 +255,20 @@ public: boost::container::flat_set& ofrsToRm, TOut const& out); + /** Forward pass. + * + * Re-runs offer consumption from the actual available input `in`. + * Requires `cache_` to be set (i.e., `revImp` must have run first). + * A subtle normalization adjustment reconciles cases where IOU mantissa + * subtraction yields zero, making an offer appear fully consumed when + * it is not. + * + * @param sb Payment sandbox (mutable layered view). + * @param afView Apply view used for the offer stream. + * @param ofrsToRm Accumulates keys of unfunded/bad offers to erase. + * @param in Actual input amount available for this step. + * @return Actual `(in, out)` amounts consumed/produced. + */ std::pair fwdImp( PaymentSandbox& sb, @@ -175,13 +276,33 @@ public: boost::container::flat_set& ofrsToRm, TIn const& in); + /** Validates the forward pass by re-running `fwdImp` and comparing the + * result against the cached reverse output via `checkNear`. Returns + * `false` (rejecting the strand) if amounts diverge, which can happen + * when ledger state changes between pathfinding reverse and apply forward. + * + * @param sb Payment sandbox. + * @param afView Apply view. + * @param in Input amount to validate. + * @return `{true, out}` on success; `{false, zero}` on mismatch. + */ std::pair validFwd(PaymentSandbox& sb, ApplyView& afView, EitherAmount const& in) override; - // Check for errors frozen constraints. + /** Validates structural invariants for this book step before a strand is + * committed. Checks: same-asset book, issuer existence, loop detection + * via `seenBookOuts`, NoRipple flag on the preceding trust line (IOU), + * and MPT tradability. + * + * @param ctx Strand construction context. + * @return `tesSUCCESS` or an appropriate error code. + */ [[nodiscard]] TER check(StrandContext const& ctx) const; + /** Returns true if too many offers were consumed on the last pass, causing + * the strand solver to abandon this strand. + */ [[nodiscard]] bool inactive() const override { @@ -189,6 +310,12 @@ public: } protected: + /** Formats book issuers and currencies into a human-readable string for + * logging. Called by `logString()` in each derived class. + * + * @param name Class name prefix (e.g. `"BookPaymentStep"`). + * @return Multi-line diagnostic string. + */ std::string logStringImpl(char const* name) const { @@ -199,6 +326,14 @@ protected: return ostr.str(); } + /** Returns the transfer rate for `asset` when sending to `dstAccount`. + * Returns parity (`kPARITY_RATE`) for XRP or when the issuer is the + * destination (self-transfer). + * + * @param view Read-only ledger view. + * @param asset Asset whose issuer's transfer rate is queried. + * @param dstAccount Destination account for this hop. + */ [[nodiscard]] Rate rate(ReadView const& view, Asset const& asset, AccountID const& dstAccount) const; diff --git a/src/xrpld/consensus/Consensus.cpp b/src/xrpld/consensus/Consensus.cpp index 5498f7cf79..1ff4e8502d 100644 --- a/src/xrpld/consensus/Consensus.cpp +++ b/src/xrpld/consensus/Consensus.cpp @@ -1,3 +1,13 @@ +/** @file + * Policy functions that drive the XRP Ledger consensus phase transitions. + * + * Contains three pure decision functions — `shouldCloseLedger`, + * `checkConsensusReached`, and `checkConsensus` — that consume observable + * network state and return boolean or enum results. All mutable consensus + * state lives in the `Consensus` template in `Consensus.h`; this + * file holds only the timing and agreement policies that are independently + * testable as free functions. + */ #include #include @@ -21,8 +31,8 @@ shouldCloseLedger( std::size_t proposersClosed, std::size_t proposersValidated, std::chrono::milliseconds prevRoundTime, - std::chrono::milliseconds timeSincePrevClose, // Time since last ledger's close time - std::chrono::milliseconds openTime, // Time waiting to close this ledger + std::chrono::milliseconds timeSincePrevClose, + std::chrono::milliseconds openTime, std::chrono::milliseconds idleInterval, ConsensusParms const& parms, beast::Journal j, @@ -40,7 +50,6 @@ shouldCloseLedger( using namespace std::chrono_literals; if ((prevRoundTime < -1s) || (prevRoundTime > 10min) || (timeSincePrevClose > 10min)) { - // These are unexpected cases, we just close the ledger std::stringstream ss; ss << "shouldCloseLedger Trans=" << (anyTransactions ? "yes" : "no") << " Prop: " << prevProposers << "/" << proposersClosed @@ -53,7 +62,6 @@ shouldCloseLedger( if ((proposersClosed + proposersValidated) > (prevProposers / 2)) { - // If more than half of the network has closed, we close JLOG(j.trace()) << "Others have closed"; CLOG(clog) << "closing ledger because enough others have already. "; return true; @@ -61,12 +69,10 @@ shouldCloseLedger( if (!anyTransactions) { - // Only close at the end of the idle interval CLOG(clog) << "no transactions, returning. "; - return timeSincePrevClose >= idleInterval; // normal idle + return timeSincePrevClose >= idleInterval; } - // Preserve minimum ledger open time if (openTime < parms.ledgerMIN_CLOSE) { JLOG(j.debug()) << "Must wait minimum time before closing"; @@ -74,9 +80,9 @@ shouldCloseLedger( return false; } - // Don't let this ledger close more than twice as fast as the previous - // ledger reached consensus so that slower validators can slow down - // the network + // Don't close more than twice as fast as the previous round: slower + // validators must be able to keep up, and this rate-limit prevents a + // fast node from driving the network beyond their capacity. if (openTime < (prevRoundTime / 2)) { JLOG(j.debug()) << "Ledger has not been open long enough"; @@ -84,11 +90,37 @@ shouldCloseLedger( return false; } - // Close the ledger CLOG(clog) << "no reason to not close. "; return true; } +/** Determine whether a raw vote count satisfies the consensus threshold. + * + * Internal helper shared by `checkConsensus()` for two distinct queries: + * "have we reached agreement?" and "have enough peers moved on?". The two + * calls differ only in which counters are passed and whether `countSelf` is + * set. + * + * @param agreeing Number of peers whose position matches ours. + * @param total Total number of current proposers (excluding self). + * @param countSelf If `true`, add one to both `agreeing` and `total` + * before computing the percentage. Pass `proposing` from + * `checkConsensus()`; observers never count themselves. + * @param minConsensusPct Percentage threshold required to declare consensus + * (typically `ConsensusParms::minCONSENSUS_PCT` = 80). + * @param reachedMax `true` when `currentAgreeTime > ledgerMAX_CONSENSUS`. + * Used only in the zero-peer path to guard against premature self-close. + * @param stalled `true` when all disputed transactions have unambiguous + * supermajority agreement either for or against inclusion. Bypasses the + * percentage check and returns `true` immediately to prevent a Byzantine + * minority from manipulating which transactions make the cut. + * @param clog Optional log buffer; appended when non-null. + * @return `true` if consensus is considered reached, `false` otherwise. + * @note When `total == 0` (no peer proposals received yet), the function + * returns `false` until `reachedMax` is set. This guards against + * prematurely closing on a solo position before any network proposals + * have arrived, which would likely cause a desync once peers are heard. + */ bool checkConsensusReached( std::size_t agreeing, @@ -103,15 +135,6 @@ checkConsensusReached( << ", count_self: " << countSelf << ", minConsensusPct: " << minConsensusPct << ", reachedMax: " << reachedMax << ". "; - // If we are alone for too long, we have consensus. - // Delaying consensus like this avoids a circumstance where a peer - // gets ahead of proposers insofar as it has not received any proposals. - // This could happen if there's a slowdown in receiving proposals. Reaching - // consensus prematurely in this way means that the peer will likely desync. - // The check for reachedMax should allow plenty of time for proposals to - // arrive, and there should be no downside. If a peer is truly not - // receiving any proposals, then there should be no hurry. There's - // really nowhere to go. if (total == 0) { if (reachedMax) @@ -124,11 +147,6 @@ checkConsensusReached( return false; } - // We only get stalled when there are disputed transactions and all of them - // unequivocally have 80% (minConsensusPct) agreement, either for or - // against. That is: either under 20% or over 80% consensus (respectively - // "nay" or "yay"). This prevents manipulation by a minority of byzantine - // peers of which transactions make the cut to get into the ledger. if (stalled) { CLOG(clog) << "consensus stalled. "; @@ -188,8 +206,9 @@ checkConsensus( if (currentProposers < (prevProposers * 3 / 4)) { - // Less than 3/4 of the last ledger's proposers are present; don't - // rush: we may need more time. + // Fewer than 75 % of the previous round's proposers are visible; + // wait an extra ledgerMIN_CONSENSUS before acting to allow laggards + // to arrive. Rushing here would exclude slow-but-honest validators. if (currentAgreeTime < (previousAgreeTime + parms.ledgerMIN_CONSENSUS)) { JLOG(j.trace()) << "too fast, not enough proposers"; @@ -198,8 +217,6 @@ checkConsensus( } } - // Have we, together with the nodes on our UNL list, reached the threshold - // to declare consensus? if (checkConsensusReached( currentAgree, currentProposers, @@ -215,8 +232,6 @@ checkConsensus( return ConsensusState::Yes; } - // Have sufficient nodes on our UNL list moved on and reached the threshold - // to declare consensus? if (checkConsensusReached( currentFinished, currentProposers, @@ -242,7 +257,6 @@ checkConsensus( return ConsensusState::Expired; } - // no consensus yet JLOG(j.trace()) << "no consensus"; CLOG(clog) << "No consensus. "; return ConsensusState::No; diff --git a/src/xrpld/consensus/Consensus.h b/src/xrpld/consensus/Consensus.h index d2c5faca22..bdbbb08b22 100644 --- a/src/xrpld/consensus/Consensus.h +++ b/src/xrpld/consensus/Consensus.h @@ -1,3 +1,14 @@ +/** @file + * XRPL consensus algorithm — complete state machine and decision logic. + * + * Defines the `Consensus` class template that drives a single node + * through the open → establish → accepted phase cycle each round, plus two + * free-standing policy functions (`shouldCloseLedger`, `checkConsensus`) and + * the threshold helper `participantsNeeded`. Because the class is fully + * templated it is header-only; all implementation lives in this file. + * + * Thread safety: none. The caller must serialize all public method calls. + */ #pragma once #include @@ -21,8 +32,17 @@ namespace xrpl { /** Determines whether the current ledger should close at this time. - This function should be called when a ledger is open and there is no close - in progress, or when a transaction is received and no close is in progress. + Should be called on each timer tick while the ledger is open. The + decision is based on peer activity, elapsed time, and the network's + idle interval. Priority order: + 1. Force-close immediately if any timing value is outside sane bounds + (negative, or longer than 10 minutes). + 2. Close if more than half of the previous round's proposers have + already closed this ledger. + 3. If there are no transactions and the idle interval has not yet + elapsed, keep the ledger open. + 4. Never close before `parms.ledgerMIN_CLOSE` has elapsed since open. + 5. Rate-limit: do not close faster than half the previous round time. @param anyTransactions indicates whether any transactions have been received @param prevProposers proposers in the last closing @@ -37,6 +57,7 @@ namespace xrpl { @param parms Consensus constant parameters @param j journal for logging @param clog log object to which to append + @return `true` if the ledger should close now, `false` to keep it open. */ bool shouldCloseLedger( @@ -54,6 +75,20 @@ shouldCloseLedger( /** Determine whether the network reached consensus and whether we joined. + Evaluates vote counts and timing to classify the current state of + convergence. The function is free-standing (not a method) so it can be + tested independently of the `Consensus` object. + + Possible outcomes in priority order: + - `ConsensusState::No` — not enough time or agreement yet. + - `ConsensusState::Expired` — round time exceeded the abandon threshold; + the node should leave consensus. + - `ConsensusState::MovedOn` — ≥80% of previous proposers already validated + a later ledger without us. + - `ConsensusState::Yes` — sufficient agreement reached (normally ≥80% + when counting self if proposing; bypassed + entirely when `stalled` is `true`). + @param prevProposers proposers in the last closing (not including us) @param currentProposers proposers in this closing so far (not including us) @param currentAgree proposers who agree with us @@ -70,6 +105,7 @@ shouldCloseLedger( @param proposing whether we should count ourselves @param j journal for logging @param clog log object to which to append + @return a `ConsensusState` value classifying the current convergence state. */ ConsensusState checkConsensus( @@ -285,22 +321,30 @@ class Consensus using Result = ConsensusResult; - // Helper class to ensure adaptor is notified whenever the ConsensusMode - // changes + /** Wraps `ConsensusMode` so that every transition notifies the adaptor. + * + * Every call to `set()` invokes `adaptor_.onModeChange(before, after)` + * before updating the stored value, making silent mode changes + * structurally impossible. + */ class MonitoredMode { ConsensusMode mode_; public: + /** Construct with an initial mode; does not call `onModeChange`. */ MonitoredMode(ConsensusMode m) : mode_{m} { } + + /** Return the current consensus mode. */ [[nodiscard]] ConsensusMode get() const { return mode_; } + /** Transition to @p mode, notifying @p a via `onModeChange`. */ void set(ConsensusMode mode, Adaptor& a) { @@ -310,7 +354,11 @@ class Consensus }; public: - //! Clock type for measuring time within the consensus code + /** Steady-clock abstraction used to sample consensus progress. + * + * The indirection through `AbstractClock` allows unit tests to inject a + * deterministic manual clock without changing production code paths. + */ using clock_type = beast::AbstractClock; Consensus(Consensus&&) noexcept = default; @@ -408,6 +456,13 @@ public: return prevLedgerID_; } + /** Return the current consensus phase. + * + * Transitions: `open → establish → accepted`. The machine returns to + * `open` at the start of each new round via `startRound()`. + * + * @return the current `ConsensusPhase` value. + */ [[nodiscard]] ConsensusPhase phase() const { @@ -425,6 +480,19 @@ public: getJson(bool full) const; private: + /** Initialize internal state for a new consensus round. + * + * Called by both `startRound()` (normal entry) and `handleWrongLedger()` + * (ledger-switch recovery). Resets all per-round state, computes the + * next close-time resolution, and replays buffered peer proposals. + * + * @param now current network-adjusted time + * @param prevLedgerID ID of the ledger this round builds on + * @param prevLedger the ledger this round builds on + * @param mode starting consensus mode (e.g. `Proposing`, + * `SwitchedLedger`) + * @param clog optional diagnostic log + */ void startRoundInternal( NetClock::time_point const& now, @@ -433,48 +501,82 @@ private: ConsensusMode mode, std::unique_ptr const& clog); - // Change our view of the previous ledger + /** Recover from discovering we are on the wrong previous ledger. + * + * Broadcasts a bow-out proposal, drops to `observing` mode, clears all + * peer state, and replays buffered proposals for `lgrId`. If the correct + * ledger is already available locally, calls `startRoundInternal()` in + * `SwitchedLedger` mode; otherwise enters `WrongLedger` mode and waits. + * + * @param lgrId the ledger ID the network considers correct + * @param clog optional diagnostic log + */ void handleWrongLedger( typename Ledger_t::ID const& lgrId, std::unique_ptr const& clog); - /** Check if our previous ledger matches the network's. - - If the previous ledger differs, we are no longer in sync with - the network and need to bow out/switch modes. - */ + /** Verify that our working ledger matches the network's preferred ledger. + * + * Queries `adaptor_.getPrevLedger()` on every timer tick. If the + * returned ID diverges from `prevLedgerID_`, calls + * `handleWrongLedger()` to initiate recovery. Also fires when the + * locally-held ledger object does not match the expected ID (e.g. the + * object arrived after the ID was already known). + * + * @param clog optional diagnostic log + */ void checkLedger(std::unique_ptr const& clog); - /** If we radically changed our consensus context for some reason, - we need to replay recent proposals so that they're not lost. - */ + /** Replay buffered peer proposals for the current `prevLedgerID_`. + * + * Called after a ledger switch so that proposals received while the node + * was on the wrong ledger are not discarded. Iterates + * `recentPeerPositions_` (capped at 10 per peer) and feeds any proposal + * whose `prevLedger()` matches `prevLedgerID_` back through + * `peerProposalInternal()`. Accepted proposals are re-shared with peers. + */ void playbackProposals(); - /** Handle a replayed or a new peer proposal. + /** Process a peer proposal, whether newly received or replayed. + * + * Shared implementation for `peerProposal()` (live path) and + * `playbackProposals()` (replay path). Short-circuits if the phase is + * `accepted` or the proposal's `prevLedger()` does not match our own. + * Handles bow-outs by removing the peer from disputes and `deadNodes_`. + * + * @param now current network-adjusted time + * @param newProposal the peer's position to process + * @return `true` if the proposal was accepted and should be relayed. */ bool peerProposalInternal(NetClock::time_point const& now, PeerPosition_t const& newProposal); - /** Handle pre-close phase. - - In the pre-close phase, the ledger is open as we wait for new - transactions. After enough time has elapsed, we will close the ledger, - switch to the establish phase and start the consensus process. - */ + /** Drive the open phase on each timer tick. + * + * Computes the time since the previous close (using the ledger's recorded + * close time when it was agreed upon, falling back to the internally + * tracked `prevCloseTime_` otherwise) and calls `shouldCloseLedger()`. + * Transitions to `establish` by calling `closeLedger()` when ready. + * + * @param clog optional diagnostic log + */ void phaseOpen(std::unique_ptr const& clog); - /** Handle establish phase. - - In the establish phase, the ledger has closed and we work with peers - to reach consensus. Update our position only on the timer, and in this - phase. - - If we have consensus, move to the accepted phase. - */ + /** Drive the establish phase on each timer tick. + * + * After the ledger closes, each tick calls `updateOurPositions()`, + * then checks `shouldPause()` and `haveConsensus()`. The minimum guard + * `parms.ledgerMIN_CONSENSUS` is enforced before any position updates + * begin, giving every node a chance to cast an initial vote. Transitions + * to `accepted` by setting `phase_` and calling `adaptor_.onAccept()` + * once both transaction-set and close-time consensus are reached. + * + * @param clog optional diagnostic log + */ void phaseEstablish(std::unique_ptr const& clog); @@ -498,110 +600,215 @@ private: * 80%, the 2nd phase would be 90%. Once the final phase is reached, * if consensus still fails to occur, the cycle is begun again at phase 1. * - * @return Whether to pause to wait for lagging proposers. + * @param clog optional diagnostic log + * @return `true` if consensus should pause to wait for lagging validators. */ [[nodiscard]] bool shouldPause(std::unique_ptr const& clog) const; - // Close the open ledger and establish initial position. + /** Freeze the open ledger and establish this node's initial position. + * + * Transitions phase to `establish`, calls `adaptor_.onClose()` to produce + * the initial `ConsensusResult`, broadcasts the resulting transaction set, + * and (when proposing) sends the initial position to peers. Also creates + * disputes against any peer positions already received. + * + * @pre `result_` must be empty (asserted internally). + * @param clog optional diagnostic log + */ void closeLedger(std::unique_ptr const& clog); - // Adjust our positions to try to agree with other validators. + /** Adjust our transaction set and close-time position to converge with peers. + * + * Called each establish-phase tick after `ledgerMIN_CONSENSUS` elapses. + * Steps: + * 1. Prune stale peer proposals (older than `parms.proposeFRESHNESS`). + * 2. Run avalanche voting on each `DisputedTx` via `updateVote()`; + * rebuild the `MutableTxSet` if any vote flipped. + * 3. Determine close-time consensus using the current avalanche weight. + * 4. If our position changed (tx set or close time), re-share the new + * set and re-propose. + * + * @param clog optional diagnostic log + */ void updateOurPositions(std::unique_ptr const& clog); + /** Evaluate whether the round has converged and, if so, complete it. + * + * Counts peers that agree and disagree with our current position, detects + * the `stalled` condition (close-time agreement reached but no peer vote + * has changed in `avSTALLED_ROUNDS` ticks), then calls `checkConsensus()` + * to classify the state. On `Expired`, calls `leaveConsensus()`. + * + * @param clog optional diagnostic log + * @return `true` if consensus has been reached (`Yes`, `MovedOn`, or + * `Expired`); `false` to continue the establish phase. + */ bool haveConsensus(std::unique_ptr const& clog); - // Create disputes between our position and the provided one. + /** Create `DisputedTx` objects for every transaction that differs between + * our current position and @p o. + * + * Uses `result_->compares` as a work-avoidance set: if @p o has already + * been compared this round, the call is a no-op. Each new `DisputedTx` + * is immediately populated with all current peer votes and shared with + * peers via `adaptor_.share()`. + * + * @param o the peer's transaction set to compare against ours + * @param clog optional diagnostic log + */ void createDisputes(TxSet_t const& o, std::unique_ptr const& clog = {}); - // Update our disputes given that this node has adopted a new position. - // Will call createDisputes as needed. + /** Register one peer's vote across all existing disputes. + * + * Calls `createDisputes()` first if @p other has not been seen before, + * then iterates all disputes and calls `setVote()` for @p node. Any + * vote change resets `peerUnchangedCounter_` to zero, which feeds the + * stall-detection logic in `haveConsensus()`. + * + * @param node the peer whose position changed + * @param other the peer's new transaction set + */ void updateDisputes(NodeID_t const& node, TxSet_t const& other); - // Revoke our outstanding proposal, if any, and cease proposing - // until this round ends. + /** Broadcast a bow-out and drop to `observing` mode. + * + * If the current mode is `Proposing` and we have an active position, + * calls `result_->position.bowOut()` and proposes the updated (bow-out) + * position so peers remove us from their vote counts. Then transitions + * mode to `Observing`. Idempotent if already observing. + * + * @param clog optional diagnostic log + */ void leaveConsensus(std::unique_ptr const& clog); - // The rounded or effective close time estimate from a proposer + /** Round @p raw to the current close-time resolution. + * + * Delegates to `roundCloseTime(raw, closeResolution_)`. All peer and + * self close-time votes must be rounded before comparison so that minor + * clock differences do not prevent agreement. + * + * @param raw unrounded close-time estimate + * @return the estimate rounded to `closeResolution_` + */ [[nodiscard]] NetClock::time_point asCloseTime(NetClock::time_point raw) const; private: Adaptor& adaptor_; + /** Current phase of the state machine (open → establish → accepted). */ ConsensusPhase phase_{ConsensusPhase::Accepted}; + + /** Current operating mode, wrapped to fire `onModeChange` on transitions. */ MonitoredMode mode_{ConsensusMode::Observing}; + + /** `true` until the first call to `startRound()` completes. + * + * On the very first round, `prevCloseTime_` is seeded from the genesis + * ledger's close time rather than the previous round's self-estimate. + */ bool firstRound_ = true; + + /** `true` once enough peers agree on a close time this establish phase. */ bool haveCloseTimeConsensus_ = false; + /** Steady clock used to measure round durations. */ clock_type const& clock_; - // How long the consensus convergence has taken, expressed as - // a percentage of the time that we expected it to take. + /** Round duration so far expressed as a percentage of `prevRoundTime_`. + * + * Used as the time axis for the avalanche state machine. A value of 100 + * means this round has taken exactly as long as the previous one. + */ int convergePercent_{0}; - // How long has this round been open + /** Elapsed time since the ledger was opened (steady-clock domain). */ ConsensusTimer openTime_; + /** Granularity at which close times are rounded for this round. */ NetClock::duration closeResolution_ = kLEDGER_DEFAULT_TIME_RESOLUTION; + /** Current avalanche state for close-time voting (separate from tx voting). */ ConsensusParms::AvalancheState closeTimeAvalancheState_ = ConsensusParms::AvalancheState::Init; - // Time it took for the last consensus round to converge + /** How long the previous round took to converge. + * + * Feeds the `convergePercent_` calculation and the `shouldCloseLedger` + * rate-limit check. + */ std::chrono::milliseconds prevRoundTime_{}; - //------------------------------------------------------------------------- - // Network time measurements of consensus progress + // --- Network-time measurements of consensus progress --- - // The current network adjusted time. This is the network time the - // ledger would close if it closed now + /** Most recent network-adjusted time supplied to the state machine. */ NetClock::time_point now_; + + /** Our internally tracked estimate of when the previous ledger closed. + * + * Used as the close-time reference when the ledger's own recorded close + * time is not trustworthy (mode is `WrongLedger`, or peers did not agree + * on close time). + */ NetClock::time_point prevCloseTime_; - //------------------------------------------------------------------------- - // Non-peer (self) consensus data + // --- Self (non-peer) consensus data --- - // Last validated ledger ID provided to consensus + /** ID of the ledger this consensus round is building on. */ typename Ledger_t::ID prevLedgerID_; - // Last validated ledger seen by consensus + + /** The ledger this consensus round is building on. */ Ledger_t previousLedger_; - // Transaction Sets, indexed by hash of transaction tree + /** Transaction sets acquired from the network this round, keyed by ID. */ hash_map acquired_; + /** Current round's result (position + disputes). Empty between rounds. */ std::optional result_; + + /** Raw (unrounded) close-time votes from peers and self for this round. */ ConsensusCloseTimes rawCloseTimes_; - // The number of calls to phaseEstablish where none of our peers - // have changed any votes on disputed transactions. + /** Number of consecutive establish-phase ticks with no peer vote change. + * + * Reset to zero by `updateDisputes()` / `createDisputes()` whenever any + * `DisputedTx::setVote()` call returns `true`. Feeds the stall-detection + * predicate in `haveConsensus()`. + */ std::size_t peerUnchangedCounter_ = 0; - // The total number of times we have called phaseEstablish + /** Total number of times `phaseEstablish()` has been called this round. + * + * Guards against declaring `Expired` before each avalanche level has had + * at least `avMIN_ROUNDS` ticks. + */ std::size_t establishCounter_ = 0; - //------------------------------------------------------------------------- - // Peer related consensus data + // --- Peer-related consensus data --- - // Peer proposed positions for the current round + /** Active peer positions for the current round, keyed by node ID. */ hash_map currPeerPositions_; - // Recently received peer positions, available when transitioning between - // ledgers or rounds + /** Ring buffer of recent proposals per peer (capped at 10 per peer). + * + * Stored regardless of which ledger they reference so that + * `playbackProposals()` can replay them after a ledger switch. + */ hash_map> recentPeerPositions_; - // The number of proposers who participated in the last consensus round + /** Number of proposers that participated in the previous consensus round. */ std::size_t prevProposers_ = 0; - // nodes that have bowed out of this consensus process + /** Peers that have bowed out of this round; permanently excluded. */ hash_set deadNodes_; - // Journal for debugging + /** Journal used for debug and informational logging. */ beast::Journal const j_; }; @@ -1021,7 +1228,6 @@ Consensus::getJson(bool full) const return ret; } -// Handle a change in the prior ledger during a consensus round template void Consensus::handleWrongLedger( diff --git a/src/xrpld/consensus/ConsensusParms.h b/src/xrpld/consensus/ConsensusParms.h index 88a6318b3c..f32852d24c 100644 --- a/src/xrpld/consensus/ConsensusParms.h +++ b/src/xrpld/consensus/ConsensusParms.h @@ -1,3 +1,14 @@ +/** @file + * Single source of truth for every numeric constant governing the XRP Ledger + * consensus algorithm. + * + * Two temporal domains are represented: validation/proposal parameters use + * NetClock second resolution because they are compared against ledger close + * timestamps shared across the network; consensus-loop timers use millisecond + * resolution against an internal monotonic clock for sub-second granularity. + * Do not mix values from these two domains. + */ + #pragma once #include @@ -10,144 +21,282 @@ namespace xrpl { -/** Consensus algorithm parameters - - Parameters which control the consensus algorithm. This are not - meant to be changed arbitrarily. -*/ +/** Immutable bundle of tuning constants for the consensus algorithm. + * + * All members are `const`, making instances effectively compile-time + * named-constant bundles. Simulation harnesses may construct a custom + * instance with alternate values injected via designated initializers; + * production code uses the defaults. + * + * @note Two temporal domains coexist in this struct. Parameters compared + * against network ledger close times (validations, proposals) are in + * NetClock seconds. Parameters governing the internal consensus timer + * are in milliseconds. See member-group comments for which domain + * each constant belongs to. + */ struct ConsensusParms { explicit ConsensusParms() = default; - //------------------------------------------------------------------------- - // Validation and proposal durations are relative to NetClock times, so use - // second resolution - /** The duration a validation remains current after its ledger's - close time. + // --- NetClock-domain parameters (second resolution) --- - This is a safety to protect against very old validations and the time - it takes to adjust the close time accuracy window. - */ + /** Maximum age of a validation relative to its ledger's close time. + * + * Validations whose `signTime` is more than this duration after the + * ledger's close time are rejected. Guards against stale validations + * that reference ledger close timestamps far in the past and protects + * the close-time accuracy adjustment window. + */ std::chrono::seconds const validationVALID_WALL = std::chrono::minutes{5}; - /** Duration a validation remains current after first observed. - - The duration a validation remains current after the time we - first saw it. This provides faster recovery in very rare cases where the - number of validations produced by the network is lower than normal - */ + /** Maximum age of a validation relative to when we first observed it. + * + * A validation is kept current for this long after the local wall clock + * at first observation, independent of the ledger close time. Enables + * faster recovery in rare network conditions where the total number of + * validations produced is unusually low. + */ std::chrono::seconds const validationVALID_LOCAL = std::chrono::minutes{3}; - /** Duration pre-close in which validations are acceptable. - - The number of seconds before a close time that we consider a validation - acceptable. This protects against extreme clock errors - */ + /** How far before a ledger's close time a validation is still acceptable. + * + * A validation timestamped up to this duration *before* the ledger's + * recorded close time is accepted. Provides tolerance for extreme + * inter-node clock skew without rejecting honest validators. + */ std::chrono::seconds const validationVALID_EARLY = std::chrono::minutes{3}; - //! How long we consider a proposal fresh + /** Maximum age of a peer proposal before it is discarded as stale. + * + * Proposals older than this threshold are pruned during each call to + * `updateOurPositions()`. Must be greater than `proposeINTERVAL` so + * that actively-proposing peers are never pruned. + */ std::chrono::seconds const proposeFRESHNESS = std::chrono::seconds{20}; - //! How often we force generating a new proposal to keep ours fresh + /** How often a node re-broadcasts its position to keep it fresh. + * + * Even when the node's position has not changed, it re-proposes on + * this interval to prevent peers from discarding it as stale. Must + * be less than `proposeFRESHNESS`. + */ std::chrono::seconds const proposeINTERVAL = std::chrono::seconds{12}; - //------------------------------------------------------------------------- - // Consensus durations are relative to the internal Consensus clock and use - // millisecond resolution. + // --- Monotonic-clock-domain parameters (millisecond resolution) --- - //! The percentage threshold above which we can declare consensus. + /** Minimum fraction of peers that must agree before consensus is declared. + * + * Used by `checkConsensus()` and `DisputedTx::stalled()`. A round + * reaches consensus only when at least this percentage of current + * proposers agree on the same tx set. Also used as the split threshold + * for stall detection: a vote is considered stalled when the winning + * side exceeds this percentage in either direction. + */ std::size_t const minCONSENSUS_PCT = 80; - //! The duration a ledger may remain idle before closing + /** Maximum time a ledger may remain open with no activity before closing. + * + * If no transactions arrive and the network is quiet, the ledger is + * force-closed after this interval to keep the chain advancing. + */ std::chrono::milliseconds const ledgerIDLE_INTERVAL = std::chrono::seconds{15}; - //! The number of seconds we wait minimum to ensure participation + /** Minimum time spent in the establish phase before positions are updated. + * + * Ensures all peers have had at least one opportunity to participate + * before the engine starts adjusting its tx-set position based on + * peer votes. + */ std::chrono::milliseconds const ledgerMIN_CONSENSUS = std::chrono::milliseconds{1950}; - /** The maximum amount of time to spend pausing for laggards. + /** Maximum time the engine waits for laggard validators before closing. * - * This should be sufficiently less than validationFRESHNESS so that - * validators don't appear to be offline that are merely waiting for - * laggards. + * Must remain comfortably below `validationFRESHNESS` (20 s) so that + * a validator pausing for laggards is not mistaken for an offline node + * by its peers. Bounds the normal consensus window from above; + * `ledgerABANDON_CONSENSUS` is the hard outer limit. */ std::chrono::milliseconds const ledgerMAX_CONSENSUS = std::chrono::seconds{15}; - //! Minimum number of seconds to wait to ensure others have computed the LCL + /** Minimum wait after ledger close before the next round may begin. + * + * Gives all peers time to finish computing the last-closed ledger (LCL) + * before the engine opens the next round and starts processing proposals. + */ std::chrono::milliseconds const ledgerMIN_CLOSE = std::chrono::seconds{2}; - //! How often we check state or change positions + /** How often the consensus timer fires and progress is evaluated. + * + * The establish phase calls `timerEntry()` on this interval. The test + * suite treats this value as one unit of simulated time when connecting + * peers at fractions of the granularity. + */ std::chrono::milliseconds const ledgerGRANULARITY = std::chrono::seconds{1}; - //! How long to wait before completely abandoning consensus + /** Multiplier used to compute the per-round abandon deadline. + * + * The actual abandon timeout is `previousAgreeTime * this factor`, + * clamped to `[ledgerMAX_CONSENSUS, ledgerABANDON_CONSENSUS]`. The + * factor guards against aborting a slow-but-progressing round: a round + * that is merely twice as slow as normal will not be abandoned unless + * it also exceeds the absolute cap. + * + * @see ledgerABANDON_CONSENSUS + */ std::size_t const ledgerABANDON_CONSENSUS_FACTOR = 10; - /** - * Maximum amount of time to give a consensus round + /** Absolute upper bound on any consensus round's duration. * - * Does not include the time to build the LCL, so there is no reason for a - * round to go this long, regardless of how big the ledger is. + * Does not include the time to build the LCL. After this timeout + * `checkConsensus()` returns `ConsensusState::Expired` regardless of + * agreement level. In practice the computed deadline + * (`previousAgreeTime * ledgerABANDON_CONSENSUS_FACTOR`) will hit this + * cap only for rounds many times longer than average. + * + * @see ledgerABANDON_CONSENSUS_FACTOR */ std::chrono::milliseconds const ledgerABANDON_CONSENSUS = std::chrono::seconds{120}; - /** The minimum amount of time to consider the previous round - to have taken. - - The minimum amount of time to consider the previous round - to have taken. This ensures that there is an opportunity - for a round at each avalanche threshold even if the - previous consensus was very fast. This should be at least - twice the interval between proposals (0.7s) divided by - the interval between mid and late consensus ([85-50]/100). - */ + /** Floor on the "previous round duration" used for avalanche time scaling. + * + * When computing `convergePercent_` (elapsed ms / reference duration), + * the reference is `max(prevRoundTime_, avMIN_CONSENSUS_TIME)`. This + * floor ensures that every avalanche state threshold is reachable even + * when the prior round was unusually fast. Derived constraint: value + * must be at least `2 * proposeINTERVAL / (latePct - midPct)` = + * `2 * 0.7 s / 0.35 ≈ 4 s`. + */ std::chrono::milliseconds const avMIN_CONSENSUS_TIME = std::chrono::seconds{5}; - //------------------------------------------------------------------------------ - // Avalanche tuning - // As a function of the percent this round's duration is of the prior round, - // we increase the threshold for yes votes to add a transaction to our - // position. + // --- Avalanche tuning --- + + /** Stages of the avalanche convergence ratchet for transaction voting. + * + * States advance monotonically `Init → Mid → Late → Stuck`. Once + * `Stuck`, the threshold stays at 95 % until the round ends — + * the state never relaxes. See `avalancheCutoffs` for the time and + * percentage associated with each state. + */ enum class AvalancheState { Init, Mid, Late, Stuck }; + + /** Time and percentage thresholds for one avalanche state. + * + * Bundles the three facts the engine needs for each `AvalancheState`: + * when to enter it, what yes-vote fraction is required while in it, + * and which state to advance to next. + */ struct AvalancheCutoff { + /** Percentage of the previous round's duration that must have elapsed + * before this state activates (0 = immediately, 200 = twice as long). + */ int const consensusTime; + + /** Minimum yes-vote percentage required to include a transaction while + * in this state. + */ std::size_t const consensusPct; + + /** The next `AvalancheState` to advance to once `consensusTime` and + * `avMIN_ROUNDS` are satisfied. `Stuck` maps to itself. + */ AvalancheState const next; }; - //! Map the consensus requirement avalanche state to the amount of time that - //! must pass before moving to that state, the agreement percentage required - //! at that state, and the next state. "stuck" loops back on itself because - //! once we're stuck, we're stuck. - //! This structure allows for "looping" of states if needed. + + /** Data-driven avalanche state machine governing transaction-vote convergence. + * + * Maps each `AvalancheState` to its `AvalancheCutoff`. The ratchet is + * asymmetric: thresholds rise over time to force convergence by attrition. + * + * | State | Time threshold | Required yes-vote | Next state | + * |--------|---------------|-------------------|------------| + * | Init | 0 % | 50 % | Mid | + * | Mid | 50 % | 65 % | Late | + * | Late | 85 % | 70 % | Stuck | + * | Stuck | 200 % | 95 % | Stuck | + * + * Using a `std::map` rather than a `switch` keeps traversal data-driven + * and would support hypothetical looping state machines. All four keys + * are always present; `at()` calls on this map are safe. + * + * @note Once in `Stuck`, the 95 % threshold makes it effectively + * impossible to add new transactions, forcing disputes to resolve + * by attrition rather than new votes. + */ std::map const avalancheCutoffs{ - // {state, {time, percent, nextState}}, - // Initial state: 50% of nodes must vote yes {AvalancheState::Init, {.consensusTime = 0, .consensusPct = 50, .next = AvalancheState::Mid}}, - // mid-consensus starts after 50% of the previous round time, and - // requires 65% yes {AvalancheState::Mid, {.consensusTime = 50, .consensusPct = 65, .next = AvalancheState::Late}}, - // late consensus starts after 85% time, and requires 70% yes {AvalancheState::Late, {.consensusTime = 85, .consensusPct = 70, .next = AvalancheState::Stuck}}, - // we're stuck after 2x time, requires 95% yes votes {AvalancheState::Stuck, {.consensusTime = 200, .consensusPct = 95, .next = AvalancheState::Stuck}}, }; - //! Percentage of nodes required to reach agreement on ledger close time + /** Minimum yes-vote percentage required to agree on the ledger close time. + * + * Used exclusively for close-time consensus, which is a simpler majority + * question and does not go through the multi-round avalanche ratchet. + * Deliberately lower than `minCONSENSUS_PCT` (80 %) because close-time + * disagreement is less critical than tx-set disagreement. + */ std::size_t const avCT_CONSENSUS_PCT = 75; - //! Number of rounds before certain actions can happen. - // (Moving to the next avalanche level, considering that votes are stalled - // without consensus.) + /** Minimum rounds a node must spend in an avalanche state before advancing. + * + * Guards against premature state escalation caused by clock jitter: + * even when the elapsed-time percentage is high enough to warrant + * the next state, the node stays put until it has completed at least + * this many voting rounds. Also used in `haveConsensus()` to set the + * minimum round counter before declaring consensus. + */ std::size_t const avMIN_ROUNDS = 2; - //! Number of rounds before a stuck vote is considered unlikely to change - //! because voting stalled + /** Rounds of unchanged peer votes before a dispute is declared stalled. + * + * `DisputedTx::stalled()` returns `true` when either + * `peerUnchangedCounter_` or `currentVoteCounter_` reaches this value, + * signalling that further voting is unlikely to produce a different + * outcome and the engine should treat the dispute as deadlocked. + */ std::size_t const avSTALLED_ROUNDS = 4; }; +/** Query the avalanche state machine for the current vote threshold. + * + * Returns the yes-vote percentage required right now and, if the engine + * should advance to the next `AvalancheState`, the new state. The caller + * is responsible for persisting the state transition: + * @code + * auto const [pct, newState] = getNeededWeight(p, state_, pct_, rounds_, p.avMIN_ROUNDS); + * if (newState) + * state_ = *newState; + * @endcode + * + * The `minimumRounds` guard prevents premature escalation on clock jitter: + * a state transition only occurs when both the time percentage *and* the + * round count conditions are satisfied. + * + * Called per-transaction by `DisputedTx::updateVote()` and once per tick + * for close-time consensus in `Consensus::updateOurPositions()`. The + * close-time path always passes `currentRounds = 0` and `minimumRounds = 0` + * because close-time convergence does not track discrete voting rounds. + * + * @param p The consensus parameter bundle. + * @param currentState The avalanche state the caller is currently in. + * @param percentTime Elapsed time expressed as a percentage of the previous + * round's duration (i.e. `convergePercent_`). + * @param currentRounds Number of voting rounds spent in `currentState`. + * @param minimumRounds Minimum rounds required before a state transition is + * allowed. Pass `p.avMIN_ROUNDS` for transaction voting; pass `0` for + * close-time consensus. + * @return A pair of: (1) the `consensusPct` in effect for the current tick, + * and (2) an optional next `AvalancheState` — `nullopt` when no + * transition occurs, otherwise the state the caller should advance to. + * @note `at()` calls on `avalancheCutoffs` are safe because the map is + * constructed with all four valid keys. + */ inline std::pair> getNeededWeight( ConsensusParms const& p, @@ -156,16 +305,10 @@ getNeededWeight( std::size_t currentRounds, std::size_t minimumRounds) { - // at() can throw, but the map is built by hand to ensure all valid - // values are available. auto const& currentCutoff = p.avalancheCutoffs.at(currentState); - // Should we consider moving to the next state? if (currentCutoff.next != currentState && currentRounds >= minimumRounds) { - // at() can throw, but the map is built by hand to ensure all - // valid values are available. auto const& nextCutoff = p.avalancheCutoffs.at(currentCutoff.next); - // See if enough time has passed to move on to the next. XRPL_ASSERT( nextCutoff.consensusTime >= currentCutoff.consensusTime, "xrpl::getNeededWeight : next state valid"); diff --git a/src/xrpld/consensus/ConsensusProposal.h b/src/xrpld/consensus/ConsensusProposal.h index 24a31b3820..5dbee2100e 100644 --- a/src/xrpld/consensus/ConsensusProposal.h +++ b/src/xrpld/consensus/ConsensusProposal.h @@ -1,3 +1,14 @@ +/** @file + * Defines ConsensusProposal, the fundamental unit of peer communication in + * the XRP Ledger BFT consensus protocol. + * + * Each round, every participating validator broadcasts a ConsensusProposal + * identifying the transaction set it believes should be included in the next + * ledger and when that ledger should close. The class is a header-only + * template decoupled from concrete XRPL types; the production instantiation + * is `ConsensusProposal` wrapped by `RCLCxPeerPos` + * with a cryptographic signature for network propagation. + */ #pragma once #include @@ -12,28 +23,33 @@ #include namespace xrpl { -/** Represents a proposed position taken during a round of consensus. - - During consensus, peers seek agreement on a set of transactions to - apply to the prior ledger to generate the next ledger. Each peer takes a - position on whether to include or exclude potential transactions. - The position on the set of transactions is proposed to its peers as an - instance of the ConsensusProposal class. - - An instance of ConsensusProposal can be either our own proposal or one of - our peer's. - - As consensus proceeds, peers may change their position on the transaction, - or choose to abstain. Each successive proposal includes a strictly - monotonically increasing number (or, if a peer is choosing to abstain, - the special value `kSEQ_LEAVE`). - - Refer to @ref Consensus for requirements of the template arguments. - - @tparam NodeId Type used to uniquely identify nodes/peers - @tparam LedgerId Type used to uniquely identify ledgers - @tparam Position Type used to represent the position taken on transactions - under consideration during this round of consensus +/** A proposed position broadcast by a validator during a consensus round. + * + * Encapsulates a peer's view of two things: which transaction set (identified + * by `Position`) should be included in the next ledger, and when that ledger + * should close (`NetClock::time_point`). Either field may be updated during + * the round via `changePosition()`; each update increments `proposeSeq_` so + * recipients can discard out-of-order or duplicate messages. A peer that + * withdraws from consensus entirely calls `bowOut()`, which sets `proposeSeq_` + * to the sentinel `kSEQ_LEAVE`. + * + * This class is a header-only template to keep the consensus engine decoupled + * from concrete XRPL types. Production code instantiates it as + * `ConsensusProposal` inside `RCLCxPeerPos`, which + * adds a cryptographic signature. Unit tests may use simple integer types. + * + * @note The signing hash covers five fields (prefix, seq, close time, prev + * ledger, position) and is lazily cached in `signingHash_`. Callers + * must not mutate the proposal without going through `changePosition()` or + * `bowOut()`, both of which invalidate the cache via `signingHash_.reset()`. + * + * Refer to @ref Consensus for requirements of the template arguments. + * + * @tparam NodeId Type used to uniquely identify nodes/peers + * @tparam LedgerId Type used to uniquely identify ledgers + * @tparam Position Type used to represent the position taken on transactions + * under consideration during this round of consensus (typically a tx-set + * hash, not the full set) */ template class ConsensusProposal @@ -41,21 +57,37 @@ class ConsensusProposal public: using NodeID = NodeId; - //< Sequence value when a peer initially joins consensus + /** Sequence value for a peer's first proposal in a round. + * + * `isInitial()` tests for this sentinel. The consensus engine collects + * `closeTime()` from all initial proposals to measure inter-peer clock + * drift via `ConsensusCloseTimes`. + */ static std::uint32_t const kSEQ_JOIN = 0; - //< Sequence number when a peer wants to bow out and leave consensus + /** Sequence value signalling that a peer is voluntarily leaving consensus. + * + * `isBowOut()` tests for this sentinel. Once set, `changePosition()` + * will not increment the sequence number further, preventing accidental + * re-entry. Receiving peers remove the bowing-out node from + * `currPeerPositions_` and add it to `deadNodes_` for the remainder of + * the round. + */ static std::uint32_t const kSEQ_LEAVE = 0xffffffff; - /** Constructor - - @param prevLedger The previous ledger this proposal is building on. - @param seq The sequence number of this proposal. - @param position The position taken on transactions in this round. - @param closeTime Position of when this ledger closed. - @param now Time when the proposal was taken. - @param nodeID ID of node/peer taking this position. - */ + /** Construct a proposal for the given round and position. + * + * @param prevLedger ID of the ledger this proposal builds on. + * @param seq Sequence number of this proposal; use `kSEQ_JOIN` (0) for + * a node's first proposal in the round. + * @param position Hash of the transaction set this node proposes to + * include in the next ledger. + * @param closeTime This node's estimate (in `NetClock`) of when the + * ledger should close. + * @param now Wall-clock time at which the proposal was created; stored + * as `seenTime()` for staleness detection. + * @param nodeID Identifier of the node originating this proposal. + */ ConsensusProposal( LedgerId const& prevLedger, std::uint32_t seq, @@ -72,83 +104,124 @@ public: { } - //! Identifying which peer took this position. + /** Identifier of the node that originated this proposal. */ NodeId const& nodeID() const { return nodeID_; } - //! Get the proposed position. + /** Hash identifying the transaction set this node proposes to include. */ Position const& position() const { return position_; } - //! Get the prior accepted ledger this position is based on. + /** ID of the ledger this proposal is building upon. + * + * The consensus engine rejects any incoming proposal whose `prevLedger()` + * does not match the current working ledger ID, preventing split-brain + * when a ledger switch is in progress. + */ LedgerId const& prevLedger() const { return previousLedger_; } - /** Get the sequence number of this proposal - - Starting with an initial sequence number of `kSEQ_JOIN`, successive - proposals from a peer will increase the sequence number. - - @return the sequence number - */ + /** Monotonically increasing counter ordering this peer's proposals. + * + * Starts at `kSEQ_JOIN` (0) for the first proposal of the round. + * Incremented by `changePosition()` on each position update. + * Set to `kSEQ_LEAVE` (0xffffffff) when the peer bows out. + * The sequence number is included in the signing hash, so stale + * proposals cannot be replayed with a forged higher sequence. + * + * @return The current proposal sequence number. + */ std::uint32_t proposeSeq() const { return proposeSeq_; } - //! The current position on the consensus close time. + /** This node's estimate of when the ledger should close, in `NetClock`. + * + * @note This is a `NetClock` value representing the proposer's preferred + * ledger-close time — it is unrelated to the local wall-clock + * observation time returned by `seenTime()`. Conflating the two + * would be a temporal-domain bug. + */ NetClock::time_point const& closeTime() const { return closeTime_; } - //! Get when this position was taken. + /** Local wall-clock time when this proposal was last created or updated. + * + * Used by `isStale()` to detect peers that have gone silent. This is + * the time the proposal was *observed locally*, not the proposer's + * estimate of the ledger close time (see `closeTime()`). + */ NetClock::time_point const& seenTime() const { return time_; } - /** Whether this is the first position taken during the current - consensus round. - */ + /** Whether this is the first position broadcast in the current round. + * + * True when `proposeSeq_ == kSEQ_JOIN` (0). The consensus engine + * records the `closeTime()` of all initial proposals in + * `ConsensusCloseTimes` to measure inter-peer clock drift. + */ bool isInitial() const { return proposeSeq_ == kSEQ_JOIN; } - //! Get whether this node left the consensus process + /** Whether this peer has voluntarily withdrawn from the consensus round. + * + * True when `proposeSeq_ == kSEQ_LEAVE` (0xffffffff). Receiving peers + * erase the node from active peer positions and add it to `deadNodes_`, + * permanently excluding its votes for the remainder of the round. + */ bool isBowOut() const { return proposeSeq_ == kSEQ_LEAVE; } - //! Get whether this position is stale relative to the provided cutoff + /** Whether this proposal has not been updated recently enough to act on. + * + * Compares `seenTime()` (local observation time) against `cutoff`. + * The consensus engine computes separate cutoffs for peer proposals and + * the local node's own position; proposals older than their cutoff are + * discarded to prevent blocking on a silently-dead peer. + * + * @param cutoff Threshold time; proposals seen at or before this instant + * are considered stale. + * @return `true` if `seenTime() <= cutoff`. + */ bool isStale(NetClock::time_point cutoff) const { return time_ <= cutoff; } - /** Update the position during the consensus process. This will increment - the proposal's sequence number if it has not already bowed out. - - @param newPosition The new position taken. - @param newCloseTime The new close time. - @param now the time The new position was taken + /** Update the transaction-set position and close-time estimate. + * + * Increments `proposeSeq_` unless the peer has already bowed out + * (`kSEQ_LEAVE`), which would otherwise allow a stale position to + * masquerade as a new one. Invalidates the cached signing hash so that + * `signingHash()` recomputes over the updated fields. + * + * @param newPosition Hash of the updated transaction set. + * @param newCloseTime Updated `NetClock` estimate of ledger close time. + * @param now Local wall-clock time of this update; stored as `seenTime()`. */ void changePosition( @@ -164,11 +237,14 @@ public: ++proposeSeq_; } - /** Leave consensus - - Update position to indicate the node left consensus. - - @param now Time when this node left consensus. + /** Signal that this node is voluntarily withdrawing from the round. + * + * Sets `proposeSeq_` to `kSEQ_LEAVE` and updates `seenTime()`. + * Invalidates the cached signing hash. After this call `isBowOut()` + * returns `true` and subsequent `changePosition()` calls will not + * increment the sequence number further. + * + * @param now Local wall-clock time of the withdrawal. */ void bowOut(NetClock::time_point now) @@ -178,6 +254,13 @@ public: proposeSeq_ = kSEQ_LEAVE; } + /** Produce a human-readable single-line summary for logging. + * + * Includes all six fields: previous ledger, sequence, position, close + * time, seen time, bow-out flag, and node ID. + * + * @return A diagnostic string; format is not stable across versions. + */ std::string render() const { @@ -189,7 +272,15 @@ public: return ss.str(); } - //! Get JSON representation for debugging + /** JSON representation of the proposal for diagnostics. + * + * Always includes `previous_ledger` and `close_time`. When the proposal + * is a bow-out (`isBowOut()` is true), `transaction_hash` and + * `propose_seq` are omitted to avoid surfacing the meaningless + * `0xffffffff` sentinel in log output. + * + * @return A JSON object suitable for debug logging or RPC responses. + */ json::Value getJson() const { @@ -209,7 +300,21 @@ public: return ret; } - //! The digest for this proposal, used for signing purposes. + /** SHA-512 half-digest covering the five signed fields of this proposal. + * + * The hash is computed over `HashPrefix::Proposal`, `proposeSeq_`, + * `closeTime_` epoch count, `previousLedger_`, and `position_` — exactly + * the fields that `RCLCxPeerPos::checkSign()` verifies against the peer's + * signature. + * + * The result is lazily cached in `signingHash_` and recomputed on first + * access after any mutation. `changePosition()` and `bowOut()` must + * call `signingHash_.reset()` before modifying fields; reading a stale + * cache would silently produce an incorrect hash. + * + * @return Reference to the cached signing hash (valid until the next + * mutation). + */ uint256 const& signingHash() const { @@ -236,7 +341,7 @@ private: //! The ledger close time this position is taking NetClock::time_point closeTime_; - // !The time this position was last updated + //! The time this position was last updated NetClock::time_point time_; //! The sequence number of these positions taken by this node @@ -249,6 +354,15 @@ private: mutable std::optional signingHash_; }; +/** Compare two proposals for equality across all six fields. + * + * Compares node ID, sequence number, previous ledger, position, close time, + * and `seenTime()`. Because `seenTime()` is the local wall-clock observation + * time, two logically identical proposals received at different instants will + * *not* compare equal. This is intentional for de-duplication within a + * single round; network-level suppression of duplicate wire messages is + * handled separately by the hash router in `RCLCxPeerPos`. + */ template bool operator==( diff --git a/src/xrpld/consensus/ConsensusTypes.h b/src/xrpld/consensus/ConsensusTypes.h index 64a7f5fdea..86fbeb29b3 100644 --- a/src/xrpld/consensus/ConsensusTypes.h +++ b/src/xrpld/consensus/ConsensusTypes.h @@ -10,44 +10,57 @@ namespace xrpl { -/** Represents how a node currently participates in Consensus. - - A node participates in consensus in varying modes, depending on how - the node was configured by its operator and how well it stays in sync - with the network during consensus. - - @code - proposing observing - \ / - \---> wrongLedger <---/ - ^ - | - | - v - switchedLedger - @endcode - - We enter the round proposing or observing. If we detect we are working - on the wrong prior ledger, we go to wrongLedger and attempt to acquire - the right one. Once we acquire the right one, we go to the switchedLedger - mode. It is possible we fall behind again and find there is a new better - ledger, moving back and forth between wrongLedger and switchLedger as - we attempt to catch up. -*/ +/** Represents how a node currently participates in consensus. + * + * A node enters each round in one of two initial states — `Proposing` or + * `Observing` — and may transition to `WrongLedger` if it detects it is + * building on the wrong prior ledger. Successfully switching to the correct + * ledger mid-round lands the node in `SwitchedLedger`. + * + * @code + * proposing observing + * \ / + * \---> wrongLedger <---/ + * ^ + * | + * v + * switchedLedger + * @endcode + * + * `SwitchedLedger` is kept distinct from `Observing` because close-time + * calculations in `Consensus.h` guard against `WrongLedger` mode when + * deciding whether the previous ledger's close time is authoritative; the + * mode label is preserved throughout the round so diagnostics and close-time + * logic can account for mid-round recovery. `MonitoredMode` inside + * `Consensus.h` wraps this enum so every transition calls + * `Adaptor::onModeChange`, making silent changes structurally impossible. + * + * @see ConsensusPhase + */ enum class ConsensusMode { - //! We are normal participant in consensus and propose our position + //! Actively broadcasting our position to the network each round. Proposing, - //! We are observing peer positions, but not proposing our position + //! Listening to peer proposals without broadcasting our own position. Observing, - //! We have the wrong ledger and are attempting to acquire it + //! Detected we are on the wrong prior ledger; attempting to acquire the + //! correct one. Most voting logic treats this like `Observing`, but + //! close-time code explicitly excludes it from "correct" close-time + //! calculations. WrongLedger, - //! We switched ledgers since we started this consensus round but are now - //! running on what we believe is the correct ledger. This mode is as - //! if we entered the round observing, but is used to indicate we did - //! have the wrongLedger at some point. + //! Successfully switched to the correct ledger mid-round. Behaves like + //! `Observing` in voting logic but preserves the history that a ledger + //! switch occurred, which diagnostics and close-time code rely on. SwitchedLedger }; +/** Return a human-readable name for a `ConsensusMode` value. + * + * Intended for logging and JSON diagnostics. Returns `"unknown"` for any + * out-of-range value. + * + * @param m The consensus mode to stringify. + * @return A lowercase string label matching the enumerator name. + */ inline std::string to_string(ConsensusMode m) { @@ -66,35 +79,51 @@ to_string(ConsensusMode m) } } -/** Phases of consensus for a single ledger round. - - @code - "close" "accept" - open ------- > establish ---------> accepted - ^ | | - |---------------| | - ^ "startRound" | - |------------------------------------| - @endcode - - The typical transition goes from open to establish to accepted and - then a call to startRound begins the process anew. However, if a wrong prior - ledger is detected and recovered during the establish or accept phase, - consensus will internally go back to open (see Consensus::handleWrongLedger). -*/ +/** Coarse phase of a single ledger consensus round. + * + * Governs the high-level state machine step the node is currently in. + * Most `Consensus` entry points (`timerEntry`, `gotTxSet`, `peerProposal`) + * short-circuit immediately when the phase is `Accepted`. + * + * @code + * "close" "accept" + * open -------> establish ---------> accepted + * ^ | | + * |---------------| | + * ^ "startRound" | + * |------------------------------------| + * @endcode + * + * The unusual backwards arc — `establish` → `open` — occurs inside + * `Consensus::handleWrongLedger` when a ledger switch is detected + * mid-round. The engine re-enters `open` on the correct ledger without + * tearing down surrounding state. + * + * @see ConsensusMode + */ enum class ConsensusPhase { - //! We haven't closed our ledger yet, but others might have + //! Accumulating transactions; no position declared yet. The node waits + //! here until `shouldCloseLedger` triggers the `close` transition. Open, - //! Establishing consensus by exchanging proposals with our peers + //! Exchanging and converging on proposals with peers via avalanche voting. + //! Ends when `checkConsensus` returns a non-`No` outcome. Establish, - //! We have accepted a new last closed ledger and are waiting on a call - //! to startRound to begin the next consensus round. No changes - //! to consensus phase occur while in this phase. + //! Round concluded; ledger committed. The node stays here until the + //! application calls `startRound` to kick off the next cycle. No + //! consensus state changes occur in this phase. Accepted, }; +/** Return a human-readable name for a `ConsensusPhase` value. + * + * Intended for logging and JSON diagnostics. Returns `"unknown"` for any + * out-of-range value. + * + * @param p The consensus phase to stringify. + * @return A lowercase string label matching the enumerator name. + */ inline std::string to_string(ConsensusPhase p) { @@ -111,7 +140,26 @@ to_string(ConsensusPhase p) } } -/** Measures the duration of phases of consensus +/** Elapsed-time stopwatch with dual real-clock and simulation tick modes. + * + * Provides a single `read()` value — milliseconds since the last `reset()` — + * via two distinct `tick()` overloads: + * + * - **Wall-clock tick**: computes `duration_cast(tp - start_)`, + * giving real elapsed time from a `steady_clock::time_point`. + * - **Fixed-increment tick**: accumulates a caller-supplied `milliseconds` + * delta, enabling deterministic simulation in unit tests without touching + * the system clock. + * + * Both paths update the same `dur_` field, so `read()` is always valid + * regardless of which variant was used. This is the timing substrate for + * `ConsensusResult::roundTime`, which `Consensus.h` uses to record how long + * the `Establish` phase lasted and feed into `prevRoundTime_` heuristics for + * the next round's timeout calculations. + * + * @note The wall-clock overload measures elapsed time from `start_`; it does + * not accumulate across calls. The fixed-increment overload does + * accumulate. Do not mix both overloads on the same timer instance. */ class ConsensusTimer { @@ -120,18 +168,37 @@ class ConsensusTimer std::chrono::milliseconds dur_{}; public: + /** Return elapsed time since the last `reset()`. + * + * @return Milliseconds elapsed, as recorded by the most recent `tick()`. + */ [[nodiscard]] std::chrono::milliseconds read() const { return dur_; } + /** Advance the timer by a fixed increment (simulation mode). + * + * Adds @p fixed to the accumulated duration. Use this overload in + * deterministic unit tests to drive the timer without a real clock. + * + * @param fixed Duration to add to the current elapsed time. + */ void tick(std::chrono::milliseconds fixed) { dur_ += fixed; } + /** Reset the timer origin to @p tp and clear the elapsed duration. + * + * Must be called before the first wall-clock `tick(time_point)` to + * establish the reference point. `Consensus::closeLedger` calls this + * when transitioning to the `Establish` phase. + * + * @param tp The new start time for elapsed-time measurement. + */ void reset(time_point tp) { @@ -139,6 +206,14 @@ public: dur_ = std::chrono::milliseconds{0}; } + /** Update elapsed time from a wall-clock sample (production mode). + * + * Sets `dur_` to `duration_cast(tp - start_)`. The + * measurement is absolute from the last `reset()`, not incremental — + * calling this multiple times with the same @p tp is idempotent. + * + * @param tp Current `steady_clock` time point. + */ void tick(time_point tp) { @@ -147,39 +222,74 @@ public: } }; -/** Stores the set of initial close times - - The initial consensus proposal from each peer has that peer's view of - when the ledger closed. This object stores all those close times for - analysis of clock drift between peers. -*/ +/** Histogram of peer-reported close times for clock-drift analysis. + * + * Each peer's initial consensus proposal (`seqJoin`) includes that peer's + * estimate of when the ledger closed. This struct collects those estimates + * so the engine can find the most-agreed-upon close-time bucket during + * close-time consensus resolution. + * + * `peers` is a `std::map` rather than an unordered container so that + * iteration is in ascending time order, giving deterministic traversal + * and reproducible bucket selection across nodes with different hash seeds. + * `rawCloseTimes_` in `Consensus.h` is the live instance populated during + * the `Establish` phase and passed to `Adaptor::onAccept` at round end. + */ struct ConsensusCloseTimes { explicit ConsensusCloseTimes() = default; - //! Close time estimates, keep ordered for predictable traverse + //! Histogram of peer close-time estimates (time → vote count); ordered + //! ascending for deterministic traversal during close-time resolution. std::map peers; - //! Our close time estimate + //! This node's own close-time estimate. NetClock::time_point self; }; -/** Whether we have or don't have a consensus */ +/** Outcome classification returned by `checkConsensus`. + * + * Every call to `checkConsensus` resolves to exactly one of these values, + * in priority order: `No` first, then `Expired`, then `MovedOn`, then `Yes`. + * Both `MovedOn` and `Expired` cause `Consensus` to transition to the + * `Accepted` phase, but they are logged and recorded differently in + * `ConsensusResult::state` for diagnostics. + */ enum class ConsensusState { - No, //!< We do not have consensus - MovedOn, //!< The network has consensus without us - Expired, //!< Consensus time limit has hard-expired - Yes //!< We have consensus along with the network + //! Insufficient time has elapsed or agreement is below threshold; the + //! establish phase continues. + No, + //! Enough of the network already validated a later ledger without us. + //! The local node accepts whatever the network decided. This is normal + //! network dynamics — a slow node catching up. + MovedOn, + //! Round time exceeded the abandon threshold (`prevAgreeTime * + //! ledgerABANDON_CONSENSUS_FACTOR`, clamped to + //! `[ledgerMAX_CONSENSUS, ledgerABANDON_CONSENSUS]`). Forces acceptance + //! to prevent indefinite stalls; rare and diagnostic-worthy. + Expired, + //! Local node and the network agree on the transaction set (normally + //! ≥80% of proposers, counting self when proposing). Also triggered + //! immediately when all disputes have a clear supermajority (`stalled` + //! path in `checkConsensus`). + Yes }; -/** Encapsulates the result of consensus. - - Stores all relevant data for the outcome of consensus on a single - ledger. - - @tparam Traits Traits class defining the concrete consensus types used - by the application. -*/ +/** Aggregated outcome of a single consensus round. + * + * Instantiated once per round inside `Consensus::closeLedger` and stored + * as `Consensus::result_` (`std::optional`). Bundles the agreed + * transaction set, the node's signed position, live dispute state, and + * round-timing data needed to hand the round off to `Adaptor::onAccept`. + * + * @invariant `txns.id() == position.position()` — the node's declared + * position is always a commitment to a specific transaction set. + * Enforced by assertion in the constructor. + * + * @tparam Traits Traits class supplying `Ledger_t`, `TxSet_t`, and + * `NodeID_t`; the same traits type used by the enclosing `Consensus` + * template. + */ template struct ConsensusResult { @@ -191,31 +301,48 @@ struct ConsensusResult using Proposal_t = ConsensusProposal; using Dispute_t = DisputedTx; + /** Construct a round result from a transaction set and matching proposal. + * + * @param s The transaction set produced by `Adaptor::onClose`; ownership + * is transferred. + * @param p This node's initial consensus proposal; ownership is + * transferred. + * @pre `s.id() == p.position()` — asserted internally. Violating this + * precondition terminates the process in debug builds. + */ ConsensusResult(TxSet_t&& s, Proposal_t&& p) : txns{std::move(s)}, position{std::move(p)} { XRPL_ASSERT(txns.id() == position.position(), "xrpl::ConsensusResult : valid inputs"); } - //! The set of transactions consensus agrees go in the ledger + //! The transaction set that consensus agrees should go into the ledger. TxSet_t txns; - //! Our proposed position on transactions/close time + //! This node's signed proposal committing to `txns.id()` and a close time. Proposal_t position; - //! Transactions which are under dispute with our peers + //! Live avalanche-voting state for each transaction where peers disagree + //! with our current position. Only genuinely-differing transactions are + //! tracked here — transactions already agreed upon are absent. hash_map disputes; - // Set of TxSet ids we have already compared/created disputes + //! Work-avoidance cache of transaction-set IDs already compared against + //! `txns`. Checked before `createDisputes` to prevent O(n²) recomputation + //! when multiple peers share the same transaction-set ID. hash_set compares; - // Measures the duration of the establish phase for this consensus round + //! Elapsed time in the `Establish` phase. Reset when `closeLedger` + //! transitions to `Establish`; read at round end to update `prevRoundTime_` + //! for the next round's timeout heuristics. ConsensusTimer roundTime; - // Indicates state in which consensus ended. Once in the accept phase - // will be either Yes or MovedOn or Expired + //! Outcome of `checkConsensus` for this round. Starts as `No`; set to + //! `Yes`, `MovedOn`, or `Expired` when the `Accepted` phase is entered. ConsensusState state = ConsensusState::No; - // The number of peers proposing during the round + //! Snapshot of the number of peers that proposed during this round. + //! Passed to `Adaptor::onAccept` and used to seed `prevProposers_` for the + //! next round's consensus-threshold calculations. std::size_t proposers = 0; }; } // namespace xrpl diff --git a/src/xrpld/consensus/DisputedTx.h b/src/xrpld/consensus/DisputedTx.h index e888b95ed6..134d1db8f3 100644 --- a/src/xrpld/consensus/DisputedTx.h +++ b/src/xrpld/consensus/DisputedTx.h @@ -1,3 +1,12 @@ +/** @file + * Per-transaction dispute tracking for the XRPL consensus protocol. + * + * A `DisputedTx` is created for each transaction that appears in one + * validator's proposed set but not another's. It records every peer's + * yes/no vote and drives the local node's vote toward convergence via the + * avalanche-style state machine defined in `ConsensusParms`. + */ + #pragma once #include @@ -12,19 +21,26 @@ namespace xrpl { -/** A transaction discovered to be in dispute during consensus. - - During consensus, a @ref DisputedTx is created when a transaction - is discovered to be disputed. The object persists only as long as - the dispute. - - Undisputed transactions have no corresponding @ref DisputedTx object. - - Refer to @ref Consensus for details on the template type requirements. - - @tparam Tx The type for a transaction - @tparam NodeId The type for a node identifier -*/ +/** Tracks peer votes and drives local vote convergence for a single disputed + * transaction during the establish phase of XRPL consensus. + * + * A `DisputedTx` is instantiated by `Consensus::createDisputes()` for every + * transaction that is present in one observed position but absent from + * another. It survives for exactly the duration of the establish phase and + * is destroyed when consensus is reached or the round is abandoned. + * Transactions that are unanimously accepted or rejected have no + * corresponding `DisputedTx` object. + * + * Peer votes are stored in a `boost::container::flat_map` (contiguous + * memory, cache-friendly for the typical small-to-medium validator set). + * Separate `yays_` and `nays_` counters allow O(1) percentage computation + * in `updateVote()` without scanning the map on every tick. + * + * @tparam Tx The transaction type; must expose a nested `ID` typedef and + * an `id()` accessor. + * @tparam NodeId The node-identifier type used as the map key. + * @see Consensus, ConsensusParms, getNeededWeight + */ template class DisputedTx @@ -33,35 +49,75 @@ class DisputedTx using Map_t = boost::container::flat_map; public: - /** Constructor - - @param tx The transaction under dispute - @param ourVote Our vote on whether tx should be included - @param numPeers Anticipated number of peer votes - @param j Journal for debugging - */ + /** Construct a dispute record for a single transaction. + * + * The internal vote map is pre-reserved to `numPeers` entries to avoid + * rehashing during the burst of `setVote()` calls that immediately follows + * dispute creation. + * + * @param tx The transaction under dispute. + * @param ourVote `true` if the local node currently includes this + * transaction in its proposed set. + * @param numPeers Number of currently-connected validators; used to + * size the vote map capacity up-front. + * @param j Journal for debug and informational logging. + */ DisputedTx(Tx tx, bool ourVote, std::size_t numPeers, beast::Journal j) : ourVote_(ourVote), tx_(std::move(tx)), j_(j) { votes_.reserve(numPeers); } - //! The unique id/hash of the disputed transaction. + /** The unique identifier (hash) of the disputed transaction. */ [[nodiscard]] TxID_t const& id() const { return tx_.id(); } - //! Our vote on whether the transaction should be included. + /** Whether the local node currently votes to include this transaction. */ [[nodiscard]] bool getOurVote() const { return ourVote_; } - //! Are we and our peers "stalled" where we probably won't change - //! our vote? + /** Determine whether this dispute has reached a stable, irresolvable state. + * + * Called by `Consensus::checkConsensus()` after close-time consensus is + * established but disputed transactions remain. A stall indicates that the + * network is overwhelmingly aligned (≥ `minCONSENSUS_PCT`, i.e. 80%) yet + * further avalanche rounds are unlikely to change the outcome — either + * because the avalanche state machine has reached the terminal `Stuck` + * loop, or because neither peers nor the local vote have moved in + * `avSTALLED_ROUNDS` consecutive rounds. + * + * All four conditions must hold simultaneously: + * 1. The avalanche state machine is in a terminal loop + * (`nextCutoff.consensusTime <= currentCutoff.consensusTime`). + * 2. The current state has been active for at least `avMIN_ROUNDS`. + * 3. `peersUnchanged >= avSTALLED_ROUNDS` **or** (when proposing) + * `currentVoteCounter_ >= avSTALLED_ROUNDS`. The *or* rather than + * *and* prevents a malicious peer from resetting the peer counter via + * flip-flopping while the local vote remains stable. + * 4. The yes-vote share exceeds `minCONSENSUS_PCT` in either direction. + * + * A stall is logged at `error` level because stalling on even one + * transaction is an abnormal event warranting investigation. + * + * @param p Consensus parameters (thresholds and round counts). + * @param proposing Whether the local node is actively proposing this round. + * @param peersUnchanged Number of consecutive rounds in which no peer + * changed its vote on any disputed transaction (maintained by the + * caller via `peerUnchangedCounter_`). + * @param j Journal for error-level stall logging. + * @param clog Optional diagnostic log stream; receives the stall message + * when non-null. + * @return `true` if the dispute is stalled and consensus should proceed + * without waiting for further vote changes. + * @note `at()` calls on `avalancheCutoffs` are safe because the map is + * constructed with all four valid `AvalancheState` keys. + */ [[nodiscard]] bool stalled( ConsensusParms const& p, @@ -70,50 +126,37 @@ public: beast::Journal j, std::unique_ptr const& clog) const { - // at() can throw, but the map is built by hand to ensure all valid - // values are available. auto const& currentCutoff = p.avalancheCutoffs.at(avalancheState_); auto const& nextCutoff = p.avalancheCutoffs.at(currentCutoff.next); - // We're have not reached the final avalanche state, or been there long - // enough, so there's room for change. Check the times in case the state - // machine is altered to allow states to loop. + // Not yet in the terminal stuck loop, or haven't dwelled long enough. + // Check the time comparison in case the state machine is ever extended + // to allow non-terminal loops. if (nextCutoff.consensusTime > currentCutoff.consensusTime || avalancheCounter_ < p.avMIN_ROUNDS) return false; - // We've haven't had this vote for minimum rounds yet. Things could - // change. if (proposing && currentVoteCounter_ < p.avMIN_ROUNDS) return false; - // If we or any peers have changed a vote in several rounds, then - // things could still change. But if _either_ has not changed in that - // long, we're unlikely to change our vote any time soon. (This prevents - // a malicious peer from flip-flopping a vote to prevent consensus.) + // Use OR: a peer that flip-flops cannot reset both counters. As long + // as our own vote is stable, the stall guard holds. if (peersUnchanged < p.avSTALLED_ROUNDS && (proposing && currentVoteCounter_ < p.avSTALLED_ROUNDS)) return false; - // Does this transaction have more than 80% agreement - - // Compute the percentage of nodes voting 'yes' (possibly including us) int const support = (yays_ + (proposing && ourVote_ ? 1 : 0)) * 100; int const total = nays_ + yays_ + (proposing ? 1 : 0); if (total == 0) { - // There are no votes, so we know nothing return false; } int const weight = support / total; - // Returns true if the tx has more than minCONSENSUS_PCT (80) percent - // agreement. Either voting for _or_ voting against the tx. bool const stalled = weight > p.minCONSENSUS_PCT || weight < (100 - p.minCONSENSUS_PCT); if (stalled) { - // stalling is an error condition for even a single - // transaction. + // Stalling on even a single transaction is an error condition. std::stringstream s; s << "Transaction " << id() << " is stalled. We have been voting " << (getOurVote() ? "YES" : "NO") << " for " << currentVoteCounter_ @@ -126,79 +169,112 @@ public: return stalled; } - //! The disputed transaction. + /** The full disputed transaction object. */ [[nodiscard]] Tx const& tx() const { return tx_; } - //! Change our vote + /** Override the local node's vote directly. + * + * @param o `true` to vote yes (include the transaction). + * @note Prefer `updateVote()` for normal avalanche-driven vote changes; + * use this only when the engine needs to force a position (e.g. + * during wrong-ledger recovery). + */ void setOurVote(bool o) { ourVote_ = o; } - /** Change a peer's vote - - @param peer Identifier of peer. - @param votesYes Whether peer votes to include the disputed transaction. - - @return bool Whether the peer changed its vote. (A new vote counts as a - change.) - */ + /** Record or update a peer's vote on this transaction. + * + * Maintains the `yays_` and `nays_` counters incrementally so + * `updateVote()` can compute percentages in O(1) without scanning the + * full vote map. A brand-new vote counts as a change. + * + * @param peer Identifier of the voting peer. + * @param votesYes `true` if the peer votes to include the transaction. + * @return `true` if the peer's vote changed (including a first-time + * vote); `false` if the peer already held this position. + */ [[nodiscard]] bool setVote(NodeId const& peer, bool votesYes); - /** Remove a peer's vote - - @param peer Identifier of peer. - */ + /** Remove a peer's vote, adjusting the `yays_`/`nays_` counters. + * + * Called when a peer disconnects, bows out of consensus, or its position + * is superseded by a new proposal. Has no effect if the peer has not + * previously voted. + * + * @param peer Identifier of the peer whose vote is to be removed. + */ void unVote(NodeId const& peer); - /** Update our vote given progression of consensus. - - Updates our vote on this disputed transaction based on our peers' votes - and how far along consensus has proceeded. - - @param percentTime Percentage progress through consensus, e.g. 50% - through or 90%. - @param proposing Whether we are proposing to our peers in this round. - @param p Consensus parameters controlling thresholds for voting - @return Whether our vote changed - */ + /** Update the local node's vote using the avalanche state machine. + * + * Called once per consensus tick during the establish phase. Advances the + * avalanche state when the time threshold and minimum-rounds dwell are + * both met, then recomputes the local position. + * + * Behaviour differs based on whether the local node is proposing: + * - **Proposing**: counts its own vote alongside peers: + * `weight = (yays_*100 + (ourVote_?100:0)) / (nays_+yays_+1)`; + * flips when `weight > requiredPct` (escalating avalanche threshold). + * - **Not proposing** (observer): simplifies to `newPosition = yays_ > nays_` + * with `weight = -1`. The observer never distorts the weighted vote that + * proposing nodes rely on. + * + * `currentVoteCounter_` is incremented on every tick without a flip and + * reset to zero on any flip; this streak feeds both `stalled()` and the + * informational log output. + * + * @param percentTime Elapsed consensus time as a percentage of the prior + * round's duration; drives avalanche state transitions. + * @param proposing Whether the local node is actively proposing this round. + * @param p Consensus parameters (avalanche cutoffs, round thresholds). + * @return `true` if the local vote flipped; `false` if the position is + * unchanged. + * @note Short-circuits without avalanche work if `ourVote_` is already + * consistent with unanimous peer agreement (all yes or all no). + */ bool updateVote(int percentTime, bool proposing, ConsensusParms const& p); - //! JSON representation of dispute, used for debugging + /** Serialise the dispute state to JSON for diagnostics. + * + * Includes `yays`, `nays`, `our_vote`, and a per-peer vote map. + * + * @return A JSON object suitable for logging or RPC debug output. + */ [[nodiscard]] json::Value getJson() const; private: - int yays_{0}; //< Number of yes votes - int nays_{0}; //< Number of no votes - bool ourVote_; //< Our vote (true is yes) - Tx tx_; //< Transaction under dispute - Map_t votes_; //< Map from NodeID to vote - //! The number of rounds we've gone without changing our vote + int yays_{0}; ///< Peers currently voting yes; updated by setVote/unVote. + int nays_{0}; ///< Peers currently voting no; updated by setVote/unVote. + bool ourVote_; ///< Local node's current position (true = include tx). + Tx tx_; ///< The transaction under dispute. + Map_t votes_; ///< Per-peer votes; flat_map for cache locality. + /** Consecutive rounds without a local vote flip; reset to 0 on any flip. + * Feeds the stall guard in `stalled()` and info-level log output. */ std::size_t currentVoteCounter_ = 0; - //! Which minimum acceptance percentage phase we are currently in + /** Current avalanche phase; advances as `percentTime` crosses thresholds. */ ConsensusParms::AvalancheState avalancheState_ = ConsensusParms::AvalancheState::Init; - //! How long we have been in the current acceptance phase + /** Rounds spent in `avalancheState_`; reset to 0 on each state transition. */ std::size_t avalancheCounter_ = 0; beast::Journal const j_; }; -// Track a peer's yes/no vote on a particular disputed tx_ template bool DisputedTx::setVote(NodeId const& peer, bool votesYes) { auto const [it, inserted] = votes_.insert(std::make_pair(peer, votesYes)); - // new vote if (inserted) { if (votesYes) @@ -213,7 +289,6 @@ DisputedTx::setVote(NodeId const& peer, bool votesYes) } return true; } - // changes vote to yes if (votesYes && !it->second) { JLOG(j_.debug()) << "Peer " << peer << " now votes YES on " << tx_.id(); @@ -222,7 +297,6 @@ DisputedTx::setVote(NodeId const& peer, bool votesYes) it->second = true; return true; } - // changes vote to no if (!votesYes && it->second) { JLOG(j_.debug()) << "Peer " << peer << " now votes NO on " << tx_.id(); @@ -234,7 +308,6 @@ DisputedTx::setVote(NodeId const& peer, bool votesYes) return false; } -// Remove a peer's vote on this disputed transaction template void DisputedTx::unVote(NodeId const& peer) @@ -269,12 +342,6 @@ DisputedTx::updateVote(int percentTime, bool proposing, ConsensusPar bool newPosition = false; int weight = 0; - // When proposing, to prevent avalanche stalls, we increase the needed - // weight slightly over time. We also need to ensure that the consensus has - // made a minimum number of attempts at each "state" before moving - // to the next. - // Proposing or not, we need to keep track of which state we've reached so - // we can determine if the vote has stalled. auto const [requiredPct, newState] = getNeededWeight(p, avalancheState_, percentTime, ++avalancheCounter_, p.avMIN_ROUNDS); if (newState) @@ -283,16 +350,15 @@ DisputedTx::updateVote(int percentTime, bool proposing, ConsensusPar avalancheCounter_ = 0; } - if (proposing) // give ourselves full weight + if (proposing) { - // This is basically the percentage of nodes voting 'yes' (including us) weight = (yays_ * 100 + (ourVote_ ? 100 : 0)) / (nays_ + yays_ + 1); - newPosition = weight > requiredPct; } else { - // don't let us outweigh a proposing node, just recognize consensus + // Observer: follow majority without distorting the weighted vote + // that proposing nodes use for threshold calculations. weight = -1; newPosition = yays_ > nays_; } diff --git a/src/xrpld/consensus/LedgerTrie.h b/src/xrpld/consensus/LedgerTrie.h index 9d76c7f283..4e1b062861 100644 --- a/src/xrpld/consensus/LedgerTrie.h +++ b/src/xrpld/consensus/LedgerTrie.h @@ -1,3 +1,17 @@ +/** @file + * Compressed ancestry trie for consensus support tracking. + * + * Implements `LedgerTrie`, the core data structure powering the + * preferred-ledger calculation in XRPL's consensus algorithm. The trie + * exploits the fact that ledger history is a string over the alphabet of + * ledger IDs: two ledgers sharing ancestry share a common string prefix, + * so their validator support can be tracked on shared branches of a + * compressed prefix trie. + * + * Also defines the helper types `SpanTip` (return type of + * `getPreferred()`) and the internal `ledger_trie_detail::Span` + * and `ledger_trie_detail::Node` that form the trie's structure. + */ #pragma once #include @@ -15,7 +29,15 @@ namespace xrpl { -/** The tip of a span of ledger ancestry +/** Identifies the tip of a span of ledger ancestry. + * + * A lightweight value type returned by `LedgerTrie::getPreferred()` and + * `Span::tip()`. Carries the sequence number and ID of the tip ledger and + * retains a copy of the ledger so that ancestor IDs at earlier sequence + * numbers can be retrieved via `ancestor()` without reaching back into the + * trie. + * + * @tparam Ledger A type satisfying the LedgerTrie `Ledger` concept. */ template class SpanTip @@ -28,10 +50,8 @@ public: { } - // The sequence number of the tip ledger - Seq seq; - // The ID of the tip ledger - ID id; + Seq seq; /**< Sequence number of the tip ledger. */ + ID id; /**< Hash / ID of the tip ledger. */ /** Lookup the ID of an ancestor of the tip ledger @@ -54,7 +74,21 @@ private: namespace ledger_trie_detail { -// Represents a span of ancestry of a ledger +/** A contiguous half-open interval `[start_, end_)` of ledger sequence numbers. + * + * A `Span` is backed by a single `Ledger` instance from which ancestor IDs can + * be retrieved via `operator[](Seq)`. It represents the range of sequence + * positions that a single trie node covers. Spans are cheap to copy because + * `Ledger` is required to be lightweight. + * + * The key design principle is a clean separation of concerns: the `Ledger` + * value owns "what ID exists at each position", while `[start_, end_)` owns + * "which range of positions this node covers". The `diff()` method delegates to + * the free function `mismatch()` to find the first position where two ledger + * histories diverge. + * + * @tparam Ledger A type satisfying the LedgerTrie `Ledger` concept. + */ template class Span { @@ -67,12 +101,22 @@ class Span Ledger ledger_; public: + /** Construct the genesis span `[0, 1)`. + * + * The default-constructed span represents the genesis ledger, which is + * the common ancestor prefix of all ledger histories. Asserts that the + * genesis ledger has sequence number 0. + */ Span() : ledger_{typename Ledger::MakeGenesis{}} { // Require default ledger to be genesis seq XRPL_ASSERT(ledger_.seq() == start_, "xrpl::Span::Span : ledger is genesis"); } + /** Construct a span `[0, ledger.seq()+1)` backed by `ledger`. + * + * @param ledger The ledger whose full history this span represents. + */ Span(Ledger ledger) : end_{ledger.seq() + Seq{1}}, ledger_{std::move(ledger)} { } @@ -84,48 +128,81 @@ public: Span& operator=(Span&&) = default; + /** Return the inclusive lower bound of the interval. */ [[nodiscard]] Seq start() const { return start_; } + /** Return the exclusive upper bound of the interval. */ [[nodiscard]] Seq end() const { return end_; } - // Return the Span from [spot,end_) or none if no such valid span + /** Return the sub-span `[spot, end_)`, or `std::nullopt` if empty. + * + * `spot` is clamped to `[start_, end_]` before slicing, so values + * outside the current interval never produce a span that extends beyond + * the original bounds. + * + * @param spot The desired new start sequence number. + * @return The sub-span, or `std::nullopt` if `spot >= end_`. + */ [[nodiscard]] std::optional from(Seq spot) const { return sub(spot, end_); } - // Return the Span from [start_,spot) or none if no such valid span + /** Return the sub-span `[start_, spot)`, or `std::nullopt` if empty. + * + * `spot` is clamped to `[start_, end_]` before slicing. + * + * @param spot The desired exclusive end sequence number. + * @return The sub-span, or `std::nullopt` if `spot <= start_`. + */ [[nodiscard]] std::optional before(Seq spot) const { return sub(start_, spot); } - // Return the ID of the ledger that starts this span + /** Return the ledger ID at the start of this span. + * + * Used as a deterministic tie-breaker in `getPreferred()` when two + * children have equal branch support. + * + * @return The ID of the ancestor ledger at sequence `start_`. + */ [[nodiscard]] ID startID() const { return ledger_[start_]; } - // Return the ledger sequence number of the first possible difference - // between this span and a given ledger. + /** Return the first sequence number where this span may differ from `o`. + * + * Delegates to `mismatch(ledger_, o)` and clamps the result to + * `[start_, end_]` so callers receive a position always within the span. + * + * @param o The ledger to compare against. + * @return The first sequence number in this span that could diverge from `o`. + */ [[nodiscard]] Seq diff(Ledger const& o) const { return clamp(mismatch(ledger_, o)); } - // The tip of this span + /** Return a `SpanTip` describing the last position in this span. + * + * The tip sequence is `end_ - 1`. + * + * @return `SpanTip` with the tip's sequence number, ID, and ledger copy. + */ [[nodiscard]] SpanTip tip() const { @@ -146,7 +223,6 @@ private: return std::min(std::max(start_, val), end_); } - // Return a span of this over the half-open interval [from,to) [[nodiscard]] std::optional sub(Seq from, Seq to) const { @@ -163,10 +239,20 @@ private: return o << s.tip().id << "[" << s.start_ << "," << s.end_ << ")"; } + /** Combine two overlapping spans into one. + * + * The merged span covers `[min(a.start_, b.start_), max(a.end_, b.end_))`. + * The backing ledger is taken from whichever span has the higher `end_`, + * since that ledger's history reaches further back and knows all ancestors + * within the combined range. + * + * @param a The first span. + * @param b The second span. + * @return A span covering the union of both intervals. + */ friend Span merge(Span const& a, Span const& b) { - // Return combined span, using ledger_ from higher sequence span if (a.end_ < b.end_) return Span(std::min(a.start_, b.start_), b.end_, b.ledger_); @@ -174,7 +260,17 @@ private: } }; -// A node in the trie +/** A node in the compressed ancestry trie. + * + * Each node owns the `Span` it covers, two support counters that are + * maintained incrementally on every `insert`/`remove`, a raw non-owning + * pointer to its parent, and an owned list of child nodes. + * + * The ownership model is strictly top-down: each node owns its children via + * `std::unique_ptr` but holds only a raw pointer to its parent. + * + * @tparam Ledger A type satisfying the LedgerTrie `Ledger` concept. + */ template struct Node { @@ -188,12 +284,20 @@ struct Node { } - Span span; + Span span; /**< The half-open sequence interval this node covers. */ + + /** Number of current validations whose exact ledger matches this node's tip. */ std::uint32_t tipSupport = 0; + + /** `tipSupport` plus the sum of all descendants' `branchSupport`. + * + * Counts every validator that has validated this ledger or any of its + * descendants. Propagated up the parent chain on every insert and remove. + */ std::uint32_t branchSupport = 0; - std::vector> children; - Node* parent = nullptr; + std::vector> children; /**< Owned child nodes. */ + Node* parent = nullptr; /**< Non-owning pointer to the parent node; null for the root. */ /** Remove the given node from this Node's children @@ -219,6 +323,14 @@ struct Node return o << s.span << "(T:" << s.tipSupport << ",B:" << s.branchSupport << ")"; } + /** Return a JSON representation of this node and its subtree. + * + * Emits `span`, `startID`, `seq`, `tipSupport`, `branchSupport`, and an + * optional `children` array. Primarily used by `LedgerTrie::getJson()` for + * diagnostics and debugging. + * + * @return A `json::Value` object tree rooted at this node. + */ [[nodiscard]] json::Value getJson() const { @@ -329,11 +441,22 @@ class LedgerTrie using Node = ledger_trie_detail::Node; using Span = ledger_trie_detail::Span; - // The root of the trie. The root is allowed to break the no-single child - // invariant. + /** Root node of the trie. + * + * Represents the genesis ledger and is always present even when the trie + * is logically empty (`root_->branchSupport == 0`). The root is exempt from + * the compression invariant: it may have zero or one child. + */ std::unique_ptr root_; - // Count of the tip support for each sequence number + /** Per-sequence-number count of tip support. + * + * Maps each sequence number to the total number of validators whose last + * validation tip has exactly that sequence number. This ordered map is + * iterated in sequence order by `getPreferred()` to accumulate the + * "uncommitted" validator count — validators who have not yet expressed a + * preference at the current frontier. + */ std::map seqSupport_; /** Find the node in the trie that represents the longest common ancestry @@ -418,10 +541,23 @@ public: { } - /** Insert and/or increment the support for the given ledger. - - @param ledger A ledger and its ancestry - @param count The count of support for this ledger + /** Insert a ledger into the trie and increment its tip support. + * + * Locates the longest common-prefix match in the trie and, if needed, + * performs up to two structural modifications: + * + * 1. **Split** — if the found node's span extends beyond the divergence + * point, its suffix is extracted into a new child that inherits the + * node's existing children and support counts. + * 2. **Branch** — if the new ledger extends beyond the divergence point, + * a new leaf node is appended for the remainder. + * + * After any structural changes, `tipSupport` is incremented on the target + * node and `branchSupport` is propagated up to the root. `seqSupport_` is + * also updated for `ledger.seq()`. + * + * @param ledger A ledger and its full ancestry to insert. + * @param count The amount of tip support to add; defaults to 1. */ void insert(Ledger const& ledger, std::uint32_t count = 1) @@ -502,13 +638,26 @@ public: seqSupport_[ledger.seq()] += count; } - /** Decrease support for a ledger, removing and compressing if possible. - - @param ledger The ledger history to remove - @param count The amount of tip support to remove - - @return Whether a matching node was decremented and possibly removed. - */ + /** Decrease tip support for a ledger and re-compress the trie if needed. + * + * Uses an O(n) exact-match search (`findByLedgerID`) rather than the + * prefix-based `find()`, because only the exact-tip node should have its + * `tipSupport` decremented. `count` is clamped to the node's current + * `tipSupport` so it is safe to pass a value larger than what was inserted. + * + * After decrementing, the compression walk restores the invariant that no + * non-root node with zero `tipSupport` has fewer than two children: + * - A leaf with zero `tipSupport` is deleted. + * - A node with zero `tipSupport` and exactly one child is merged with that + * child via `merge()`. + * - A node with zero `tipSupport` and multiple children is left in place. + * + * @param ledger The ledger whose tip support should be decremented. + * @param count The amount of tip support to remove; clamped to the node's + * current `tipSupport`. Defaults to 1. + * @return `true` if a matching node with non-zero `tipSupport` was found and + * decremented; `false` if the ledger is absent or has zero `tipSupport`. + */ bool remove(Ledger const& ledger, std::uint32_t count = 1) { @@ -562,10 +711,13 @@ public: return true; } - /** Return count of tip support for the specific ledger. - - @param ledger The ledger to lookup - @return The number of entries in the trie for this *exact* ledger + /** Return the number of validations for this exact ledger (tip only). + * + * Unlike `branchSupport()`, this counts only validators whose last + * validation matches `ledger` precisely, not any of its descendants. + * + * @param ledger The ledger to look up. + * @return The tip support count, or 0 if the ledger is not in the trie. */ [[nodiscard]] std::uint32_t tipSupport(Ledger const& ledger) const @@ -575,11 +727,16 @@ public: return 0; } - /** Return the count of branch support for the specific ledger - - @param ledger The ledger to lookup - @return The number of entries in the trie for this ledger or a - descendant + /** Return the total number of validations for this ledger or any descendant. + * + * Returns the `branchSupport` of the exact matching node when found. + * If no exact match exists but `ledger` is a proper prefix of a trie node's + * span (i.e., the ledger is an ancestor of the node's tip), returns that + * node's `branchSupport` instead. Returns 0 if `ledger` is not in the trie + * at all. + * + * @param ledger The ledger to look up. + * @return The branch support count, or 0 if no match is found. */ [[nodiscard]] std::uint32_t branchSupport(Ledger const& ledger) const @@ -756,7 +913,13 @@ public: return curr->span.tip(); } - /** Return whether the trie is tracking any ledgers + /** Return whether the trie has any active support. + * + * The root node always exists; "empty" means `root_->branchSupport == 0`, + * i.e., no ledger has been inserted (or all inserts have been balanced by + * removes). + * + * @return `true` if no ledger currently has support; `false` otherwise. */ [[nodiscard]] bool empty() const @@ -764,7 +927,12 @@ public: return !root_ || root_->branchSupport == 0; } - /** Dump an ascii representation of the trie to the stream + /** Write an ASCII diagram of the trie to `o` for debugging. + * + * Each node is printed with its span, tip support, and branch support. + * Children are indented relative to their parent. + * + * @param o The output stream to write to. */ void dump(std::ostream& o) const @@ -772,7 +940,12 @@ public: dumpImpl(o, root_, 0); } - /** Dump JSON representation of trie state + /** Return a JSON snapshot of the full trie state. + * + * Emits the recursive node tree under `"trie"` and the `seqSupport_` map + * under `"seq_support"`. Intended for diagnostics and RPC inspection. + * + * @return A `json::Value` with `"trie"` and `"seq_support"` fields. */ [[nodiscard]] json::Value getJson() const @@ -785,7 +958,20 @@ public: return res; } - /** Check the compressed trie and support invariants. + /** Validate the compressed trie structure and all support invariants. + * + * Performs a full DFS of the trie and verifies: + * 1. No non-root node with zero `tipSupport` has fewer than two children + * (compression invariant). + * 2. Every node's `branchSupport` equals its `tipSupport` plus the sum of + * its children's `branchSupport`. + * 3. Every child's `parent` pointer refers back to the correct parent node. + * 4. The `seqSupport_` map matches the sum of `tipSupport` values grouped by + * the tip sequence number of each node. + * + * Called after every mutation in the consensus test suite. + * + * @return `true` if all invariants hold; `false` if any violation is found. */ [[nodiscard]] bool checkInvariants() const diff --git a/src/xrpld/consensus/Validations.h b/src/xrpld/consensus/Validations.h index 825d0f6076..edf13bebc2 100644 --- a/src/xrpld/consensus/Validations.h +++ b/src/xrpld/consensus/Validations.h @@ -1,3 +1,17 @@ +/** @file + * Ledger validation tracking for the XRPL consensus engine. + * + * Defines `ValidationParms`, `SeqEnforcer`, `isCurrent()`, `ValStatus`, and + * the primary `Validations` template. Together these components + * receive validator attestations from the network, enforce freshness and + * monotonicity invariants, index them for efficient querying, and feed the + * `LedgerTrie` that drives preferred-ledger selection. + * + * The entire implementation is generic: the `Adaptor` template parameter + * supplies types and callbacks so that simulations and the production rippled + * application can share the same logic while substituting test clocks or + * storage backends. + */ #pragma once #include @@ -17,11 +31,16 @@ namespace xrpl { -/** Timing parameters to control validation staleness and expiration. - - @note These are protocol level parameters that should not be changed without - careful consideration. They are *not* implemented as static constexpr - to allow simulation code to test alternate parameter settings. +/** Protocol timing thresholds that govern validation staleness and expiration. + * + * All durations are compared against `NetClock` (second-resolution network + * time) for the wall-clock checks, and against `std::chrono::steady_clock` + * for the local-observation check. + * + * @note These are protocol-level parameters; changing them without careful + * consideration can break consensus safety or liveness. They are + * intentionally *not* `static constexpr` so that simulation code can + * inject alternate values without recompiling. */ struct ValidationParms { @@ -58,23 +77,32 @@ struct ValidationParms */ std::chrono::seconds validationSET_EXPIRES = std::chrono::minutes{10}; - /** How long we consider a validation fresh. + /** Maximum age of a validation (by local seen-time) for it to be + * considered from a *live* proposer in `laggards()` queries. * - * The number of seconds since a validation has been seen for it to - * be considered to accurately represent a live proposer's most recent - * validation. This value should be sufficiently higher than - * ledgerMAX_CONSENSUS such that validators who are waiting for - * laggards are not considered offline. + * A validation is "fresh" if `now < seenTime + validationFRESHNESS`. + * This threshold is used only for online/laggard detection, not for + * general staleness — see `isCurrent()` for that. The value must exceed + * `ledgerMAX_CONSENSUS` so that validators waiting on slow peers are not + * misclassified as offline. */ std::chrono::seconds validationFRESHNESS = std::chrono::seconds{20}; }; -/** Enforce validation increasing sequence requirement. - - Helper class for enforcing that a validation must be larger than all - unexpired validation sequence numbers previously issued by the validator - tracked by the instance of this class. -*/ +/** Enforces a monotonically-increasing ledger sequence invariant per validator. + * + * Each instance tracks the highest validation sequence seen for one + * particular validator. A new validation is accepted only when its sequence + * number strictly exceeds every unexpired prior sequence from that validator. + * + * The high-water mark resets to zero after `validationSET_EXPIRES` elapses + * with no new validation, so a validator returning after a long offline + * period is not permanently locked out — it can start fresh at the current + * network sequence. + * + * @tparam Seq Ledger sequence type (must be default-constructible to zero and + * support `<=` and `>` comparisons). + */ template class SeqEnforcer { @@ -107,6 +135,9 @@ public: return true; } + /** Return the highest accepted sequence number, or zero if no unexpired + * validation has been accepted yet (including after a reset). + */ [[nodiscard]] Seq largest() const { @@ -114,17 +145,27 @@ public: } }; -/** Whether a validation is still current - - Determines whether a validation can still be considered the current - validation from a node based on when it was signed by that node and first - seen by this node. - - @param p ValidationParms with timing parameters - @param now Current time - @param signTime When the validation was signed - @param seenTime When the validation was first seen locally -*/ +/** Determine whether a validation is still considered current. + * + * Checks two independent time conditions: + * 1. **Sign-time window** — the validator's declared signing time must fall + * within `(now - validationCURRENT_EARLY, now + validationCURRENT_WALL)`, + * rejecting both ancient and future-dated attestations. + * 2. **Seen-time backstop** — if the local node has recorded a first-seen + * time, it must be no later than `now + validationCURRENT_LOCAL`, guarding + * against extreme local clock drift. + * + * @note Because `signTime` originates from an untrusted remote node, all + * arithmetic is performed after promoting the unsigned 32-bit NetClock + * epoch values to signed 64-bit to prevent underflow on subtraction. + * + * @param p Timing thresholds governing the staleness windows. + * @param now Current network time used as the reference point. + * @param signTime The time the validation was signed by the remote node. + * @param seenTime When this node first observed the validation; pass the + * default-constructed `NetClock::time_point{}` if unknown. + * @return `true` if the validation passes both time-window checks. + */ inline bool isCurrent( ValidationParms const& p, @@ -132,32 +173,43 @@ isCurrent( NetClock::time_point signTime, NetClock::time_point seenTime) { - // Because this can be called on untrusted, possibly - // malicious validations, we do our math in a way - // that avoids any chance of overflowing or underflowing - // the signing time. All of the expressions below are - // promoted from unsigned 32 bit to signed 64 bit prior - // to computation. - return (signTime > (now - p.validationCURRENT_EARLY)) && (signTime < (now + p.validationCURRENT_WALL)) && ((seenTime == NetClock::time_point{}) || (seenTime < (now + p.validationCURRENT_LOCAL))); } -/** Status of validation we received */ +/** Classification of a received validation returned by `Validations::add()`. + * + * The values form a rough severity ordering. Only `Current` means the + * validation was accepted and stored. All other values indicate the + * validation was discarded, with different implications for monitoring: + * `Conflicting` is the most serious (possible Byzantine behavior), while + * `Stale` is routine churn. + */ enum class ValStatus { - /// This was a new validation and was added + /// Validation was new and accepted into the tracking structures. Current, - /// Not current or was older than current from this node + /// Validation was not current (too old or older than an existing one from + /// this node); discarded without further processing. Stale, - /// A validation violates the increasing seq requirement + /// Sequence number is not strictly greater than an unexpired prior + /// validation from the same node; plain regression, not necessarily + /// malicious. BadSeq, - /// Multiple validations by a validator for the same ledger + /// Same node, same sequence, same ledger but different cookie — likely + /// accidental misconfiguration (e.g., duplicate validator restart). Multiple, - /// Multiple validations by a validator for different ledgers + /// Same node, same sequence, different ledger or different sign time — + /// indicates a potentially Byzantine validator signing conflicting ledgers. Conflicting }; +/** Convert a `ValStatus` to its lowercase string representation for logging. + * + * @param m The status value to convert. + * @return A lowercase string (`"current"`, `"stale"`, `"badSeq"`, + * `"multiple"`, `"conflicting"`, or `"unknown"`). + */ inline std::string to_string(ValStatus m) { @@ -178,27 +230,37 @@ to_string(ValStatus m) } } -/** Maintains current and recent ledger validations. - - Manages storage and queries related to validations received on the network. - Stores the most current validation from nodes and sets of recent - validations grouped by ledger identifier. - - Stored validations are not necessarily from trusted nodes, so clients - and implementations should take care to use `trusted` member functions or - check the validation's trusted status. - - This class uses a generic interface to allow adapting Validations for - specific applications. The Adaptor template implements a set of helper - functions and type definitions. The code stubs below outline the - interface and type requirements. - - - @warning The Adaptor::MutexType is used to manage concurrent access to - private members of Validations but does not manage any data in the - Adaptor instance itself. - - @code +/** Tracks ledger validations received from the network and drives preferred- + * ledger selection. + * + * Maintains five coordinated data structures under a single `Mutex`: + * - `current_` — most recent valid validation per node (fast quorum path). + * - `byLedger_` — all validations indexed by ledger ID with LRU expiry. + * - `bySequence_` — validations indexed by sequence for Byzantine detection. + * - `trie_` — `LedgerTrie` reflecting all trusted validators for preferred- + * ledger computation. + * - `acquiring_` — trusted validations waiting on locally-unavailable ledgers. + * + * The critical path is `add()`, which enforces freshness (via `isCurrent()`), + * monotonic sequence (via `SeqEnforcer`), and Byzantine detection before + * inserting into the indexes and the trie. + * + * All trie queries flow through `withTrie()`, which flushes stale entries + * and promotes pending acquisitions before delegating to the trie — ensuring + * the trie is always consistent with `current_`. + * + * @warning `Adaptor::Mutex` protects all private members of this class but + * does *not* cover the `Adaptor` instance itself — the adaptor manages + * its own synchronization. + * + * @note Stored validations are not necessarily from trusted nodes. Callers + * must use the `trusted`-prefixed query methods or check + * `Validation::trusted()` explicitly. + * + * @tparam Adaptor Supplies `Mutex`, `Validation`, `Ledger`, `now()`, and + * `acquire()`. See the code stub below for the full interface contract. + * + * @code // Conforms to the Ledger type requirements of LedgerTrie struct Ledger; @@ -262,7 +324,6 @@ to_string(ValStatus m) }; @endcode - @tparam Adaptor Provides type definitions and callbacks */ template class Validations @@ -278,19 +339,27 @@ class Validations using WrappedValidationType = std::decay_t>; - // Manages concurrent access to members + // Protects all mutable state below. The adaptor_ member is explicitly + // excluded and manages its own synchronization. mutable Mutex mutex_; - // Validations from currently listed and trusted nodes (partial and full) + // Most recent valid validation from each known node (partial and full). + // Continuously pruned for staleness by current(); the fast path for + // quorum and laggard queries. hash_map current_; - // Used to enforce the largest validation invariant for the local node + // SeqEnforcer for the local node; tracks the highest sequence this node + // has validated so that canValidateSeq() can enforce the monotonic + // sequence invariant locally. SeqEnforcer localSeqEnforcer_; - // Sequence of the largest validation received from each node + // Per-peer SeqEnforcers used in add() to reject sequence regressions and + // to classify Byzantine violations. hash_map> seqEnforcers_; - //! Validations from listed nodes, indexed by ledger id (partial and full) + //! All validations (partial and full) grouped by ledger ID with LRU-style + //! time-based expiry via beast::expire(). Access via byLedger() to get + //! automatic LRU touch. beast::aged_unordered_map< ID, hash_map, @@ -298,7 +367,10 @@ class Validations beast::Uhash<>> byLedger_; - // Partial and full validations indexed by sequence + // All validations (partial and full) grouped by ledger sequence number. + // Used exclusively in add() for Byzantine detection — allows checking + // whether any prior validation from the same node exists for the same + // sequence. beast::aged_unordered_map< Seq, hash_map, @@ -306,7 +378,9 @@ class Validations beast::Uhash<>> bySequence_; - // A range [low_, high_) of validations to keep from expire + // Half-open range [low, high) of sequence numbers that expire() must not + // evict. Set via setSeqToKeep(); entries are "touched" just before their + // natural expiry time to reset their LRU timestamp. struct KeepRange { Seq low; @@ -314,25 +388,39 @@ class Validations }; std::optional toKeep_; - // Represents the ancestry of validated ledgers + // Compressed prefix trie over ledger ancestry, keyed on sequence-indexed + // ancestor IDs. Drives getPreferred(). Must only be accessed through + // withTrie() to ensure stale entries are flushed first. LedgerTrie trie_; - // Last (validated) ledger successfully acquired. If in this map, it is - // accounted for in the trie. + // Maps each node to the last ledger it contributed to the trie. Used by + // removeTrie() to atomically undo a node's prior trie contribution before + // inserting the new one, keeping trie updates effectively atomic. hash_map lastLedger_; - // Set of ledgers being acquired from the network + // Trusted validations whose target ledger has not yet been locally + // acquired. Keyed by (Seq, ID); value is the set of validating nodes. + // When the ledger becomes available, all nodes are promoted into the trie. hash_map, hash_set> acquiring_; - // Parameters to determine validation staleness + // Staleness and expiration thresholds; immutable after construction. ValidationParms const parms_; - // Adaptor instance - // Is NOT managed by the mutex_ above + // Adaptor instance — NOT protected by mutex_; adaptor manages its own + // synchronization. Adaptor adaptor_; private: - // Remove support of a validated ledger + /** Remove a node's contribution to the trie and/or acquiring_ map. + * + * Erases `nodeID` from `acquiring_` for the ledger described by `val` + * (if present) and, if `lastLedger_` records the same ledger for that + * node, removes it from `trie_` and clears the `lastLedger_` entry. + * + * @param lock Proof that the caller holds `mutex_`. + * @param nodeID The node whose trie contribution is being withdrawn. + * @param val The validation identifying the ledger to un-register. + */ void removeTrie(std::scoped_lock const&, NodeID const& nodeID, Validation const& val) { @@ -355,7 +443,14 @@ private: } } - // Check if any pending acquire ledger requests are complete + /** Promote any pending acquisitions whose ledger is now locally available. + * + * Iterates `acquiring_` and calls `adaptor_.acquire()` for each pending + * ledger. For any that are now available, inserts all waiting node IDs + * into the trie and erases the entry from `acquiring_`. + * + * @param lock Proof that the caller holds `mutex_`. + */ void checkAcquired(std::scoped_lock const& lock) { @@ -375,7 +470,15 @@ private: } } - // Update the trie to reflect a new validated ledger + /** Insert or update a node's trie contribution with a directly-acquired ledger. + * + * If the node already has a `lastLedger_` entry, removes the old ledger + * from the trie before inserting the new one, keeping the trie consistent. + * + * @param lock Proof that the caller holds `mutex_`. + * @param nodeID The validating node. + * @param ledger The locally-acquired ledger to register in the trie. + */ void updateTrie(std::scoped_lock const&, NodeID const& nodeID, Ledger ledger) { @@ -443,43 +546,47 @@ private: } } - /** Use the trie for a calculation - - Accessing the trie through this helper ensures acquiring validations - are checked and any stale validations are flushed from the trie. - - @param lock Existing lock of mutex_ - @param f Invocable with signature (LedgerTrie &) - - @warning The invocable `f` is expected to be a simple transformation of - its arguments and will be called with mutex_ under lock. - - */ + /** Execute a query against the trie after flushing stale state. + * + * This is the **only** sanctioned entry point into the trie. It first + * calls `current()` to evict stale entries from `current_` (and remove + * their trie contributions), then calls `checkAcquired()` to promote any + * newly-available ledgers. This lazy-flush design keeps the trie accurate + * without a background sweep. + * + * @param lock Proof that the caller holds `mutex_`. + * @param f Callable with signature `auto(LedgerTrie&)`. + * Invoked with the trie under lock; must be a simple, + * non-blocking transformation — no external I/O. + * @return Whatever `f` returns. + */ template auto withTrie(std::scoped_lock const& lock, F&& f) { - // Call current to flush any stale validations current(lock, [](auto) {}, [](auto, auto) {}); checkAcquired(lock); return f(trie_); } - /** Iterate current validations. - - Iterate current validations, flushing any which are stale. - - @param lock Existing lock of mutex_ - @param pre Invocable with signature (std::size_t) called prior to - looping. - @param f Invocable with signature (NodeID const &, Validations const &) - for each current validation. - - @note The invocable `pre` is called _prior_ to checking for staleness - and reflects an upper-bound on the number of calls to `f. - @warning The invocable `f` is expected to be a simple transformation of - its arguments and will be called with mutex_ under lock. - */ + /** Iterate all non-stale entries in `current_`, evicting stale ones. + * + * Walks `current_`, calling `isCurrent()` on each entry. Stale entries + * are removed from `current_` and their trie contributions withdrawn via + * `removeTrie()`. Live entries are passed to `f`. + * + * @param lock Proof that the caller holds `mutex_`. + * @param pre Called once before the loop with the *current* (pre- + * eviction) map size — useful for `reserve()` calls. + * Signature: `void(std::size_t)`. + * @param f Called for each live entry. Signature: + * `void(NodeID const&, Validation const&)`. Invoked under + * lock; must be a simple, non-blocking transformation. + * + * @note `pre` receives an upper bound, not an exact count: stale entries + * are evicted *during* iteration, so the actual number of `f` calls + * may be lower. + */ template void @@ -490,7 +597,6 @@ private: auto it = current_.begin(); while (it != current_.end()) { - // Check for staleness if (!isCurrent(parms_, t, it->second.signTime(), it->second.seenTime())) { removeTrie(lock, it->first, it->second); @@ -499,25 +605,30 @@ private: else { auto cit = typename decltype(current_)::const_iterator{it}; - // contains a live record f(cit->first, cit->second); ++it; } } } - /** Iterate the set of validations associated with a given ledger id - - @param lock Existing lock on mutex_ - @param ledgerID The identifier of the ledger - @param pre Invocable with signature(std::size_t) - @param f Invocable with signature (NodeID const &, Validation const &) - - @note The invocable `pre` is called prior to iterating validations. The - argument is the number of times `f` will be called. - @warning The invocable f is expected to be a simple transformation of - its arguments and will be called with mutex_ under lock. - */ + /** Iterate all validations stored for a specific ledger ID. + * + * Looks up `ledgerID` in `byLedger_` and, if found, touches the entry + * (resetting its LRU timestamp) then invokes `pre` and `f`. + * + * @param lock Proof that the caller holds `mutex_`. + * @param ledgerID The ledger hash to query. + * @param pre Called with the exact count of validations before any + * `f` invocations — suitable for `reserve()`. Signature: + * `void(std::size_t)`. + * @param f Called for each stored validation. Signature: + * `void(NodeID const&, Validation const&)`. Invoked + * under lock; must be a simple, non-blocking + * transformation. + * + * @note Does nothing (no calls to `pre` or `f`) if `ledgerID` is absent + * from `byLedger_`. + */ template void byLedger(std::scoped_lock const&, ID const& ledgerID, Pre&& pre, F&& f) @@ -565,13 +676,17 @@ public: return parms_; } - /** Return whether the local node can issue a validation for the given - sequence number - - @param s The sequence number of the ledger the node wants to validate - @return Whether the validation satisfies the invariant, updating the - largest sequence number seen accordingly - */ + /** Check whether the local node may issue a validation for sequence `s`. + * + * Delegates to the local `SeqEnforcer` — returns `true` only if `s` + * strictly exceeds every unexpired sequence previously validated by this + * node. On success, `s` becomes the new high-water mark. On failure, the + * high-water mark is unchanged. + * + * @param s The ledger sequence number the local node wants to validate. + * @return `true` if the validation may proceed; `false` if `s` would + * violate the monotonic sequence invariant. + */ bool canValidateSeq(Seq const s) { @@ -579,14 +694,29 @@ public: return localSeqEnforcer_(byLedger_.clock().now(), s, parms_); } - /** Add a new validation - - Attempt to add a new validation. - - @param nodeID The identity of the node issuing this validation - @param val The validation to store - @return The outcome - */ + /** Process and store an incoming validation. + * + * Executes the following checks in order before storing: + * 1. **Freshness** — `isCurrent()` is called *before* acquiring the lock; + * stale validations are discarded immediately as `ValStatus::Stale`. + * 2. **Byzantine detection** — `bySequence_` is inspected for any prior + * validation from `nodeID` at the same sequence number. Conflicts on + * ledger ID or sign time return `ValStatus::Conflicting`; same-ledger + * cookie collisions return `ValStatus::Multiple`. + * 3. **Monotonic sequence** — the per-node `SeqEnforcer` rejects + * regressions as `ValStatus::BadSeq`. + * 4. **Currency** — if a newer validation from this node already exists + * in `current_`, the incoming one is discarded as `ValStatus::Stale`. + * + * Trusted validations that pass all checks are routed to `updateTrie()`, + * which either inserts them into the trie immediately (if the ledger is + * locally available) or parks `nodeID` in `acquiring_` to wait. + * + * @param nodeID The stable node identifier of the validating node. + * @param val The validation to store. + * @return Classification of the validation (see `ValStatus`). Only + * `ValStatus::Current` means the validation was accepted. + */ ValStatus add(NodeID const& nodeID, Validation const& val) { @@ -673,11 +803,20 @@ public: return ValStatus::Current; } - /** - * Set the range [low, high) of validations to keep from expire - * @param low the lower sequence number - * @param high the higher sequence number - * @note high must be greater than low + /** Pin a half-open range of sequence numbers against expiry. + * + * `expire()` normally evicts `byLedger_` and `bySequence_` entries older + * than `validationSET_EXPIRES`. This method designates the range + * `[low, high)` as protected: `expire()` will "touch" those entries just + * before their natural expiry time, resetting the LRU timestamp and + * preventing eviction. + * + * @param low First sequence number to protect (inclusive). + * @param high One past the last sequence number to protect (exclusive). + * @pre `low < high`. + * + * @note Only one range can be active at a time; calling this again + * replaces the previous range. */ void setSeqToKeep(Seq const& low, Seq const& high) @@ -687,11 +826,18 @@ public: toKeep_ = {low, high}; } - /** Expire old validation sets - - Remove validation sets that were accessed more than - validationSET_EXPIRES ago and were not asked to keep. - */ + /** Evict aged entries from `byLedger_` and `bySequence_`. + * + * Calls `beast::expire()` on both indexes, removing entries that have + * not been touched for longer than `validationSET_EXPIRES`. + * + * If a `setSeqToKeep()` range is active, this method first "touches" all + * matching entries — but only once per `(validationSET_EXPIRES - + * validationFRESHNESS)` window — to reset their LRU timestamps and + * prevent premature eviction. + * + * @param j Journal for debug-level timing diagnostics. + */ void expire(beast::Journal& j) { @@ -742,15 +888,20 @@ public: << "ms"; } - /** Update trust status of validations - - Updates the trusted status of known validations to account for nodes - that have been added or removed from the UNL. This also updates the trie - to ensure only currently trusted nodes' validations are used. - - @param added Identifiers of nodes that are now trusted - @param removed Identifiers of nodes that are no longer trusted - */ + /** Propagate a UNL membership change through all stored validations. + * + * Iterates both `current_` and the full `byLedger_` index to mark + * validations as trusted or untrusted. Additionally updates the trie so + * that it reflects only currently-trusted validators: + * - Nodes in `added` have their `current_` entry inserted into the trie. + * - Nodes in `removed` have their trie contribution withdrawn. + * + * @param added Node IDs now considered trusted (joined the UNL). + * @param removed Node IDs no longer considered trusted (left the UNL). + * + * @note This iterates the entire `byLedger_` map; it is not on the hot + * path but may be slow if the map is large. + */ void trustChanged(hash_set const& added, hash_set const& removed) { @@ -787,6 +938,11 @@ public: } } + /** Return a JSON representation of the ledger trie for diagnostics. + * + * @return JSON object describing the trie's current structure and support + * counts; intended for debug endpoints and monitoring tools. + */ json::Value getJsonTrie() const { @@ -794,18 +950,27 @@ public: return trie_.getJson(); } - /** Return the sequence number and ID of the preferred working ledger - - A ledger is preferred if it has more support amongst trusted validators - and is *not* an ancestor of the current working ledger; otherwise it - remains the current working ledger. - - @param curr The local node's current working ledger - - @return The sequence and id of the preferred working ledger, - or std::nullopt if no trusted validations are available to - determine the preferred ledger. - */ + /** Determine the preferred working ledger according to trusted validators. + * + * Three-tier fallback: + * 1. **Trie** — delegates to `LedgerTrie::getPreferred()` using the + * local high-water sequence, which weights branches by trusted-validator + * support while accounting for uncommitted validators. + * 2. **Acquiring** — if the trie has no trusted entries, falls back to the + * `acquiring_` map entry with the most waiting validators (tie-broken + * by ledger ID). + * 3. **Nullopt** — if both are empty, returns `std::nullopt`, signalling + * the caller to rely on raw peer counts. + * + * Conservative switch rule: if the preferred ledger is the immediate child + * of `curr` (same ancestry), the node stays on `curr` — it may be about to + * build that child itself. A genuinely different chain or a ledger further + * ahead overrides the working ledger unconditionally. + * + * @param curr The local node's current working ledger. + * @return `{seq, id}` of the preferred ledger, or `std::nullopt` if + * there are no trusted validations to guide the choice. + */ std::optional> getPreferred(Ledger const& curr) { @@ -813,18 +978,14 @@ public: std::optional> preferred = withTrie(lock, [this](LedgerTrie& trie) { return trie.getPreferred(localSeqEnforcer_.largest()); }); - // No trusted validations to determine branch if (!preferred) { - // fall back to majority over acquiring ledgers auto it = std::max_element( acquiring_.begin(), acquiring_.end(), [](auto const& a, auto const& b) { std::pair const& aKey = a.first; typename hash_set::size_type const& aSize = a.second.size(); std::pair const& bKey = b.first; typename hash_set::size_type const& bSize = b.second.size(); - // order by number of trusted peers validating that ledger - // break ties with ledger ID return std::tie(aSize, aKey.second) < std::tie(bSize, bKey.second); }); if (it != acquiring_.end()) @@ -832,34 +993,30 @@ public: return std::nullopt; } - // If we are the parent of the preferred ledger, stick with our - // current ledger since we might be about to generate it if (preferred->seq == curr.seq() + Seq{1} && preferred->ancestor(curr.seq()) == curr.id()) return std::make_pair(curr.seq(), curr.id()); - // A ledger ahead of us is preferred regardless of whether it is - // a descendant of our working ledger or it is on a different chain if (preferred->seq > curr.seq()) return std::make_pair(preferred->seq, preferred->id); - // Only switch to earlier or same sequence number - // if it is a different chain. if (curr[preferred->seq] != preferred->id) return std::make_pair(preferred->seq, preferred->id); - // Stick with current ledger return std::make_pair(curr.seq(), curr.id()); } - /** Get the ID of the preferred working ledger that exceeds a minimum valid - ledger sequence number - - @param curr Current working ledger - @param minValidSeq Minimum allowed sequence number - - @return ID Of the preferred ledger, or curr if the preferred ledger - is not valid - */ + /** Return the ID of the preferred working ledger, subject to a minimum + * sequence constraint. + * + * Delegates to `getPreferred(Ledger const&)`. If the preferred ledger's + * sequence is at least `minValidSeq` it is returned; otherwise the + * current working ledger's ID is returned unchanged. + * + * @param curr Current working ledger. + * @param minValidSeq Minimum acceptable sequence number for the result. + * @return ID of the preferred ledger if its sequence ≥ + * `minValidSeq`, otherwise `curr.id()`. + */ ID getPreferred(Ledger const& curr, Seq minValidSeq) { @@ -869,36 +1026,41 @@ public: return curr.id(); } - /** Determine the preferred last closed ledger for the next consensus round. - - Called before starting the next round of ledger consensus to determine - the preferred working ledger. Uses the dominant peerCount ledger if no - trusted validations are available. - - @param lcl Last closed ledger by this node - @param minSeq Minimum allowed sequence number of the trusted preferred - ledger - @param peerCounts Map from ledger ids to count of peers with that as the - last closed ledger - @return The preferred last closed ledger ID - - @note The minSeq does not apply to the peerCounts, since this function - does not know their sequence number - */ + /** Select the preferred last-closed ledger to open the next consensus round. + * + * Uses trusted validations when available; falls back to raw peer counts + * when no trusted validations exist. + * + * Decision order: + * 1. If trusted validations are available and the preferred ledger's + * sequence ≥ `minSeq`, return its ID. + * 2. If trusted validations are available but the preferred ledger is too + * old (sequence < `minSeq`), stay on `lcl`. + * 3. If no trusted validations, return the ID with the highest peer count + * (tie-broken by ledger ID). + * 4. If `peerCounts` is also empty, return `lcl.id()`. + * + * @param lcl Last closed ledger reported by this node. + * @param minSeq Minimum acceptable sequence for a trusted result. + * @param peerCounts Map from ledger ID to the number of peers reporting + * that ledger as their LCL; used only when no trusted + * validations are available. + * @return ID of the preferred last-closed ledger. + * + * @note `minSeq` applies only to the trusted-validation path; peer counts + * are accepted regardless of sequence because sequence is not known + * from `peerCounts` alone. + */ ID getPreferredLCL(Ledger const& lcl, Seq minSeq, hash_map const& peerCounts) { std::optional> preferred = getPreferred(lcl); - // Trusted validations exist, but stick with local preferred ledger if - // preferred is in the past if (preferred) return (preferred->first >= minSeq) ? preferred->second : lcl.id(); - // Otherwise, rely on peer ledgers + // max_element expects true if a < b; prefer larger counts then larger IDs on ties. auto it = std::max_element(peerCounts.begin(), peerCounts.end(), [](auto& a, auto& b) { - // Prefer larger counts, then larger ids on ties - // (max_element expects this to return true if a < b) return std::tie(a.second, a.first) < std::tie(b.second, b.first); }); @@ -907,23 +1069,28 @@ public: return lcl.id(); } - /** Count the number of current trusted validators working on a ledger - after the specified one. - - @param ledger The working ledger - @param ledgerID The preferred ledger - @return The number of current trusted validators working on a descendant - of the preferred ledger - - @note If ledger.id() != ledgerID, only counts immediate child ledgers of - ledgerID - */ + /** Count current trusted validators whose validated ledger descends from + * `ledgerID`. + * + * Used during the establish phase to assess how many peers have already + * advanced beyond the current preferred ledger. + * + * When `ledger.id() == ledgerID`, the full trie (`branchSupport - + * tipSupport`) is used, counting all descendants at any depth. When + * they differ (we are on a different chain), the method falls back to + * counting entries in `lastLedger_` whose parent ID equals `ledgerID`, + * i.e. immediate children only. + * + * @param ledger The local node's current working ledger. + * @param ledgerID The preferred ledger whose descendants to count. + * @return Number of current trusted validators working on a + * ledger that descends from `ledgerID`. + */ std::size_t getNodesAfter(Ledger const& ledger, ID const& ledgerID) { std::scoped_lock const lock{mutex_}; - // Use trie if ledger is the right one if (ledger.id() == ledgerID) { return withTrie(lock, [&ledger](LedgerTrie& trie) { @@ -931,17 +1098,21 @@ public: }); } - // Count parent ledgers as fallback return std::count_if(lastLedger_.begin(), lastLedger_.end(), [&ledgerID](auto const& it) { auto const& curr = it.second; return curr.seq() > Seq{0} && curr[curr.seq() - Seq{1}] == ledgerID; }); } - /** Get the currently trusted full validations - - @return Vector of validations from currently trusted validators - */ + /** Return the unwrapped form of all current trusted full validations. + * + * Flushes stale entries from `current_` (via `current()`), then returns + * the underlying validation objects for every entry that is both trusted + * and full. Partial validations are excluded. + * + * @return Vector of `WrappedValidationType` values; empty if no current + * trusted full validations exist. + */ std::vector currentTrusted() { @@ -957,10 +1128,15 @@ public: return ret; } - /** Get the set of node ids associated with current validations - - @return The set of node ids for active, listed validators - */ + /** Return the node IDs of all validators with a current (non-stale) + * validation. + * + * Stale entries are evicted from `current_` as a side effect (via + * `current()`). Both trusted and untrusted nodes are included. + * + * @return Set of `NodeID` values for every node that has a live entry + * in `current_`. + */ auto getCurrentNodeIDs() -> hash_set { @@ -974,11 +1150,15 @@ public: return ret; } - /** Count the number of trusted full validations for the given ledger - - @param ledgerID The identifier of ledger of interest - @return The number of trusted validations - */ + /** Count trusted full validations stored for a specific ledger hash. + * + * Queries `byLedger_` (touching the entry to defer its expiry). Partial + * validations are not counted. + * + * @param ledgerID The ledger hash to query. + * @return Number of trusted full validations for `ledgerID`; + * zero if no validations are stored for that ledger. + */ std::size_t numTrustedForLedger(ID const& ledgerID) { @@ -995,12 +1175,18 @@ public: return count; } - /** Get trusted full validations for a specific ledger - - @param ledgerID The identifier of ledger of interest - @param seq The sequence number of ledger of interest - @return Trusted validations associated with ledger - */ + /** Return the unwrapped trusted full validations for a specific ledger. + * + * Queries `byLedger_` for `ledgerID`, then filters to entries that are + * trusted, full, and whose recorded sequence equals `seq`. The `seq` + * filter guards against stale cross-sequence entries that may share a + * ledger ID under unusual conditions. + * + * @param ledgerID The ledger hash to query. + * @param seq The expected ledger sequence number. + * @return Vector of matching `WrappedValidationType` values; + * empty if none match. + */ std::vector getTrustedForLedger(ID const& ledgerID, Seq const& seq) { @@ -1018,12 +1204,19 @@ public: return res; } - /** Returns fees reported by trusted full validators in the given ledger - - @param ledgerID The identifier of ledger of interest - @param baseFee The fee to report if not present in the validation - @return Vector of fees - */ + /** Collect the load fees reported by trusted full validators for a ledger. + * + * For each trusted full validation stored under `ledgerID`, appends the + * validator's reported load fee (if present) or `baseFee` as a fallback. + * The resulting vector is consumed by fee-scaling logic to compute a + * consensus fee level. + * + * @param ledgerID The ledger hash to query. + * @param baseFee Fee value to use when a validator's validation does not + * include an explicit load fee. + * @return One fee entry per trusted full validation for + * `ledgerID`; empty if no matching validations exist. + */ std::vector fees(ID const& ledgerID, std::uint32_t baseFee) { @@ -1050,7 +1243,10 @@ public: return res; } - /** Flush all current validations + /** Clear all entries from `current_`. + * + * Used during shutdown or test teardown to release references to + * validation objects. Does not evict `byLedger_` or `bySequence_`. */ void flush() @@ -1059,20 +1255,27 @@ public: current_.clear(); } - /** Return quantity of lagging proposers, and remove online proposers - * for purposes of evaluating whether to pause. + /** Count lagging proposers and cull the online-proposer set. * - * Laggards are the trusted proposers whose sequence number is lower - * than the sequence number from which our current pending proposal - * is based. Proposers from whom we have not received a validation for - * awhile are considered offline. + * Iterates current validations looking for proposers whose signing key + * is in `trustedKeys` and whose most-recent validation was seen within + * `validationFRESHNESS`. For each such *online* proposer, the key is + * removed from `trustedKeys` (so the caller can detect truly offline + * nodes as the keys left in the set) and, if that proposer's validation + * sequence is below `seq`, the laggard counter is incremented. * - * Note: the trusted flag is not used in this evaluation because it's made - * redundant by checking the list of proposers. + * Callers use the laggard count and the residual `trustedKeys` set to + * decide whether to pause consensus and wait for slow validators. * - * @param seq Our current sequence number. - * @param trustedKeys Public keys of trusted proposers. - * @return Quantity of laggards. + * @note The `Validation::trusted()` flag is deliberately ignored here; + * freshness of the signing key against the caller-supplied + * `trustedKeys` set is sufficient. + * + * @param seq The local node's current validation sequence number. + * @param trustedKeys In/out: public keys of all trusted proposers. Online + * proposers are erased during the call; remaining keys + * on return represent offline nodes. + * @return Number of online proposers whose sequence < `seq`. */ std::size_t laggards(Seq const seq, hash_set& trustedKeys) @@ -1095,6 +1298,7 @@ public: return laggards; } + /** Return the number of entries in the `current_` map (diagnostic). */ std::size_t sizeOfCurrentCache() const { @@ -1102,6 +1306,7 @@ public: return current_.size(); } + /** Return the number of per-node `SeqEnforcer` entries cached (diagnostic). */ std::size_t sizeOfSeqEnforcersCache() const { @@ -1109,6 +1314,7 @@ public: return seqEnforcers_.size(); } + /** Return the number of distinct ledger IDs in `byLedger_` (diagnostic). */ std::size_t sizeOfByLedgerCache() const { @@ -1116,6 +1322,7 @@ public: return byLedger_.size(); } + /** Return the number of distinct sequence numbers in `bySequence_` (diagnostic). */ std::size_t sizeOfBySequenceCache() const { diff --git a/src/xrpld/overlay/Cluster.h b/src/xrpld/overlay/Cluster.h index 982f11aaae..2e20cfcae2 100644 --- a/src/xrpld/overlay/Cluster.h +++ b/src/xrpld/overlay/Cluster.h @@ -1,3 +1,13 @@ +/** @file + * Registry of trusted cluster nodes in the XRPL overlay. + * + * A "cluster" is a set of XRPL nodes run by the same administrative entity. + * Cluster members are treated with elevated trust by the overlay — for example, + * they are exempt from load-based fee throttling applied to anonymous peers. + * This file defines `Cluster`, the single authority for cluster membership + * queries and state maintenance throughout the overlay layer. + */ + #pragma once #include @@ -13,9 +23,24 @@ namespace xrpl { +/** Manages the set of trusted cluster nodes for the local XRPL node. + * + * Cluster membership is loaded at startup from the `[cluster_nodes]` config + * section and updated at runtime via gossip messages received from peers. + * All public methods are thread-safe. + * + * @note `forEach` holds the internal lock for the duration of its callback. + * Calling `update` from inside a `forEach` callback will deadlock. + */ class Cluster { private: + /** Transparent comparator enabling heterogeneous lookup by `PublicKey`. + * + * The `is_transparent` tag lets `nodes_.find(PublicKey)` accept a raw + * `PublicKey` directly, avoiding construction of a temporary `ClusterNode` + * on every membership check. + */ struct Comparator { explicit Comparator() = default; @@ -46,25 +71,50 @@ private: beast::Journal mutable j_; public: - Cluster(beast::Journal j); + explicit Cluster(beast::Journal j); - /** Determines whether a node belongs in the cluster - @return std::nullopt if the node isn't a member, - otherwise, the comment associated with the - node (which may be an empty string). - */ + /** Check whether a node is a member of this cluster. + * + * Uses heterogeneous lookup so no temporary `ClusterNode` is allocated. + * + * @param node The public key of the node to look up. + * @return `std::nullopt` if the node is not a cluster member; otherwise + * the human-readable name/comment associated with the node, which may + * be an empty string if no name was configured or gossiped. + */ std::optional member(PublicKey const& node) const; - /** The number of nodes in the cluster list. */ + /** Return the number of nodes currently registered in the cluster. */ std::size_t size() const; - /** Store information about the state of a cluster node. - @param identity The node's public identity - @param name The node's name (may be empty) - @return true if we updated our information - */ + /** Update the stored state for a cluster node. + * + * Enforces a monotonic-time invariant: the update is applied only if + * `reportTime` is strictly newer than the currently stored report time. + * This prevents stale gossip arriving out of order from overwriting fresh + * data. If `name` is empty and an existing entry already has a non-empty + * name, the existing name is preserved — a status gossip that omits a + * name will never blank out a label loaded from config. + * + * Because `std::set` elements are logically const after insertion, an + * accepted update erases the old entry and reinserts a new one at the + * same hint position (O(1) amortized). + * + * Also used by `load()` to insert nodes during initial config parsing, + * with `reportTime` left at its zero default. + * + * @param identity The public key identifying the cluster node. + * @param name Human-readable label for the node; empty string is valid + * and will preserve any previously stored name. + * @param loadFee The node's current load fee scalar (default 0). + * @param reportTime The NetClock time of this state report (default epoch). + * Updates with a `reportTime` equal to or earlier than the stored time + * are silently rejected. + * @return `true` if the entry was inserted or updated; `false` if the + * update was rejected because `reportTime` was not strictly newer. + */ bool update( PublicKey const& identity, @@ -72,23 +122,33 @@ public: std::uint32_t loadFee = 0, NetClock::time_point reportTime = NetClock::time_point{}); - /** Invokes the callback once for every cluster node. - @note You are not allowed to call `update` from - within the callback. - */ + /** Invoke a callback once for every registered cluster node. + * + * The lock is held for the entire iteration, so the callback must not + * call `update` — doing so would deadlock on the non-recursive mutex. + * + * @param func Callable invoked with a `ClusterNode const&` for each + * registered node. The order of iteration is by public key (the set's + * natural ordering). + */ void forEach(std::function func) const; - /** Load the list of cluster nodes. - - The section contains entries consisting of a base58 - encoded node public key, optionally followed by - a comment. - - @return false if an entry could not be parsed or - contained an invalid node public key, - true otherwise. - */ + /** Populate the cluster from a `[cluster_nodes]` config section. + * + * Each line in `nodes` must be a base58-encoded node public key + * (`TokenType::NodePublic`) optionally followed by whitespace and a + * free-form comment. Leading and trailing whitespace in comments is + * trimmed. Duplicate entries emit a warning and are skipped (first entry + * wins). The parse is fail-fast: the first malformed line or invalid key + * causes an immediate `false` return and no further lines are processed — + * nodes parsed before the bad entry are retained. + * + * @param nodes The configuration section to parse. + * @return `true` if all entries were loaded successfully (including the + * empty-section case); `false` if any entry could not be parsed or + * contained an invalid node public key. + */ bool load(Section const& nodes); }; diff --git a/src/xrpld/overlay/ClusterNode.h b/src/xrpld/overlay/ClusterNode.h index 16c8062840..a5cb9b63ca 100644 --- a/src/xrpld/overlay/ClusterNode.h +++ b/src/xrpld/overlay/ClusterNode.h @@ -1,3 +1,7 @@ +/** @file + * Defines `ClusterNode`, the value type for a single trusted cluster peer. + */ + #pragma once #include @@ -9,11 +13,43 @@ namespace xrpl { +/** Immutable-identity record for a single trusted peer in an XRPL cluster. + * + * A cluster is a set of XRPL validator nodes operated by the same + * administrative entity that extend automatic trust to each other. + * `ClusterNode` captures the four pieces of state exchanged via `TMCluster` + * gossip: node identity, human-readable name, current load fee, and the + * NetClock timestamp of the last report. + * + * `identity_` is `const` and serves as the primary key inside + * `Cluster`'s `std::set`. Because `std::set` elements are logically + * immutable after insertion, `Cluster::update()` must erase and reinsert + * rather than mutate in place. + * + * All data fields are written exactly once at construction and then only + * read, making instances safe to observe concurrently provided the caller + * holds the enclosing `Cluster::mutex_` across the lookup and the read. + * + * @see Cluster + */ class ClusterNode { public: ClusterNode() = delete; + /** Construct a cluster node record. + * + * @param identity The node's public key; used as the set key in `Cluster` + * and must remain stable for the lifetime of this object. + * @param name Human-readable label from config or peer gossip; may be + * an empty string. + * @param fee The load fee scalar the node is currently advertising. + * Defaults to 0 for newly-loaded config entries that have not yet + * received a live status report. + * @param rtime The NetClock time of the originating status report. + * Defaults to the epoch so that `Cluster::update()` will always + * accept the first real gossip update for a freshly configured node. + */ ClusterNode( PublicKey const& identity, std::string name, @@ -23,24 +59,55 @@ public: { } + /** Return the human-readable label for this node. + * + * May be an empty string if no name was configured or gossiped. + * `Cluster::update()` preserves a non-empty name when a subsequent + * gossip update arrives with an empty one. + * + * @return The node name, or an empty string if none is known. + */ [[nodiscard]] std::string const& name() const { return name_; } + /** Return the load fee scalar most recently reported by this node. + * + * Aggregated across all cluster peers by `NetworkOPs` via + * `Cluster::forEach()` to compute a cluster-wide fee floor. + * + * @return The load fee scalar; 0 if no live report has been received. + */ [[nodiscard]] std::uint32_t getLoadFee() const { return loadFee_; } + /** Return the NetClock time of the most recent status report. + * + * `Cluster::update()` rejects any incoming report whose timestamp is + * not strictly greater than this value, ensuring cluster state advances + * monotonically. Uses `NetClock` (logical network time) rather than + * wall-clock time to avoid skew between nodes. + * + * @return The report timestamp; the epoch if no live report has arrived. + */ [[nodiscard]] NetClock::time_point getReportTime() const { return reportTime_; } + /** Return the public key that uniquely identifies this cluster node. + * + * Acts as the sort key for `Cluster`'s `std::set` and is used by + * `Cluster::Comparator` for heterogeneous `PublicKey` lookups. + * + * @return A const reference to the node's public key. + */ [[nodiscard]] PublicKey const& identity() const { diff --git a/src/xrpld/overlay/Compression.h b/src/xrpld/overlay/Compression.h index 91417913e3..49ab07dd0d 100644 --- a/src/xrpld/overlay/Compression.h +++ b/src/xrpld/overlay/Compression.h @@ -1,3 +1,14 @@ +/** + * @file Compression.h + * @brief Overlay compression dispatch layer and wire-format constants. + * + * Sits between `Message` (which decides *whether* to compress) and + * `CompressionAlgorithms.h` (which implements the codec). Centralises + * algorithm selection, exception suppression, and the wire-format constants + * that are shared across the send (`Message.cpp`) and receive + * (`ProtocolMessage.h`) paths. + */ + #pragma once #include @@ -5,22 +16,67 @@ namespace xrpl::compression { +/** Size in bytes of an uncompressed XRPL message header. + * + * Layout: 4 bytes payload length (top 6 bits reserved/zero) + 2 bytes + * protobuf message type. + */ std::size_t constexpr kHEADER_BYTES = 6; + +/** Size in bytes of a compressed XRPL message header. + * + * Layout: 4 bytes (compression algorithm in top 4 bits, payload length in + * remaining bits) + 2 bytes message type + 4 bytes original uncompressed + * size. The extra 4 bytes over `kHEADER_BYTES` is why `Message::compress()` + * only takes the compressed path when savings exceed 4 bytes. + */ std::size_t constexpr kHEADER_BYTES_COMPRESSED = 10; -// All values other than 'none' must have the high bit. The low order four bits -// must be 0. +/** Compression algorithm identifier encoded in the wire-format header. + * + * The encoding is load-bearing: `ProtocolMessage::parseMessageHeader()` + * detects compression via `*iter & 0x80`, extracts the algorithm via + * `*iter & 0xF0`, and validates reserved bits via `*iter & 0x0C == 0`. + * Enum values can therefore be extracted by masking the first wire byte with + * no further translation. + * + * @note All values other than `None` must have the high bit set and the low + * nibble zero. For example, a future algorithm should use `0xA0`, `0xB0`, + * etc. `None = 0x00` signals an uncompressed message (high bit clear). + * @note Adding a new value here requires updating the dispatch branches in + * `compress()` and `decompress()`; the `UNREACHABLE` guards will catch + * any omission at runtime. + */ enum class Algorithm : std::uint8_t { None = 0x00, LZ4 = 0x90 }; +/** Controls whether a caller requests the compressed form of a message buffer. + * + * Passed to `Message::getBuffer()` to select between the uncompressed + * `buffer_` and the lazily-compressed `bufferCompressed_`. + */ enum class Compressed : std::uint8_t { On, Off }; -/** Decompress input stream. - * @tparam InputStream ZeroCopyInputStream - * @param in Input source stream - * @param inSize Size of compressed data - * @param decompressed Buffer to hold decompressed message - * @param algorithm Compression algorithm type - * @return Size of decompressed data or zero if failed to decompress +/** Decompress a scatter-gather input stream into a contiguous output buffer. + * + * Delegates to `lz4Decompress`, which accepts data arriving in discontiguous + * Boost.Asio chunks: it uses the first chunk directly when it is large enough + * to hold the entire compressed payload, and falls back to a contiguous copy + * otherwise. Any exception thrown by the underlying codec is caught and + * suppressed; the overlay receive path cannot propagate exceptions. + * + * @tparam InputStream A `ZeroCopyInputStream` wrapping a `ConstBufferSequence` + * (typically `ZeroCopyInputStream` from `ZeroCopyStream.h`). + * @param in Scatter-gather stream positioned at the start of the compressed + * payload (i.e., after the 10-byte compressed header has been consumed). + * @param inSize Number of compressed bytes to read from `in`. + * @param decompressed Caller-allocated output buffer; must be at least + * `decompressedSize` bytes. + * @param decompressedSize Expected size of the decompressed message, taken + * from the 4-byte uncompressed-size field in the compressed wire header. + * @param algorithm Algorithm tag parsed from the wire header; defaults to + * `LZ4` (the only currently supported algorithm). + * @return Number of bytes written to `decompressed`, or `0` on any failure + * (including codec error or unrecognised algorithm). */ template std::size_t @@ -53,14 +109,27 @@ decompress( return 0; } -/** Compress input data. - * @tparam BufferFactory Callable object or lambda. - * Takes the requested buffer size and returns allocated buffer pointer. - * @param in Data to compress - * @param inSize Size of the data - * @param bf Compressed buffer allocator - * @param algorithm Compression algorithm type - * @return Size of compressed data, or zero if failed to compress +/** Compress a contiguous input buffer using a lazy output allocator. + * + * Delegates to `lz4Compress`, which calls `bf(capacity)` exactly once with + * the bound computed by `LZ4_compressBound(inSize)` to obtain an output + * pointer, then writes compressed bytes there. In practice `Message::compress` + * passes a lambda that resizes `bufferCompressed_` and returns a pointer + * offset by `kHEADER_BYTES_COMPRESSED`, so the codec writes directly into the + * final wire buffer leaving the header region intact. Any exception thrown by + * the codec is caught and suppressed; the overlay send path cannot propagate + * exceptions. + * + * @tparam BufferFactory Callable with signature `void*(std::size_t capacity)`. + * Called once with the worst-case compressed size; must return a pointer + * to a buffer of at least `capacity` bytes. + * @param in Contiguous buffer holding the serialized protobuf payload (i.e., + * the uncompressed message body, not including any header bytes). + * @param inSize Number of bytes at `in` to compress. + * @param bf Lazy allocator for the compressed output buffer; see above. + * @param algorithm Algorithm to use; defaults to `LZ4`. + * @return Number of compressed bytes written via `bf`, or `0` on any failure + * (including codec error or unrecognised algorithm). */ template std::size_t diff --git a/src/xrpld/overlay/Message.h b/src/xrpld/overlay/Message.h index 7c2701d443..450ac6c321 100644 --- a/src/xrpld/overlay/Message.h +++ b/src/xrpld/overlay/Message.h @@ -1,3 +1,12 @@ +/** @file + * Wire-protocol framing envelope for overlay peer messages. + * + * Defines `Message`, which serializes a protobuf object once, optionally + * compresses it once, and lets every concurrent peer send share the same + * read-only buffer. Also defines `kMAXIMUM_MESSAGE_SIZE`, the hard 64 MiB + * cap enforced by the 26-bit payload length field in both wire formats. + */ + #pragma once #include @@ -11,6 +20,12 @@ namespace xrpl { +/** Hard upper bound on the serialized payload size for any overlay message. + * + * The wire-format header encodes payload length in 26 bits, which physically + * cannot represent a value larger than 64 MiB. The receiving path drops any + * message that exceeds this limit and may disconnect the sender. + */ constexpr std::size_t kMAXIMUM_MESSAGE_SIZE = megabytes(64); // VFALCO NOTE If we forward declare Message and write out shared_ptr @@ -18,58 +33,118 @@ constexpr std::size_t kMAXIMUM_MESSAGE_SIZE = megabytes(64); // entire ripple.pb.h from the main headers. // -// packaging of messages into length/type-prepended buffers -// ready for transmission. -// -// Message implements simple "packing" of protocol buffers Messages into -// a string prepended by a header specifying the message length. -// MessageType should be a Message class generated by the protobuf compiler. -// - +/** Serialization envelope for a single overlay protocol message. + * + * Encapsulates the serialized bytes of one protobuf message together with + * a 6-byte (uncompressed) or 10-byte (compressed) wire header. The object + * is designed to be held in a `shared_ptr` and placed into every peer's + * outbound send queue: the protobuf is serialized exactly once at + * construction, and LZ4 compression — if eligible — is performed at most + * once via `std::call_once`, regardless of how many peers share the pointer. + * + * Traffic accounting (`TrafficCount::categorize`) is also done once at + * construction; the result is readable via `getCategory()` without + * re-inspecting the protobuf type on each send. + * + * The optional `validatorKey_` field is present for `mtVALIDATION` and + * `mtPROPOSE_LEDGER` messages so that `PeerImp::send()` can check the + * per-peer squelch state before transmitting. + * + * @note Thread-safe for concurrent `getBuffer()` calls after construction. + * `compress()` is guarded by `once_flag_`; `buffer_` is immutable after + * the constructor returns. + */ class Message : public std::enable_shared_from_this { using Compressed = compression::Compressed; using Algorithm = compression::Algorithm; public: - /** Constructor - * @param message Protocol message to serialize - * @param type Protocol message type - * @param validator Public Key of the source validator for Validation or - * Proposal message. Used to check if the message should be squelched. + /** Serialize a protobuf message into a wire-ready buffer. + * + * The protobuf object is serialized immediately into `buffer_` with a + * 6-byte uncompressed header prepended. Traffic category is computed + * once via `TrafficCount::categorize` and stored in `category_`. + * Compression is deferred until the first call to `getBuffer(Compressed::On)`. + * + * @param message Protobuf message to serialize; must be non-empty. + * @param type Wire protocol message type tag (e.g. `mtTRANSACTION`). + * @param validator For `mtVALIDATION` and `mtPROPOSE_LEDGER` messages, + * the public key of the originating validator. `PeerImp::send()` uses + * this to gate squelch-suppressed sends. Pass the default `{}` for + * all other message types. */ Message( ::google::protobuf::Message const& message, protocol::MessageType type, std::optional const& validator = {}); - /** Retrieve the size of the packed but uncompressed message data. */ + /** Return the byte length of the uncompressed wire buffer (header + payload). */ std::size_t getBufferSize(); + /** Return the serialized size of the protobuf payload, excluding the header. + * + * Uses `ByteSizeLong()` on protobuf >= 3.11, `ByteSize()` otherwise. + * + * @param message Protobuf message to measure. + * @return Number of bytes the message occupies when serialized. + */ static std::size_t messageSize(::google::protobuf::Message const& message); + /** Return the total wire size of the message: header plus serialized payload. + * + * Equivalent to `messageSize(message) + compression::kHEADER_BYTES`. + * Useful for pre-flight size checks before constructing a `Message`. + * + * @param message Protobuf message to measure. + * @return Total uncompressed wire size in bytes. + */ static std::size_t totalSize(::google::protobuf::Message const& message); - /** Retrieve the packed message data. If compressed message is requested but - * the message is not compressible then the uncompressed buffer is returned. - * @param compressed Request compressed (Compress::On) or - * uncompressed (Compress::Off) payload buffer - * @return Payload buffer + /** Return the wire buffer, optionally in compressed form. + * + * When `tryCompressed == Compressed::On`, compression is attempted via + * `std::call_once` so the LZ4 pass runs at most once regardless of + * concurrent callers. If the message is ineligible for compression (type + * not whitelisted, payload ≤ 70 bytes, or compressed result offers no net + * saving over the 4-byte header overhead), the uncompressed `buffer_` is + * returned as a fallback. + * + * @param tryCompressed `Compressed::On` to request the compressed buffer; + * `Compressed::Off` to always receive the uncompressed buffer. + * @return `const` reference to an internal buffer valid for the lifetime + * of this `Message`. Callers must keep the `shared_ptr` alive + * for any async I/O that reads from this reference. */ std::vector const& getBuffer(Compressed tryCompressed); - /** Get the traffic category */ + /** Return the `TrafficCount` category index assigned at construction. + * + * The category is computed once by `TrafficCount::categorize()` and used + * by `PeerImp::send()` to record outbound traffic metrics per message + * type without re-inspecting the protobuf payload. + * + * @return Index into `TrafficCount::category_t` for this message. + */ std::size_t getCategory() const { return category_; } - /** Get the validator's key */ + /** Return the validator public key embedded in this message, if any. + * + * Non-empty only for `mtVALIDATION` and `mtPROPOSE_LEDGER` messages + * constructed with an explicit `validator` argument. `PeerImp::send()` + * calls `squelch_.expireSquelch(*key)` against this value to decide + * whether to transmit or suppress the message for a given peer. + * + * @return Optional public key of the originating validator. + */ std::optional const& getValidatorKey() const { @@ -83,13 +158,29 @@ private: std::once_flag once_flag_; std::optional validatorKey_; - /** Set the payload header - * @param in Pointer to the payload - * @param payloadBytes Size of the payload excluding the header size - * @param type Protocol message type - * @param compression Compression algorithm used in compression, - * currently LZ4 only. If None then the message is uncompressed. - * @param uncompressedBytes Size of the uncompressed message + /** Write the wire-format header into an already-allocated buffer region. + * + * Encodes a 6-byte header for uncompressed messages or a 10-byte header + * for compressed messages. All multi-byte fields are big-endian. + * + * Uncompressed layout (6 bytes): 6 reserved zero bits | 26-bit payload + * size | 16-bit message type. + * + * Compressed layout (10 bytes): 1 compression flag | 3-bit algorithm | + * 2 reserved bits | 26-bit compressed payload size | 16-bit message type + * | 32-bit original uncompressed size. + * + * @param in Pointer to the start of the header region; must + * have at least `kHEADER_BYTES` (uncompressed) or + * `kHEADER_BYTES_COMPRESSED` (compressed) bytes available. + * @param payloadBytes Size of the payload that follows the header, + * not counting the header itself. + * @param type Protocol message type (16-bit value). + * @param compression Algorithm to encode in the header; `None` + * produces a 6-byte uncompressed header. + * @param uncompressedBytes Original payload size before compression; + * written into the trailing 4 bytes of the compressed header. + * Ignored when `compression == Algorithm::None`. */ static void setHeader( @@ -99,18 +190,30 @@ private: Algorithm compression, std::uint32_t uncompressedBytes); - /** Try to compress the payload. - * Can be called concurrently by multiple peers but is compressed once. - * If the message is not compressible then the serialized buffer_ is used. + /** Attempt LZ4 compression and populate `bufferCompressed_`. + * + * Invoked at most once per `Message` instance via `std::call_once` in + * `getBuffer()`. Applies a two-stage eligibility policy before invoking + * the codec: the payload must exceed 70 bytes and the message type must + * appear in the compression whitelist. If the compressed result does not + * beat the uncompressed size by more than the 4-byte extra header + * overhead, `bufferCompressed_` is cleared so `getBuffer()` falls back + * to `buffer_`. + * + * @note Safe to call concurrently — `std::call_once` serializes the + * single execution; subsequent calls are no-ops. */ void compress(); - /** Get the message type from the payload header. - * First four bytes are the compression/algorithm flag and the payload size. - * Next two bytes are the message type - * @param in Payload header pointer - * @return Message type + /** Decode the message type field from a wire-format header. + * + * Reads bytes 4 and 5 (0-indexed) of the header, which hold the 16-bit + * message type in big-endian order for both the 6-byte and 10-byte + * header formats. + * + * @param in Pointer to the first byte of the header. + * @return Integer message type (e.g. `protocol::mtTRANSACTION`). */ static int getType(std::uint8_t const* in); diff --git a/src/xrpld/overlay/Overlay.h b/src/xrpld/overlay/Overlay.h index 80430293d8..ae12e9ac30 100644 --- a/src/xrpld/overlay/Overlay.h +++ b/src/xrpld/overlay/Overlay.h @@ -1,3 +1,12 @@ +/** @file + * Defines the abstract `Overlay` interface, the single authority for the + * XRPL peer-to-peer mesh. + * + * All subsystems that need to broadcast across or query the live peer set + * go through this interface. The concrete implementation lives in + * `detail/OverlayImpl.h` and is created exclusively via `make_Overlay()`. + */ + #pragma once #include @@ -20,7 +29,16 @@ class context; namespace xrpl { -/** Manages the set of connected peers. */ +/** Abstract interface for the XRP Ledger peer-to-peer overlay network. + * + * Every subsystem that needs to send messages to, query, or broadcast + * across peers does so through this interface. Registers itself as the + * `"peers"` node in the diagnostics `PropertyStream` tree so monitoring + * tools can walk into it without extra wiring. + * + * The concrete implementation is hidden behind this interface and created + * exclusively via the `make_Overlay()` factory in `make_Overlay.h`. + */ class Overlay : public beast::PropertyStream::Source { protected: @@ -35,117 +53,246 @@ protected: } public: - enum class Promote { Automatic, Never, Always }; + /** Controls promotion eligibility for an incoming connection slot. + * + * The promotion decision is a policy of the overlay as a whole, not + * of the individual connection, so the enum lives here rather than + * on `Peer`. + */ + enum class Promote { + Automatic, /**< Promote based on normal slot-availability rules. */ + Never, /**< Never promote; treat as outbound-only slot. */ + Always /**< Always promote regardless of slot availability. */ + }; + /** Configuration that must be known at overlay construction time. + * + * Populated by `setupOverlay()` (which reads `[overlay]`, `[crawl]`, + * `[vl]`, and `[network_id]` config sections) and passed to the + * `make_Overlay()` factory. Using a value type keeps the factory + * signature stable as configuration grows. + */ struct Setup { explicit Setup() = default; + /** Shared TLS context reused across all peer connections. */ std::shared_ptr context; + + /** Server's public IP address, embedded in handshake headers for + * NAT diagnostics and remote-address cross-checking. + */ beast::IP::Address publicIp; + + /** Maximum number of simultaneous connections allowed from a single + * IP address. Zero means no per-IP limit. + */ int ipLimit = 0; + + /** Bitmask of `CrawlOptions` flags controlling what the `/crawl` + * endpoint exposes (overlay topology, server info, counts, UNL). + * Zero disables the endpoint entirely. + */ std::uint32_t crawlOptions = 0; + + /** Optional numeric network identifier exchanged during the peer + * handshake. A mismatch causes the connection to be rejected, + * preventing testnet or devnet nodes from accidentally joining + * mainnet. Conventional values: 0 = mainnet, 1 = testnet, + * 2 = devnet. Absent means no network-ID check is performed. + */ std::optional networkID; + + /** Whether to serve signed validator lists via the `/vl/` endpoint. + * Controlled by `[vl] enabled` in the config. + */ bool vlEnabled = true; }; + /** Snapshot of currently active peers, returned by `getActivePeers()`. */ using PeerSequence = std::vector>; ~Overlay() override = default; + /** Start the overlay, initiating peer discovery and accepting connections. */ virtual void start() { } + /** Shut down the overlay, closing all peer connections and stopping timers. */ virtual void stop() { } - /** Conditionally accept an incoming HTTP request. */ + /** Handle an inbound TLS connection that may be a peer protocol upgrade. + * + * Called by the HTTP server layer when it receives an upgrade request. + * Ownership of the TLS stream is transferred via `bundle`; if the + * method returns a `Handoff` that accepts the connection, the caller + * must not touch the stream again. If the method declines, the caller + * may treat the request as a regular HTTP request. + * + * @param bundle Owning pointer to the TLS stream for this connection. + * @param request The HTTP upgrade request received on the stream. + * @param remoteAddress Remote TCP endpoint of the connecting client. + * @return A `Handoff` whose `moved` flag indicates whether the overlay + * accepted the connection. + */ virtual Handoff onHandoff( std::unique_ptr&& bundle, http_request_type&& request, boost::asio::ip::tcp::endpoint remoteAddress) = 0; - /** Establish a peer connection to the specified endpoint. - The call returns immediately, the connection attempt is - performed asynchronously. - */ + /** Schedule an asynchronous outbound connection to the given endpoint. + * + * Returns immediately; the connection attempt proceeds on the I/O + * strand. Success or failure is handled internally and reflected in + * the active peer count. + * + * @param address The remote endpoint to connect to. + */ virtual void connect(beast::IP::Endpoint const& address) = 0; - /** Returns the maximum number of peers we are configured to allow. */ + /** Return the configured maximum number of active peers. + * + * The gap between `limit()` and `size()` represents available peer slots. + */ virtual int limit() = 0; - /** Returns the number of active peers. - Active peers are only those peers that have completed the - handshake and are using the peer protocol. - */ + /** Return the number of peers that have completed the peer-protocol handshake. + * + * Peers mid-negotiation are not counted. Use `limit()` to determine + * available capacity. + */ [[nodiscard]] virtual std::size_t size() const = 0; - /** Return diagnostics on the status of all peers. - @deprecated This is superseded by PropertyStream - */ + /** Return a JSON diagnostics object describing all active peers. + * + * @deprecated Superseded by the `PropertyStream` interface; prefer + * walking the `"peers"` node in the diagnostics tree. + */ virtual json::Value json() = 0; - /** Returns a sequence representing the current list of peers. - The snapshot is made at the time of the call. - */ + /** Return a point-in-time snapshot of all active peers. + * + * The snapshot is taken under an internal lock and then released, + * so the caller iterates without holding any overlay mutex. A peer + * may disconnect after the snapshot is taken; callers must handle + * stale `shared_ptr` values gracefully. + * + * @return A vector of `shared_ptr` for each peer that has + * completed the handshake at the moment of the call. + */ [[nodiscard]] virtual PeerSequence getActivePeers() const = 0; - /** Calls the checkTracking function on each peer - @param index the value to pass to the peer's checkTracking function - */ + /** Propagate a ledger sequence number to every active peer for tracking. + * + * Each peer compares `index` against its own latest validated ledger + * to determine whether it is converged, diverged, or unknown. + * + * @param index The latest validated ledger sequence to check against. + */ virtual void checkTracking(std::uint32_t index) = 0; - /** Returns the peer with the matching short id, or null. */ + /** Find a peer by its ephemeral short connection ID. + * + * The short ID (`Peer::id_t`, a `uint32_t`) is stable only for the + * lifetime of the connection. + * + * @param id The ephemeral connection-local identifier. + * @return The matching peer, or `nullptr` if no connected peer has + * that ID. + */ [[nodiscard]] virtual std::shared_ptr findPeerByShortID(Peer::id_t const& id) const = 0; - /** Returns the peer with the matching public key, or null. */ + /** Find a peer by its node public key. + * + * Used by the consensus and validation layers, which operate in terms + * of validator identity rather than connection identity. + * + * @param pubKey The node public key to look up. + * @return The matching peer, or `nullptr` if no connected peer has + * that public key. + */ virtual std::shared_ptr findPeerByPublicKey(PublicKey const& pubKey) = 0; - /** Broadcast a proposal. */ + /** Send a proposal to every active peer without deduplication. + * + * Use this when the local node originates the proposal. For forwarding + * a received proposal, use `relay()` instead. + * + * @param m The proposal message to broadcast. + */ virtual void broadcast(protocol::TMProposeSet& m) = 0; - /** Broadcast a validation. */ + /** Send a validation to every active peer without deduplication. + * + * Use this when the local node originates the validation. For + * forwarding a received validation, use `relay()` instead. + * + * @param m The validation message to broadcast. + */ virtual void broadcast(protocol::TMValidation& m) = 0; - /** Relay a proposal. - * @param m the serialized proposal - * @param uid the id used to identify this proposal - * @param validator The pubkey of the validator that issued this proposal - * @return the set of peers which have already sent us this proposal + /** Forward a received proposal to peers that have not yet seen it. + * + * Consults `HashRouter` to suppress duplicate relays. Applies the + * squelch/reduce-relay logic to select a subset of "source" peers per + * validator and temporarily silence the rest via `TMSquelch`. + * + * @param m The proposal message to relay. + * @param uid Deduplication key identifying this proposal. + * @param validator Public key of the validator that issued the proposal. + * @return The set of peer IDs that already forwarded this proposal to + * us, i.e., peers that already have it and should not receive it + * again. Empty if `HashRouter` suppressed the relay entirely. */ virtual std::set relay(protocol::TMProposeSet& m, uint256 const& uid, PublicKey const& validator) = 0; - /** Relay a validation. - * @param m the serialized validation - * @param uid the id used to identify this validation - * @param validator The pubkey of the validator that issued this validation - * @return the set of peers which have already sent us this validation + /** Forward a received validation to peers that have not yet seen it. + * + * Consults `HashRouter` to suppress duplicate relays. Applies the + * squelch/reduce-relay logic to select a subset of "source" peers per + * validator and temporarily silence the rest via `TMSquelch`. + * + * @param m The validation message to relay. + * @param uid Deduplication key identifying this validation. + * @param validator Public key of the validator that issued the validation. + * @return The set of peer IDs that already forwarded this validation to + * us, i.e., peers that already have it and should not receive it + * again. Empty if `HashRouter` suppressed the relay entirely. */ virtual std::set relay(protocol::TMValidation& m, uint256 const& uid, PublicKey const& validator) = 0; - /** Relay a transaction. If the tx reduce-relay feature is enabled then - * randomly select peers to relay to and queue transaction's hash - * for the rest of the peers. - * @param hash transaction's hash - * @param m transaction's protocol message to relay - * @param toSkip peers which have already seen this transaction + /** Forward a transaction to peers that have not yet seen it. + * + * When tx reduce-relay is active (negotiated via `FEATURE_TXRR`), a + * random subset of peers receives the full message immediately; the + * remainder receive only a hash announcement via `TMHaveTransactions`, + * batched by a periodic flush. Peers that did not negotiate the + * feature always receive the full message for backward compatibility. + * + * @param hash SHA-512 half hash of the transaction, used for + * deduplication and hash-announcement queuing. + * @param m The full transaction message. May be absent when + * only queuing the hash for peers that will request it later. + * @param toSkip Peers that have already seen this transaction and + * must not receive it again. */ virtual void relay( @@ -153,12 +300,15 @@ public: std::optional> m, std::set const& toSkip) = 0; - /** Visit every active peer. + /** Invoke a function on every active peer. * - * The visitor must be invocable as: - * Function(std::shared_ptr const& peer); + * Built on `getActivePeers()`: takes a snapshot first, then iterates + * without holding any internal lock. Non-virtual because the snapshot + * semantics are defined entirely by `getActivePeers()`. * - * @param f the invocable to call with every peer + * @tparam Function Callable with signature + * `void(std::shared_ptr const&)`. + * @param f The callable to invoke for each peer. */ template void @@ -168,37 +318,66 @@ public: f(p); } - /** Increment and retrieve counter for transaction job queue overflows. */ + /** Record a transaction job-queue overflow event. + * + * Called when a received transaction is dropped because + * `JobQueue::getJobCount(JtTransaction)` exceeds `MAX_TRANSACTIONS`. + * The accumulated count is exposed via `getJqTransOverflow()` and + * reported in the `server_info` RPC response as `jq_trans_overflow`. + */ virtual void incJqTransOverflow() = 0; + + /** Return the total number of transaction job-queue overflow events. + * + * @return Cumulative count since process start. + */ [[nodiscard]] virtual std::uint64_t getJqTransOverflow() const = 0; - /** Increment and retrieve counters for total peer disconnects, and - * disconnects we initiate for excessive resource consumption. + /** Record a peer disconnection event. + * + * Called whenever a peer connection is fully torn down, regardless of + * cause. Exposed via `getPeerDisconnect()` in `server_info` as + * `peer_disconnects`. */ virtual void incPeerDisconnect() = 0; + + /** Return the total number of peer disconnections since process start. */ [[nodiscard]] virtual std::uint64_t getPeerDisconnect() const = 0; + + /** Record a peer disconnection caused by excessive resource consumption. + * + * Called by the resource charging system when a peer's charge level + * causes a forced disconnect. Reported separately from the general + * disconnect count as `peer_disconnects_resources` in `server_info`. + */ virtual void incPeerDisconnectCharges() = 0; + + /** Return the number of resource-charge-triggered peer disconnections. */ [[nodiscard]] virtual std::uint64_t getPeerDisconnectCharges() const = 0; - /** Returns the ID of the network this server is configured for, if any. - - The ID is just a numerical identifier, with the IDs 0, 1 and 2 used to - identify the mainnet, the testnet and the devnet respectively. - - @return The numerical identifier configured by the administrator of the - server. An unseated optional, otherwise. - */ + /** Return the numeric network ID this server is configured for, if any. + * + * The ID is exchanged during the peer handshake; a mismatch causes the + * connection to be rejected, preventing accidental cross-network + * connections. Conventional values: 0 = mainnet, 1 = testnet, + * 2 = devnet. + * + * @return The configured network ID, or an empty optional if no ID + * was configured (no network-ID check is performed on handshake). + */ [[nodiscard]] virtual std::optional networkID() const = 0; - /** Returns tx reduce-relay metrics - @return json value of tx reduce-relay metrics + /** Return a JSON snapshot of tx reduce-relay rolling metrics. + * + * @return JSON object with per-interval averages for transaction + * relay counts, hash-announcement counts, and related statistics. */ [[nodiscard]] virtual json::Value txMetrics() const = 0; diff --git a/src/xrpld/overlay/Peer.h b/src/xrpld/overlay/Peer.h index 0c86776030..9770bf716c 100644 --- a/src/xrpld/overlay/Peer.h +++ b/src/xrpld/overlay/Peer.h @@ -1,3 +1,13 @@ +/** @file + * Defines the pure abstract interface for a single authenticated peer + * connection in the XRPL overlay network. + * + * `PeerImp` (in `detail/`) provides the concrete async I/O implementation. + * All other subsystems interact exclusively through this interface, keeping + * them decoupled from the SSL shutdown state machine, circular buffers, and + * protobuf handling inside `PeerImp`. + */ + #pragma once #include @@ -13,23 +23,43 @@ namespace Resource { class Charge; } // namespace Resource +/** Opt-in protocol capabilities that a peer may have negotiated at handshake. + * + * Used by `Peer::supportsFeature` to gate message types that older peers + * cannot handle, ensuring backward compatibility across protocol versions. + */ enum class ProtocolFeature { - ValidatorListPropagation, - ValidatorList2Propagation, - LedgerReplay, + ValidatorListPropagation, /**< Peer accepts v1 validator list messages. */ + ValidatorList2Propagation, /**< Peer accepts v2 validator list messages. */ + LedgerReplay, /**< Peer supports targeted ledger replay requests. */ }; -/** Represents a peer connection in the overlay. */ +/** Abstract interface for a single authenticated, handshake-complete peer + * connection in the XRPL overlay network. + * + * `Overlay` manages a collection of `Peer` objects; `predicates.h` provides + * composable functors that filter and iterate over them. The concrete + * implementation `PeerImp` is never exposed outside `detail/` — callers + * route messages, query ledger state, and apply resource charges entirely + * through this interface. + * + * Shared ownership via `Peer::ptr` (`std::shared_ptr`) is the + * canonical handle. For long-lived references that must not extend a peer's + * lifetime, store a `Peer::id_t` and resolve it later with + * `Overlay::findPeerByShortID()`. + */ class Peer { public: + /** Shared-ownership handle; the canonical way to hold a reference to a peer. */ using ptr = std::shared_ptr; - /** Uniquely identifies a peer. - This can be stored in tables to find the peer later. Callers - can discover if the peer is no longer connected and make - adjustments as needed. - */ + /** Stable numeric identifier for a peer connection. + * + * Safe to store in tables and to outlive the `Peer` object itself. + * Callers can discover whether the peer is still connected by attempting + * a lookup via `Overlay::findPeerByShortID()`. + */ using id_t = std::uint32_t; virtual ~Peer() = default; @@ -38,25 +68,70 @@ public: // Network // + /** Enqueue a protocol message for delivery to this peer. + * + * @param m The wire-encoded message to send. + */ virtual void send(std::shared_ptr const& m) = 0; + /** Return the remote IP endpoint of this peer connection. + * + * @return The peer's remote address and port. + */ [[nodiscard]] virtual beast::IP::Endpoint getRemoteAddress() const = 0; - /** Send aggregated transactions' hashes. */ + /** Flush the accumulated transaction-hash queue to this peer as a single + * `TMHaveTransactions` batch. + * + * Only called when `txReduceRelayEnabled()` is true. Sending the queue + * in a batch amortizes per-message overhead compared to flooding full + * transactions to every peer individually. + * + * @see addTxQueue + */ virtual void sendTxQueue() = 0; - /** Aggregate transaction's hash. */ + /** Accumulate a transaction hash in this peer's reduce-relay queue. + * + * Instead of forwarding the full transaction immediately, the hash is + * batched and delivered via `sendTxQueue()`. The queue is bounded by + * `MAX_TX_QUEUE_SIZE` (10,000 hashes) to stay within the 64 MiB wire + * limit at high TPS. + * + * Only meaningful when `txReduceRelayEnabled()` is true. + * + * @param hash The 256-bit transaction hash to enqueue. + * @see sendTxQueue, removeTxQueue + */ virtual void addTxQueue(uint256 const&) = 0; - /** Remove hash from the transactions' hashes queue. */ + /** Remove a transaction hash from this peer's reduce-relay queue. + * + * Called when a transaction is retracted or no longer needs to be + * announced to this peer. + * + * @param hash The 256-bit transaction hash to remove. + * @see addTxQueue + */ virtual void removeTxQueue(uint256 const&) = 0; - /** Adjust this peer's load balance based on the type of load imposed. */ + /** Apply a resource charge against this peer's load-balance score. + * + * Called when the peer imposes cost on the local node — for example, by + * sending malformed messages, triggering expensive validation, or causing + * job-queue overflow. If the accumulated charge exceeds the Drop + * threshold, the peer is disconnected and + * `Overlay::incPeerDisconnectCharges()` is incremented. + * + * @param fee The charge type and magnitude to apply. + * @param context Diagnostic label identifying what triggered the charge; + * flows into disconnect accounting logs. + */ virtual void charge(Resource::Charge const& fee, std::string const& context) = 0; @@ -64,56 +139,193 @@ public: // Identity // + /** Return this peer's stable numeric identifier. + * + * @return The `id_t` assigned at connection activation. Safe to store + * in tables; see `Peer::id_t`. + */ [[nodiscard]] virtual id_t id() const = 0; - /** Returns `true` if this connection is a member of the cluster. */ + /** Return `true` if this connection is a member of the trusted cluster. + * + * Cluster peers bypass slot limits and receive preferential routing. + */ [[nodiscard]] virtual bool cluster() const = 0; + /** Return `true` if the measured round-trip latency exceeds the high-latency + * threshold. + * + * Used by the ledger acquisition logic to prefer lower-latency peers when + * selecting candidates for data requests. + */ [[nodiscard]] virtual bool isHighLatency() const = 0; + /** Compute a priority score for use in peer-selection during data acquisition. + * + * The score combines a random tiebreaker, a bonus when the peer is known + * to have the requested item, a penalty proportional to measured latency, + * and a large penalty when latency is unknown. Higher is better. + * + * @param haveItem `true` if the peer is believed to have the item being + * requested (e.g., via `hasLedger` or `hasTxSet`). + * @return A signed integer score; higher values indicate a more + * preferable peer. + */ [[nodiscard]] virtual int - getScore(bool) const = 0; + getScore(bool haveItem) const = 0; + /** Return the node public key that authenticated this peer during the handshake. + * + * @return A reference to the peer's Ed25519 or secp256k1 public key. + */ [[nodiscard]] virtual PublicKey const& getNodePublic() const = 0; + /** Return a JSON snapshot of this peer's current state for diagnostics. + * + * Used by the `/crawl` endpoint and the `peers` RPC command. + * + * @return A `json::Value` object containing identity, latency, ledger + * range, and protocol version fields. + */ virtual json::Value json() = 0; + /** Return `true` if this peer negotiated the given protocol feature at handshake. + * + * Use this before sending a message type the peer may not understand, to + * maintain backward compatibility across protocol versions. + * + * @param f The capability to test. + * @return `true` if the feature was mutually negotiated; `false` otherwise. + * @see ProtocolFeature + */ [[nodiscard]] virtual bool supportsFeature(ProtocolFeature f) const = 0; + /** Return the last-seen sequence number for a validator list publisher, if any. + * + * The squelch system uses this to avoid re-sending a validator list version + * that this peer has already propagated. Each publisher is tracked + * independently by its `PublicKey`. + * + * @param publisherKey The validator list publisher's public key. + * @return The tracked sequence number, or `std::nullopt` if none has + * been recorded for this publisher. + * @see setPublisherListSequence + */ [[nodiscard]] virtual std::optional publisherListSequence(PublicKey const&) const = 0; + /** Record the sequence number of a validator list propagated to this peer. + * + * Called after successfully forwarding a validator list so that future + * redundant sends can be suppressed. + * + * @param publisherKey The validator list publisher's public key. + * @param seq The sequence number of the list that was sent. + * @see publisherListSequence + */ virtual void setPublisherListSequence(PublicKey const&, std::size_t const) = 0; + /** Return a human-readable fingerprint string identifying this peer. + * + * Derived from the remote endpoint, node public key, and numeric ID. + * Used as a log prefix so all messages from a given peer are easily + * correlated in log output. + * + * @return A stable string for the lifetime of this connection. + */ [[nodiscard]] virtual std::string const& fingerprint() const = 0; + // // Ledger // + /** Return the hash of the most recent closed ledger this peer has advertised. + * + * @return The peer's last reported closed-ledger hash. + */ [[nodiscard]] virtual uint256 const& getClosedLedgerHash() const = 0; + + /** Return `true` if this peer is known to have the specified ledger. + * + * A hit is recorded if `seq` falls within the peer's advertised ledger + * range while the peer is converged, OR if `hash` appears in the recent + * ledger cache. + * + * @param hash The ledger hash to test for. + * @param seq The ledger sequence number; pass 0 to skip the range check + * and rely solely on the hash cache. + * @return `true` if the peer is believed to hold this ledger. + */ [[nodiscard]] virtual bool hasLedger(uint256 const& hash, std::uint32_t seq) const = 0; + + /** Retrieve the ledger sequence range this peer has advertised. + * + * @param minSeq Output parameter set to the peer's minimum available + * ledger sequence. + * @param maxSeq Output parameter set to the peer's maximum available + * ledger sequence. + */ virtual void ledgerRange(std::uint32_t& minSeq, std::uint32_t& maxSeq) const = 0; + + /** Return `true` if this peer is known to have a specific transaction set. + * + * Checked during consensus when fetching a missing SHAMap transaction set + * from the network. + * + * @param hash The root hash of the transaction set (SHAMap). + * @return `true` if the hash appears in the peer's recent transaction-set + * cache. + */ [[nodiscard]] virtual bool hasTxSet(uint256 const& hash) const = 0; + + /** Advance the peer's closed-ledger tracking to the next consensus round. + * + * Moves `closedLedgerHash_` to `previousLedgerHash_` and zeroes the + * current hash, signalling that a new round of status updates is expected + * from this peer. + */ virtual void cycleStatus() = 0; + + /** Return `true` if this peer can serve ledgers across the requested range. + * + * Checks that the peer is not in the `Diverged` tracking state and that + * the requested range falls entirely within its advertised ledger range. + * + * @param uMin The minimum ledger sequence of the requested range. + * @param uMax The maximum ledger sequence of the requested range. + * @return `true` if the peer is converged and covers `[uMin, uMax]`. + */ virtual bool hasRange(std::uint32_t uMin, std::uint32_t uMax) = 0; + /** Return `true` if LZ4 payload compression was negotiated with this peer. + * + * When `true`, `Message::getBuffer(Compressed::On)` is used so the + * compressed form is computed once and shared across all eligible peers. + */ [[nodiscard]] virtual bool compressionEnabled() const = 0; + /** Return `true` if the TX reduce-relay feature was negotiated with this peer. + * + * When `true`, full transactions are sent only to a quota of peers while + * the remainder receive hash-only announcements via `addTxQueue` / + * `sendTxQueue`. Falls back to flooding when `false` for compatibility + * with older peers. + */ [[nodiscard]] virtual bool txReduceRelayEnabled() const = 0; }; diff --git a/src/xrpld/overlay/PeerSet.h b/src/xrpld/overlay/PeerSet.h index f50b7130f4..f95ef41e4e 100644 --- a/src/xrpld/overlay/PeerSet.h +++ b/src/xrpld/overlay/PeerSet.h @@ -6,26 +6,49 @@ namespace xrpl { -/** Supports data retrieval by managing a set of peers. - - When desired data (such as a ledger or a transaction set) - is missing locally it can be obtained by querying connected - peers. This class manages common aspects of the retrieval. - Callers maintain the set by adding and removing peers depending - on whether the peers have useful information. - - The data is represented by its hash. -*/ +/** Manages the set of peers queried during a single distributed data retrieval. + * + * When a node lacks locally-stored data — a ledger, transaction set, or + * SHAMap subtree — it fetches it from connected peers. `PeerSet` owns the + * peer-selection, deduplication, and message-dispatch concerns for one + * in-flight acquisition task. + * + * Each acquisition (`InboundLedger`, `TransactionAcquire`, `LedgerReplayer`, + * etc.) holds a `std::unique_ptr` as its exclusive transport handle. + * Because ownership is exclusive, `PeerSet` itself needs no internal locking; + * callers serialize access through their own acquisition mutex. + * + * The concrete implementation (`PeerSetImpl`, hidden in `detail/PeerSet.cpp`) + * maintains a `std::set` that prevents the same peer from being + * contacted twice for the same acquisition across multiple `addPeers` calls. + * + * @see makePeerSetBuilder, makeDummyPeerSet + */ class PeerSet { public: virtual ~PeerSet() = default; - /** - * Try add more peers - * @param limit number of peers to add - * @param hasItem callback that helps to select peers - * @param onPeerAdded callback called when a peer is added + /** Select and contact additional peers for this acquisition. + * + * Scores every currently connected peer via `Peer::getScore(hasItem(peer))`, + * sorts candidates in descending score order (favouring peers that have + * signalled they hold the target data), then adds up to `limit` peers + * that have not been contacted before. `onPeerAdded` is invoked + * immediately for each newly accepted peer so the caller can send an + * initial request. + * + * Deduplication is enforced across calls: a peer already in the tracked + * set is never re-added, regardless of how many times `addPeers` is + * called. + * + * @param limit Maximum number of new peers to add in this call. + * @param hasItem Predicate returning `true` if the given peer is + * believed to hold the desired item; used to boost + * the peer's score during selection. + * @param onPeerAdded Called once per accepted peer immediately after it + * is inserted into the tracked set; typically used to + * send the initial data request. */ virtual void addPeers( @@ -33,7 +56,21 @@ public: std::function const&)> hasItem, std::function const&)> onPeerAdded) = 0; - /** send a message */ + /** Send a typed protobuf request to one peer or broadcast to all tracked peers. + * + * Type-safe wrapper that deduces the `protocol::MessageType` from the + * concrete protobuf message type via `protocolMessageType()` (defined in + * `detail/ProtocolMessage.h`), then delegates to the virtual overload. + * This solves the templated-virtual-function impossibility: the + * type-safe API lives here, while the single virtual dispatch point + * handles the erased protobuf base class. + * + * @param message Protobuf message to send (e.g. `TMGetLedger`, + * `TMReplayDeltaRequest`). + * @param peer If non-null, sends only to that peer. If null, + * broadcasts to every peer in the tracked set by + * resolving each `Peer::id_t` through the overlay. + */ template void sendRequest(MessageType const& message, std::shared_ptr const& peer) @@ -41,33 +78,78 @@ public: this->sendRequest(message, protocolMessageType(message), peer); } + /** Send a protobuf message (type-erased) to one peer or all tracked peers. + * + * When `peer` is non-null the packet is sent only to that peer. + * When `peer` is null the packet is broadcast to every `Peer::id_t` + * in the tracked set, resolving each through `Overlay::findPeerByShortID`. + * Peers that have disconnected since they were added are silently skipped. + * + * @param message Serialized protobuf message. + * @param type Wire-protocol message type tag. + * @param peer Target peer, or null to broadcast to all tracked peers. + */ virtual void sendRequest( ::google::protobuf::Message const& message, protocol::MessageType type, std::shared_ptr const& peer) = 0; - /** get the set of ids of previously added peers */ + /** Return the set of IDs of all peers added to this acquisition so far. + * + * Callers (e.g. `InboundLedger`) inspect this to count active peers and + * decide whether an acquisition should be abandoned. The returned + * reference is valid for the lifetime of this `PeerSet`. + */ [[nodiscard]] virtual std::set const& getPeerIds() const = 0; }; +/** Abstract factory that creates `PeerSet` instances on demand. + * + * Subsystems that manage multiple concurrent acquisitions + * (`InboundLedgersImp`, `InboundTransactions`, `LedgerReplayer`) receive a + * `std::unique_ptr` at construction and call `build()` each + * time a new acquisition begins. This defers concrete `PeerSetImpl` + * construction — which requires a live `Application&` — to the moment it is + * actually needed, and allows tests to inject a mock builder without touching + * production code paths. + * + * @see makePeerSetBuilder + */ class PeerSetBuilder { public: virtual ~PeerSetBuilder() = default; + /** Construct and return a new `PeerSet` for one acquisition task. */ virtual std::unique_ptr build() = 0; }; +/** Create a `PeerSetBuilder` backed by the live overlay. + * + * Each call to `PeerSetBuilder::build()` on the returned object produces a + * `PeerSetImpl` that selects peers from `app.getOverlay()` and dispatches + * real protobuf messages over the network. + * + * @param app The running application; held by reference in each built + * `PeerSet` for overlay and journal access. + */ std::unique_ptr makePeerSetBuilder(Application& app); -/** - * Make a dummy PeerSet that does not do anything. - * @note For the use case of InboundLedger in ApplicationImp::loadOldLedger(), - * where a real PeerSet is not needed. +/** Create a no-op `PeerSet` that logs an error on every operation. + * + * Used by `ApplicationImp::loadOldLedger()` when constructing `InboundLedger` + * objects to replay historical state without live peer activity. Rather than + * propagating a nullable pointer that every call site must guard, the + * codebase injects a `DummyPeerSet` that fulfils the `PeerSet` contract while + * emitting an error-level log entry and doing nothing else. This catches + * accidental calls in development without crashing production nodes during + * startup replay. + * + * @param app Application reference (used only to obtain a journal). */ std::unique_ptr makeDummyPeerSet(Application& app); diff --git a/src/xrpld/overlay/ReduceRelayCommon.h b/src/xrpld/overlay/ReduceRelayCommon.h index 4105003315..8b18cec67f 100644 --- a/src/xrpld/overlay/ReduceRelayCommon.h +++ b/src/xrpld/overlay/ReduceRelayCommon.h @@ -1,39 +1,130 @@ +/** @file + * Shared tuning constants for the XRPL reduce-relay gossip optimisation. + * + * All components of the reduce-relay subsystem — `Slot.h`, `Squelch.h`, + * and `PeerImp.cpp` — import this header so that numeric policy is defined + * in one place. The feature replaces full-flood validator message gossip + * with a selective scheme: a small set of "selected" peers relays each + * validator's messages while the remainder are squelched via `TMSquelch` + * for a bounded, randomised window. + * + * @see https://xrpl.org/blog/2021/message-routing-optimizations-pt-1-proposal-validation-relaying.html + */ + #pragma once #include -// Blog post explaining the rationale behind reduction of flooding gossip -// protocol: -// https://xrpl.org/blog/2021/message-routing-optimizations-pt-1-proposal-validation-relaying.html - namespace xrpl::reduce_relay { -// Peer's squelch is limited in time to -// rand{MIN_UNSQUELCH_EXPIRE, max_squelch}, -// where max_squelch is -// min(max(MAX_UNSQUELCH_EXPIRE_DEFAULT, SQUELCH_PER_PEER * number_of_peers), -// MAX_UNSQUELCH_EXPIRE_PEERS) +// --- Squelch duration bounds --- + +/** Minimum duration of a squelch window. + * + * A squelched peer is instructed to suppress relay for at least this long. + * `Squelch::addSquelch()` rejects any incoming `TMSquelch` + * whose duration falls below this bound as out-of-range. + */ static constexpr auto kMIN_UNSQUELCH_EXPIRE = std::chrono::seconds{300}; + +/** Baseline ceiling for the randomised squelch window. + * + * When the number of squelched peers is small (fewer than 60), the upper + * bound of the squelch duration is clamped to this value. Also used by + * `Slot` to detect a stale selection round: if the time since the last + * selection exceeds `2 * kMAX_UNSQUELCH_EXPIRE_DEFAULT`, the slot resets + * to `Counting` state to re-run peer selection. + */ static constexpr auto kMAX_UNSQUELCH_EXPIRE_DEFAULT = std::chrono::seconds{600}; + +/** Per-peer scaling factor for the squelch window upper bound. + * + * The computed ceiling is + * `max(kMAX_UNSQUELCH_EXPIRE_DEFAULT, kSQUELCH_PER_PEER * number_of_peers)`, + * capped at `kMAX_UNSQUELCH_EXPIRE_PEERS`. A larger peer set therefore + * receives a proportionally longer squelch, reducing the chance that many + * peers unsquelch simultaneously and cause a relay burst. + */ static constexpr auto kSQUELCH_PER_PEER = std::chrono::seconds(10); + +/** Absolute maximum squelch duration regardless of peer count. + * + * Caps the per-peer-scaled ceiling so that no peer is ever silenced for + * more than one hour. `Squelch::addSquelch()` rejects any + * incoming `TMSquelch` whose duration exceeds this bound. + */ static constexpr auto kMAX_UNSQUELCH_EXPIRE_PEERS = std::chrono::seconds{3600}; -// No message received threshold before identifying a peer as idled + +// --- Idle detection --- + +/** Inactivity threshold after which a peer is considered idle. + * + * If no message from a tracked peer has been observed within this window, + * `Slot::deleteIdlePeer()` treats the peer as disconnected. When an + * *elected* peer idles, all squelched peers are immediately unsquelched + * and the slot reverts to `Counting` state to restart peer selection. + * + * Also used by `Slots` as the expiry duration for the `peersWithMessage_` + * aged map, which deduplicates messages seen within the same idle window, + * and by `PeerImp` to suppress duplicate relay of recently forwarded + * validator messages. + */ static constexpr auto kIDLED = std::chrono::seconds{8}; -// Message count threshold to start selecting peers as the source -// of messages from the validator. We add peers who reach -// kMIN_MESSAGE_THRESHOLD to considered pool once kMAX_SELECTED_PEERS -// reach kMAX_MESSAGE_THRESHOLD. + +// --- Peer selection thresholds --- + +/** First-stage message count for admitting a peer to the candidate pool. + * + * A peer must surpass this count before it is added to the *considered* + * pool inside `Slot`. Only peers in the considered pool are eligible to + * be selected as relay sources. The one-message gap between this value + * and `kMAX_MESSAGE_THRESHOLD` confirms that the peer has continued + * sending before it is committed as a candidate. + * + * @see kMAX_MESSAGE_THRESHOLD + */ static constexpr uint16_t kMIN_MESSAGE_THRESHOLD = 19; + +/** Second-stage message count that triggers a selection round. + * + * Once `kMAX_SELECTED_PEERS` distinct peers each individually reach this + * count, `Slot` fires a selection round: `kMAX_SELECTED_PEERS` peers are + * randomly chosen as relay sources and the rest are squelched. + * + * @see kMIN_MESSAGE_THRESHOLD + */ static constexpr uint16_t kMAX_MESSAGE_THRESHOLD = 20; -// Max selected peers to choose as the source of messages from validator + +/** Maximum number of peers selected as relay sources per validator. + * + * Keeping multiple sources provides redundancy if a selected peer + * disconnects or idles; keeping the number small limits redundant + * bandwidth. Five is the chosen balance between resilience and + * bandwidth reduction. + */ static constexpr uint16_t kMAX_SELECTED_PEERS = 5; -// Wait before reduce-relay feature is enabled on boot up to let -// the server establish peer connections + +// --- Boot-up guard --- + +/** Delay before reduce-relay becomes active after process start. + * + * `Slots::reduceRelayReady()` returns `false` until this much time has + * elapsed since the process epoch. A freshly started node has not yet + * established a stable peer set; activating squelching during this churn + * phase could silence relay paths before a healthy selection is possible. + */ static constexpr auto kWAIT_ON_BOOTUP = std::chrono::minutes{10}; -// Maximum size of the aggregated transaction hashes per peer. -// Once we get to high tps throughput, this cap will prevent -// TMTransactions from exceeding the current protocol message -// size limit of 64MB. + +// --- TX reduce-relay queue cap --- + +/** Maximum number of transaction hashes buffered per peer for batch relay. + * + * `PeerImp::addTxQueue()` flushes the outbound `TMTransactions` batch when + * this limit is reached. `PeerImp::doTransactions()` rejects incoming + * requests that exceed this count as malformed. The cap is sized so that + * the resulting `TMTransactions` payload stays well within the 64 MiB + * protocol message size limit even at high transaction throughput. + */ static constexpr std::size_t kMAX_TX_QUEUE_SIZE = 10000; } // namespace xrpl::reduce_relay diff --git a/src/xrpld/overlay/Slot.h b/src/xrpld/overlay/Slot.h index 26be3946c1..0bdb66dbc2 100644 --- a/src/xrpld/overlay/Slot.h +++ b/src/xrpld/overlay/Slot.h @@ -1,3 +1,20 @@ +/** @file + * Per-validator peer selection state machine for the reduce-relay subsystem. + * + * Implements the flooding-suppression scheme that designates a small, fixed + * set of peers as the authoritative relay sources for each validator's + * proposals and validations, then instructs all other peers to stop + * forwarding via timed `TMSquelch` protocol messages. + * + * The file is entirely within the `xrpl::reduce_relay` namespace and is + * header-only. The three cooperating abstractions are: + * - `SquelchHandler` — inversion-of-control callback interface + * - `Slot` — per-validator peer selection state machine + * - `Slots` — container and lifecycle manager for all slots + * + * @see ReduceRelayCommon.h for all shared numeric constants. + */ + #pragma once #include @@ -24,18 +41,29 @@ namespace xrpl::reduce_relay { template class Slots; -/** Peer's State */ +/** Lifecycle state of a single peer within a `Slot`. */ enum class PeerState : uint8_t { - Counting, // counting messages - Selected, // selected to relay, counting if Slot in Counting - Squelched, // squelched, doesn't relay -}; -/** Slot's State */ -enum class SlotState : uint8_t { - Counting, // counting messages - Selected, // peers selected, stop counting + Counting, /**< Accumulating messages; eligible to enter the candidate pool. */ + Selected, /**< Designated relay source for this validator. */ + Squelched, /**< Instructed to stop forwarding; awaiting squelch expiry. */ }; +/** Overall state of a `Slot` for one validator. */ +enum class SlotState : uint8_t { + Counting, /**< Actively counting peer messages to select relay sources. */ + Selected, /**< Relay sources chosen; message counting suspended until reset. */ +}; + +/** Convert a time-point to a duration since the clock epoch. + * + * Used internally to produce millisecond-resolution timestamps for logging + * and for the `getPeers()` snapshot returned to callers. + * + * @tparam Unit The duration type to cast to (e.g. `std::chrono::milliseconds`). + * @tparam TP The time-point type (clock-specific). + * @param t The time-point to convert. + * @return The duration since the clock epoch, expressed in `Unit`. + */ template Unit epoch(TP const& t) @@ -43,38 +71,63 @@ epoch(TP const& t) return std::chrono::duration_cast(t.time_since_epoch()); } -/** Abstract class. Declares squelch and unsquelch handlers. - * OverlayImpl inherits from this class. Motivation is - * for easier unit tests to facilitate on the fly - * changing callbacks. */ +/** Inversion-of-control interface for sending squelch/unsquelch commands. + * + * `OverlayImpl` inherits from this class and provides the real implementation + * that transmits `TMSquelch` protocol messages to peers. The indirection + * decouples the peer-selection algorithm from the network layer and lets unit + * tests inject mock handlers without modifying `Slot` logic. + */ class SquelchHandler { public: virtual ~SquelchHandler() = default; - /** Squelch handler - * @param validator Public key of the source validator - * @param id Peer's id to squelch - * @param duration Squelch duration in seconds + + /** Instruct a peer to suppress relay of messages from a validator. + * + * The implementation must send a `TMSquelch(squelch=true)` message to + * the peer identified by `id`. + * + * @param validator Public key of the validator whose messages should be suppressed. + * @param id ID of the peer to squelch. + * @param duration Duration of the squelch window in seconds. */ virtual void squelch(PublicKey const& validator, Peer::id_t id, std::uint32_t duration) const = 0; - /** Unsquelch handler - * @param validator Public key of the source validator - * @param id Peer's id to unsquelch + + /** Lift a previously issued squelch, allowing the peer to relay again. + * + * The implementation must send a `TMSquelch(squelch=false)` message to + * the peer identified by `id`. + * + * @param validator Public key of the validator being unsquelched. + * @param id ID of the peer to unsquelch. */ virtual void unsquelch(PublicKey const& validator, Peer::id_t id) const = 0; }; -/** - * Slot is associated with a specific validator via validator's public key. - * Slot counts messages from a validator, selects peers to be the source - * of the messages, and communicates the peers to be squelched. Slot can be - * in the following states: 1) Counting. This is the peer selection state - * when Slot counts the messages and selects the peers; 2) Selected. Slot - * doesn't count messages in Selected state. A message received from - * unsquelched, disconnected peer, or idling peer may transition Slot to - * Counting state. +/** Per-validator peer selection state machine for reduce-relay. + * + * Tracks all peers that have forwarded messages from a single validator and + * decides which subset should be designated as authoritative relay sources. + * Non-selected peers are squelched via `SquelchHandler::squelch()` for a + * randomized duration to suppress redundant flooding. + * + * Two independent state machines run in parallel: + * - **`SlotState`**: `Counting` (selecting relay sources) or `Selected` + * (sources chosen, counting paused). + * - **`PeerState`** per peer: `Counting`, `Selected`, or `Squelched`. + * + * Construction is private; only `Slots` (a `friend`) creates + * instances, ensuring each slot is always owned by its container. + * + * @tparam ClockType Monotonic clock used for timeout and idle detection. + * Must satisfy the TrivialClock requirements. Use `UptimeClock` in + * production; use a manually-advanced clock in tests. + * + * @note Not thread-safe. All callers must serialize access externally + * (e.g., via the overlay strand as done in `OverlayImpl`). */ template class Slot final @@ -84,14 +137,23 @@ private: using id_t = Peer::id_t; using time_point = typename ClockType::time_point; - // a callback to report ignored squelches + /** Callback type invoked when a message is received from an already-squelched peer. + * + * Passed through to `update()` so callers can track how often squelched + * peers ignore their squelch instructions (counted as + * `TrafficCount::squelch_ignored` upstream). + */ using ignored_squelch_callback = std::function; - /** Constructor - * @param journal Journal for logging - * @param handler Squelch/Unsquelch implementation - * @param maxSelectedPeers the maximum number of peers to be selected as - * validator message source + /** Construct a slot for a single validator. + * + * Only callable by `Slots` (friend). `lastSelected_` is + * initialised to `ClockType::now()` so the inactivity guard in `update()` + * does not immediately reset a freshly created slot. + * + * @param handler Squelch/unsquelch callback implementation. + * @param journal Journal for trace/warn logging. + * @param maxSelectedPeers Maximum relay sources to designate per round. */ Slot(SquelchHandler const& handler, beast::Journal journal, uint16_t maxSelectedPeers) : lastSelected_(ClockType::now()) @@ -101,24 +163,28 @@ private: { } - /** Update peer info. If the message is from a new - * peer or from a previously expired squelched peer then switch - * the peer's and slot's state to Counting. If time of last - * selection round is > 2 * kMAX_UNSQUELCH_EXPIRE_DEFAULT then switch the - * slot's state to Counting. If the number of messages for the peer is > - * kMIN_MESSAGE_THRESHOLD then add peer to considered peers pool. If the - * number of considered peers who reached kMAX_MESSAGE_THRESHOLD is - * maxSelectedPeers_ then randomly select maxSelectedPeers_ from - * considered peers, and call squelch handler for each peer, which is not - * selected and not already in Squelched state. Set the state for those - * peers to Squelched and reset the count of all peers. Set slot's state to - * Selected. Message count is not updated when the slot is in Selected - * state. - * @param validator Public key of the source validator - * @param id Peer id which received the message - * @param type Message type (Validation and Propose Set only, - * others are ignored, future use) - * @param callback A callback to report ignored squelches + /** Process a validator message received from a peer and advance slot state. + * + * The full selection algorithm runs here: + * 1. New peer → insert with `PeerState::Counting` and call `initCounting()`. + * 2. Peer with expired squelch → reset to `Counting` and call `initCounting()`. + * 3. Inactivity guard: if `lastSelected_` is more than + * `2 × kMAX_UNSQUELCH_EXPIRE_DEFAULT` in the past, reset via `initCounting()`. + * 4. Increment count; once it exceeds `kMIN_MESSAGE_THRESHOLD` the peer + * enters the `considered_` candidate pool. + * 5. When `maxSelectedPeers_` peers each reach `kMAX_MESSAGE_THRESHOLD + 1`, + * randomly draw `maxSelectedPeers_` non-idle candidates as relay sources + * and squelch the rest. If fewer than `maxSelectedPeers_` non-idle + * candidates exist, `initCounting()` defers to the next round. + * + * @param validator Public key of the source validator. + * @param id ID of the peer that forwarded this message. + * @param type Protocol message type; only `mtVALIDATION` and + * `mtPROPOSE_LEDGER` are meaningful (others reserved for future use). + * @param callback Invoked if a message arrives from an already-squelched peer. + * + * @note No-op if the slot is in `SlotState::Selected` and the peer is + * not squelched (counting is suspended until the slot resets). */ void update( @@ -127,104 +193,163 @@ private: protocol::MessageType type, ignored_squelch_callback callback); - /** Handle peer deletion when a peer disconnects. - * If the peer is in Selected state then - * call unsquelch handler for every peer in squelched state and reset - * every peer's state to Counting. Switch Slot's state to Counting. - * @param validator Public key of the source validator - * @param id Deleted peer id - * @param erase If true then erase the peer. The peer is not erased - * when the peer when is idled. The peer is deleted when it - * disconnects + /** React to a peer leaving the relay pool. + * + * If the removed peer was `Selected`, every currently `Squelched` peer + * is unsquelched via `SquelchHandler::unsquelch()`, all peer states are + * reset to `Counting`, and the slot transitions back to + * `SlotState::Counting` to restart selection. If the peer was only in + * the `considered_` pool, `reachedThreshold_` is decremented to keep + * counts consistent. + * + * @param validator Public key of the source validator. + * @param id ID of the peer being removed. + * @param erase If `true`, remove the `PeerInfo` entry entirely + * (peer disconnected). If `false`, retain the entry with reset + * counts (peer merely went idle — may reconnect later). + * + * @note The `unsquelch()` callbacks are fired *after* the optional + * erase, so the peer map is already consistent when callbacks run. */ void deletePeer(PublicKey const& validator, id_t id, bool erase); - /** Get the time of the last peer selection round */ + /** Time of the most recent completed peer-selection round. + * + * `Slots::deleteIdlePeers()` compares this against the current time to + * decide whether the entire slot has gone stale and should be removed. + * + * @return Reference to `lastSelected_`; valid for the lifetime of this slot. + */ [[nodiscard]] time_point const& getLastSelected() const { return lastSelected_; } - /** Return number of peers in state */ + /** Count peers currently in the given `PeerState`. + * + * @param state The state to filter by. + * @return Number of peers in `state`. + */ [[nodiscard]] std::uint16_t inState(PeerState state) const; - /** Return number of peers not in state */ + /** Count peers NOT in the given `PeerState`. + * + * @param state The state to exclude. + * @return Number of peers whose state differs from `state`. + */ [[nodiscard]] std::uint16_t notInState(PeerState state) const; - /** Return Slot's state */ + /** Current slot-level state. + * + * @return `SlotState::Counting` while selecting relay sources, + * `SlotState::Selected` once sources are chosen. + */ [[nodiscard]] SlotState getState() const { return state_; } - /** Return selected peers */ + /** IDs of peers currently in `PeerState::Selected`. + * + * @return Sorted set of selected peer IDs; empty when the slot is in + * `SlotState::Counting`. + */ [[nodiscard]] std::set getSelected() const; - /** Get peers info. Return map of peer's state, count, squelch - * expiration milsec, and last message time milsec. + /** Snapshot of all tracked peers for inspection and testing. + * + * @return Map from peer ID to a tuple of + * `(PeerState, message_count, squelch_expiry_ms, last_message_ms)` + * where timestamps are milliseconds since the clock epoch. */ [[nodiscard]] std::unordered_map> getPeers() const; - /** Check if peers stopped relaying messages. If a peer is - * selected peer then call unsquelch handler for all - * currently squelched peers and switch the slot to - * Counting state. - * @param validator Public key of the source validator + /** Scan all tracked peers and retire any that have gone silent. + * + * A peer is considered idle if `ClockType::now() - lastMessage > kIDLED`. + * Idle peers are passed to `deletePeer(..., false)` — their entry is + * retained with reset counts so a resumed peer is treated as a new + * participant rather than a clean insert. If an idle peer was + * `Selected`, all `Squelched` peers are unsquelched and the slot + * reverts to `SlotState::Counting`. + * + * @param validator Public key of the source validator (forwarded to + * `deletePeer()` for logging and unsquelch callbacks). */ void deleteIdlePeer(PublicKey const& validator); - /** Get random squelch duration between kMIN_UNSQUELCH_EXPIRE and - * min(max(kMAX_UNSQUELCH_EXPIRE_DEFAULT, kSQUELCH_PER_PEER * npeers), - * kMAX_UNSQUELCH_EXPIRE_PEERS) - * @param npeers number of peers that can be squelched in the Slot + /** Compute a randomized squelch window for the current selection round. + * + * The duration is drawn uniformly from + * `[kMIN_UNSQUELCH_EXPIRE, min(max(kMAX_UNSQUELCH_EXPIRE_DEFAULT, + * kSQUELCH_PER_PEER × npeers), kMAX_UNSQUELCH_EXPIRE_PEERS)]`. + * Scaling by `npeers` prevents simultaneous unsquelch storms on + * well-connected nodes. + * + * @param npeers Number of peers to be squelched in this round. + * @return A randomized squelch duration in seconds. */ std::chrono::seconds getSquelchDuration(std::size_t npeers); private: - /** Reset counts of peers in Selected or Counting state */ + /** Zero the message counts for all peers in `Counting` or `Selected` state. + * + * Called as part of `initCounting()` and after a completed selection + * round so that counts accurately reflect activity since the last reset. + */ void resetCounts(); - /** Initialize slot to Counting state */ + /** Transition the slot to `SlotState::Counting` and clear selection state. + * + * Clears `considered_`, resets `reachedThreshold_` to zero, resets all + * peer message counts, and sets `state_` to `SlotState::Counting`. + * Called whenever a new peer appears, a squelch expires, inactivity is + * detected, or a selected peer is lost. + */ void initCounting(); - /** Data maintained for each peer */ + /** Per-peer tracking data maintained inside `peers_`. */ struct PeerInfo { - PeerState state; // peer's state - std::size_t count; // message count - time_point expire; // squelch expiration time - time_point lastMessage; // time last message received + PeerState state; /**< Current lifecycle state of this peer. */ + std::size_t count; /**< Messages received since the last reset. */ + time_point expire; /**< When the current squelch expires (only meaningful if `Squelched`). */ + time_point lastMessage; /**< Timestamp of the most recent message from this peer. */ }; - std::unordered_map peers_; // peer's data + /** All peers that have ever forwarded a message for this validator. */ + std::unordered_map peers_; - // pool of peers considered as the source of messages - // from validator - peers that reached kMIN_MESSAGE_THRESHOLD + /** Peers that have surpassed `kMIN_MESSAGE_THRESHOLD` and are + * eligible to be randomly selected as relay sources. */ std::unordered_set considered_; - // number of peers that reached kMAX_MESSAGE_THRESHOLD + /** Number of peers in `considered_` that have reached `kMAX_MESSAGE_THRESHOLD`. + * Selection fires when this equals `maxSelectedPeers_`. */ std::uint16_t reachedThreshold_{0}; - // last time peers were selected, used to age the slot + /** Timestamp of the most recently completed selection round. + * Used by `Slots::deleteIdlePeers()` to age out stale slots. */ typename ClockType::time_point lastSelected_; - SlotState state_{SlotState::Counting}; // slot's state - SquelchHandler const& handler_; // squelch/unsquelch handler - beast::Journal const journal_; // logging + SlotState state_{SlotState::Counting}; /**< Current slot-level state. */ + SquelchHandler const& handler_; /**< Squelch/unsquelch callback implementation. */ + beast::Journal const journal_; /**< Logger. */ - // the maximum number of peers that should be selected as a validator - // message source + /** Maximum number of peers designated as relay sources per selection round. + * Configurable via `Config::VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS` + * (default 5). */ uint16_t const maxSelectedPeers_; }; @@ -519,9 +644,25 @@ Slot::getPeers() const return r; } -/** Slots is a container for validator's Slot and handles Slot update - * when a message is received from a validator. It also handles Slot aging - * and checks for peers which are disconnected or stopped relaying the messages. +/** Container and lifecycle manager for all per-validator `Slot` instances. + * + * `Slots` is the sole public entry point for the reduce-relay subsystem. + * `OverlayImpl` holds a `Slots` member and calls + * `updateSlotAndSquelch()` each time a validator message arrives. + * + * Key responsibilities: + * - Create and look up `Slot` objects keyed by validator `PublicKey`. + * - Deduplicate messages via the `peersWithMessage_` aged map so each + * (message-hash, peer-ID) pair is counted at most once per `kIDLED` window. + * - Enforce the `kWAIT_ON_BOOTUP` boot delay before squelching begins. + * - Periodically retire idle peers and stale slots via `deleteIdlePeers()`. + * + * @tparam ClockType Monotonic clock; same constraint as `Slot`. + * + * @note `peersWithMessage_` is `inline static`, shared across all + * instantiations of `Slots`, not per-instance. + * + * @note Not thread-safe. All callers must serialize externally. */ template class Slots final @@ -535,10 +676,16 @@ class Slots final HardenedHash>; public: - /** - * @param registry The service registry. - * @param handler Squelch/unsquelch implementation - * @param config reference to the global config + /** Construct the slot container. + * + * Reads `Config::VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE` and + * `Config::VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS` to configure + * feature enablement and the per-slot peer limit. + * + * @param registry Service registry used to obtain the journal. + * @param handler Squelch/unsquelch callback implementation + * (typically `OverlayImpl`). + * @param config Global node configuration. */ Slots(ServiceRegistry& registry, SquelchHandler const& handler, Config const& config) : handler_(handler) @@ -550,14 +697,27 @@ public: } ~Slots() = default; - /** Check if base squelching feature is enabled and ready */ + /** Return `true` if base squelching is both configured and past the boot delay. + * + * Convenience wrapper combining `baseSquelchEnabled_` and `reduceRelayReady()`. + * `OverlayImpl` checks this before invoking squelch logic on inbound messages. + */ bool baseSquelchReady() { return baseSquelchEnabled_ && reduceRelayReady(); } - /** Check if reduce_relay::kWAIT_ON_BOOTUP time passed since startup */ + /** Return `true` once `kWAIT_ON_BOOTUP` has elapsed since process start. + * + * Activating squelching too early, while the peer set is still forming, + * risks designating only the first few connected peers as relay sources + * for all validators. The boot delay ensures a representative sample + * exists before any peer is squelched. + * + * @note Result is cached in `reduceRelayReady_`; once `true` it never + * reverts to `false` for the lifetime of this object. + */ bool reduceRelayReady() { @@ -570,12 +730,17 @@ public: return reduceRelayReady_; } - /** Calls Slot::update of Slot associated with the validator, with a noop - * callback. - * @param key Message's hash - * @param validator Validator's public key - * @param id Peer's id which received the message - * @param type Received protocol message type + /** Update the slot for `validator` using a no-op ignored-squelch callback. + * + * Convenience overload for callers that do not need to track squelch + * violations. Deduplicates the message via `addPeerMessage()` and + * creates the `Slot` on first call for this validator. + * + * @param key Hash of the received message (used for deduplication). + * A zero hash (`key.isZero()`) bypasses deduplication. + * @param validator Public key of the validator that produced the message. + * @param id ID of the peer that forwarded the message. + * @param type Protocol message type. */ void updateSlotAndSquelch( @@ -587,12 +752,18 @@ public: updateSlotAndSquelch(key, validator, id, type, []() {}); } - /** Calls Slot::update of Slot associated with the validator. - * @param key Message's hash - * @param validator Validator's public key - * @param id Peer's id which received the message - * @param type Received protocol message type - * @param callback A callback to report ignored validations + /** Update the slot for `validator` with an explicit ignored-squelch callback. + * + * The message is first checked against `peersWithMessage_`; if the + * (hash, peer) pair was already seen within the `kIDLED` window the call + * returns immediately without updating any slot. Otherwise the slot for + * `validator` is created if absent, then `Slot::update()` is called. + * + * @param key Hash of the received message. + * @param validator Public key of the source validator. + * @param id ID of the forwarding peer. + * @param type Protocol message type. + * @param callback Invoked when a message arrives from a currently-squelched peer. */ void updateSlotAndSquelch( @@ -602,13 +773,24 @@ public: protocol::MessageType type, typename Slot::ignored_squelch_callback callback); - /** Check if peers stopped relaying messages - * and if slots stopped receiving messages from the validator. + /** Retire idle peers and stale slots. + * + * For every slot, calls `Slot::deleteIdlePeer()` to identify and clean up + * peers that have not forwarded a message within `kIDLED`. After + * processing each slot, removes the entire slot entry if its + * `lastSelected_` timestamp is older than `kMAX_UNSQUELCH_EXPIRE_DEFAULT` + * — a validator that has stopped producing messages should not leave + * permanent state. Called periodically from `OverlayImpl`'s timer. */ void deleteIdlePeers(); - /** Return number of peers in state */ + /** Count peers in `state` for the given validator's slot. + * + * @param validator Validator public key. + * @param state Peer state to count. + * @return Count, or `std::nullopt` if no slot exists for `validator`. + */ [[nodiscard]] std::optional inState(PublicKey const& validator, PeerState state) const { @@ -618,7 +800,12 @@ public: return {}; } - /** Return number of peers not in state */ + /** Count peers NOT in `state` for the given validator's slot. + * + * @param validator Validator public key. + * @param state Peer state to exclude. + * @return Count, or `std::nullopt` if no slot exists for `validator`. + */ [[nodiscard]] std::optional notInState(PublicKey const& validator, PeerState state) const { @@ -628,7 +815,13 @@ public: return {}; } - /** Return true if Slot is in state */ + /** Return `true` if the given validator's slot is in `state`. + * + * Returns `false` if no slot exists for `validator`. + * + * @param validator Validator public key. + * @param state Slot state to test. + */ [[nodiscard]] bool inState(PublicKey const& validator, SlotState state) const { @@ -638,7 +831,12 @@ public: return false; } - /** Get selected peers */ + /** IDs of peers currently selected as relay sources for `validator`. + * + * @param validator Validator public key. + * @return Sorted set of selected peer IDs; empty if no slot exists or + * the slot is in `SlotState::Counting`. + */ std::set getSelected(PublicKey const& validator) { @@ -648,8 +846,14 @@ public: return {}; } - /** Get peers info. Return map of peer's state, count, and squelch - * expiration milliseconds. + /** Snapshot of peer tracking data for `validator`'s slot. + * + * Primarily used for testing and diagnostics. + * + * @param validator Validator public key. + * @return Map from peer ID to + * `(PeerState, message_count, squelch_expiry_ms, last_message_ms)`, + * or an empty map if no slot exists. */ std:: unordered_map> @@ -661,7 +865,11 @@ public: return {}; } - /** Get Slot's state */ + /** Current state of the slot for `validator`. + * + * @param validator Validator public key. + * @return Slot state, or `std::nullopt` if no slot exists. + */ std::optional getState(PublicKey const& validator) { @@ -671,36 +879,53 @@ public: return {}; } - /** Called when a peer is deleted. If the peer was selected to be the - * source of messages from the validator then squelched peers have to be - * unsquelched. - * @param id Peer's id - * @param erase If true then erase the peer + /** Notify all slots that a peer has disconnected. + * + * Iterates every validator slot and calls `Slot::deletePeer()`. If the + * peer was a selected relay source in any slot, the affected squelched + * peers are unsquelched and that slot reverts to `SlotState::Counting`. + * + * @param id ID of the disconnected peer. + * @param erase If `true`, remove the peer's `PeerInfo` entry entirely. */ void deletePeer(id_t id, bool erase); private: - /** Add message/peer if have not seen this message - * from the peer. A message is aged after IDLED seconds. - * Return true if added */ + /** Record a (message, peer) pair and return whether it is novel. + * + * Entries age out after `kIDLED` seconds via `beast::expire()`. A zero + * hash (`key.isZero()`) is always treated as novel (no deduplication). + * + * @param key Hash of the incoming message. + * @param id ID of the peer that forwarded it. + * @return `true` if the pair is new and the slot should be updated; + * `false` if this (message, peer) combination was already seen + * within the current idle window. + */ bool addPeerMessage(uint256 const& key, id_t id); + /** Set to `true` once `kWAIT_ON_BOOTUP` has passed; never reverts. */ std::atomic_bool reduceRelayReady_{false}; + /** Per-validator slot instances. */ hash_map> slots_; - SquelchHandler const& handler_; // squelch/unsquelch handler + + SquelchHandler const& handler_; /**< Squelch/unsquelch callback. */ Logs& logs_; beast::Journal const journal_; - bool const baseSquelchEnabled_; - uint16_t const maxSelectedPeers_; + bool const baseSquelchEnabled_; /**< Whether base squelching is enabled in config. */ + uint16_t const maxSelectedPeers_; /**< Per-slot relay-source cap from config. */ - // Maintain aged container of message/peers. This is required - // to discard duplicate message from the same peer. A message - // is aged after IDLED seconds. A message received IDLED seconds - // after it was relayed is ignored by PeerImp. + /** Deduplication map: message hash → set of peer IDs that have forwarded it. + * + * Entries expire after `kIDLED` seconds. Declared `inline static` so + * that all `Slots` instances share a single map — necessary + * because the production code constructs only one instance but tests may + * construct several. + */ inline static messages peersWithMessage{beast::getAbstractClock()}; }; diff --git a/src/xrpld/overlay/Squelch.h b/src/xrpld/overlay/Squelch.h index 21a1b228a1..bbd20bed90 100644 --- a/src/xrpld/overlay/Squelch.h +++ b/src/xrpld/overlay/Squelch.h @@ -1,3 +1,15 @@ +/** @file + * Per-peer validator relay suppression for the XRPL reduce-relay protocol. + * + * This is the downstream enforcement half of the reduce-relay system. + * `Squelch` records instructions received via `TMSquelch` and + * gates outbound validator messages accordingly. The upstream selection + * logic — which decides *which* peers to squelch — lives in `Slot.h`. + * + * @see Slot.h, ReduceRelayCommon.h + * @see https://xrpl.org/blog/2021/message-routing-optimizations-pt-1-proposal-validation-relaying.html + */ + #pragma once #include @@ -10,42 +22,97 @@ namespace xrpl::reduce_relay { -/** Maintains squelching of relaying messages from validators */ +/** Enforces per-validator relay suppression for a single peer connection. + * + * Each `PeerImp` owns one `Squelch` instance. When the upstream + * coordinator (`Slots`) selects a preferred relay set for a validator, it + * sends a `TMSquelch` protobuf message to every non-selected peer. + * `PeerImp::onMessage(TMSquelch)` calls `addSquelch` or `removeSquelch` to + * record the directive here. Before forwarding any outbound validator + * message, `PeerImp::send` calls `expireSquelch`; a `false` return + * short-circuits the send and increments the `squelch_suppressed` traffic + * counter. + * + * Expiry is lazy: squelch entries are only removed when `expireSquelch` is + * called after the deadline passes. No background cleanup task is needed. + * + * @tparam ClockType Clock used for computing squelch expiry deadlines. + * Production code passes `UptimeClock`; unit tests inject a controlled + * `ManualClock` to advance time deterministically. + * + * @note This class is not thread-safe on its own. Callers must ensure + * all method calls run on the owning `PeerImp`'s strand. + */ template class Squelch { using time_point = typename ClockType::time_point; public: + /** Construct with a diagnostic journal. + * + * @param journal Journal used to log invalid squelch duration errors. + */ explicit Squelch(beast::Journal journal) : journal_(journal) { } virtual ~Squelch() = default; - /** Squelch validation/proposal relaying for the validator - * @param validator The validator's public key - * @param squelchDuration Squelch duration in seconds - * @return false if invalid squelch duration + /** Record a squelch directive for a validator. + * + * Stores an expiry deadline of `ClockType::now() + squelchDuration` for + * the given validator key. If `squelchDuration` falls outside + * `[kMIN_UNSQUELCH_EXPIRE, kMAX_UNSQUELCH_EXPIRE_PEERS]`, the duration + * is rejected: an error is logged, any existing entry for the key is + * proactively cleared via `removeSquelch`, and `false` is returned. + * The caller (`PeerImp::onMessage(TMSquelch)`) then charges the sending + * peer a `feeInvalidData` resource fee. + * + * @param validator The validator's public key. + * @param squelchDuration Requested suppression window. Must be in + * `[kMIN_UNSQUELCH_EXPIRE (300 s), kMAX_UNSQUELCH_EXPIRE_PEERS (3600 s)]`. + * @return `true` if the squelch was recorded; `false` if the duration + * was out of range (any prior entry for this key is also cleared). */ bool addSquelch(PublicKey const& validator, std::chrono::seconds const& squelchDuration); - /** Remove the squelch - * @param validator The validator's public key + /** Clear a squelch entry for a validator. + * + * Called when `PeerImp::onMessage(TMSquelch)` receives a message with + * `squelch == false` — the unsquelch signal sent by the upstream + * coordinator when a previously selected peer disconnects or idles, + * freeing the suppressed peers to resume relaying. + * + * @param validator The validator's public key. */ void removeSquelch(PublicKey const& validator); - /** Remove expired squelch - * @param validator Validator's public key - * @return true if removed or doesn't exist, false if still active + /** Check whether a validator message may be forwarded. + * + * Returns `true` (allow forward) when no active squelch exists for the + * key, or when an existing entry's deadline has passed — in which case + * the stale entry is lazily removed from the map. Returns `false` + * (suppress) when the squelch is still active. + * + * Called by `PeerImp::send()` on every outbound message that carries a + * validator public key. There is no background timer; cleanup happens + * naturally the first time a post-expiry message is about to be sent. + * + * @param validator The validator's public key. + * @return `true` if the message should be forwarded; `false` if it + * should be dropped because the squelch window is still active. */ bool expireSquelch(PublicKey const& validator); private: - /** Maintains the list of squelched relaying to downstream peers. - * Expiration time is included in the TMSquelch message. */ + /** Maps each squelched validator key to its suppression deadline. + * + * Entries are added by `addSquelch` and removed either explicitly by + * `removeSquelch` or lazily by `expireSquelch` once the deadline passes. + */ hash_map squelched_; beast::Journal const journal_; }; @@ -64,7 +131,6 @@ Squelch::addSquelch( JLOG(journal_.error()) << "squelch: invalid squelch duration " << squelchDuration.count(); - // unsquelch if invalid duration removeSquelch(validator); return false; @@ -89,7 +155,6 @@ Squelch::expireSquelch(PublicKey const& validator) if (it->second > now) return false; - // squelch expired squelched_.erase(it); return true; diff --git a/src/xrpld/overlay/detail/Cluster.cpp b/src/xrpld/overlay/detail/Cluster.cpp index dcb40a54f5..03688be866 100644 --- a/src/xrpld/overlay/detail/Cluster.cpp +++ b/src/xrpld/overlay/detail/Cluster.cpp @@ -1,3 +1,27 @@ +/** @file + * Implements `xrpl::Cluster`, the in-process registry of trusted cluster + * peers for an XRPL node. + * + * Cluster membership is loaded once at startup from the `[cluster_nodes]` + * configuration section and then kept up to date via `TMCluster` gossip + * messages received from overlay peers. Being registered here grants a peer + * elevated trust: its load-fee gossip is propagated network-wide and it is + * exempt from anonymous-peer throttling. + * + * Storage is a `std::set` with a transparent + * comparator that supports `find(PublicKey)` without constructing a temporary + * `ClusterNode`. Because `std::set` elements are logically immutable after + * insertion, state updates (name, load fee, report time) use an + * erase-then-reinsert pattern rather than in-place mutation. + * + * All public methods are guarded by `mutex_` (non-recursive). The `forEach` + * callback must not call `update` — both would attempt to acquire the same + * mutex on the same thread, causing a deadlock. + * + * @see Cluster + * @see ClusterNode + */ + #include #include diff --git a/src/xrpld/overlay/detail/ConnectAttempt.cpp b/src/xrpld/overlay/detail/ConnectAttempt.cpp index c83478ce1e..7a01bd903e 100644 --- a/src/xrpld/overlay/detail/ConnectAttempt.cpp +++ b/src/xrpld/overlay/detail/ConnectAttempt.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements the outbound peer connection state machine for the XRPL overlay. + * + * `ConnectAttempt` drives a five-phase async pipeline — TCP connect, TLS + * handshake, HTTP upgrade write, HTTP upgrade read, and response processing — + * culminating in either promoting the result to a live `PeerImp` or reporting + * failure back to `PeerFinder`. All async I/O is serialized on a strand; the + * dual-timer scheme (`timer_` global ceiling + `stepTimer_` per-phase budget) + * gives fine-grained diagnostics without separate handler functions. + */ #include #include @@ -46,6 +56,25 @@ namespace xrpl { +/** Construct a `ConnectAttempt` and bind it to its `PeerFinder` slot. + * + * Initializes the TLS stream from the shared SSL context and wires + * convenience references (`socket_`, `stream_`) into `streamPtr_`. + * The slot is retained; it will be released to `PeerFinder::onClosed` + * by the destructor unless ownership is moved to a `PeerImp` on success. + * Call `run()` to begin the async pipeline. + * + * @param app Application context providing config and services. + * @param ioContext Asio I/O context for all async operations. + * @param remoteEndpoint TCP endpoint of the peer to dial. + * @param usage Resource consumer used for rate-limiting this peer. + * @param context Shared SSL context (TLS version, ciphers, etc.). + * @param id Unique numeric identifier for this connection attempt. + * @param slot PeerFinder slot that was reserved for this outbound + * connection; must not be null. + * @param journal Journal sink for diagnostic logging. + * @param overlay Parent overlay manager that owns this child. + */ ConnectAttempt::ConnectAttempt( Application& app, boost::asio::io_context& ioContext, @@ -76,14 +105,25 @@ ConnectAttempt::ConnectAttempt( { } +/** Release the PeerFinder slot if the connection never succeeded. + * + * On a successful connection, `processResponse()` moves `slot_` into the + * newly created `PeerImp`, leaving `slot_` null here. Any other outcome + * (failure, timeout, early stop) leaves `slot_` non-null, so the destructor + * reports the closure to `PeerFinder` to keep its bookkeeping consistent. + */ ConnectAttempt::~ConnectAttempt() { - // slot_ will be null if we successfully connected - // and transferred ownership to a PeerImp if (slot_ != nullptr) overlay_.peerFinder().onClosed(slot_); } +/** Abort the connection attempt from any thread. + * + * If called from outside the strand, the call is re-posted to the strand + * so that `shutdown()` always executes with strand ownership. Idempotent + * when the socket is already closed. + */ void ConnectAttempt::stop() { @@ -101,6 +141,12 @@ ConnectAttempt::stop() shutdown(); } +/** Begin the async connection pipeline from any thread. + * + * Re-posts to the strand if necessary, then arms both the global timeout + * and the `TcpConnect` step timer before issuing the async TCP connect. + * `onConnect()` continues the pipeline on success. + */ void ConnectAttempt::run() { @@ -114,7 +160,6 @@ ConnectAttempt::run() ioPending_ = true; - // Allow up to connectTimeout_ seconds to establish remote peer connection setTimer(ConnectionStep::TcpConnect); stream_.next_layer().async_connect( @@ -126,6 +171,17 @@ ConnectAttempt::run() //------------------------------------------------------------------------------ +/** Mark the connection as shutting down and begin teardown. + * + * Sets `shutdown_` to prevent further protocol steps, cancels all pending + * async I/O on the lowest-layer socket, then delegates to + * `tryAsyncShutdown()` which will either start the SSL shutdown handshake + * or close the socket directly, depending on how far the TLS handshake + * progressed. + * + * @note Must be called from the strand. Idempotent when the socket is + * already closed. + */ void ConnectAttempt::shutdown() { @@ -141,6 +197,20 @@ ConnectAttempt::shutdown() tryAsyncShutdown(); } +/** Initiate the SSL shutdown handshake when it is safe to do so. + * + * This method is a no-op in three situations: shutdown has not been + * requested yet, shutdown is already in progress, or an async I/O + * operation is still in flight (`ioPending_`). The last guard prevents + * calling `stream_.async_shutdown()` while a concurrent `async_read` or + * `async_write` is outstanding, which would be undefined behavior. + * + * If the TLS handshake never completed (the connection is still in the + * `TcpConnect` or `TlsHandshake` phase) there is no TLS session to + * close, so `close()` is called directly instead. + * + * @note Must be called from the strand. + */ void ConnectAttempt::tryAsyncShutdown() { @@ -154,7 +224,6 @@ ConnectAttempt::tryAsyncShutdown() if (ioPending_) return; - // gracefully shutdown the SSL socket, performing a shutdown handshake if (currentStep_ != ConnectionStep::TcpConnect && currentStep_ != ConnectionStep::TlsHandshake) { setTimer(ConnectionStep::ShutdownStarted); @@ -167,6 +236,24 @@ ConnectAttempt::tryAsyncShutdown() close(); } +/** Handle completion of the async TLS shutdown handshake. + * + * Several error codes are expected during connection teardown and are + * intentionally suppressed to avoid log noise: + * - `eof` — the stream was cleanly closed by the peer. + * - `operation_aborted` — the step timer expired before the shutdown + * handshake could complete. + * - `stream_truncated` — the TCP connection dropped before the TLS close + * notify exchange finished (benign if the peer doesn't do graceful + * disconnect). + * - `"application data after close notify"` — a benign OpenSSL condition + * triggered by some peers sending application data after the close notify. + * + * Any other error is logged at debug level. In all cases `close()` is + * called to finalize socket teardown. + * + * @param ec Error code from the async TLS shutdown operation. + */ void ConnectAttempt::onShutdown(error_code ec) { @@ -174,12 +261,6 @@ ConnectAttempt::onShutdown(error_code ec) if (ec) { - // - eof: the stream was cleanly closed - // - operation_aborted: an expired timer (slow shutdown) - // - stream_truncated: the tcp connection closed (no handshake) it could - // occur if a peer does not perform a graceful disconnect - // - broken_pipe: the peer is gone - // - application data after close notify: benign SSL shutdown condition bool const shouldLog = (ec != boost::asio::error::eof && ec != boost::asio::error::operation_aborted && ec.message().find("application data after close notify") == std::string::npos); @@ -193,6 +274,16 @@ ConnectAttempt::onShutdown(error_code ec) close(); } +/** Cancel all timers and close the underlying TCP socket. + * + * This is the terminal cleanup step, called after either a successful TLS + * shutdown or when bypassing the TLS shutdown (pre-handshake teardown). + * The error code from `socket_.close()` is deliberately discarded — the + * connection is being torn down regardless of any OS-level close error. + * + * @note Must be called from the strand. Idempotent when the socket is + * already closed. + */ void ConnectAttempt::close() { @@ -207,6 +298,10 @@ ConnectAttempt::close() socket_.close(ec); // NOLINT(bugprone-unused-return-value) } +/** Log a failure reason at debug level and begin connection teardown. + * + * @param reason Human-readable description of why the connection failed. + */ void ConnectAttempt::fail(std::string const& reason) { @@ -214,6 +309,11 @@ ConnectAttempt::fail(std::string const& reason) shutdown(); } +/** Log a system error at debug level and begin connection teardown. + * + * @param name Context label prepended to the error message (e.g. "onConnect"). + * @param ec The system error code describing the failure. + */ void ConnectAttempt::fail(std::string const& name, error_code ec) { @@ -221,12 +321,32 @@ ConnectAttempt::fail(std::string const& name, error_code ec) shutdown(); } +/** Advance the current connection step and arm the appropriate timers. + * + * Two timers are maintained: + * - **Global timer** (`timer_`): armed once for the entire attempt + * (`kCONNECT_TIMEOUT = 25 s`). Subsequent calls to this method are + * no-ops for the global timer (guarded by checking against the + * default-constructed `time_point{}`). + * - **Step timer** (`stepTimer_`): reset on every call via + * `expires_after`, which automatically cancels the previous step timer. + * Phase-specific budgets: TcpConnect 8 s, TlsHandshake 8 s, + * HttpWrite 3 s, HttpRead 3 s, ShutdownStarted 2 s. + * + * Both timers share `onTimer()` as their handler; the handler + * distinguishes which fired by comparing their expiry against + * `steady_clock::now()`. + * + * `Init` and `Complete` steps do not arm a step timer. On any exception + * from the Asio timer API, `close()` is called immediately. + * + * @param step The phase being entered; updates `currentStep_`. + */ void ConnectAttempt::setTimer(ConnectionStep step) { currentStep_ = step; - // Set global timer (only if not already set) if (timer_.expiry() == std::chrono::steady_clock::time_point{}) { try @@ -246,7 +366,6 @@ ConnectAttempt::setTimer(ConnectionStep step) } } - // Set step-specific timer try { std::chrono::seconds stepTimeout; @@ -269,10 +388,10 @@ ConnectAttempt::setTimer(ConnectionStep step) break; case ConnectionStep::Complete: case ConnectionStep::Init: - return; // No timer needed for init or complete step + return; } - // call to expires_after cancels previous timer + // expires_after implicitly cancels the previous step timer. stepTimer_.expires_after(stepTimeout); stepTimer_.async_wait( boost::asio::bind_executor( @@ -290,6 +409,11 @@ ConnectAttempt::setTimer(ConnectionStep step) } } +/** Cancel both the global timer and the current step timer. + * + * Any `system_error` thrown by `cancel()` is swallowed — timers that are + * already expired or closed produce benign errors that must not propagate. + */ void ConnectAttempt::cancelTimer() { @@ -304,6 +428,21 @@ ConnectAttempt::cancelTimer() } } +/** Shared handler for both the global and step timers. + * + * `operation_aborted` is returned whenever a timer is cancelled (e.g. by + * `expires_after` or `cancelTimer()`); this is expected and ignored. Any + * other non-zero error code is unexpected and triggers an immediate + * `close()`. + * + * When the error code is zero (a genuine expiry), both timers' expiry + * times are compared against `steady_clock::now()` to determine which + * one fired — the global ceiling (`timer_`) or the per-step budget + * (`stepTimer_`). Either way the connection is closed; the distinction + * exists only for logging granularity. + * + * @param ec Error code from the Asio timer wait operation. + */ void ConnectAttempt::onTimer(error_code ec) { @@ -312,7 +451,8 @@ ConnectAttempt::onTimer(error_code ec) if (ec) { - // do not initiate shutdown, timers are frequently cancelled + // Timers are frequently cancelled (step transition, cleanup); do not + // treat operation_aborted as a failure. if (ec == boost::asio::error::operation_aborted) return; @@ -322,7 +462,6 @@ ConnectAttempt::onTimer(error_code ec) return; } - // Determine which timer expired by checking their expiry times auto const now = std::chrono::steady_clock::now(); bool const globalExpired = (timer_.expiry() <= now); bool const stepExpired = (stepTimer_.expiry() <= now); @@ -343,6 +482,21 @@ ConnectAttempt::onTimer(error_code ec) close(); } +/** Handle completion of the async TCP connect. + * + * Clears `ioPending_` first, then checks the error code. + * `operation_aborted` means a timer fired while the connect was in flight; + * in that case shutdown is already in progress and `tryAsyncShutdown()` is + * called to continue teardown cleanly. + * + * After a nominal connect, `local_endpoint()` is queried to confirm the OS + * actually established a route (the connect can succeed at the Asio level + * with a closed socket in rare edge cases). TLS certificate verification + * is explicitly disabled (`verify_none`); security derives from the + * node-key signature in the HTTP handshake, not from the cert chain. + * + * @param ec Error code from the async TCP connect. + */ void ConnectAttempt::onConnect(error_code ec) { @@ -363,7 +517,7 @@ ConnectAttempt::onConnect(error_code ec) if (!socket_.is_open()) return; - // check if connection has really been established + // Confirm the route is live; a closed socket can slip through on some OSes. socket_.local_endpoint(ec); if (ec) { @@ -389,6 +543,20 @@ ConnectAttempt::onConnect(error_code ec) std::bind(&ConnectAttempt::onHandshake, shared_from_this(), std::placeholders::_1))); } +/** Handle completion of the async TLS handshake. + * + * After a successful TLS handshake this method: + * 1. Confirms the connection is not a self-loop via + * `peerFinder().onConnected()`. + * 2. Derives the TLS-channel-bound shared value with `makeSharedValue()`; + * a degenerate zero-XOR result causes an immediate shutdown (logged + * inside `makeSharedValue`). + * 3. Builds the HTTP upgrade request with `makeRequest()` and populates + * identity headers with `buildHandshake()`. + * 4. Starts the async HTTP write to send the upgrade to the peer. + * + * @param ec Error code from the async TLS handshake. + */ void ConnectAttempt::onHandshake(error_code ec) { @@ -415,7 +583,6 @@ ConnectAttempt::onHandshake(error_code ec) setTimer(ConnectionStep::HttpWrite); - // check if we connected to ourselves if (!overlay_.peerFinder().onConnected( slot_, beast::IPAddressConversion::fromAsio(localEndpoint))) { @@ -461,6 +628,13 @@ ConnectAttempt::onHandshake(error_code ec) std::bind(&ConnectAttempt::onWrite, shared_from_this(), std::placeholders::_1))); } +/** Handle completion of the async HTTP upgrade request write. + * + * On success, arms the `HttpRead` step timer and starts the async read of + * the peer's HTTP upgrade response into `response_`. + * + * @param ec Error code from the async HTTP write. + */ void ConnectAttempt::onWrite(error_code ec) { @@ -497,6 +671,17 @@ ConnectAttempt::onWrite(error_code ec) std::bind(&ConnectAttempt::onRead, shared_from_this(), std::placeholders::_1))); } +/** Handle completion of the async HTTP upgrade response read. + * + * Cancels all timers and advances `currentStep_` to `Complete` before + * inspecting the error code. An `eof` here means the peer closed the + * connection before sending a full HTTP response, which is treated as a + * soft failure (logged at debug rather than warning). On success, + * delegates to `processResponse()` to validate the peer's identity and + * promote the connection. + * + * @param ec Error code from the async HTTP read. + */ void ConnectAttempt::onRead(error_code ec) { @@ -534,13 +719,44 @@ ConnectAttempt::onRead(error_code ec) //-------------------------------------------------------------------------- +/** Validate the peer's HTTP upgrade response and promote to a live `PeerImp`. + * + * This is the security-critical terminal step of the connection pipeline. + * Two response paths are handled: + * + * **HTTP 101 (Switching Protocols)** — the normal success path: + * 1. The `Upgrade` header is parsed for exactly one protocol version that + * is also locally supported via `isProtocolSupported()`; ambiguous or + * unsupported negotiations are rejected. + * 2. `makeSharedValue()` re-derives the TLS channel-bound value; a + * degenerate zero-XOR result triggers an immediate shutdown. + * 3. `verifyHandshake()` validates the peer's node-key signature over the + * shared value, checks `Network-ID`, `Network-Time`, and IP headers. + * Any failure throws `std::runtime_error`, caught below. + * 4. `peerFinder().activate()` confirms the slot is still acceptable + * (duplicate-key check, slot-count limits). + * 5. `streamPtr_` and `slot_` are moved into a new `PeerImp`; after the + * move both are null here, so the destructor skips `onClosed`. + * `overlay_.addActive()` registers the live peer. + * + * **HTTP 503 (Service Unavailable)** — the redirect path: + * A 503 with a JSON body containing a `"peer-ips"` array is the XRPL + * redirect mechanism: the peer is overloaded and provides alternative + * addresses. Valid endpoints are forwarded to + * `peerFinder().onRedirects()` for future connection attempts. A 503 + * without a valid redirect body (e.g. a plain HTTP proxy error) is + * logged as a warning and the connection is torn down. Either way this + * `ConnectAttempt` is terminated after handling. + * + * @note `verifyHandshake()` throws on any check failure; callers of this + * method do not need to check a return value — failure is always + * exception-based. + */ void ConnectAttempt::processResponse() { if (!OverlayImpl::isPeerUpgrade(response_)) { - // A peer may respond with service_unavailable and a list of alternative - // peers to connect to, a differing status code is unexpected if (response_.result() != boost::beast::http::status::service_unavailable) { JLOG(journal_.warn()) << "Unable to upgrade to peer protocol: " << response_.result() @@ -549,8 +765,6 @@ ConnectAttempt::processResponse() return; } - // Parse response body to determine if this is a redirect or other - // service unavailable std::string responseBody; responseBody.reserve(boost::asio::buffer_size(response_.body().data())); for (auto const buffer : response_.body().data()) @@ -563,7 +777,6 @@ ConnectAttempt::processResponse() json::Reader reader; auto const isValidJson = reader.parse(responseBody, json); - // Check if this is a redirect response (contains peer-ips field) auto const isRedirect = isValidJson && json.isObject() && json.isMember("peer-ips"); if (!isRedirect) @@ -583,7 +796,6 @@ ConnectAttempt::processResponse() return; } - // Extract and validate peer endpoints std::vector redirectEndpoints; redirectEndpoints.reserve(peerIps.size()); @@ -598,15 +810,15 @@ ConnectAttempt::processResponse() redirectEndpoints.push_back(endpoint); } - // Notify PeerFinder about the redirect redirectEndpoints may be empty + // redirectEndpoints may be empty if all entries were malformed. overlay_.peerFinder().onRedirects(remoteEndpoint_, redirectEndpoints); fail("processResponse: failed to connect to peer: redirected"); return; } - // Just because our peer selected a particular protocol version doesn't - // mean that it's acceptable to us. Check that it is: + // The peer echoes back the protocol version it selected; verify it is + // also acceptable to us (exactly one version, locally supported). std::optional negotiatedProtocol; { diff --git a/src/xrpld/overlay/detail/ConnectAttempt.h b/src/xrpld/overlay/detail/ConnectAttempt.h index 70ce7912ba..1688712f21 100644 --- a/src/xrpld/overlay/detail/ConnectAttempt.h +++ b/src/xrpld/overlay/detail/ConnectAttempt.h @@ -6,36 +6,26 @@ namespace xrpl { -/** - * @class ConnectAttempt - * @brief Manages outbound peer connection attempts with comprehensive timeout - * handling +/** Manages one outbound TCP/TLS/HTTP connection attempt to a remote XRPL peer. * - * The ConnectAttempt class handles the complete lifecycle of establishing an - * outbound connection to a peer in the XRPL network. It implements a - * sophisticated dual-timer system that provides both global timeout protection - * and per-step timeout diagnostics. + * Instantiated by `OverlayImpl` when `PeerFinder` decides a new outbound + * connection should be made. Drives a five-phase async pipeline — TCP + * connect, TLS handshake, HTTP upgrade write, HTTP upgrade read, response + * processing — culminating in either promoting the result to a live `PeerImp` + * or reporting failure and releasing the `PeerFinder` slot. * - * The connection establishment follows these steps: - * 1. **TCP Connect**: Establish basic network connection - * 2. **TLS Handshake**: Negotiate SSL/TLS encryption - * 3. **HTTP Write**: Send peer handshake request - * 4. **HTTP Read**: Receive and validate peer response - * 5. **Complete**: Connection successfully established + * Inherits `OverlayImpl::Child` so that `OverlayImpl::stopChildren()` can + * reach every live attempt during overlay shutdown. Inherits + * `enable_shared_from_this` because every async callback captures a + * `shared_ptr` via `shared_from_this()`, keeping the object alive for the + * duration of any in-flight operation. * - * Uses a hybrid timeout approach: - * - **Global Timer**: Hard limit (20s) for entire connection process - * - **Step Timers**: Individual timeouts for each connection phase - * - * - All errors result in connection termination - * - * All operations are serialized using boost::asio::strand to ensure thread - * safety. The class is designed to be used exclusively within the ASIO event - * loop. - * - * @note This class should not be used directly. It is managed by OverlayImpl - * as part of the peer discovery and connection management system. + * All mutable state is accessed only on `strand_`. Both `run()` and `stop()` + * re-post to the strand when called from outside it, so callers need no + * external synchronisation. * + * @note Do not use this class directly; it is constructed and managed by + * `OverlayImpl::connect()`. */ class ConnectAttempt : public OverlayImpl::Child, public std::enable_shared_from_this @@ -50,49 +40,43 @@ private: using stream_type = boost::beast::ssl_stream; using shared_context = std::shared_ptr; - /** - * @enum ConnectionStep - * @brief Represents the current phase of the connection establishment - * process + /** Phases of the outbound connection pipeline. * - * Used for tracking progress and providing detailed timeout diagnostics. - * Each step has its own timeout value defined in StepTimeouts. + * Tracks which async operation is currently in flight. Used by the + * dual-timer scheme to emit phase-specific timeout diagnostics and by + * `tryAsyncShutdown()` to decide whether a TLS-level shutdown is needed. */ enum class ConnectionStep { - Init, // Initial state, nothing started - TcpConnect, // Establishing TCP connection to remote peer - TlsHandshake, // Performing SSL/TLS handshake - HttpWrite, // Sending HTTP upgrade request - HttpRead, // Reading HTTP upgrade response - Complete, // Connection successfully established - ShutdownStarted // Connection shutdown has started + Init, /**< Initial state; no async operation started yet. */ + TcpConnect, /**< Async TCP connect in progress. */ + TlsHandshake, /**< Async TLS handshake in progress. */ + HttpWrite, /**< Async HTTP upgrade request write in progress. */ + HttpRead, /**< Async HTTP upgrade response read in progress. */ + Complete, /**< HTTP response received; pipeline finished. */ + ShutdownStarted /**< Async TLS shutdown in progress. */ }; - // A timeout for connection process, greater than all step timeouts + /** Hard ceiling for the entire connection attempt (all phases combined). */ static constexpr std::chrono::seconds kCONNECT_TIMEOUT{25}; - /** - * @struct StepTimeouts - * @brief Defines timeout values for each connection step + /** Per-phase timeout budgets used by the step timer. * - * These timeouts are designed to detect slow individual phases while - * allowing the global timeout to enforce the overall time limit. + * The step timer is reset at each phase transition. A phase that + * exceeds its individual budget is terminated even when the global + * `kCONNECT_TIMEOUT` has not yet elapsed, providing fine-grained + * diagnostics for slow individual phases. */ struct StepTimeouts { - // TCP connection timeout - static constexpr std::chrono::seconds kTCP_CONNECT{8}; - // SSL handshake timeout - static constexpr std::chrono::seconds kTLS_HANDSHAKE{8}; - // HTTP write timeout - static constexpr std::chrono::seconds kHTTP_WRITE{3}; - // HTTP read timeout - static constexpr std::chrono::seconds kHTTP_READ{3}; - // SSL shutdown timeout - static constexpr std::chrono::seconds kTLS_SHUTDOWN{2}; + static constexpr std::chrono::seconds kTCP_CONNECT{8}; /**< TCP connect budget. */ + static constexpr std::chrono::seconds kTLS_HANDSHAKE{8}; /**< TLS handshake budget. */ + static constexpr std::chrono::seconds kHTTP_WRITE{3}; /**< HTTP write budget. */ + static constexpr std::chrono::seconds kHTTP_READ{3}; /**< HTTP read budget. */ + static constexpr std::chrono::seconds kTLS_SHUTDOWN{2}; /**< TLS shutdown budget. */ }; - // Core application and networking components + // --- Core components --- + Application& app_; Peer::id_t const id_; beast::WrappedSink sink_; @@ -101,38 +85,66 @@ private: Resource::Consumer usage_; boost::asio::strand strand_; + + /** Global hard-limit timer; armed once when `run()` is first called. */ boost::asio::basic_waitable_timer timer_; + + /** Per-phase timer; reset at every phase transition via `setTimer()`. */ boost::asio::basic_waitable_timer stepTimer_; - std::unique_ptr streamPtr_; // SSL stream (owned) + /** Owned TLS stream; moved into `PeerImp` on successful promotion. */ + std::unique_ptr streamPtr_; + + /** Convenience reference into `streamPtr_->next_layer().socket()`. */ socket_type& socket_; + + /** Convenience reference into `*streamPtr_`. */ stream_type& stream_; + boost::beast::multi_buffer readBuf_; response_type response_; + + /** PeerFinder slot reserved for this outbound connection. + * + * Moved into the `PeerImp` on success, leaving this member null. + * The destructor calls `peerFinder().onClosed(slot_)` only when + * `slot_` is non-null, ensuring exactly-once release. + */ std::shared_ptr slot_; + request_type req_; - bool shutdown_ = false; // Shutdown has been initiated - bool ioPending_ = false; // Async I/O operation in progress + /** True once `stop()` or a failure handler sets the shutdown flag. */ + bool shutdown_ = false; + + /** True while an async I/O operation is outstanding on `stream_`. + * + * `tryAsyncShutdown()` defers the SSL shutdown handshake until this + * flag is false, because calling `stream_.async_shutdown()` while + * another async operation is in flight is undefined behaviour. + */ + bool ioPending_ = false; + ConnectionStep currentStep_ = ConnectionStep::Init; public: - /** - * @brief Construct a new ConnectAttempt object + /** Construct a `ConnectAttempt` and bind it to its `PeerFinder` slot. * - * @param app Application context providing configuration and services - * @param ioContext ASIO I/O context for async operations - * @param remoteEndpoint Target peer endpoint to connect to - * @param usage Resource usage tracker for rate limiting - * @param context Shared SSL context for encryption - * @param id Unique peer identifier for this connection attempt - * @param slot PeerFinder slot representing this connection - * @param journal Logging interface for diagnostics - * @param overlay Parent overlay manager + * Initializes the TLS stream from the shared SSL context and wires + * convenience references (`socket_`, `stream_`) into `streamPtr_`. + * Call `run()` to begin the async pipeline. * - * @note The constructor only initializes the object. Call run() to begin - * the actual connection attempt. + * @param app Application context providing config and services. + * @param ioContext Asio I/O context for all async operations. + * @param remoteEndpoint TCP endpoint of the peer to dial. + * @param usage Resource consumer used for rate-limiting this peer. + * @param context Shared SSL context (TLS version, ciphers, etc.). + * @param id Unique numeric identifier for this connection attempt. + * @param slot PeerFinder slot reserved for this outbound + * connection; must not be null. + * @param journal Journal sink for diagnostic logging. + * @param overlay Parent overlay manager that owns this child. */ ConnectAttempt( Application& app, @@ -145,90 +157,223 @@ public: beast::Journal journal, OverlayImpl& overlay); + /** Release the `PeerFinder` slot if the connection never succeeded. + * + * On a successful connection `processResponse()` moves `slot_` into the + * newly created `PeerImp`, leaving it null here. Any other outcome + * (failure, timeout, or external stop) leaves `slot_` non-null, so the + * destructor reports the closure to `PeerFinder` to keep its + * bookkeeping consistent. + */ ~ConnectAttempt() override; - /** - * @brief Stop the connection attempt + /** Abort the connection attempt from any thread. * - * This method is thread-safe and can be called from any thread. + * If called from outside the strand, the call is re-posted so that the + * actual teardown always runs with strand ownership. Idempotent if + * the socket is already closed. */ void stop() override; - /** - * @brief Begin the connection attempt + /** Begin the async connection pipeline from any thread. * - * This method is thread-safe and posts to the strand if needed. + * Re-posts to the strand if necessary, then arms both the global ceiling + * timer and the `TcpConnect` step timer before issuing the async TCP + * connect. `onConnect()` continues the pipeline on success. */ void run(); private: - /** - * @brief Set timers for the specified connection step + /** Advance the current phase and arm the appropriate timers. * - * @param step The connection step to set timers for + * The global timer (`timer_`) is armed exactly once for the lifetime of + * the attempt (guarded by checking against the epoch `time_point{}`). + * The step timer (`stepTimer_`) is reset on every call via + * `expires_after`, which implicitly cancels the previous step timer. + * Both timers share `onTimer()` as their completion handler. * - * Sets both the step-specific timer and the global timer (if not already - * set). + * `Init` and `Complete` steps do not arm a step timer. On any + * exception from the Asio timer API, `close()` is called immediately. + * + * @param step The phase being entered; updates `currentStep_`. */ void setTimer(ConnectionStep step); - /** - * @brief Cancel both global and step timers + /** Cancel both the global timer and the current step timer. * - * Used during cleanup and when connection completes successfully. - * Exceptions from timer cancellation are safely ignored. + * Any `system_error` thrown by `cancel()` is swallowed; timers that + * are already expired or closed produce benign errors that must not + * propagate. */ void cancelTimer(); - /** - * @brief Handle timer expiration events + /** Shared completion handler for both the global and step timers. * - * @param ec Error code from timer operation + * `operation_aborted` is returned whenever a timer is cancelled (e.g. + * by `expires_after` or `cancelTimer()`); this is expected and ignored. + * On a genuine expiry (ec == 0) the handler compares both timers' + * expiry times against `steady_clock::now()` to identify which fired — + * the global ceiling or the per-step budget — and logs accordingly + * before calling `close()`. * - * Determines which timer expired (global vs step) and logs appropriate - * diagnostic information before terminating the connection. + * @param ec Error code from the Asio timer wait. */ void onTimer(error_code ec); - // Connection phase handlers - void - onConnect(error_code ec); // TCP connection completion handler - void - onHandshake(error_code ec); // TLS handshake completion handler - void - onWrite(error_code ec); // HTTP write completion handler - void - onRead(error_code ec); // HTTP read completion handler - - // Error and cleanup handlers - void - fail(std::string const& reason); // Fail with custom reason - void - fail(std::string const& name, error_code ec); // Fail with system error - void - shutdown(); // Initiate graceful shutdown - void - tryAsyncShutdown(); // Attempt async SSL shutdown - void - onShutdown(error_code ec); // SSL shutdown completion handler - void - close(); // Force close socket - - /** - * @brief Process the HTTP upgrade response from peer + /** Handle completion of the async TCP connect. * - * Validates the peer's response, extracts protocol information, - * verifies handshake, and either creates a PeerImp or handles - * redirect responses. + * On success, disables TLS certificate verification (`verify_none`) and + * starts the async TLS handshake. Security derives from the node-key + * signature in the HTTP headers, not the cert chain. + * + * @param ec Error code from the async TCP connect. + */ + void + onConnect(error_code ec); + + /** Handle completion of the async TLS handshake. + * + * On success: confirms the connection is not a self-loop via + * `peerFinder().onConnected()`; derives the TLS-channel-bound shared + * value with `makeSharedValue()` (a degenerate zero-XOR result causes + * immediate shutdown); builds and sends the HTTP upgrade request with + * identity headers via `buildHandshake()`. + * + * @param ec Error code from the async TLS handshake. + */ + void + onHandshake(error_code ec); + + /** Handle completion of the async HTTP upgrade request write. + * + * On success, arms the `HttpRead` step timer and starts the async read + * of the peer's HTTP upgrade response. + * + * @param ec Error code from the async HTTP write. + */ + void + onWrite(error_code ec); + + /** Handle completion of the async HTTP upgrade response read. + * + * Cancels all timers and advances `currentStep_` to `Complete` before + * inspecting the error code. An `eof` here means the peer closed the + * connection before sending a full response and is treated as a soft + * failure. On success, delegates to `processResponse()`. + * + * @param ec Error code from the async HTTP read. + */ + void + onRead(error_code ec); + + /** Log a failure reason and begin connection teardown. + * + * @param reason Human-readable description of why the connection failed. + */ + void + fail(std::string const& reason); + + /** Log a system error and begin connection teardown. + * + * @param name Context label prepended to the error message (e.g. + * `"onConnect"`). + * @param ec The system error code describing the failure. + */ + void + fail(std::string const& name, error_code ec); + + /** Mark the connection as shutting down and begin teardown. + * + * Sets `shutdown_` to prevent further protocol steps, cancels all + * pending async I/O on the lowest-layer socket, then delegates to + * `tryAsyncShutdown()`. + * + * @note Must be called from the strand. Idempotent when the socket is + * already closed. + */ + void + shutdown(); + + /** Initiate the TLS shutdown handshake when it is safe to do so. + * + * This method is a no-op when shutdown has not been requested, when + * shutdown is already in progress, or when `ioPending_` is true. The + * last guard prevents calling `stream_.async_shutdown()` while a + * concurrent async read or write is outstanding, which would be + * undefined behaviour. + * + * If the TLS handshake never completed (`TcpConnect` or `TlsHandshake` + * phase), there is no TLS session to close, so `close()` is called + * directly instead. + * + * @note Must be called from the strand. + */ + void + tryAsyncShutdown(); + + /** Handle completion of the async TLS shutdown handshake. + * + * Several error codes are expected during teardown and suppressed to + * avoid log noise: `eof`, `operation_aborted`, `stream_truncated`, and + * the OpenSSL `"application data after close notify"` condition. Any + * other error is logged at debug level. In all cases `close()` is + * called to finalize socket teardown. + * + * @param ec Error code from the async TLS shutdown. + */ + void + onShutdown(error_code ec); + + /** Cancel all timers and close the underlying TCP socket. + * + * Terminal cleanup step called after TLS shutdown or when bypassing it + * (pre-handshake teardown). The error code from `socket_.close()` is + * deliberately discarded. + * + * @note Must be called from the strand. Idempotent when the socket is + * already closed. + */ + void + close(); + + /** Validate the peer's HTTP upgrade response and promote to a live peer. + * + * **HTTP 101 (Switching Protocols)** — normal success path: + * Negotiates exactly one locally-supported protocol version from the + * `Upgrade` header; re-derives the TLS-channel-bound shared value; + * calls `verifyHandshake()` to authenticate the remote node's public key + * (throws `std::runtime_error` on any check failure); calls + * `peerFinder().activate()` to confirm slot acceptability; then moves + * `streamPtr_` and `slot_` into a new `PeerImp` and hands it to + * `overlay_.addActive()`. + * + * **HTTP 503 (Service Unavailable)** — redirect path: + * If the body contains a valid `"peer-ips"` JSON array, the endpoints + * are forwarded to `peerFinder().onRedirects()` for future connection + * attempts. A 503 without a valid redirect body is logged as a warning. + * In either case this `ConnectAttempt` terminates. + * + * Any other HTTP status code is treated as a hard failure. + * + * @note Once `streamPtr_` and `slot_` are moved out, the destructor's + * `onClosed` guard is a no-op; ownership has transferred to the + * new `PeerImp`. */ void processResponse(); + /** Convert a `ConnectionStep` enumerator to a human-readable string. + * + * Used in log messages emitted by `onTimer()` and `setTimer()`. + * + * @param step The phase to stringify. + * @return A string literal naming the phase (e.g. `"TcpConnect"`). + */ static std::string stepToString(ConnectionStep step) { @@ -252,6 +397,18 @@ private: return "Unknown"; }; + /** Parse an IP:port string into a Boost.Asio TCP endpoint. + * + * Used by `processResponse()` to decode the `"peer-ips"` redirect + * array from a 503 response body. Parsing is performed via + * `beast::IP::Endpoint` and then converted to Asio's representation. + * + * @param s The endpoint string in `"address:port"` format. + * @param ec Set to `invalid_argument` if the string cannot be parsed; + * left unchanged on success. + * @return The parsed endpoint, or a default-constructed endpoint if + * parsing fails. + */ template static boost::asio::ip::tcp::endpoint parseEndpoint(std::string const& s, boost::system::error_code& ec) diff --git a/src/xrpld/overlay/detail/Handshake.cpp b/src/xrpld/overlay/detail/Handshake.cpp index b32d5280e2..9285d5f46f 100644 --- a/src/xrpld/overlay/detail/Handshake.cpp +++ b/src/xrpld/overlay/detail/Handshake.cpp @@ -1,3 +1,21 @@ +/** @file + * Application-layer handshake exchanged immediately after TLS connection. + * + * Responsibilities of this translation unit: + * - Derive a TLS-channel-bound shared value (`makeSharedValue`) that ties + * the node-identity proof to the specific TLS session, preventing MITM + * attacks even though TLS certificate verification is disabled. + * - Build (`buildHandshake`) and verify (`verifyHandshake`) the HTTP upgrade + * headers carrying node public keys, signatures, clock values, and IP + * cross-checks. + * - Negotiate optional protocol features (LZ4 compression, ledger replay, + * TX reduce-relay, VP reduce-relay) via `X-Protocol-Ctl` headers. + * - Assemble the outbound HTTP upgrade request (`makeRequest`) and the 101 + * Switching Protocols response (`makeResponse`). + * + * Neither side begins exchanging XRPL protocol messages until + * `verifyHandshake` succeeds. + */ #include #include @@ -47,6 +65,16 @@ namespace xrpl { +/** Extract the raw value string for a named feature from `X-Protocol-Ctl`. + * + * Searches the `X-Protocol-Ctl` header for a `feature=` token using + * a regex that stops at `;` or whitespace, matching only the first occurrence. + * + * @param headers HTTP headers to search. + * @param feature Feature name (e.g. `"compr"`, `"txrr"`). + * @return The value string if found; `std::nullopt` if the header is absent + * or the feature is not present. + */ std::optional getFeatureValue(boost::beast::http::fields const& headers, std::string const& feature) { @@ -61,6 +89,18 @@ getFeatureValue(boost::beast::http::fields const& headers, std::string const& fe return {}; } +/** Check whether a feature's value matches a given string using RFC 2616 + * token-list semantics. + * + * Delegates to `beast::rfc2616::tokenInList`, which correctly handles + * comma-separated value lists (e.g. `compr=lz4,zstd`). + * + * @param headers HTTP headers to inspect. + * @param feature Feature name to look up. + * @param value Single token to match against (not a list itself). + * @return `true` if the feature is present and its value list contains + * `value`; `false` if absent or no match. + */ bool isFeatureValue( boost::beast::http::fields const& headers, @@ -73,12 +113,32 @@ isFeatureValue( return false; } +/** Return `true` if the named feature is present with value `"1"`. + * + * Thin wrapper over `isFeatureValue(..., "1")` — the conventional + * boolean-enable sentinel used in `X-Protocol-Ctl`. + * + * @param headers HTTP headers to inspect. + * @param feature Feature name to look up. + */ bool featureEnabled(boost::beast::http::fields const& headers, std::string const& feature) { return isFeatureValue(headers, feature, "1"); } +/** Build the `X-Protocol-Ctl` value for an outbound connection request. + * + * The initiator unconditionally advertises every locally enabled feature. + * The responder will echo back only those it also supports (see + * `makeFeaturesResponseHeader`), achieving single-round-trip negotiation. + * + * @param comprEnabled Advertise LZ4 compression (`compr=lz4`). + * @param ledgerReplayEnabled Advertise ledger-replay (`ledgerreplay=1`). + * @param txReduceRelayEnabled Advertise TX reduce-relay (`txrr=1`). + * @param vpReduceRelayEnabled Advertise VP reduce-relay (`vprr=1`). + * @return Semicolon-delimited feature string, empty if no features enabled. + */ std::string makeFeaturesRequestHeader( bool comprEnabled, @@ -98,6 +158,19 @@ makeFeaturesRequestHeader( return str.str(); } +/** Build the `X-Protocol-Ctl` value for a 101 Switching Protocols response. + * + * A feature is echoed back only when it is both locally configured *and* + * present in the peer's request header. This AND-gate ensures both sides + * converge on the same enabled feature set without an extra round-trip. + * + * @param headers The incoming HTTP upgrade request headers. + * @param comprEnabled Accept LZ4 compression if peer requested it. + * @param ledgerReplayEnabled Accept ledger-replay if peer requested it. + * @param txReduceRelayEnabled Accept TX reduce-relay if peer requested it. + * @param vpReduceRelayEnabled Accept VP reduce-relay if peer requested it. + * @return Semicolon-delimited feature string, empty if no features agreed. + */ std::string makeFeaturesResponseHeader( http_request_type const& headers, @@ -118,20 +191,27 @@ makeFeaturesResponseHeader( return str.str(); } -/** Hashes the latest finished message from an SSL stream. - - @param ssl the session to get the message from. - @param get a pointer to the function to call to retrieve the finished - message. This can be either: - - `SSL_get_finished` or - - `SSL_get_peer_finished`. - @return `true` if successful, `false` otherwise. - - @note This construct is non-standard. There are potential "standard" - alternatives that should be considered. For a discussion, on - this topic, see https://github.com/openssl/openssl/issues/5509 and - https://github.com/XRPLF/rippled/issues/2413. -*/ +/** Retrieve and SHA-512 hash a TLS finished message. + * + * Calls `get` (either `SSL_get_finished` or `SSL_get_peer_finished`) to + * copy the raw TLS finished message into a stack buffer, then computes its + * SHA-512 digest. The finished message is derived from the full TLS + * handshake transcript, so it is unique to this specific TLS session. + * + * Returns `std::nullopt` if the finished message is shorter than the + * RFC-mandated minimum (12 bytes), which indicates the TLS handshake has + * not yet completed on that side. + * + * @param ssl The SSL session to query. + * @param get Function pointer — either `SSL_get_finished` (local side) or + * `SSL_get_peer_finished` (remote side). + * @return 512-bit digest of the finished message, or `std::nullopt` if the + * handshake is not yet complete. + * + * @note This approach is non-standard. For alternatives and discussion, see + * https://github.com/openssl/openssl/issues/5509 and + * https://github.com/XRPLF/rippled/issues/2413. + */ static std::optional> hashLastMessage(SSL const* ssl, size_t (*get)(const SSL*, void*, size_t)) { @@ -150,6 +230,29 @@ hashLastMessage(SSL const* ssl, size_t (*get)(const SSL*, void*, size_t)) return cookie; } +/** Derive a 256-bit value that is cryptographically bound to this TLS session. + * + * Algorithm: + * 1. SHA-512 hash our own TLS finished message (`SSL_get_finished`). + * 2. SHA-512 hash the peer's TLS finished message (`SSL_get_peer_finished`). + * 3. XOR the two 512-bit digests. + * 4. Reduce to 256 bits via `sha512Half`. + * + * Because TLS finished messages are derived from a transcript of the entire + * TLS handshake, both endpoints compute the same value only when they share + * the same session. A man-in-the-middle terminates two separate TLS sessions, + * producing different finished messages and therefore a different shared + * value — which causes `verifyHandshake` to reject the signature. + * + * A degenerate edge case — both finished messages hashing to the same + * 512-bit value, yielding an all-zero XOR — is treated as a hard failure to + * avoid a trivially forgeable shared value. + * + * @param ssl The established TLS stream whose finished messages are read. + * @param journal For error logging when either finished message is unavailable + * or the degenerate zero case is detected. + * @return The 256-bit shared value, or `std::nullopt` on any failure. + */ std::optional makeSharedValue(stream_type& ssl, beast::Journal journal) { @@ -169,8 +272,6 @@ makeSharedValue(stream_type& ssl, beast::Journal journal) auto const result = (*cookie1 ^ *cookie2); - // Both messages hash to the same value and the cookie - // is 0. Don't allow this. if (result == beast::kZERO) { JLOG(journal.error()) << "Cookie generation: identical finished messages"; @@ -180,6 +281,32 @@ makeSharedValue(stream_type& ssl, beast::Journal journal) return sha512Half(Slice(result.data(), result.size())); } +/** Populate HTTP upgrade headers with node identity, authentication, and hints. + * + * Inserts the following fields: + * - `Network-ID` — if configured, allows early cross-network detection + * before spending resources on full negotiation. + * - `Network-Time` — local XRPL clock value; recipient enforces ±20 s + * tolerance to prevent replay and clock-skew attacks. + * - `Public-Key` — base58-encoded secp256k1 node identity key. + * - `Session-Signature` — `sharedValue` signed by the node private key, + * base64-encoded. Proves key possession and binds identity to this TLS + * session (see `verifyHandshake`). + * - `Instance-Cookie` — runtime-unique identifier for duplicate-connection + * detection. + * - `Server-Domain` — optional TOML domain hint (omitted if unconfigured). + * - `Remote-IP` — peer's observed IP, if public; aids NAT diagnostics. + * - `Local-IP` — our public IP, if known; aids NAT diagnostics. + * - `Closed-Ledger` / `Previous-Ledger` — hex hashes of the most recent + * closed ledger header, if available. + * + * @param h Header fields container to populate (request or response). + * @param sharedValue TLS-channel-bound value from `makeSharedValue`. + * @param networkID Optional network identifier from configuration. + * @param publicIp Our public IP address (may be unspecified). + * @param remoteIp The peer's IP address as seen from our socket. + * @param app Application reference for clock, identity, and config. + */ void buildHandshake( boost::beast::http::fields& h, @@ -191,9 +318,6 @@ buildHandshake( { if (networkID) { - // The network identifier, if configured, can be used to specify - // what network we intend to connect to and detect if the remote - // end connects to the same network. h.insert("Network-ID", std::to_string(*networkID)); } @@ -225,6 +349,36 @@ buildHandshake( } } +/** Validate peer identity headers and return the peer's public key. + * + * Performs layered checks, cheapest first: + * 1. `Server-Domain` — must be a well-formed TOML domain if present. + * 2. `Network-ID` — must match our configured network identifier if both + * sides supply one; mismatch is an early reject before crypto work. + * 3. `Network-Time` — must be within ±20 s of our local XRPL clock. + * 4. `Public-Key` — must parse as a valid secp256k1 node public key. + * 5. `Session-Signature` — the peer's signature of `sharedValue` under + * its claimed public key. This check simultaneously proves: + * (a) the peer holds the private key matching the claimed identity, and + * (b) the TLS session is end-to-end with that node — a MITM terminating + * two separate TLS sessions would produce a different `sharedValue` + * and therefore an invalid signature. + * 6. Self-connection guard — rejects a connection to our own node key. + * 7. `Local-IP` cross-check — if the peer's observed public IP is known, + * it must match what the peer claims as its own local IP. + * 8. `Remote-IP` cross-check — if both our public IP and the peer's public + * address are known, the peer's reported remote IP must match ours. + * + * @param headers HTTP headers from the upgrade request or response. + * @param sharedValue TLS-channel-bound value from `makeSharedValue`. + * @param networkID Our configured network identifier, if any. + * @param publicIp Our public IP address (may be unspecified). + * @param remote The peer's IP address as seen from our socket. + * @param app Application reference for clock, identity, and config. + * @return The peer's authenticated public key. + * @throws std::runtime_error on any validation failure; callers should + * catch and tear down the connection. + */ PublicKey verifyHandshake( boost::beast::http::fields const& headers, @@ -301,12 +455,6 @@ verifyHandshake( throw std::runtime_error("Bad node public key"); }(); - // This check gets two birds with one stone: - // - // 1) it verifies that the node we are talking to has access to the - // private key corresponding to the public node identity it claims. - // 2) it verifies that our SSL session is end-to-end with that node - // and not through a proxy that establishes two separate sessions. { auto const iter = headers.find("Session-Signature"); @@ -347,8 +495,6 @@ verifyHandshake( if (beast::IP::isPublic(remote) && !beast::IP::isUnspecified(publicIp)) { - // We know our public IP and peer reports our connection came - // from some other IP. if (remoteIp != publicIp) { throw std::runtime_error( @@ -361,6 +507,22 @@ verifyHandshake( return publicKey; } +/** Build the outbound HTTP/1.1 upgrade request that initiates a peer connection. + * + * Uses the WebSocket-style protocol upgrade pattern so the handshake looks + * like a standard HTTP upgrade to any intermediate infrastructure: + * `Connection: Upgrade`, `Upgrade: `, + * `Connect-As: Peer`. The `X-Protocol-Ctl` header is populated with all + * locally enabled features via `makeFeaturesRequestHeader` — the responder + * will echo back only the intersection. + * + * @param crawlPublic If `true`, advertise `Crawl: public`. + * @param comprEnabled Advertise LZ4 compression support. + * @param ledgerReplayEnabled Advertise ledger-replay support. + * @param txReduceRelayEnabled Advertise TX reduce-relay support. + * @param vpReduceRelayEnabled Advertise VP reduce-relay support. + * @return An HTTP request with empty body ready for async write. + */ auto makeRequest( bool crawlPublic, @@ -385,6 +547,23 @@ makeRequest( return m; } +/** Build the 101 Switching Protocols response that accepts a peer connection. + * + * Sets the agreed `ProtocolVersion` in the `Upgrade` header (narrowing from + * the list of versions the initiator offered), echoes only the mutually + * supported features via `makeFeaturesResponseHeader`, and then populates + * all identity and authentication fields via `buildHandshake`. + * + * @param crawlPublic If `true`, advertise `Crawl: public`. + * @param req The incoming HTTP upgrade request. + * @param publicIp Our public IP address (may be unspecified). + * @param remoteIp The peer's IP address as seen from our socket. + * @param sharedValue TLS-channel-bound value from `makeSharedValue`. + * @param networkID Our configured network identifier, if any. + * @param protocol The single agreed protocol version to echo back. + * @param app Application reference for config, clock, and identity. + * @return A complete HTTP response ready for async write. + */ http_response_type makeResponse( bool crawlPublic, diff --git a/src/xrpld/overlay/detail/Handshake.h b/src/xrpld/overlay/detail/Handshake.h index 77a346477f..74a2e88f76 100644 --- a/src/xrpld/overlay/detail/Handshake.h +++ b/src/xrpld/overlay/detail/Handshake.h @@ -1,3 +1,16 @@ +/** @file + * Interface contract for the XRPL peer-to-peer overlay handshake. + * + * A new TCP connection is not promoted to the live XRPL protocol stream + * immediately; it goes through a two-phase sequence: first TLS setup, then + * an HTTP/1.1 Upgrade exchange that carries cryptographic identity proof and + * capability advertisement. Every function declared here belongs to one of + * those phases; `Handshake.cpp` is the sole implementation site. + * + * The file also pins the canonical network type aliases used across the + * entire overlay subsystem, and defines the `X-Protocol-Ctl` feature names + * and delimiters for optional capability negotiation. + */ #pragma once #include @@ -18,25 +31,65 @@ namespace xrpl { +/** Underlying TCP stream used for all peer connections. */ using socket_type = boost::beast::tcp_stream; + +/** TLS stream wrapping the TCP socket; used for all encrypted peer I/O. */ using stream_type = boost::beast::ssl_stream; + +/** Outbound HTTP/1.1 Upgrade request (empty body); built by `makeRequest`. */ using request_type = boost::beast::http::request; + +/** Inbound HTTP/1.1 Upgrade request (dynamic body); received from the peer. */ using http_request_type = boost::beast::http::request; + +/** HTTP/1.1 101 Switching Protocols response (dynamic body); built by `makeResponse`. */ using http_response_type = boost::beast::http::response; -/** Computes a shared value based on the SSL connection state. - - When there is no man in the middle, both sides will compute the same - value. In the presence of an attacker, the computed values will be - different. - - @param ssl the SSL/TLS connection state. - @return A 256-bit value on success; an unseated optional otherwise. -*/ +/** Derive a 256-bit value cryptographically bound to this TLS session. + * + * XORs the SHA-512 hashes of both TLS Finished messages (`SSL_get_finished` + * and `SSL_get_peer_finished`), then reduces the 512-bit XOR to 256 bits + * via `sha512Half`. Both endpoints compute identical values only when they + * share the same direct TLS session; a man-in-the-middle who terminates two + * separate sessions will produce a different value on each side, causing + * `verifyHandshake` to reject the `Session-Signature`. + * + * A degenerate case — both Finished hashes XORing to zero — is treated as a + * hard failure to prevent a trivially forgeable shared secret. + * + * @param ssl The fully established TLS stream. + * @param journal Used to log errors when finished messages are unavailable + * or the zero-XOR degenerate case is detected. + * @return The 256-bit shared value, or `std::nullopt` on any failure. + * @note This technique is non-standard; TLS channel binding via Finished + * messages is fragile with session resumption. See OpenSSL issue #5509 + * and XRPLF/rippled #2413. + */ std::optional makeSharedValue(stream_type& ssl, beast::Journal journal); -/** Insert fields headers necessary for upgrading the link to the peer protocol. +/** Populate HTTP upgrade headers with node identity, authentication, and hints. + * + * Inserts the following fields into `h`: + * - `Public-Key` — base58-encoded secp256k1 node identity key. + * - `Session-Signature` — `sharedValue` signed by the node's private key, + * base64-encoded. Proves key ownership and binds identity to this TLS + * session; verified by the peer in `verifyHandshake`. + * - `Network-ID` — optional numeric tag for early cross-network detection. + * - `Network-Time` — sender's XRPL clock value; receiver enforces ±20 s. + * - `Instance-Cookie` — per-process unique ID for reconnection detection. + * - `Local-IP` / `Remote-IP` — public IPs for NAT diagnostics (omitted if + * unknown or private). + * - `Closed-Ledger` / `Previous-Ledger` — hex hashes of the most recently + * closed ledger, if available. + * + * @param h HTTP fields container to populate (request or response). + * @param sharedValue TLS-channel-bound value from `makeSharedValue`. + * @param networkID Optional network identifier from configuration. + * @param publicIp Our public IP address (may be unspecified). + * @param remoteIp The peer's IP address as observed on our socket. + * @param app Application reference for clock, identity, and config. */ void buildHandshake( @@ -47,17 +100,34 @@ buildHandshake( beast::IP::Address remoteIp, Application& app); -/** Validate header fields necessary for upgrading the link to the peer - protocol. - - This performs critical security checks that ensure that prevent - MITM attacks on our peer-to-peer links and that the remote peer - has the private keys that correspond to the public identity it - claims. - - @return The public key of the remote peer. - @throw A class derived from std::exception. -*/ +/** Validate identity and authentication headers from a peer upgrade exchange. + * + * Performs layered checks, cheapest first, so expensive cryptography is only + * reached if the network and clock pre-checks pass: + * 1. `Network-ID` — must match our configured identifier if both sides + * supply one; mismatch causes early rejection before crypto work. + * 2. `Network-Time` — must be within ±20 s of our local XRPL clock. + * 3. `Public-Key` — must parse as a valid secp256k1 node public key. + * 4. `Session-Signature` — the peer's signature of `sharedValue` under its + * claimed public key. Simultaneously proves key ownership and end-to-end + * TLS (a MITM would produce a different `sharedValue` and fail here). + * 5. Self-connection guard — rejects connections from our own node key. + * 6. `Local-IP` cross-check — peer's claimed public IP must match the + * address from which we see the connection arriving (NAT sanity check). + * 7. `Remote-IP` cross-check — peer's reported view of our IP must match + * our known public IP, if both are available. + * + * @param headers HTTP fields from the upgrade request or 101 response. + * @param sharedValue TLS-channel-bound value from `makeSharedValue`. + * @param networkID Our configured network identifier, if any. + * @param publicIp Our public IP address (may be unspecified). + * @param remote The peer's IP address as observed on our socket. + * @param app Application reference for clock, identity, and config. + * @return The peer's authenticated public key; callers use this to identify + * the connection going forward. + * @throws std::runtime_error on any validation failure. Callers in + * `ConnectAttempt` and `PeerImp` catch this and tear down the connection. + */ PublicKey verifyHandshake( boost::beast::http::fields const& headers, @@ -67,16 +137,23 @@ verifyHandshake( beast::IP::Address remote, Application& app); -/** Make outbound http request - - @param crawlPublic if true then server's IP/Port are included in crawl - @param comprEnabled if true then compression feature is enabled - @param ledgerReplayEnabled if true then ledger-replay feature is enabled - @param txReduceRelayEnabled if true then transaction reduce-relay feature is - enabled - @param vpReduceRelayEnabled if true then validation/proposal reduce-relay - feature is enabled - @return http request with empty body +/** Build the outbound HTTP/1.1 upgrade request that initiates a peer connection. + * + * Sets `Connection: Upgrade`, `Upgrade: `, and + * `Connect-As: Peer`. Populates `X-Protocol-Ctl` with all locally enabled + * features via `makeFeaturesRequestHeader`; the responder echoes back only + * the intersection. Identity headers (`Public-Key`, `Session-Signature`, etc.) + * are NOT included here — the caller adds them via a separate `buildHandshake` + * call on the returned fields. + * + * @param crawlPublic If `true`, advertise `Crawl: public` so the + * node's IP is included in peer-crawler responses. + * @param comprEnabled Advertise LZ4 message compression support. + * @param ledgerReplayEnabled Advertise ledger-replay subsystem support. + * @param txReduceRelayEnabled Advertise TX reduce-relay support. + * @param vpReduceRelayEnabled Advertise validation/proposal reduce-relay + * support. + * @return An HTTP request with empty body, ready for async write. */ request_type makeRequest( @@ -86,17 +163,22 @@ makeRequest( bool txReduceRelayEnabled, bool vpReduceRelayEnabled); -/** Make http response - - @param crawlPublic if true then server's IP/Port are included in crawl - @param req incoming http request - @param publicIp server's public IP - @param remoteIp peer's IP - @param sharedValue shared value based on the SSL connection state - @param networkID specifies what network we intend to connect to - @param version supported protocol version - @param app Application's reference to access some common properties - @return http response +/** Build the HTTP/1.1 101 Switching Protocols response that accepts a peer. + * + * Sets the agreed protocol version in the `Upgrade` header (narrowing from + * the list the initiator offered), echoes back only the mutually supported + * features via `makeFeaturesResponseHeader`, and then calls `buildHandshake` + * to append all identity and authentication headers in one shot. + * + * @param crawlPublic If `true`, advertise `Crawl: public`. + * @param req The incoming HTTP upgrade request from the peer. + * @param publicIp Our public IP address (may be unspecified). + * @param remoteIp The peer's IP address as observed on our socket. + * @param sharedValue TLS-channel-bound value from `makeSharedValue`. + * @param networkID Our configured network identifier, if any. + * @param version The single agreed protocol version to echo back. + * @param app Application reference for config, clock, and identity. + * @return A complete HTTP response with identity headers, ready for async write. */ http_response_type makeResponse( @@ -109,38 +191,56 @@ makeResponse( ProtocolVersion version, Application& app); -// Protocol features negotiated via HTTP handshake. -// The format is: -// X-Protocol-Ctl: feature1=value1[,value2]*[\s*;\s*feature2=value1[,value2]*]* -// value: \S+ +// --- X-Protocol-Ctl feature negotiation --- +// Wire format: +// X-Protocol-Ctl: feature1=value1[,value2]*[; feature2=value1[,value2]*]* +// Requesters advertise all locally enabled features; responders echo back +// only those they also support (AND-gate, single round-trip negotiation). -// compression feature +/** Wire name for the LZ4 message compression feature (`compr=lz4`). */ static constexpr char kFEATURE_COMPR[] = "compr"; -// validation/proposal reduce-relay base squelch feature + +/** Wire name for the validation/proposal reduce-relay (squelch) feature (`vprr=1`). */ static constexpr char kFEATURE_VPRR[] = "vprr"; -// transaction reduce-relay feature + +/** Wire name for the transaction reduce-relay feature (`txrr=1`). */ static constexpr char kFEATURE_TXRR[] = "txrr"; -// ledger replay + +/** Wire name for the ledger-replay subsystem feature (`ledgerreplay=1`). */ static constexpr char kFEATURE_LEDGER_REPLAY[] = "ledgerreplay"; + +/** Delimiter separating distinct features in the `X-Protocol-Ctl` header. */ static constexpr char kDELIM_FEATURE[] = ";"; + +/** Delimiter separating multiple values for a single feature. */ static constexpr char kDELIM_VALUE[] = ","; -/** Get feature's header value - @param headers request/response header - @param feature name - @return seated optional with feature's value if the feature - is found in the header, unseated optional otherwise +/** Extract the raw value string for a named feature from `X-Protocol-Ctl`. + * + * Uses a regex search (`feature=`, stopping at `;` or whitespace) + * and returns the first match. The returned string may itself be a + * comma-separated list (e.g. `"lz4,zstd"`); use `isFeatureValue` to test + * membership within that list. + * + * @param headers HTTP fields to search (request or response). + * @param feature Feature name to look up (e.g. `"compr"`, `"txrr"`). + * @return The feature's value string if found; `std::nullopt` if the + * `X-Protocol-Ctl` header is absent or the feature is not present. */ std::optional getFeatureValue(boost::beast::http::fields const& headers, std::string const& feature); -/** Check if a feature's value is equal to the specified value - @param headers request/response header - @param feature to check - @param value of the feature to check, must be a single value; i.e. not - value1,value2... - @return true if the feature's value matches the specified value, false if - doesn't match or the feature is not found in the header +/** Check whether a feature's value list contains a specific token. + * + * Uses RFC 2616 token-list semantics via `beast::rfc2616::tokenInList`, so + * a feature advertised as `compr=lz4,zstd` will match both `"lz4"` and + * `"zstd"` as individual tokens. + * + * @param headers HTTP fields to inspect (request or response). + * @param feature Feature name to look up. + * @param value Single token to match; must not itself be a comma list. + * @return `true` if the feature is present and its value list contains + * `value`; `false` if absent or no match. */ bool isFeatureValue( @@ -148,23 +248,33 @@ isFeatureValue( std::string const& feature, std::string const& value); -/** Check if a feature is enabled - @param headers request/response header - @param feature to check - @return true if enabled +/** Return `true` if the named feature is present with value `"1"`. + * + * Convenience wrapper over `isFeatureValue(..., "1")` — the conventional + * boolean-enable sentinel used for non-compression features in + * `X-Protocol-Ctl`. + * + * @param headers HTTP fields to inspect (request or response). + * @param feature Feature name to look up. + * @return `true` if the feature is present and its value is `"1"`. */ bool featureEnabled(boost::beast::http::fields const& headers, std::string const& feature); -/** Check if a feature should be enabled for a peer. The feature - is enabled if its configured value is true and the http header - has the specified feature value. - @tparam headers request (inbound) or response (outbound) header - @param request http headers - @param feature to check - @param config feature's configuration value - @param value feature's value to check in the headers - @return true if the feature is enabled +/** Decide whether to activate a feature for a peer connection. + * + * A feature is active only when the local configuration enables it AND the + * peer's HTTP headers carry the expected feature value. This is the final + * predicate called by `PeerImp` after the handshake completes. + * + * @tparam Headers HTTP fields type (request for inbound, response for outbound). + * @param request The peer's HTTP headers to inspect. + * @param feature Feature name (e.g. `kFEATURE_COMPR`). + * @param value Required value token (e.g. `"lz4"`). + * @param config Local configuration flag; if `false` the feature is + * disabled regardless of what the peer advertises. + * @return `true` only when both `config` is `true` and `isFeatureValue` + * confirms the peer's header contains `value`. */ template bool @@ -177,7 +287,18 @@ peerFeatureEnabled( return config && isFeatureValue(request, feature, value); } -/** Wrapper for enable(1)/disable type(0) of feature */ +/** Decide whether to activate a boolean (enable/disable) feature for a peer. + * + * Convenience overload for features that use `"1"` as their enable sentinel. + * Equivalent to `peerFeatureEnabled(request, feature, "1", config)`. + * + * @tparam Headers HTTP fields type (request for inbound, response for outbound). + * @param request The peer's HTTP headers to inspect. + * @param feature Feature name (e.g. `kFEATURE_TXRR`). + * @param config Local configuration flag. + * @return `true` only when both `config` is `true` and the peer's header + * has the feature with value `"1"`. + */ template bool peerFeatureEnabled(Headers const& request, std::string const& feature, bool config) @@ -185,14 +306,19 @@ peerFeatureEnabled(Headers const& request, std::string const& feature, bool conf return config && peerFeatureEnabled(request, feature, "1", config); } -/** Make request header X-Protocol-Ctl value with supported features - @param comprEnabled if true then compression feature is enabled - @param ledgerReplayEnabled if true then ledger-replay feature is enabled - @param txReduceRelayEnabled if true then transaction reduce-relay feature is - enabled - @param vpReduceRelayEnabled if true then validation/proposal reduce-relay - base squelch feature is enabled - @return X-Protocol-Ctl header value +/** Build the `X-Protocol-Ctl` value for an outbound connection request. + * + * The initiator unconditionally advertises every locally enabled feature. + * The responder will echo back only those it also supports (see + * `makeFeaturesResponseHeader`), achieving single-round-trip negotiation. + * Compression is advertised as `compr=lz4`; all other features use `=1`. + * + * @param comprEnabled Advertise LZ4 compression (`compr=lz4`). + * @param ledgerReplayEnabled Advertise ledger-replay (`ledgerreplay=1`). + * @param txReduceRelayEnabled Advertise TX reduce-relay (`txrr=1`). + * @param vpReduceRelayEnabled Advertise VP reduce-relay (`vprr=1`). + * @return Semicolon-delimited feature string, or an empty string if no + * features are enabled. */ std::string makeFeaturesRequestHeader( @@ -201,18 +327,20 @@ makeFeaturesRequestHeader( bool txReduceRelayEnabled, bool vpReduceRelayEnabled); -/** Make response header X-Protocol-Ctl value with supported features. - If the request has a feature that we support enabled - and the feature's configuration is enabled then enable this feature in - the response header. - @param header request's header - @param comprEnabled if true then compression feature is enabled - @param ledgerReplayEnabled if true then ledger-replay feature is enabled - @param txReduceRelayEnabled if true then transaction reduce-relay feature is - enabled - @param vpReduceRelayEnabled if true then validation/proposal reduce-relay - base squelch feature is enabled - @return X-Protocol-Ctl header value +/** Build the `X-Protocol-Ctl` value for a 101 Switching Protocols response. + * + * A feature is included in the response only when the local configuration + * enables it AND the peer's request header already declares it. This AND-gate + * ensures both sides converge on the same enabled feature set without an + * additional round-trip. + * + * @param headers The incoming HTTP upgrade request headers. + * @param comprEnabled Accept LZ4 compression if peer requested it. + * @param ledgerReplayEnabled Accept ledger-replay if peer requested it. + * @param txReduceRelayEnabled Accept TX reduce-relay if peer requested it. + * @param vpReduceRelayEnabled Accept VP reduce-relay if peer requested it. + * @return Semicolon-delimited feature string containing only mutually agreed + * features, or an empty string if none are agreed. */ std::string makeFeaturesResponseHeader( diff --git a/src/xrpld/overlay/detail/Message.cpp b/src/xrpld/overlay/detail/Message.cpp index 30f4c281ed..3eef9d7c1a 100644 --- a/src/xrpld/overlay/detail/Message.cpp +++ b/src/xrpld/overlay/detail/Message.cpp @@ -1,3 +1,9 @@ +/** @file + * Implementation of `Message` — the wire-format envelope for overlay peer + * messages. Serialization is eager (constructor), compression is lazy + * (`std::call_once` on first `getBuffer(Compressed::On)` call). See + * `Message.h` for the full public interface contract. + */ #include #include @@ -43,7 +49,17 @@ Message::Message( "xrpl::Message::Message : message size matches the buffer"); } -// static +/** Return the serialized byte length of a protobuf message. + * + * Dispatches to `ByteSizeLong()` (returns `size_t`, available from protobuf + * 3.11.0) or the older `ByteSize()` (returns `int`, risks signed overflow on + * payloads larger than 2 GiB) depending on the detected library version. + * The compile-time guard avoids undefined signed-overflow behavior on + * unusually large messages when building against an older protobuf. + * + * @param message Protobuf message to measure. + * @return Serialized size in bytes, excluding any wire header. + */ std::size_t Message::messageSize(::google::protobuf::Message const& message) { @@ -61,6 +77,25 @@ Message::totalSize(::google::protobuf::Message const& message) return messageSize(message) + compression::kHEADER_BYTES; } +/** Attempt LZ4 compression of `buffer_` and populate `bufferCompressed_`. + * + * Applies a two-level eligibility policy before invoking the codec: + * 1. Payload must exceed 70 bytes — smaller messages cannot recover the + * 4-byte extra header overhead that the compressed wire format adds. + * 2. The message type must be in the compression whitelist (bulk data: + * `mtTRANSACTION`, `mtLEDGER_DATA`, `mtVALIDATOR_LIST`, etc.). + * Latency-sensitive control messages (`mtPING`, `mtVALIDATION`, + * `mtPROPOSE_LEDGER`, `mtSTATUS_CHANGE`, `mtHAVE_SET`) are excluded. + * + * If the compressed result is not strictly smaller than + * `messageBytes - (kHEADER_BYTES_COMPRESSED - kHEADER_BYTES)` — + * i.e., it does not recover the 4-byte header overhead — then + * `bufferCompressed_` is cleared to zero length so that `getBuffer()` + * falls back to returning the uncompressed `buffer_`. + * + * @note Invoked at most once per instance via `std::call_once` in + * `getBuffer()`. Not intended to be called directly. + */ void Message::compress() { @@ -197,6 +232,18 @@ Message::getBufferSize() return buffer_.size(); } +/** Return the wire buffer, triggering compression on first eligible call. + * + * When `tryCompressed == Compressed::On`, `compress()` is invoked via + * `std::call_once` so the LZ4 pass runs exactly once regardless of + * concurrent callers. If compression was skipped or produced no net gain, + * `bufferCompressed_` is empty and `buffer_` (uncompressed) is returned. + * + * @param tryCompressed `Compressed::On` to request the compressed buffer; + * `Compressed::Off` to bypass compression entirely. + * @return `const` reference to an internal buffer valid for the lifetime + * of this `Message`. + */ std::vector const& Message::getBuffer(Compressed tryCompressed) { @@ -213,6 +260,15 @@ Message::getBuffer(Compressed tryCompressed) return buffer_; } +/** Decode the 16-bit message type from a wire-format header. + * + * Bytes 4 and 5 (0-indexed) hold the message type in big-endian order for + * both the 6-byte uncompressed and 10-byte compressed header formats, so + * this function works identically for either variant. + * + * @param in Pointer to the first byte of a complete wire header. + * @return Integer message type (e.g. `protocol::mtTRANSACTION`). + */ int Message::getType(std::uint8_t const* in) { diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp index bfed850e5c..4a4babe9b4 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.cpp +++ b/src/xrpld/overlay/detail/OverlayImpl.cpp @@ -1,3 +1,18 @@ +/** @file + * Concrete implementation of the XRPL peer-to-peer overlay network. + * + * Implements `OverlayImpl`, the operational heart of an XRPL node's P2P + * layer. The class manages the full lifecycle of peer connections — from + * initial TLS acceptance and HTTP upgrade through cryptographic handshake, + * protocol negotiation, active message relay, and eventual teardown. It + * also exposes three internal HTTP endpoints (`/crawl`, `/health`, `/vl/`) + * consumed by network topology tools, monitoring systems, and validator-list + * clients respectively. + * + * Also defines the `setupOverlay` config parser and the `makeOverlay` + * factory, keeping the concrete type out of translation units that only + * need the `Overlay` interface. + */ #include #include @@ -91,11 +106,21 @@ namespace xrpl { +/** Bitmask flags that control which sections appear in `/crawl` responses. + * + * Composed from the `[crawl]` config section. A value of `kDISABLED` + * (0) suppresses the endpoint entirely. + */ namespace CrawlOptions { +/** `/crawl` endpoint is disabled; no response is served. */ static constexpr auto kDISABLED = 0; +/** Include peer-connectivity topology in the `overlay` field. */ static constexpr auto kOVERLAY = (1 << 0); +/** Include local server status info in the `server` field. */ static constexpr auto kSERVER_INFO = (1 << 1); +/** Include server performance counters in the `counts` field. */ static constexpr auto kSERVER_COUNTS = (1 << 2); +/** Include UNL / validator-list info in the `unl` field. */ static constexpr auto kUNL = (1 << 3); } // namespace CrawlOptions @@ -116,15 +141,20 @@ OverlayImpl::Timer::Timer(OverlayImpl& overlay) : Child(overlay), timer(overlay_ { } +/** Cancel the timer and prevent any further rescheduling. + * + * Always called from the overlay strand, so it never races with `onTimer`. + * Sets `stopping` before cancelling so the in-flight handler (if any) + * sees the flag and exits without rescheduling. + */ void OverlayImpl::Timer::stop() { - // This method is only ever called from the same strand that calls - // Timer::on_timer, ensuring they never execute concurrently. stopping = true; timer.cancel(); } +/** Schedule the next one-second tick on the overlay strand. */ void OverlayImpl::Timer::asyncWait() { @@ -135,6 +165,20 @@ OverlayImpl::Timer::asyncWait() std::bind(&Timer::onTimer, shared_from_this(), std::placeholders::_1))); } +/** Execute per-second overlay maintenance tasks. + * + * On each tick: drives `PeerFinder::oncePerSecond`, pushes endpoint + * advertisements, opens new outbound connections, and (when TX + * reduce-relay is enabled) flushes the per-peer TX hash queues. + * Every `Tuning::kCHECK_IDLE_PEERS` ticks, purges stale squelch slots + * via `deleteIdlePeers`. Reschedules itself at the end of each + * successful tick. + * + * A deliberate `operation_aborted` cancel (from `stop()`) is silently + * ignored; any other ASIO error is logged at error level. + * + * @param ec ASIO error code from the expired timer wait. + */ void OverlayImpl::Timer::onTimer(error_code ec) { @@ -203,6 +247,29 @@ OverlayImpl::OverlayImpl( beast::PropertyStream::Source::add(peerFinder_.get()); } +/** Accept an inbound TLS connection and either handle it as a built-in HTTP + * request or upgrade it to a peer session. + * + * Non-peer requests (`/crawl`, `/health`, `/vl/`) are dispatched to + * `processRequest` and return immediately. For genuine peer upgrade + * requests the method performs sequential gating checks — resource limit, + * slot availability (IP-limit / self-connect), protocol-version negotiation, + * TLS channel-binding cookie, and cryptographic handshake — before creating + * a `PeerImp` and registering it in `peers_` and `list_`. + * + * `peer->run()` is called while holding `mutex_` so that a concurrent + * `stop()` cannot drain the list before the peer has started its I/O. + * On any failure the PeerFinder slot is released via `onClosed` to keep + * slot accounting accurate. + * + * @param streamPtr Ownership of the established TLS stream. + * @param request The parsed HTTP upgrade (or plain) request. + * @param remoteEndpoint TCP endpoint of the connecting peer. + * @return A `Handoff` that is either consumed (moved) or carries an HTTP + * response to be written back to the caller. + * @throws Nothing — all `verifyHandshake` exceptions are caught internally + * and converted to HTTP 400 responses. + */ Handoff OverlayImpl::onHandoff( std::unique_ptr&& streamPtr, @@ -243,14 +310,11 @@ OverlayImpl::onHandoff( if (slot == nullptr) { - // connection refused either IP limit exceeded or self-connect handoff.moved = false; JLOG(journal.debug()) << "Peer " << remoteEndpoint << " refused, " << to_string(result); return handoff; } - // Validate HTTP request - { auto const types = beast::rfc2616::splitCommas(request["Connect-As"]); if (std::ranges::find_if(types, [](std::string const& s) { @@ -299,8 +363,6 @@ OverlayImpl::onHandoff( consumer.setPublicKey(publicKey); { - // The node gets a reserved slot if it is in our cluster - // or if it has a reservation. bool const reserved = static_cast(app_.getCluster().member(publicKey)) || app_.getPeerReservations().contains(publicKey); auto const result = peerFinder_->activate(slot, publicKey, reserved); @@ -327,9 +389,6 @@ OverlayImpl::onHandoff( std::move(streamPtr), *this); { - // As we are not on the strand, run() must be called - // while holding the lock, otherwise new I/O can be - // queued after a call to stop(). std::scoped_lock const lock(mutex_); { auto const result = peers_.emplace(peer->slot(), peer); @@ -358,6 +417,12 @@ OverlayImpl::onHandoff( //------------------------------------------------------------------------------ +/** Return true if the request is an HTTP upgrade listing at least one + * recognized XRPL protocol version. + * + * @param request The incoming HTTP request to inspect. + * @return `true` when the request should be treated as a peer connection. + */ bool OverlayImpl::isPeerUpgrade(http_request_type const& request) { @@ -367,6 +432,11 @@ OverlayImpl::isPeerUpgrade(http_request_type const& request) return !versions.empty(); } +/** Format a zero-padded three-digit peer ID prefix for log sinks. + * + * @param id Short numeric peer identifier. + * @return String of the form `"[NNN] "` for use with `beast::WrappedSink`. + */ std::string OverlayImpl::makePrefix(std::uint32_t id) { @@ -375,6 +445,17 @@ OverlayImpl::makePrefix(std::uint32_t id) return ss.str(); } +/** Build an HTTP 503 response carrying a JSON `peer-ips` redirect list. + * + * Sent when a new inbound connection is refused due to slot limits. + * The body contains alternative peer addresses obtained from + * `PeerFinder::redirect()` so the rejected peer can try elsewhere. + * + * @param slot The refused PeerFinder slot (used for redirect list). + * @param request The original HTTP request (version is echoed back). + * @param remoteAddress Source IP echoed in the `Remote-Address` header. + * @return A `Writer` wrapping the serialised HTTP response. + */ std::shared_ptr OverlayImpl::makeRedirectResponse( std::shared_ptr const& slot, @@ -402,6 +483,15 @@ OverlayImpl::makeRedirectResponse( return std::make_shared(msg); } +/** Build an HTTP 400 response for a failed handshake or protocol error. + * + * @param slot The refused slot (unused in body, kept for symmetry + * with `makeRedirectResponse`). + * @param request The original HTTP request (version is echoed back). + * @param remoteAddress Source IP echoed in the `Remote-Address` header. + * @param text Human-readable reason appended to the status line. + * @return A `Writer` wrapping the serialised HTTP 400 response. + */ std::shared_ptr OverlayImpl::makeErrorResponse( std::shared_ptr const& slot, @@ -422,6 +512,14 @@ OverlayImpl::makeErrorResponse( //------------------------------------------------------------------------------ +/** Initiate an outbound connection to a remote peer endpoint. + * + * Checks the resource manager and PeerFinder slot availability before + * creating a `ConnectAttempt`. Silently returns if the resource limit + * is exceeded or no outbound slot is available. + * + * @param remoteEndpoint The target address and port to connect to. + */ void OverlayImpl::connect(beast::IP::Endpoint const& remoteEndpoint) { @@ -460,7 +558,15 @@ OverlayImpl::connect(beast::IP::Endpoint const& remoteEndpoint) //------------------------------------------------------------------------------ -// Adds a peer that is already handshaked and active +/** Register a fully handshaked outbound peer in both peer registries. + * + * Called by `ConnectAttempt` after a successful outbound handshake. + * Populates both `peers_` (slot → peer) and `ids_` (id → peer) atomically + * under `mutex_`, then calls `peer->run()` while still holding the lock so + * that a concurrent `stop()` cannot drain the list before I/O begins. + * + * @param peer The newly activated outbound peer. + */ void OverlayImpl::addActive(std::shared_ptr const& peer) { @@ -486,12 +592,13 @@ OverlayImpl::addActive(std::shared_ptr const& peer) JLOG(journal.debug()) << "activated"; - // As we are not on the strand, run() must be called - // while holding the lock, otherwise new I/O can be - // queued after a call to stop(). peer->run(); } +/** Remove a peer from the slot-keyed registry when its PeerFinder slot closes. + * + * @param slot The PeerFinder slot whose associated peer entry should be erased. + */ void OverlayImpl::remove(std::shared_ptr const& slot) { @@ -501,6 +608,15 @@ OverlayImpl::remove(std::shared_ptr const& slot) peers_.erase(iter); } +/** Start the overlay: configure PeerFinder, seed the boot cache, and arm + * the per-second timer. + * + * Bootstrap IPs are sourced in priority order: `[ips]` → `[ips_fixed]` → + * four hardcoded well-known nodes (Ripple Labs, ISRDC, XRPL Kuwait, XRPL + * Commons). All resolution is asynchronous; `resolver_.resolve()` callbacks + * push results into PeerFinder's fallback list. Fixed peers (`[ips_fixed]`) + * are registered separately as always-reconnect entries. + */ void OverlayImpl::start() { @@ -513,25 +629,14 @@ OverlayImpl::start() peerFinder_->setConfig(config); peerFinder_->start(); - // Populate our boot cache: if there are no entries in [ips] then we use - // the entries in [ips_fixed]. auto bootstrapIps = app_.config().IPS.empty() ? app_.config().IPS_FIXED : app_.config().IPS; - // If nothing is specified, default to several well-known high-capacity - // servers to serve as bootstrap: if (bootstrapIps.empty()) { - // Pool of servers operated by Ripple Labs Inc. - https://ripple.com - bootstrapIps.emplace_back("r.ripple.com 51235"); - - // Pool of servers operated by ISRDC - https://isrdc.in - bootstrapIps.emplace_back("sahyadri.isrdc.in 51235"); - - // Pool of servers operated by @Xrpkuwait - https://xrpkuwait.com - bootstrapIps.emplace_back("hubs.xrpkuwait.com 51235"); - - // Pool of servers operated by XRPL Commons - https://xrpl-commons.org - bootstrapIps.emplace_back("hub.xrpl-commons.org 51235"); + bootstrapIps.emplace_back("r.ripple.com 51235"); // Ripple Labs Inc. + bootstrapIps.emplace_back("sahyadri.isrdc.in 51235"); // ISRDC + bootstrapIps.emplace_back("hubs.xrpkuwait.com 51235"); // @Xrpkuwait + bootstrapIps.emplace_back("hub.xrpl-commons.org 51235"); // XRPL Commons } resolver_.resolve( @@ -556,7 +661,6 @@ OverlayImpl::start() peerFinder_->addFallbackStrings(base + name, ips); }); - // Add the ips_fixed from the xrpld.cfg file if (!app_.config().standalone() && !app_.config().IPS_FIXED.empty()) { resolver_.resolve( @@ -588,6 +692,12 @@ OverlayImpl::start() timer->asyncWait(); } +/** Shut down the overlay and block until all children have stopped. + * + * Dispatches `stopChildren` to the strand, then waits on `cond_` until + * `list_` drains to empty — each child's destructor signals `cond_` via + * `remove(Child&)`. Stops `peerFinder_` after the drain. + */ void OverlayImpl::stop() { @@ -622,18 +732,21 @@ OverlayImpl::onWrite(beast::PropertyStream::Map& stream) } //------------------------------------------------------------------------------ -/** A peer has connected successfully - This is called after the peer handshake has been completed and during - peer activation. At this point, the peer address and the public key - are known. -*/ +/** Register an inbound peer in the ID-keyed relay registry after handshake. + * + * Called once the protocol handshake is complete and the peer's public key + * is known. Adds the peer to `ids_` so it can receive broadcast and relay + * messages. (For inbound peers, `peers_` was populated earlier in + * `onHandoff`; for outbound peers `addActive` populates both maps together.) + * + * @param peer The newly activated inbound peer. + */ void OverlayImpl::activate(std::shared_ptr const& peer) { beast::WrappedSink sink{journal_.sink(), peer->prefix()}; beast::Journal const journal{sink}; - // Now track this peer { std::scoped_lock const lock(mutex_); auto const result(ids_.emplace( @@ -643,11 +756,13 @@ OverlayImpl::activate(std::shared_ptr const& peer) } JLOG(journal.debug()) << "activated"; - - // We just accepted this peer so we have non-zero active peers XRPL_ASSERT(size(), "xrpl::OverlayImpl::activate : nonzero peers"); } +/** Remove a peer from the ID-keyed relay registry on deactivation. + * + * @param id Short peer identifier to erase from `ids_`. + */ void OverlayImpl::onPeerDeactivate(Peer::id_t id) { @@ -655,6 +770,17 @@ OverlayImpl::onPeerDeactivate(Peer::id_t id) ids_.erase(id); } +/** Process a received `TMManifests` message and relay newly accepted entries. + * + * Each manifest is applied via `ValidatorManifests::applyManifest`. Those + * with `ManifestDisposition::Accepted` are republished to the application + * layer (`pubManifest`) and, if the master key is listed in the validator + * set, persisted to the wallet database. All accepted entries are + * forwarded to every active peer as a new `TMManifests` message. + * + * @param m The incoming manifest batch. + * @param from The peer that sent the message (used for journal context). + */ void OverlayImpl::onManifests( std::shared_ptr const& m, @@ -723,10 +849,13 @@ OverlayImpl::reportOutboundTraffic(TrafficCount::Category cat, int size) { traffic_.addCount(cat, false, size); } -/** The number of active peers on the network - Active peers are only those peers that have completed the handshake - and are running the XRPL protocol. -*/ +/** Return the number of fully-activated peers running the XRPL protocol. + * + * Counts only peers that have completed the handshake (present in `ids_`). + * Peers still in the TLS/HTTP upgrade phase are not counted. + * + * @return Current active peer count. + */ std::size_t OverlayImpl::size() const { @@ -734,12 +863,24 @@ OverlayImpl::size() const return ids_.size(); } +/** Return the configured maximum number of active peers. + * + * @return The `maxPeers` value from the current PeerFinder configuration. + */ int OverlayImpl::limit() { return peerFinder_->config().maxPeers; } +/** Build the `overlay.active` JSON array for the `/crawl` endpoint. + * + * Each entry contains public key (base64), connection direction, uptime, + * and optionally IP/port when the peer's `crawl()` flag permits disclosure. + * Ledger range is included when the peer has reported non-zero bounds. + * + * @return JSON object with an `active` array of peer descriptors. + */ json::Value OverlayImpl::getOverlayInfo() const { @@ -784,6 +925,15 @@ OverlayImpl::getOverlayInfo() const return jv; } +/** Build a filtered server-info JSON object for the `/crawl` endpoint. + * + * Calls `NetworkOPs::getServerInfo` with public (non-admin, non-human) + * settings and strips fields not intended for external consumption: + * `hostid`, escalation and queue load factors, quorum, and the raw fee + * fields from `validated_ledger`. + * + * @return Filtered JSON object describing local server status. + */ json::Value OverlayImpl::getServerInfo() { @@ -793,7 +943,6 @@ OverlayImpl::getServerInfo() json::Value serverInfo = app_.getOPs().getServerInfo(humanReadable, admin, counters); - // Filter out some information serverInfo.removeMember(jss::hostid); serverInfo.removeMember(jss::load_factor_fee_escalation); serverInfo.removeMember(jss::load_factor_fee_queue); @@ -811,12 +960,25 @@ OverlayImpl::getServerInfo() return serverInfo; } +/** Return server performance counters for the `/crawl` endpoint. + * + * @return JSON object from `getCountsJson` with a minimum-threshold of 10. + */ json::Value OverlayImpl::getServerCounts() { return getCountsJson(app_, 10); } +/** Build a filtered UNL/validator-list JSON object for the `/crawl` endpoint. + * + * Returns validator and publisher-list metadata with sensitive fields + * stripped (`list` entries per publisher, `signing_keys`, + * `trusted_validator_keys`, `validation_quorum`). Appends + * `validator_sites` from `ValidatorSites`. + * + * @return Filtered JSON object describing the node's validator configuration. + */ json::Value OverlayImpl::getUnlInfo() { @@ -846,7 +1008,10 @@ OverlayImpl::getUnlInfo() return validators; } -// Returns information on verified peers. +/** Return a JSON array of per-peer status objects for all active peers. + * + * @return JSON array where each element is the result of `Peer::json()`. + */ json::Value OverlayImpl::json() { @@ -897,8 +1062,6 @@ OverlayImpl::processCrawl(http_request_type const& req, Handoff& handoff) bool OverlayImpl::processValidatorList(http_request_type const& req, Handoff& handoff) { - // If the target is in the form "/vl/", - // return the most recent validator list for that key. constexpr std::string_view kPREFIX("/vl/"); if (!req.target().starts_with(kPREFIX) || !setup_.vlEnabled) @@ -936,14 +1099,10 @@ OverlayImpl::processValidatorList(http_request_type const& req, Handoff& handoff if (key.empty()) return fail(boost::beast::http::status::bad_request); - // find the list auto vl = app_.getValidators().getAvailable(key, version); if (!vl) - { - // 404 not found return fail(boost::beast::http::status::not_found); - } if (!*vl) { return fail(boost::beast::http::status::bad_request); @@ -958,6 +1117,29 @@ OverlayImpl::processValidatorList(http_request_type const& req, Handoff& handoff return true; } +/** Classify node health and respond with an HTTP status that encodes the result. + * + * Health is the maximum severity of any triggered condition: + * + * | Condition | Warning | Critical | + * |----------------------------------------|---------|----------| + * | Validated-ledger age 7–19 s | ✓ | | + * | Validated-ledger age ≥ 20 s or missing | | ✓ | + * | Amendment blocked | | ✓ | + * | 1–7 peers | ✓ | | + * | 0 peers | | ✓ | + * | Server in syncing/tracking/connected | ✓ | | + * | Server in any other non-operational | | ✓ | + * | Load factor 100–999 | ✓ | | + * | Load factor ≥ 1000 | | ✓ | + * + * HTTP status encodes the result directly (200 / 503 / 500) so load + * balancers can gate on status without parsing JSON. + * + * @param req The incoming HTTP request. + * @param handoff Populated with the response writer on match. + * @return `true` if the request was for `/health` and was handled. + */ bool OverlayImpl::processHealth(http_request_type const& req, Handoff& handoff) { @@ -1065,11 +1247,14 @@ OverlayImpl::processHealth(http_request_type const& req, Handoff& handoff) bool OverlayImpl::processRequest(http_request_type const& req, Handoff& handoff) { - // Take advantage of || short-circuiting return processCrawl(req, handoff) || processValidatorList(req, handoff) || processHealth(req, handoff); } +/** Return a snapshot of all fully-activated peers. + * + * @return Vector of shared peer pointers for all peers in `ids_`. + */ Overlay::PeerSequence OverlayImpl::getActivePeers() const { @@ -1102,7 +1287,6 @@ OverlayImpl::getActivePeers( if (p = w.lock(); p != nullptr) { bool const reduceRelayEnabled = p->txReduceRelayEnabled(); - // tx reduced relay feature disabled if (!reduceRelayEnabled) ++disabled; @@ -1120,12 +1304,25 @@ OverlayImpl::getActivePeers( return ret; } +/** Notify all active peers of the current validated ledger sequence index. + * + * Each peer compares `index` to its own tracked range to decide whether + * it should transition the `tracking_` state between `converged` and + * `diverged`. + * + * @param index The latest fully-validated ledger sequence number. + */ void OverlayImpl::checkTracking(std::uint32_t index) { forEach([index](std::shared_ptr const& sp) { sp->checkTracking(index); }); } +/** Look up an active peer by its short numeric ID. + * + * @param id The peer's short ID assigned at connection time. + * @return The peer if found and still alive, or `nullptr`. + */ std::shared_ptr OverlayImpl::findPeerByShortID(Peer::id_t const& id) const { @@ -1136,8 +1333,15 @@ OverlayImpl::findPeerByShortID(Peer::id_t const& id) const return {}; } -// A public key hash map was not used due to the peer connect/disconnect -// update overhead outweighing the performance of a small set linear search. +/** Look up an active peer by its node public key via linear scan. + * + * A dedicated hash map was not used because the connect/disconnect overhead + * of maintaining it outweighs the cost of a linear search over the (small) + * active-peer set. + * + * @param pubKey The node's Ed25519 or secp256k1 public key. + * @return The peer if found and still alive, or `nullptr`. + */ std::shared_ptr OverlayImpl::findPeerByPublicKey(PublicKey const& pubKey) { @@ -1155,6 +1359,10 @@ OverlayImpl::findPeerByPublicKey(PublicKey const& pubKey) return {}; } +/** Send a consensus proposal to every active peer without deduplication. + * + * @param m The proposal message to broadcast. + */ void OverlayImpl::broadcast(protocol::TMProposeSet& m) { @@ -1162,6 +1370,19 @@ OverlayImpl::broadcast(protocol::TMProposeSet& m) forEach([&](std::shared_ptr const& p) { p->send(sm); }); } +/** Relay a consensus proposal, skipping peers that already have it. + * + * Consults `HashRouter::shouldRelay` for deduplication. Returns the set + * of peer IDs that already relayed this message (the skip set) so callers + * can track which peers to exclude in future rounds. Returns an empty set + * if the message has already been relayed and should be suppressed. + * + * @param m The proposal message to relay. + * @param uid Unique message hash used for hash-router lookup. + * @param validator Public key of the originating validator. + * @return Skip-set of peer IDs that already received this message, or empty + * if relay was suppressed. + */ std::set OverlayImpl::relay(protocol::TMProposeSet& m, uint256 const& uid, PublicKey const& validator) { @@ -1177,6 +1398,10 @@ OverlayImpl::relay(protocol::TMProposeSet& m, uint256 const& uid, PublicKey cons return {}; } +/** Send a validation to every active peer without deduplication. + * + * @param m The validation message to broadcast. + */ void OverlayImpl::broadcast(protocol::TMValidation& m) { @@ -1184,6 +1409,14 @@ OverlayImpl::broadcast(protocol::TMValidation& m) forEach([sm](std::shared_ptr const& p) { p->send(sm); }); } +/** Relay a validation, skipping peers that already have it. + * + * @param m The validation message to relay. + * @param uid Unique message hash used for hash-router deduplication. + * @param validator Public key of the originating validator. + * @return Skip-set of peer IDs that already received this message, or empty + * if relay was suppressed. + */ std::set OverlayImpl::relay(protocol::TMValidation& m, uint256 const& uid, PublicKey const& validator) { @@ -1199,6 +1432,15 @@ OverlayImpl::relay(protocol::TMValidation& m, uint256 const& uid, PublicKey cons return {}; } +/** Return a lazily built, cached `TMManifests` protocol message. + * + * Rebuilds the message only when the `ValidatorManifests` sequence number + * has advanced since the last call. The message and its sequence number are + * guarded by `manifestLock_`. Hash-router suppression entries are added for + * each manifest so they are not re-relayed by the caller. + * + * @return The cached manifest message, or `nullptr` if there are no manifests. + */ std::shared_ptr OverlayImpl::getManifestsMessage() { @@ -1226,6 +1468,29 @@ OverlayImpl::getManifestsMessage() return manifestMessage_; } +/** Relay a transaction (or its hash) to peers not in the skip set. + * + * Pseudo-transactions are never relayed. When TX reduce-relay is disabled + * the full message is sent to all peers outside `toSkip`. When enabled and + * the peer count exceeds the reduce-relay threshold, a quota of peers + * is computed: + * @code + * enabledTarget = TX_REDUCE_RELAY_MIN_PEERS + * + (total - minRelay) * TX_RELAY_PERCENTAGE / 100 + * @endcode + * Peers with the feature disabled always receive the full message for + * backward compatibility. Peers above the quota receive only the hash via + * `addTxQueue()`. The peer list is shuffled before selection to prevent + * systematic bias. + * + * If `tx` is `nullopt`, the caller signals a hash-only announcement; + * the hash is queued on all reachable peers when reduce-relay is active, + * or the call is a no-op when it is not. + * + * @param hash SHA-256 transaction hash. + * @param tx The transaction message, or `nullopt` for hash-only relay. + * @param toSkip Peer IDs to exclude (already have the transaction). + */ void OverlayImpl::relay( uint256 const& hash, @@ -1280,9 +1545,6 @@ OverlayImpl::relay( return; } - // We have more peers than the minimum (disabled + minimum enabled), - // relay to all disabled and some randomly selected enabled that - // do not have the transaction. auto const enabledTarget = app_.config().TX_REDUCE_RELAY_MIN_PEERS + ((total - minRelay) * app_.config().TX_RELAY_PERCENTAGE / 100); @@ -1295,11 +1557,9 @@ OverlayImpl::relay( << enabledTarget << " skip " << toSkip.size() << " disabled " << disabled; - // count skipped peers with the enabled feature towards the quota std::uint16_t enabledAndRelayed = enabledInSkip; for (auto const& p : peers) { - // always relay to a peer with the disabled feature if (!p->txReduceRelayEnabled()) { p->send(sm); @@ -1318,6 +1578,13 @@ OverlayImpl::relay( //------------------------------------------------------------------------------ +/** Erase a child from the lifetime registry and signal `stop()` if empty. + * + * Called from `Child::~Child`. When the last child is removed, notifies + * `cond_` to wake the thread blocked in `stop()`. + * + * @param child The child object being destroyed. + */ void OverlayImpl::remove(Child& child) { @@ -1327,17 +1594,18 @@ OverlayImpl::remove(Child& child) cond_.notify_all(); } +/** Signal all registered children to stop and release the io_context work guard. + * + * Must run on the overlay strand. Children's `stop()` calls may re-enter + * `remove(Child&)` (and thus `list_.erase`) on the same thread, so all + * child pointers are snapshotted into a local vector before any `stop()` is + * invoked — iterating `list_` directly while modifying it is undefined. + * Resetting `work_` lets the `io_context` drain once the last async op + * completes. + */ void OverlayImpl::stopChildren() { - // Calling list_[].second->stop() may cause list_ to be modified - // (OverlayImpl::remove() may be called on this same thread). So - // iterating directly over list_ to call child->stop() could lead to - // undefined behavior. - // - // Therefore we copy all of the weak/shared ptrs out of list_ before we - // start calling stop() on them. That guarantees OverlayImpl::remove() - // won't be called until vector<> children leaves scope. std::vector> children; { std::scoped_lock const lock(mutex_); @@ -1359,6 +1627,7 @@ OverlayImpl::stopChildren() } } +/** Ask PeerFinder for new outbound connection targets and connect to them. */ void OverlayImpl::autoConnect() { @@ -1367,6 +1636,7 @@ OverlayImpl::autoConnect() connect(addr); } +/** Compute and dispatch peer endpoint advertisements via PeerFinder. */ void OverlayImpl::sendEndpoints() { @@ -1385,6 +1655,11 @@ OverlayImpl::sendEndpoints() } } +/** Flush per-peer TX hash queues to all peers that support reduce-relay. + * + * Called once per second by `onTimer` when `TX_REDUCE_RELAY_ENABLE` is + * active. Peers that did not negotiate the feature are skipped. + */ void OverlayImpl::sendTxQueue() const { @@ -1394,6 +1669,14 @@ OverlayImpl::sendTxQueue() const }); } +/** Build a `TMSquelch` protocol message. + * + * @param validator Public key of the validator whose messages should be + * squelched or unsquelched. + * @param squelch `true` to squelch, `false` to unsquelch. + * @param squelchDuration Duration in seconds (only set when squelching). + * @return Shared `Message` ready to be sent over the wire. + */ std::shared_ptr makeSquelchMessage(PublicKey const& validator, bool squelch, uint32_t squelchDuration) { @@ -1405,26 +1688,46 @@ makeSquelchMessage(PublicKey const& validator, bool squelch, uint32_t squelchDur return std::make_shared(m, protocol::mtSQUELCH); } +/** Send a `TMSquelch` unsquelch message to the specified peer. + * + * @param validator Public key of the validator to unsquelch. + * @param id Short ID of the peer to notify. + * @note Multiple unsquelch messages for different validators may be batched + * to the same peer; each is sent individually as they arrive. + */ void OverlayImpl::unsquelch(PublicKey const& validator, Peer::id_t id) const { if (auto peer = findPeerByShortID(id); peer) - { - // optimize - multiple message with different - // validator might be sent to the same peer peer->send(makeSquelchMessage(validator, false, 0)); - } } +/** Send a `TMSquelch` squelch message to the specified peer. + * + * @param validator Public key of the validator to squelch. + * @param id Short ID of the peer to notify. + * @param squelchDuration How long (seconds) the peer should suppress messages + * for this validator. + */ void OverlayImpl::squelch(PublicKey const& validator, Peer::id_t id, uint32_t squelchDuration) const { if (auto peer = findPeerByShortID(id); peer) - { peer->send(makeSquelchMessage(validator, true, squelchDuration)); - } } +/** Update squelch slots for a batch of peers and send `TMSquelch` as needed. + * + * Dispatches to the overlay strand if called from another thread — `Slots` + * is not thread-safe. Reference parameters (`key`, `validator`) are + * captured by value when posting to avoid dangling references. + * No-ops when `baseSquelchReady()` returns false (warmup period). + * + * @param key Unique message hash identifying the validator message. + * @param validator Validator whose per-peer message count is updated. + * @param peers Set of peer IDs that received the message. + * @param type Received protocol message type. + */ void OverlayImpl::updateSlotAndSquelch( uint256 const& key, @@ -1455,6 +1758,16 @@ OverlayImpl::updateSlotAndSquelch( } } +/** Single-peer overload of `updateSlotAndSquelch` to avoid set allocation. + * + * Dispatches to the overlay strand if called from another thread. + * Reference parameters are captured by value in the posted lambda. + * + * @param key Unique message hash identifying the validator message. + * @param validator Validator whose per-peer message count is updated. + * @param peer Peer ID that received the message. + * @param type Received protocol message type. + */ void OverlayImpl::updateSlotAndSquelch( uint256 const& key, @@ -1467,14 +1780,12 @@ OverlayImpl::updateSlotAndSquelch( if (!strand_.running_in_this_thread()) { - { - post( - strand_, - // Must capture copies of reference parameters (i.e. key, validator) - [this, key = key, validator = validator, peer, type]() { - updateSlotAndSquelch(key, validator, peer, type); - }); - } + post( + strand_, + // Must capture copies of reference parameters (i.e. key, validator) + [this, key = key, validator = validator, peer, type]() { + updateSlotAndSquelch(key, validator, peer, type); + }); return; } @@ -1483,6 +1794,14 @@ OverlayImpl::updateSlotAndSquelch( }); } +/** Remove a peer's squelch slot, unsquelching peers if it was a selected source. + * + * Dispatches to the overlay strand. If the deleted peer was one of the + * selected sources for a validator, the squelched peers are unsquelched so + * they may resume forwarding that validator's messages. + * + * @param id Short ID of the peer being removed. + */ void OverlayImpl::deletePeer(Peer::id_t id) { @@ -1495,6 +1814,12 @@ OverlayImpl::deletePeer(Peer::id_t id) slots_.deletePeer(id, true); } +/** Purge squelch slots for peers that have gone idle. + * + * Dispatches to the overlay strand if not already on it, ensuring + * thread-safe access to `slots_`. Called every + * `Tuning::kCHECK_IDLE_PEERS` timer ticks. + */ void OverlayImpl::deleteIdlePeers() { @@ -1509,6 +1834,19 @@ OverlayImpl::deleteIdlePeers() //------------------------------------------------------------------------------ +/** Parse config sections into an `Overlay::Setup` struct. + * + * Reads `[overlay]`, `[crawl]`, `[vl]`, and `[network_id]` sections. + * Creates an SSL context and validates all fields: + * - `ip_limit`: must be non-negative. + * - `public_ip`: must be a valid, non-private IP address. + * - `[network_id]`: may be a decimal number or one of the symbolic names + * `main` (0), `testnet` (1), `devnet` (2). + * + * @param config The application config to parse. + * @return Populated `Overlay::Setup` ready to pass to `makeOverlay`. + * @throws std::runtime_error on any invalid configuration value. + */ Overlay::Setup setupOverlay(BasicConfig const& config) { @@ -1612,6 +1950,20 @@ setupOverlay(BasicConfig const& config) return setup; } +/** Factory function that constructs an `OverlayImpl` and returns it as + * `unique_ptr`, keeping the concrete type out of translation + * units that only need the `Overlay` interface. + * + * @param app The application instance. + * @param setup Pre-parsed overlay configuration from `setupOverlay`. + * @param serverHandler HTTP server handler for inbound upgrade requests. + * @param resourceManager Resource manager for connection rate limiting. + * @param resolver Async DNS resolver used during bootstrap. + * @param ioContext ASIO io_context that owns the overlay strand. + * @param config Full application config (passed to PeerFinder). + * @param collector Metrics collector for traffic gauges. + * @return Owning pointer to the newly constructed overlay. + */ std::unique_ptr makeOverlay( Application& app, diff --git a/src/xrpld/peerfinder/PeerfinderManager.h b/src/xrpld/peerfinder/PeerfinderManager.h index 5b235a4db1..bbd5a69896 100644 --- a/src/xrpld/peerfinder/PeerfinderManager.h +++ b/src/xrpld/peerfinder/PeerfinderManager.h @@ -1,3 +1,12 @@ +/** @file + * Public interface for the PeerFinder subsystem. + * + * This is the sole external contract for peer discovery and slot management. + * All other files under `src/xrpld/peerfinder/` are internal implementation + * details. Callers interact exclusively through `Config`, `Endpoint`, and + * `Manager`. + */ + #pragma once #include @@ -13,75 +22,145 @@ namespace xrpl::PeerFinder { +/** Abstract clock injected into PeerFinder for testability. + * + * All time-dependent logic — Livecache TTLs, Bootcache cooldowns, and + * connection-retry backoff — uses this alias rather than + * `std::chrono::steady_clock` directly, so unit tests can substitute a + * controlled fake clock without touching production code paths. + */ using clock_type = beast::AbstractClock; -/** Represents a set of addresses. */ +/** A collection of peer IP addresses. */ using IPAddresses = std::vector; //------------------------------------------------------------------------------ -/** PeerFinder configuration settings. */ +/** Operational parameters for the PeerFinder subsystem. + * + * Constructed with defaults and then customised either by direct field + * assignment or by the `makeConfig()` factory. After all fields are set, + * `applyTuning()` must be called to enforce business-rule constraints (in + * particular the per-IP admission limit). + * + * @note Fixed peers registered via `Manager::addFixedPeer` are **not** + * counted toward `maxPeers`; they bypass slot limits entirely. + */ struct Config { - /** The largest number of public peer slots to allow. - This includes both inbound and outbound, but does not include - fixed peers. - */ + /** Maximum number of public peer slots (inbound + outbound combined). + * + * Does not include fixed peers, which bypass this cap. + * Defaults to `Tuning::kDEFAULT_MAX_PEERS` (21). + */ std::size_t maxPeers{Tuning::kDEFAULT_MAX_PEERS}; - /** The number of automatic outbound connections to maintain. - Outbound connections are only maintained if autoConnect - is `true`. - */ + /** Target number of automatic outbound connections to maintain. + * + * Outbound connections are only initiated when `autoConnect` is `true`. + * Initialised to `calcOutPeers()` by the default constructor; callers + * should re-invoke `calcOutPeers()` after changing `maxPeers`. + */ std::size_t outPeers; - /** The number of automatic inbound connections to maintain. - Inbound connections are only maintained if wantIncoming - is `true`. - */ + /** Maximum number of inbound connections to accept. + * + * Inbound connections are only accepted when `wantIncoming` is `true`. + * Zero until explicitly set or derived by `makeConfig()`. + */ std::size_t inPeers{0}; - /** `true` if we want our IP address kept private. */ + /** Whether to ask peers not to gossip this node's IP address. */ bool peerPrivate = true; - /** `true` if we want to accept incoming connections. */ + /** Whether this node accepts incoming peer connections. */ bool wantIncoming{true}; - /** `true` if we want to establish connections automatically */ + /** Whether to establish outbound connections autonomously. */ bool autoConnect{true}; - /** The listening port number. */ + /** TCP port on which this node listens for peer connections. */ std::uint16_t listeningPort{0}; - /** The set of features we advertise. */ + /** Protocol-feature flags advertised during the peer handshake. */ std::string features; - /** Limit how many incoming connections we allow per IP */ + /** Maximum number of simultaneous inbound connections from a single IP. + * + * Zero means "unset"; `applyTuning()` will compute a value. + * After tuning: `max(1, min(ipLimit, inPeers / 2))` — no single IP + * may hold more than half of all inbound slots. + */ int ipLimit{0}; //-------------------------------------------------------------------------- - /** Create a configuration with default values. */ + /** Construct a `Config` with default values. + * + * `outPeers` is initialised to `calcOutPeers()` so the outbound + * percentage policy is applied immediately. + */ Config(); - /** Returns a suitable value for outPeers according to the rules. */ + /** Compute the desired outbound peer count from `maxPeers`. + * + * Applies the formula `max(maxPeers * 15% + 0.5, 10)`, where 15% is + * `Tuning::kOUT_PERCENT` and 10 is `Tuning::kMIN_OUT_COUNT`. Keeping + * outbound connections to roughly 15% of total capacity reserves the + * majority of slots for inbound connections, which keeps the overlay + * open to new participants. + * + * @return The recommended value for `outPeers`. + */ [[nodiscard]] std::size_t calcOutPeers() const; - /** Adjusts the values so they follow the business rules. */ + /** Enforce business-rule constraints on all fields. + * + * If `ipLimit` is zero (unset), computes an automatic value: 2 plus + * up to 3 extra slots scaled by how far `inPeers` exceeds the + * default 21-peer maximum. The computed value is then clamped to + * `max(1, min(ipLimit, inPeers / 2))` so no single IP can monopolise + * inbound capacity. + * + * Must be called after all fields are assigned; `makeConfig()` calls + * it automatically. + */ void applyTuning(); - /** Write the configuration into a property stream */ + /** Serialize the configuration into a diagnostic property map. */ void onWrite(beast::PropertyStream::Map& map) const; - /** Make PeerFinder::Config from configuration parameters - * @param config server's configuration - * @param port server's listening port - * @param validationPublicKey true if validation public key is not empty - * @param ipLimit limit of incoming connections per IP - * @return PeerFinder::Config + /** Construct a `PeerFinder::Config` from the server-level configuration. + * + * Two configuration modes are supported: + * - **Legacy mode** (`PEERS_MAX` set, `PEERS_IN_MAX`/`PEERS_OUT_MAX` + * both zero): the outbound count is derived at `Tuning::kOUT_PERCENT` + * (15%) of `PEERS_MAX`; inbound is the remainder. + * - **Explicit mode** (`PEERS_IN_MAX` or `PEERS_OUT_MAX` non-zero): the + * two halves are taken verbatim and `maxPeers` is left at zero. + * + * Two policy decisions are embedded unconditionally: + * - If `validationPublicKey` is `true`, `peerPrivate` is forced to + * `true` regardless of the operator's `PEER_PRIVATE` setting, to + * reduce the validator's attack surface. This happens **after** + * `wantIncoming` is computed so the node still advertises inbound + * willingness internally ("soft" privacy). + * - `autoConnect` is suppressed for standalone mode and for private + * peers; autonomous connections would defeat the privacy goal. + * + * Calls `applyTuning()` before returning. + * + * @param config The server's global configuration object. + * @param port The TCP port on which the server is listening for peers; + * zero means not listening. + * @param validationPublicKey `true` if a validator key is configured, + * which forces `peerPrivate = true`. + * @param ipLimit Per-IP inbound connection limit passed through to + * `Config::ipLimit` before `applyTuning()` clamps it. + * @return A fully tuned `PeerFinder::Config`. */ static Config makeConfig( @@ -96,44 +175,84 @@ struct Config //------------------------------------------------------------------------------ -/** Describes a connectable peer address along with some metadata. */ +/** A peer address paired with its gossip-chain hop distance. + * + * `hops == 0` means the originating peer is advertising itself; each relay + * node increments the count before forwarding. The constructor clamps + * `hops` to `Tuning::kMAX_HOPS + 1` (7) so oversized values never enter + * the caches. + * + * `operator<` orders by `address` only, enabling deduplication in sets + * without regard to which relay path delivered the entry. The Livecache + * uses hop depth to cycle through the overlay horizon: serving endpoints + * from all depths to each peer drives the network toward lower diameter. + */ struct Endpoint { Endpoint() = default; + /** Construct an `Endpoint`, clamping `hops` to `Tuning::kMAX_HOPS + 1`. + * + * @param ep The peer's connectable IP address and port. + * @param hops Gossip distance from the originator; clamped to 7. + */ Endpoint(beast::IP::Endpoint ep, std::uint32_t hops); + /** Gossip-chain hop distance from the originating peer. */ std::uint32_t hops = 0; + + /** Connectable IP address and port of the peer. */ beast::IP::Endpoint address; }; +/** Order two `Endpoint` values by their `address` field only. + * + * Hop count is intentionally ignored so that sets of endpoints are + * deduplicated by address regardless of the relay path taken. + */ inline bool operator<(Endpoint const& lhs, Endpoint const& rhs) { return lhs.address < rhs.address; } -/** A set of Endpoint used for connecting. */ +/** A collection of `Endpoint` records for address exchange. */ using Endpoints = std::vector; //------------------------------------------------------------------------------ -/** Possible results from activating a slot. */ -enum class Result { InboundDisabled, DuplicatePeer, IpLimitExceeded, Full, Success }; +/** Outcome of attempting to admit a connection into a slot. + * + * Returned by `Manager::newInboundSlot`, `Manager::newOutboundSlot`, and + * `Manager::activate`. Use `to_string()` to convert to a human-readable + * label for logging. + */ +enum class Result { + /** The node is not configured to accept inbound connections. */ + InboundDisabled, -/** - * @brief Converts a `Result` enum value to its string representation. + /** A connection to this remote peer already exists. */ + DuplicatePeer, + + /** The per-IP inbound connection limit has been reached. */ + IpLimitExceeded, + + /** All peer slots are occupied. */ + Full, + + /** The connection was admitted successfully. */ + Success +}; + +/** Convert a `Result` to its human-readable string label. * - * This function provides a human-readable string for a given `Result` enum, - * which is useful for logging, debugging, or displaying status messages. + * Returns a `string_view` into a string literal — no heap allocation — + * which makes it safe to call on hot logging paths during high connection + * activity. * - * @param result The `Result` enum value to convert. - * @return A `std::string_view` representing the enum value. Returns "unknown" - * if the enum value is not explicitly handled. - * - * @note This function returns a `std::string_view` for performance. - * A `std::string` would need to allocate memory on the heap and copy the - * string literal into it every time the function is called. + * @param result The result code to convert. + * @return A non-owning view of the label string; "unknown" for any + * unrecognised value. */ inline std::string_view to_string(Result result) noexcept @@ -155,53 +274,88 @@ to_string(Result result) noexcept return "unknown"; } -/** Maintains a set of IP addresses used for getting into the network. */ +/** Manages peer discovery, slot accounting, and address-cache maintenance. + * + * `Manager` is the central coordination point for the PeerFinder subsystem. + * It is deliberately decoupled from socket I/O: callers pass plain + * `beast::IP::Endpoint` values and drive the actual network calls based on + * the addresses the manager returns. The concrete implementation is + * `ManagerImp`, instantiated by `make_Manager()`. + * + * Extending `beast::PropertyStream::Source` allows the manager's internal + * state to be streamed into the node's diagnostic property tree without + * coupling to any specific monitoring backend. + * + * **Lifetime**: call `start()` before issuing any other methods; call + * `stop()` before destruction. The destructor aborts any pending source + * fetch operations; some listener callbacks may fire before it returns. + * + * **Thread safety**: `setConfig()` may be called from any thread. All + * other methods must be called from the single thread that owns the manager + * (typically the io_service thread that also drives `oncePerSecond()`). + */ class Manager : public beast::PropertyStream::Source { protected: Manager() noexcept; public: - /** Destroy the object. - Any pending source fetch operations are aborted. - There may be some listener calls made before the - destructor returns. - */ + /** Destroy the manager. + * + * Aborts any pending source fetch operations. There may be some + * listener callbacks made before the destructor returns. + */ ~Manager() override = default; - /** Set the configuration for the manager. - The new settings will be applied asynchronously. - Thread safety: - Can be called from any threads at any time. - */ + /** Apply a new configuration to the manager asynchronously. + * + * The updated settings take effect on the manager's next processing + * cycle. May be called from any thread at any time. + * + * @param config The new configuration to apply. + */ virtual void setConfig(Config const& config) = 0; - /** Transition to the started state, synchronously. */ + /** Transition the manager to the running state, synchronously. */ virtual void start() = 0; - /** Transition to the stopped state, synchronously. */ + /** Transition the manager to the stopped state, synchronously. */ virtual void stop() = 0; - /** Returns the configuration for the manager. */ + /** Return the current configuration. */ virtual Config config() = 0; - /** Add a peer that should always be connected. - This is useful for maintaining a private cluster of peers. - The string is the name as specified in the configuration - file, along with the set of corresponding IP addresses. - */ + /** Register a peer that must always be connected. + * + * Fixed peers bypass connection limits and are attempted before the + * Livecache or Bootcache. Useful for maintaining private clusters. + * + * @param name A human-readable label (from the config file) used + * in diagnostics. + * @param addresses The set of IP endpoints to try for this peer. + */ virtual void - addFixedPeer(std::string const& name, std::vector const& addresses) = 0; + addFixedPeer( + std::string const& name, + std::vector const& addresses) = 0; - /** Add a set of strings as fallback IP::Endpoint sources. - @param name A label used for diagnostics. - */ + /** Register a static list of fallback addresses for bootstrapping. + * + * These are used when the Livecache and Bootcache are both empty. + * Corresponds to the `[ips]` / `[ips_fixed]` config sections. + * + * @param name A label used in diagnostics. + * @param strings Raw IP-address strings; malformed entries are silently + * dropped. + */ virtual void - addFallbackStrings(std::string const& name, std::vector const& strings) = 0; + addFallbackStrings( + std::string const& name, + std::vector const& strings) = 0; /** Add a URL as a fallback location to obtain IP::Endpoint sources. @param name A label used for diagnostics. @@ -213,38 +367,86 @@ public: //-------------------------------------------------------------------------- - /** Create a new inbound slot with the specified remote endpoint. - If nullptr is returned, then the slot could not be assigned. - Usually this is because of a detected self-connection. - */ + /** Allocate a slot for a newly accepted inbound connection. + * + * Called immediately after the TCP accept, before any handshake. + * The slot should subsequently be driven through `onConnected()` and + * `activate()`, then closed with `onClosed()` or `onFailure()`. + * + * @param localEndpoint The local address of the accepted socket. + * @param remoteEndpoint The remote address of the connecting peer. + * @return A pair of `{slot, result}`. `slot` is `nullptr` only on a + * detected self-connection; otherwise it is always valid. + * `result` indicates whether the connection can proceed + * (`Success`) or why it was refused. + */ virtual std::pair, Result> newInboundSlot( beast::IP::Endpoint const& localEndpoint, beast::IP::Endpoint const& remoteEndpoint) = 0; - /** Create a new outbound slot with the specified remote endpoint. - If nullptr is returned, then the slot could not be assigned. - Usually this is because of a duplicate connection. - */ + /** Allocate a slot for a newly initiated outbound connection attempt. + * + * Called before the TCP connect. The slot should subsequently be driven + * through `onConnected()` and `activate()`, then closed with `onClosed()` + * or `onFailure()`. + * + * @param remoteEndpoint The address being connected to. + * @return A pair of `{slot, result}`. `slot` is `nullptr` only when + * the connection is a duplicate; otherwise it is always valid. + * `result` indicates whether the attempt can proceed (`Success`) or + * why it was refused. + */ virtual std::pair, Result> newOutboundSlot(beast::IP::Endpoint const& remoteEndpoint) = 0; - /** Called when mtENDPOINTS is received. */ + /** Process a received `mtENDPOINTS` gossip message. + * + * Validates, rate-limits, and inserts the endpoints into the Livecache. + * Hops are incremented by 1 before storage; entries with + * `hops > Tuning::kMAX_HOPS` are dropped. First-hop entries trigger + * an async reachability probe via the `Checker`. + * + * @param slot The slot on which the message arrived. + * @param endpoints The endpoint records from the message. + */ virtual void - onEndpoints(std::shared_ptr const& slot, Endpoints const& endpoints) = 0; + onEndpoints( + std::shared_ptr const& slot, + Endpoints const& endpoints) = 0; - /** Called when the slot is closed. - This always happens when the socket is closed, unless the socket - was canceled. - */ + /** Notify the manager that a slot's connection has been closed. + * + * Must be called whenever the socket is closed, unless the socket was + * cancelled. Releases the slot's resources and updates Bootcache + * valence. + * + * @param slot The slot whose connection has ended. + */ virtual void onClosed(std::shared_ptr const& slot) = 0; - /** Called when an outbound connection is deemed to have failed */ + /** Notify the manager that an outbound connection attempt has failed. + * + * Distinct from `onClosed()`: used specifically when the TCP connect + * or handshake phase fails rather than a graceful close of an active + * connection. Penalises the peer's Bootcache valence. + * + * @param slot The slot for the failed outbound attempt. + */ virtual void onFailure(std::shared_ptr const& slot) = 0; - /** Called when we received redirect IPs from a busy peer. */ + /** Process redirect addresses received from a full remote peer. + * + * When this node's outbound connection attempt is rejected with HTTP 503, + * the remote peer may supply a list of alternative addresses. These are + * validated and forwarded into the Bootcache. At most + * `Tuning::kMAX_REDIRECTS` (30) entries are accepted per call. + * + * @param remoteAddress The peer that sent the redirect. + * @param eps The alternative addresses it provided. + */ virtual void onRedirects( boost::asio::ip::tcp::endpoint const& remoteAddress, @@ -252,34 +454,89 @@ public: //-------------------------------------------------------------------------- - /** Called when an outbound connection attempt succeeds. - The local endpoint must be valid. If the caller receives an error - when retrieving the local endpoint from the socket, it should - proceed as if the connection attempt failed by calling on_closed - instead of on_connected. - @return `true` if the connection should be kept - */ + /** Notify the manager that an outbound TCP connect has succeeded. + * + * Provides the resolved local endpoint so the manager can record which + * local address the connection is using. This is called before the + * cryptographic handshake; the peer is not yet considered legitimate. + * + * If the caller cannot retrieve the local endpoint from the socket it + * must call `onClosed()` instead of this method. + * + * @param slot The slot for this connection attempt. + * @param localEndpoint The local socket address after connect. + * @return `true` if the connection should proceed; `false` if the + * manager has determined it should be torn down (e.g. duplicate + * detected after connect). + */ virtual bool - onConnected(std::shared_ptr const& slot, beast::IP::Endpoint const& localEndpoint) = 0; + onConnected( + std::shared_ptr const& slot, + beast::IP::Endpoint const& localEndpoint) = 0; - /** Request an active slot type. */ + /** Promote a slot to the active state after a successful handshake. + * + * Called once the cryptographic handshake is complete and the peer's + * public key is known. Checks for duplicate keys and reserved-peer + * status before marking the slot active. Reserved and cluster + * connections bypass the `maxPeers` cap. + * + * @param slot The slot to activate. + * @param key The peer's verified public key. + * @param reserved `true` if this peer appears in the reservation table, + * exempting it from the active-slot count. + * @return The admission result; `Success` on activation, or a reason + * code if the slot cannot be made active. + */ virtual Result - activate(std::shared_ptr const& slot, PublicKey const& key, bool reserved) = 0; + activate( + std::shared_ptr const& slot, + PublicKey const& key, + bool reserved) = 0; - /** Returns a set of endpoints suitable for redirection. */ + /** Return addresses to offer a peer that cannot be accepted. + * + * When the node is full and must reject an inbound connection, it + * redirects the caller to up to `Tuning::kREDIRECT_ENDPOINT_COUNT` (10) + * addresses from the Livecache. + * + * @param slot The slot being redirected (used for deduplication). + * @return Up to 10 `Endpoint` records from the Livecache. + */ virtual std::vector redirect(std::shared_ptr const& slot) = 0; - /** Return a set of addresses we should connect to. */ + /** Return addresses that this node should attempt to connect to. + * + * Consults fixed peers, the Livecache, and the Bootcache in that + * priority order, returning the next batch of addresses for outbound + * connection attempts. Suppresses addresses recently tried via + * `Tuning::kRECENT_ATTEMPT_DURATION` (60 s) to avoid rapid retries. + * + * @return A list of `beast::IP::Endpoint` values to connect to. + */ virtual std::vector autoconnect() = 0; + /** Build the endpoint batches to broadcast to all connected peers. + * + * Constructs the `mtENDPOINTS` payload for each active slot using the + * fair `Handouts` round-robin algorithm. The caller is responsible for + * transmitting the resulting messages. Rate-limited per slot by + * `Tuning::kSECONDS_PER_MESSAGE` (151 s). + * + * @return A vector of `{slot, endpoints}` pairs; one entry per peer + * that should receive an endpoint message this tick. + */ virtual std::vector, std::vector>> buildEndpointsForPeers() = 0; - /** Perform periodic activity. - This should be called once per second. - */ + /** Execute all periodic maintenance tasks for one second. + * + * Must be called exactly once per second by the owning timer. Drives + * Livecache expiry, Bootcache write cooldown, idle-peer scanning, + * connection-attempt batching, and the Checker reachability loop. + */ virtual void oncePerSecond() = 0; }; diff --git a/src/xrpld/peerfinder/Slot.h b/src/xrpld/peerfinder/Slot.h index 289252e3fa..8b318edc32 100644 --- a/src/xrpld/peerfinder/Slot.h +++ b/src/xrpld/peerfinder/Slot.h @@ -1,3 +1,11 @@ +/** @file + * Defines the abstract read-only interface for a PeerFinder connection slot. + * + * `Manager` creates and owns slots internally, handing `shared_ptr` + * handles outward so callers can observe connection state without mutating it. + * All mutations happen through `SlotImp` in `detail/`. + */ + #pragma once #include @@ -7,52 +15,118 @@ namespace xrpl::PeerFinder { -/** Properties and state associated with a peer to peer overlay connection. */ +/** Read-only view of a single peer-to-peer overlay connection managed by PeerFinder. + * + * Carries both the immutable socket identity (remote endpoint, direction) and + * the mutable state that evolves as the connection progresses through its + * lifecycle. The interface is deliberately minimal — consumers only ever need + * to read properties; all writes go through `SlotImp`. + * + * @note `Manager` hands out `shared_ptr` handles, allowing the overlay + * layer to hold references safely across asynchronous operations without + * coupling to `SlotImp`'s mutable API. + * @see SlotImp, Manager + */ class Slot { public: using ptr = std::shared_ptr; - enum class State { Accept, Connect, Connected, Active, Closing }; + /** Ordered lifecycle phases of a peer connection. + * + * The state machine is enforced by `Logic`: transitions are validated by + * `XRPL_ASSERT` in `SlotImp::state()` and `SlotImp::activate()`. The + * ordering also drives diagnostic strings in `Logic::stateString()`. + */ + enum class State { + Accept, /**< Inbound socket accepted; handshake not yet started. */ + Connect, /**< Outbound connection attempt in progress. */ + Connected, /**< TCP established; overlay handshake underway. */ + Active, /**< Handshake complete; peer is fully participating. */ + Closing /**< Connection is winding down. */ + }; virtual ~Slot() = 0; - /** Returns `true` if this is an inbound connection. */ + /** Returns `true` if this is an inbound connection. + * + * Feeds the slot counters in `Counts` (which track `in_active` vs. + * `out_active` separately) and affects peer-privacy rules — only outbound + * connections are suppressed when `peerPrivate` is enabled. + */ [[nodiscard]] virtual bool inbound() const = 0; - /** Returns `true` if this is a fixed connection. - A connection is fixed if its remote endpoint is in the list of - remote endpoints for fixed connections. - */ + /** Returns `true` if the remote endpoint is in the operator-configured fixed-peers list. + * + * Fixed connections are exempt from the normal slot budget enforced by + * `Counts::can_activate()`, guaranteeing connectivity to trusted cluster + * nodes regardless of how full the peer slots are. A disconnected fixed + * outbound peer is treated by `Logic` as a permanent reconnect obligation. + */ [[nodiscard]] virtual bool fixed() const = 0; - /** Returns `true` if this is a reserved connection. - It might be a cluster peer, or a peer with a reservation. - This is only known after then handshake completes. + /** Returns `true` if this peer has a cluster membership or an explicit reservation. + * + * Like `fixed()`, reserved slots are not counted against the public slot + * cap. This flag is populated during `SlotImp::activate()` once the + * handshake reveals the peer's identity and must not be relied on before + * `State::Active`. + * + * @note Unknown (and therefore `false`) until the handshake completes. */ [[nodiscard]] virtual bool reserved() const = 0; - /** Returns the state of the connection. */ + /** Returns the current lifecycle state of the connection. + * + * @return One of the `State` enumerators reflecting where this slot sits + * in the connection state machine. + * @see State + */ [[nodiscard]] virtual State state() const = 0; - /** The remote endpoint of socket. */ + /** Returns the remote endpoint of the socket. + * + * Always known from the moment the slot is created — for both inbound + * and outbound connections. + */ [[nodiscard]] virtual beast::IP::Endpoint const& remoteEndpoint() const = 0; - /** The local endpoint of the socket, when known. */ + /** Returns the local endpoint of the socket, if known. + * + * For inbound connections the local address is populated at construction. + * For outbound connections it is only filled in when + * `Manager::onConnected()` fires and the OS-assigned local port can be + * read from the socket, so it may be `nullopt` before that point. + */ [[nodiscard]] virtual std::optional const& localEndpoint() const = 0; + /** Returns the port on which the remote peer accepts inbound connections, if known. + * + * Populated during the overlay handshake via `mtENDPOINTS`. Returns + * `nullopt` until the remote peer has advertised its listener port. + * + * @note Implemented with an `atomic` sentinel (`-1` = unknown) + * in `SlotImp`, enabling lock-free reads while the handshake thread + * writes. The sentinel is hidden from this interface. + */ [[nodiscard]] virtual std::optional listeningPort() const = 0; - /** The peer's public key, when known. - The public key is established when the handshake is complete. - */ + /** Returns the peer's public key, if the handshake has completed. + * + * Populated after a successful overlay handshake. `Logic` uses this as a + * deduplication key — if `activate()` detects a key that matches an + * already-active slot, it returns `Result::duplicatePeer` and the + * connection is rejected. + * + * @note Returns `nullopt` until `State::Active`. + */ [[nodiscard]] virtual std::optional const& publicKey() const = 0; }; diff --git a/src/xrpld/peerfinder/detail/Bootcache.cpp b/src/xrpld/peerfinder/detail/Bootcache.cpp index 269b6cc757..fb5d52a6fd 100644 --- a/src/xrpld/peerfinder/detail/Bootcache.cpp +++ b/src/xrpld/peerfinder/detail/Bootcache.cpp @@ -1,3 +1,16 @@ +/** @file + * Persistent bootstrap address cache for PeerFinder cold-start. + * + * Maintains a ranked list of IP endpoints that survive restarts, allowing the + * node to immediately attempt connections to previously-reachable peers. + * Entries are ranked by valence (a streak counter) so high-reliability + * addresses are tried first. The cache is loaded from and flushed to a + * SQLite-backed `Store`, with writes batched behind a 60-second cooldown to + * avoid excessive I/O during connection storms. + * + * @see Bootcache.h, Logic.h, Store.h + */ + #include #include @@ -74,6 +87,14 @@ Bootcache::clear() //-------------------------------------------------------------------------- +/** Load persisted entries from the Store, replacing any current in-memory state. + * + * Clears the cache before loading so the Store is always the authoritative + * source on startup. Duplicate entries returned by the Store are discarded + * with an error log. `prune()` is called after loading to enforce the + * `kBOOTCACHE_SIZE` cap in case the stored set grew beyond the limit while + * the node was offline. + */ void Bootcache::load() { @@ -94,6 +115,16 @@ Bootcache::load() } } +/** Add a dynamically-learned address to the cache with initial valence zero. + * + * This path is taken for addresses received via peer gossip. The operation is + * idempotent: if the endpoint is already present the cache is unchanged and + * `false` is returned. On a new insert, `prune()` enforces the size cap and + * `flagForUpdate()` schedules a deferred write-back. + * + * @param endpoint The IP endpoint to add. + * @return `true` if the endpoint was inserted; `false` if it already existed. + */ bool Bootcache::insert(beast::IP::Endpoint const& endpoint) { @@ -107,6 +138,20 @@ Bootcache::insert(beast::IP::Endpoint const& endpoint) return result.second; } +/** Add a statically-configured bootstrap address with elevated priority. + * + * Static addresses receive `kSTATIC_VALENCE` (32), placing them near the top + * of the sorted iteration order so the node preferentially connects to its + * own trusted seeds. If the address already exists with a lower valence the + * entry is erased and reinserted to enforce the minimum — historical failures + * cannot permanently demote a configured seed. + * + * @param endpoint The IP endpoint from the node's static configuration. + * @return `true` if the entry was inserted or upgraded; `false` if it already + * existed at `kSTATIC_VALENCE` or higher. + * @note Bimap values are logically const after insertion, so a valence upgrade + * requires the erase-then-reinsert sequence used here. + */ bool Bootcache::insertStatic(beast::IP::Endpoint const& endpoint) { @@ -128,6 +173,20 @@ Bootcache::insertStatic(beast::IP::Endpoint const& endpoint) return result.second; } +/** Record a successful outbound connection handshake for the given endpoint. + * + * Increments the endpoint's valence streak counter. Before incrementing, the + * current valence is clamped to zero so that a failure streak is fully + * cleared before recording the first success — the resulting valence is + * always 1 after the transition from any negative value, not a cumulative + * sum. If the endpoint is not yet in the cache it is inserted with valence 1. + * The bimap's immutability invariant requires an erase-then-reinsert to + * change the valence of an existing entry. + * + * @param endpoint The endpoint whose connection handshake just completed. + * @note Valence tracks a streak (consecutive outcomes), not a lifetime score. + * A peer that failed five times then succeeded once gets valence=1, not -4. + */ void Bootcache::onSuccess(beast::IP::Endpoint const& endpoint) { @@ -151,6 +210,18 @@ Bootcache::onSuccess(beast::IP::Endpoint const& endpoint) flagForUpdate(); } +/** Record a failed outbound connection attempt for the given endpoint. + * + * Decrements the endpoint's valence streak counter. Mirrors the clamping + * logic in `onSuccess`: the current valence is clamped to zero before + * decrementing, so a success streak is fully cleared on the first failure and + * the resulting valence is -1, not the sum of past successes minus one. If + * the endpoint is not yet in the cache it is inserted with valence -1. + * + * @param endpoint The endpoint whose connection attempt just failed. + * @note Valence tracks a streak (consecutive outcomes), not a lifetime score. + * A peer that succeeded ten times then failed once gets valence=-1, not 9. + */ void Bootcache::onFailure(beast::IP::Endpoint const& endpoint) { @@ -195,20 +266,28 @@ Bootcache::onWrite(beast::PropertyStream::Map& map) } } -// Checks the cache size and prunes if its over the limit. +/** Trim the cache to `kBOOTCACHE_SIZE` by removing the lowest-valence entries. + * + * Removes `kBOOTCACHE_PRUNE_PERCENT` (10%) of entries from the tail of the + * right-side multiset, which holds entries in ascending `Entry::operator<` + * order — i.e., descending valence — so the tail is always the least + * reliable addresses. + * + * @note The loop walks a forward iterator backward from `right.end()` rather + * than using a reverse iterator because Boost bimap does not support + * erasing via reverse iterators cleanly. + */ void Bootcache::prune() { if (size() <= Tuning::kBOOTCACHE_SIZE) return; - // Calculate the amount to remove auto count((size() * Tuning::kBOOTCACHE_PRUNE_PERCENT) / 100); decltype(count) pruned(0); // Work backwards because bimap doesn't handle // erasing using a reverse iterator very well. - // for (auto iter(map_.right.end()); count-- > 0 && iter != map_.right.begin(); ++pruned) { --iter; @@ -222,7 +301,16 @@ Bootcache::prune() JLOG(journal_.debug()) << beast::Leftw(18) << "Bootcache pruned " << pruned << " entries total"; } -// Updates the Store with the current set of entries if needed. +/** Serialize the entire cache to the persistent Store, if an update is pending. + * + * Snapshots the current bimap into a `vector` and calls + * `store_.save()`. Resets `needsUpdate_` and advances `whenUpdate_` by the + * cooldown period (`kBOOTCACHE_COOLDOWN_TIME` = 60 s) so that back-to-back + * mutation bursts result in at most one write per minute. + * + * @note Called unconditionally from the destructor to guarantee the final + * in-memory state is always persisted on clean shutdown. + */ void Bootcache::update() { @@ -238,12 +326,16 @@ Bootcache::update() list.push_back(se); } store_.save(list); - // Reset the flag and cooldown timer needsUpdate_ = false; whenUpdate_ = clock_.now() + Tuning::kBOOTCACHE_COOLDOWN_TIME; } -// Checks the clock and calls update if we are off the cooldown. +/** Flush to the Store if an update is pending and the cooldown has elapsed. + * + * Called from `periodicActivity()` on each maintenance tick. Does nothing if + * no mutation has occurred since the last write, or if the 60-second cooldown + * window has not yet expired. + */ void Bootcache::checkUpdate() { @@ -251,7 +343,13 @@ Bootcache::checkUpdate() update(); } -// Called when changes to an entry will affect the Store. +/** Mark the cache as dirty and attempt a write-back if the cooldown permits. + * + * Every mutation that must survive a restart — inserts, static inserts, and + * connection outcome records — calls this method. Sets `needsUpdate_` and + * immediately delegates to `checkUpdate()`, which will flush only if the + * 60-second cooldown has expired since the last write. + */ void Bootcache::flagForUpdate() { diff --git a/src/xrpld/peerfinder/detail/Bootcache.h b/src/xrpld/peerfinder/detail/Bootcache.h index c061f37595..1a85a43a83 100644 --- a/src/xrpld/peerfinder/detail/Bootcache.h +++ b/src/xrpld/peerfinder/detail/Bootcache.h @@ -1,3 +1,15 @@ +/** @file + * Persistent bootstrap address cache for PeerFinder cold-start. + * + * Defines `Bootcache`, the ranked IP endpoint cache that `Logic` consults + * when initiating outbound connections in the absence of live peer data. + * Entries are ranked by valence (a consecutive-outcome streak counter) and + * persisted to a `Store` (SQLite in production) with writes throttled behind + * a 60-second cooldown. + * + * @see Bootcache.cpp, Logic.h, Store.h + */ + #pragma once #include @@ -14,24 +26,43 @@ namespace xrpl::PeerFinder { -/** Stores IP addresses useful for gaining initial connections. - - This is one of the caches that is consulted when additional outgoing - connections are needed. Along with the address, each entry has this - additional metadata: - - Valence - A signed integer which represents the number of successful - consecutive connection attempts when positive, and the number of - failed consecutive connection attempts when negative. - - When choosing addresses from the boot cache for the purpose of - establishing outgoing connections, addresses are ranked in decreasing - order of high uptime, with valence as the tie breaker. -*/ +/** Persistent bootstrap address cache used by PeerFinder for cold-start connections. + * + * Maintains a ranked set of IP endpoints that survives process restarts. + * `Logic` iterates this cache — from highest to lowest valence — when it + * needs outbound connections but the `Livecache` is empty (e.g., on first + * launch or after losing all peers). + * + * Each entry carries a *valence* reputation score: a positive value records + * consecutive successful handshakes; a negative value records consecutive + * failures. The streak resets to zero before crossing the sign boundary, so + * a long history of successes does not shield a peer that suddenly starts + * refusing connections. Statically configured addresses start at + * `kSTATIC_VALENCE` (32) to ensure they are tried first and survive pruning. + * + * The internal data structure is a `boost::bimap` with an unordered left + * side (O(1) lookup by endpoint) and a sorted right side (iteration in + * decreasing-valence order). Because bimap entries are logically immutable + * after insertion, valence updates use an erase-then-reinsert pattern. + * + * Writes to the backing `Store` are debounced with a 60-second cooldown + * (`Tuning::kBOOTCACHE_COOLDOWN_TIME`). The destructor always flushes, + * bypassing the cooldown, to ensure the final state is persisted on shutdown. + * The cache is pruned to `Tuning::kBOOTCACHE_SIZE` (1000) entries on every + * insert and after `load()`. + * + * @note Not thread-safe; all calls must be serialized by the caller + * (in practice, `Logic` guards access with its own recursive mutex). + * @see Logic.h, Store.h, Tuning.h + */ class Bootcache { private: + /** Valence wrapper stored on the right side of the bimap. + * + * `operator<` sorts in *decreasing* valence order so that the most + * reliable endpoints appear at the front of the right-side multiset. + */ class Entry { public: @@ -69,6 +100,11 @@ private: using map_type = boost::bimap; using value_type = map_type::value_type; + /** Functor that projects a right-side bimap entry to its `IP::Endpoint`. + * + * Used with `boost::transform_iterator` to expose a valence-sorted range + * of endpoints to callers without leaking the bimap internals. + */ struct Transform { using first_argument_type = map_type::right_map::const_iterator::value_type const&; @@ -90,80 +126,165 @@ private: clock_type& clock_; beast::Journal journal_; - // Time after which we can update the database again + /** Time after which we can update the database again. */ clock_type::time_point whenUpdate_; - // Set to true when a database update is needed + /** Set to true when a database update is needed. */ bool needsUpdate_{false}; public: + /** Valence assigned to statically configured addresses. + * + * Ensures hand-configured seeds start near the top of the connection + * priority queue and are not displaced by pruning under normal operation. + */ static constexpr int kSTATIC_VALENCE = 32; + /** Forward iterator over endpoints in decreasing-valence order. + * + * Wraps the right-side bimap iterator via `Transform` so callers + * receive plain `beast::IP::Endpoint` references without needing to + * know about the bimap or `Entry` internals. + */ using iterator = boost::transform_iterator; + /** Alias for `iterator`; all iteration over this cache is read-only. */ using const_iterator = iterator; + /** Construct a Bootcache backed by the given store and clock. + * + * @param store Persistence back-end; must outlive this object. + * @param clock Monotonic clock used to enforce the write cooldown. + * @param journal Logging sink. + */ Bootcache(Store& store, clock_type& clock, beast::Journal journal); + /** Flush any pending write to the store unconditionally, then destroy. */ ~Bootcache(); - /** Returns `true` if the cache is empty. */ + /** Returns `true` if the cache holds no entries. */ [[nodiscard]] bool empty() const; - /** Returns the number of entries in the cache. */ + /** Returns the number of entries currently in the cache. */ [[nodiscard]] map_type::size_type size() const; - /** IP::Endpoint iterators that traverse in decreasing valence. */ /** @{ */ + /** Return an iterator to the first endpoint in decreasing-valence order. */ [[nodiscard]] const_iterator begin() const; [[nodiscard]] const_iterator cbegin() const; + /** Return a past-the-end iterator for the valence-sorted endpoint range. */ [[nodiscard]] const_iterator end() const; [[nodiscard]] const_iterator cend() const; + /** Erase all entries and mark the cache as needing a store update. */ void clear(); /** @} */ - /** Load the persisted data from the Store into the container. */ + /** Replace in-memory state with entries loaded from the backing Store. + * + * Clears the cache before loading so the Store is always the authoritative + * source on startup. Duplicate entries returned by the Store are discarded + * with an error log. `prune()` is called after loading to enforce the + * `kBOOTCACHE_SIZE` cap in case the stored set grew beyond the limit while + * the node was offline. + */ void load(); - /** Add a newly-learned address to the cache. */ + /** Add a dynamically-learned address to the cache with initial valence zero. + * + * Intended for addresses received via peer gossip. The operation is + * idempotent: if the endpoint is already present the cache is unchanged. + * On a new insert, `prune()` enforces the size cap and a deferred + * write-back is scheduled via `flagForUpdate()`. + * + * @param endpoint The IP endpoint to add. + * @return `true` if the endpoint was inserted; `false` if it already existed. + */ bool insert(beast::IP::Endpoint const& endpoint); - /** Add a staticallyconfigured address to the cache. */ + /** Add a statically configured bootstrap address with elevated priority. + * + * Assigns `kSTATIC_VALENCE` (32), placing the entry near the top of the + * sorted iteration order. If the endpoint is already present with a lower + * valence, the old entry is replaced so that historical failures cannot + * permanently demote a configured seed. + * + * @param endpoint The IP endpoint from the node's static configuration. + * @return `true` if the entry was inserted or upgraded to `kSTATIC_VALENCE`; + * `false` if it already existed at `kSTATIC_VALENCE` or higher. + * @note Bimap values are logically const after insertion, so a valence + * upgrade requires the erase-then-reinsert pattern used internally. + */ bool insertStatic(beast::IP::Endpoint const& endpoint); - /** Called when an outbound connection handshake completes. */ + /** Record a successful outbound connection handshake for the given endpoint. + * + * Increments the endpoint's consecutive-success streak. Any prior failure + * streak is first clamped to zero, so the resulting valence after + * transitioning from negative is always 1 — not a cumulative sum. If the + * endpoint is not yet in the cache it is inserted with valence 1. + * + * @param endpoint The endpoint whose connection handshake just completed. + * @note Valence tracks a streak, not a lifetime score. A peer that failed + * five times then succeeded once gets valence=1, not −4. + */ void onSuccess(beast::IP::Endpoint const& endpoint); - /** Called when an outbound connection attempt fails to handshake. */ + /** Record a failed outbound connection attempt for the given endpoint. + * + * Decrements the endpoint's consecutive-failure streak. Any prior success + * streak is first clamped to zero, so the resulting valence after + * transitioning from positive is always −1. If the endpoint is not yet in + * the cache it is inserted with valence −1. + * + * @param endpoint The endpoint whose connection attempt just failed. + * @note Valence tracks a streak, not a lifetime score. A peer that + * succeeded ten times then failed once gets valence=−1, not 9. + */ void onFailure(beast::IP::Endpoint const& endpoint); - /** Stores the cache in the persistent database on a timer. */ + /** Flush any pending write to the Store if the 60-second cooldown has elapsed. + * + * Called by `Logic` on each maintenance-timer tick. Does nothing if no + * mutation has occurred since the last write, or if the cooldown window + * has not yet expired. + */ void periodicActivity(); - /** Write the cache state to the property stream. */ + /** Write the current cache contents to a property stream for diagnostics. + * + * Emits an `"entries"` array under `map`, where each element contains + * `"endpoint"` (string) and `"valence"` (int32) fields. Entries appear + * in decreasing-valence order. + * + * @param map The property stream map to write into. + */ void onWrite(beast::PropertyStream::Map& map); private: + /** Remove the lowest-valence entries until the cache is within the size cap. */ void prune(); + /** Serialize the cache to the Store unconditionally; advances the cooldown timer. */ void update(); + /** Flush to the Store if `needsUpdate_` is set and the cooldown has elapsed. */ void checkUpdate(); + /** Mark the cache dirty and immediately attempt a throttled write-back. */ void flagForUpdate(); }; diff --git a/src/xrpld/peerfinder/detail/Checker.h b/src/xrpld/peerfinder/detail/Checker.h index 8ab084830c..c1dba99dda 100644 --- a/src/xrpld/peerfinder/detail/Checker.h +++ b/src/xrpld/peerfinder/detail/Checker.h @@ -1,3 +1,12 @@ +/** @file + * Async TCP reachability prober for PeerFinder. + * + * When an inbound peer reports the address it claims to accept connections on, + * `Checker` attempts an outbound TCP connection to verify that address is + * actually reachable. The result gates whether the peer's address is admitted + * to the live cache and gossiped to the rest of the network. + */ + #pragma once #include @@ -12,45 +21,82 @@ namespace xrpl::PeerFinder { -/** Tests remote listening sockets to make sure they are connectable. */ +/** Performs async TCP reachability probes for PeerFinder. + * + * When `Logic` receives an inbound peer's advertised listening endpoint via + * `on_endpoints`, it calls `asyncConnect` to verify the port is actually open. + * The completion handler (`Logic::checkComplete`) sets `SlotImp::canAccept`, + * which gates admission to the live cache and gossip propagation. + * + * In-flight operations are tracked in an intrusive list. `stop()` cancels all + * pending operations (handlers receive `operation_aborted`). `wait()` blocks + * until the list drains. The destructor calls only `wait()` — callers must + * invoke `stop()` first if cancellation is desired before destruction. + * + * @tparam Protocol Boost.Asio protocol type, defaulting to + * `boost::asio::ip::tcp`. Substitute a mock protocol in unit tests. + */ template class Checker { private: using error_code = boost::system::error_code; + /** Polymorphic base for a single in-flight async connection probe. + * + * Embeds a `boost::intrusive::list_base_hook` directly to avoid the extra + * heap allocation that `std::list>` would require. + * Concrete instances are `AsyncOp`. + */ struct BasicAsyncOp : boost::intrusive::list_base_hook< boost::intrusive::link_mode> { virtual ~BasicAsyncOp() = default; + /** Cancel the underlying socket operation without waiting for it to complete. */ virtual void stop() = 0; + /** Dispatch the completion handler with the given error code. */ virtual void operator()(error_code const& ec) = 0; }; + /** Concrete async probe that binds a socket and a caller-supplied handler. + * + * Created as a `shared_ptr` in `asyncConnect`; the lambda passed to + * `async_connect` captures that same `shared_ptr`, keeping the op alive + * for the entire async lifetime. When the lambda is destroyed the + * reference count drops to zero and the destructor calls + * `checker.remove(*this)`, deregistering the op from the tracked list + * and signalling `cond_` if the list is now empty. + */ template struct AsyncOp : BasicAsyncOp { using socket_type = typename Protocol::socket; using endpoint_type = typename Protocol::endpoint; + /** Back-reference to the owning `Checker`, used in the destructor. */ Checker& checker; + /** Socket used for the outbound connection attempt. */ socket_type socket; + /** Caller-supplied completion handler invoked with the probe result. */ Handler handler; AsyncOp(Checker& owner, boost::asio::io_context& ioContext, Handler&& handler); + /** Remove this op from the owning `Checker`'s tracking list. */ ~AsyncOp() override { checker.remove(*this); } + /** Cancel the socket, causing the pending handler to receive `operation_aborted`. */ void stop() override; + /** Invoke `handler(ec)` with the async_connect result. */ void operator()(error_code const& ec) override; // NOLINT(readability-identifier-naming) }; @@ -67,38 +113,75 @@ private: bool stop_ = false; public: + /** Construct a `Checker` that schedules probes on the given `io_context`. + * + * @param ioContext The Boost.Asio `io_context` that drives async I/O. + */ explicit Checker(boost::asio::io_context& ioContext); - /** Destroy the service. - Any pending I/O operations will be canceled. This call blocks until - all pending operations complete (either with success or with - operation_aborted) and the associated thread and io_context have - no more work remaining. - */ + /** Drain all pending probes and destroy the checker. + * + * Blocks until every in-flight `async_connect` (whether it completed + * normally or with `operation_aborted`) has finished and its handler has + * run. Does **not** cancel pending operations — call `stop()` first if + * cancellation before destruction is required. + */ ~Checker(); - /** Stop the service. - Pending I/O operations will be canceled. - This issues cancel orders for all pending I/O operations and then - returns immediately. Handlers will receive operation_aborted errors, - or if they were already queued they will complete normally. - */ + /** Cancel all pending probes and return immediately. + * + * Sets `stop_ = true` under lock, then calls `socket.cancel()` on every + * live `AsyncOp`. The cancel is asynchronous: handlers will receive + * `boost::asio::error::operation_aborted` rather than a success code. + * Handlers that were already queued for delivery before the cancel may + * still complete normally. + * + * @note This method is idempotent; calling it more than once is safe. + */ void stop(); - /** Block until all pending I/O completes. */ + /** Block until all pending probes complete. + * + * Waits on `cond_` until the intrusive list of in-flight ops is empty. + * Each `AsyncOp` destructor signals the condition variable, so this + * unblocks as soon as the last handler returns. + */ void wait(); - /** Performs an async connection test on the specified endpoint. - The port must be non-zero. Note that the execution guarantees - offered by asio handlers are NOT enforced. - */ + /** Probe `endpoint` for TCP reachability and invoke `handler` on completion. + * + * Creates an `AsyncOp`, registers it in the tracked list, and + * issues `async_connect` on the converted Boost.Asio endpoint. The handler + * is invoked with a `boost::system::error_code`: zero on success (port is + * reachable), non-zero on failure (including `operation_aborted` if + * `stop()` is called before the connect completes). + * + * @param endpoint The peer-reported listening address to probe. The port + * must be non-zero. + * @param handler Completion handler with signature + * `void(boost::system::error_code)`. Invoked on whatever thread + * services the `io_context` — Asio strand guarantees are NOT enforced. + * `Logic::checkComplete` takes `lock_` immediately on entry to guard + * shared slot state. + * + * @note The first endpoint a peer advertises is intentionally discarded + * while this probe is in flight; the peer will re-advertise shortly. + */ template void asyncConnect(beast::IP::Endpoint const& endpoint, Handler&& handler); private: + /** Remove a completed op from the tracking list and signal waiters. + * + * Called from `AsyncOp::~AsyncOp()`. Erases the op from `list_` under + * `mutex_` and notifies `cond_` if the list becomes empty so that + * `wait()` can unblock. + * + * @param op The op to remove; must be in `list_`. + */ void remove(BasicAsyncOp& op); }; diff --git a/src/xrpld/peerfinder/detail/Counts.h b/src/xrpld/peerfinder/detail/Counts.h index 9fee2efadb..8db9b60f12 100644 --- a/src/xrpld/peerfinder/detail/Counts.h +++ b/src/xrpld/peerfinder/detail/Counts.h @@ -8,32 +8,73 @@ namespace xrpl::PeerFinder { -/** Direction of a slot count adjustment. */ -enum class CountAdjustment : int { Decrement = -1, Increment = 1 }; +/** Direction of a slot count adjustment passed to `Counts::adjust`. */ +enum class CountAdjustment : int { + Decrement = -1, /**< Remove a slot from tracked counts. */ + Increment = 1 /**< Add a slot to tracked counts. */ +}; -/** Manages the count of available connections for the various slots. */ +/** Tracks the occupancy of every connection-slot category managed by PeerFinder. + * + * `Counts` is the bookkeeping core that answers the resource-management + * questions `Logic` needs: Are inbound slots available? Should more outbound + * attempts be launched? Can this handshaked slot be promoted to active? + * + * All mutations funnel through the private `adjust()` method, which is called + * by `Logic` under its own `std::recursive_mutex`. `Counts` itself carries no + * synchronization — it is a value-semantics helper embedded in a larger + * guarded object. + * + * Fixed peers (from node config) and reserved peers (cluster/reservation + * table) are tracked in separate counters and do NOT consume ordinary + * inbound/outbound slot capacity; `canActivate()` admits them unconditionally. + * + * @see Logic + */ class Counts { public: - /** Adds the slot state and properties to the slot counts. */ + /** Register a slot's state and properties in the counts. + * + * Thin wrapper around `adjust(s, Increment)`. Call this whenever a slot + * is created or transitions to a new state (after updating the slot). + * + * @param s The slot whose current state and properties should be counted. + */ void add(Slot const& s) { adjust(s, CountAdjustment::Increment); } - /** Removes the slot state and properties from the slot counts. */ + /** Deregister a slot's state and properties from the counts. + * + * Thin wrapper around `adjust(s, Decrement)`. Call this before a slot + * transitions to a new state or is destroyed. + * + * @param s The slot whose current state and properties should be removed. + */ void remove(Slot const& s) { adjust(s, CountAdjustment::Decrement); } - /** Returns `true` if the slot can become active. */ + /** Determine whether a handshaked slot may be promoted to active. + * + * Fixed and reserved slots bypass capacity limits and are always admitted. + * For ordinary slots, inbound connections require `in_active_ < in_max_` + * and outbound connections require `out_active_ < out_max_`. + * + * @param s The slot to test; must be in `Connected` or `Accept` state. + * @return `true` if the slot may become active, `false` if all slots of + * its direction are occupied. + * @note Fixed and reserved connections are never blocked by slot limits, + * ensuring administratively configured peers can always connect. + */ [[nodiscard]] bool canActivate(Slot const& s) const { - // Must be handshaked and in the right state XRPL_ASSERT( s.state() == Slot::State::Connected || s.state() == Slot::State::Accept, "xrpl::PeerFinder::Counts::can_activate : valid input state"); @@ -47,7 +88,15 @@ public: return out_active_ < out_max_; } - /** Returns the number of attempts needed to bring us to the max. */ + /** Compute how many additional outbound attempts should be launched. + * + * Compares the current in-flight attempt count against + * `Tuning::kMAX_CONNECT_ATTEMPTS` (20) to cap simultaneous outbound + * connection storms regardless of how many free slots remain. + * + * @return Number of additional attempts that may be started; 0 when the + * in-flight count has already reached the cap. + */ [[nodiscard]] std::size_t attemptsNeeded() const { @@ -56,37 +105,59 @@ public: return Tuning::kMAX_CONNECT_ATTEMPTS - attempts_; } - /** Returns the number of outbound connection attempts. */ + /** Return the current number of in-flight outbound connection attempts. + * + * Counts slots in `Connect` or `Connected` state (both are outbound + * attempts that have not yet been admitted as active). + * + * @return Number of in-flight outbound attempts. + */ [[nodiscard]] std::size_t attempts() const { return attempts_; } - /** Returns the total number of outbound slots. */ + /** Return the configured maximum number of outbound slots. + * + * Set by `onConfig()` from `Config::outPeers`. Fixed-peer connections + * do not consume this quota. + * + * @return Maximum desired outbound peer count. + */ [[nodiscard]] int outMax() const { return out_max_; } - /** Returns the number of outbound peers assigned an open slot. - Fixed peers do not count towards outbound slots used. - */ + /** Return the number of active ordinary outbound peers. + * + * Fixed and reserved peers are excluded; they have their own counters + * and do not consume outbound slot capacity. + * + * @return Count of active non-fixed, non-reserved outbound peers. + */ [[nodiscard]] int outActive() const { return out_active_; } - /** Returns the number of fixed connections. */ + /** Return the total number of fixed-peer connections (any state). + * + * @return Count of fixed slots across all states. + */ [[nodiscard]] std::size_t fixed() const { return fixed_; } - /** Returns the number of active fixed connections. */ + /** Return the number of fixed-peer connections that are fully active. + * + * @return Count of fixed slots in `Active` state. + */ [[nodiscard]] std::size_t fixedActive() const { @@ -95,7 +166,15 @@ public: //-------------------------------------------------------------------------- - /** Called when the config is set or changed. */ + /** Apply configuration limits for inbound and outbound slot capacities. + * + * Sets `out_max_` unconditionally from `config.outPeers`. Sets `in_max_` + * from `config.inPeers` only when `config.wantIncoming` is true; if the + * node does not want inbound connections, `in_max_` remains 0, which + * causes `canActivate()` to reject all inbound slots. + * + * @param config The active PeerFinder configuration. + */ void onConfig(Config const& config) { @@ -104,51 +183,87 @@ public: in_max_ = config.inPeers; } - /** Returns the number of accepted connections that haven't handshaked. */ + /** Return the number of inbound connections that have not yet handshaked. + * + * These are slots in `Accept` state — the TCP connection is established + * but the protocol handshake is still in progress. + * + * @return Count of pre-handshake inbound slots. + */ [[nodiscard]] int acceptCount() const { return acceptCount_; } - /** Returns the number of connection attempts currently active. */ + /** Return the number of outbound connection attempts currently in progress. + * + * Alias for `attempts()` using the naming convention expected by + * `onWrite()` and `stateString()`. + * + * @return Count of slots in `Connect` or `Connected` state. + */ [[nodiscard]] int connectCount() const { return attempts_; } - /** Returns the number of connections that are gracefully closing. */ + /** Return the number of connections currently undergoing graceful teardown. + * + * @return Count of slots in `Closing` state. + */ [[nodiscard]] int closingCount() const { return closingCount_; } - /** Returns the total number of inbound slots. */ + /** Return the configured maximum number of inbound slots. + * + * Zero when `Config::wantIncoming` was false at `onConfig()` time, + * causing all inbound activation attempts to be rejected. + * + * @return Maximum allowed inbound active peer count. + */ [[nodiscard]] int inMax() const { return in_max_; } - /** Returns the number of inbound peers assigned an open slot. */ + /** Return the number of active ordinary inbound peers. + * + * Fixed and reserved peers are excluded and do not consume inbound + * slot capacity. + * + * @return Count of active non-fixed, non-reserved inbound peers. + */ [[nodiscard]] int inboundActive() const { return in_active_; } - /** Returns the total number of active peers excluding fixed peers. */ + /** Return the combined active ordinary peer count (inbound + outbound). + * + * Fixed and reserved peers are excluded from both terms. + * + * @return `in_active_ + out_active_`. + */ [[nodiscard]] int totalActive() const { return in_active_ + out_active_; } - /** Returns the number of unused inbound slots. - Fixed peers do not deduct from inbound slots or count towards totals. - */ + /** Return the number of available inbound slots. + * + * Fixed and reserved peers do not consume inbound capacity, so they do + * not reduce this value. + * + * @return `in_max_ - in_active_`, or 0 if the inbound limit is reached. + */ [[nodiscard]] int inboundSlotsFree() const { @@ -157,9 +272,13 @@ public: return 0; } - /** Returns the number of unused outbound slots. - Fixed peers do not deduct from outbound slots or count towards totals. - */ + /** Return the number of available outbound slots. + * + * Fixed and reserved peers do not consume outbound capacity, so they do + * not reduce this value. + * + * @return `out_max_ - out_active_`, or 0 if the outbound limit is reached. + */ [[nodiscard]] int outboundSlotsFree() const { @@ -170,21 +289,34 @@ public: //-------------------------------------------------------------------------- - /** Returns true if the slot logic considers us "connected" to the network. + /** Determine whether this node considers itself connected to the network. + * + * Returns `true` only when `out_max_ <= 0`, which means the node is + * configured with zero desired outbound connections (pure-listener mode) + * and therefore considers itself connected without needing any outbound + * peers. In the common case where `out_max_ > 0` this always returns + * `false`; `Logic` uses `out_active_` vs `out_max_` directly to drive + * connection attempts. + * + * @note Fixed peers are not counted toward `out_active_` and do not + * influence this result. + * + * @return `true` if the node operates as a pure listener (outPeers == 0). */ [[nodiscard]] bool isConnectedToNetwork() const { - // We will consider ourselves connected if we have reached - // the number of outgoing connections desired, or if connect - // automatically is false. - // - // Fixed peers do not count towards the active outgoing total. - return out_max_ <= 0; } - /** Output statistics. */ + /** Serialize current slot counts into a property-stream map for monitoring. + * + * Emits: `accept`, `connect`, `close`, `in` (active/max), `out` + * (active/max), `fixed` (active fixed peers), `reserved`, and `total` + * (all active peers including fixed and reserved). + * + * @param map The property-stream map to write into. + */ void onWrite(beast::PropertyStream::Map& map) const { @@ -198,7 +330,14 @@ public: map["total"] = active_; } - /** Records the state for diagnostics. */ + /** Return a compact human-readable summary of current slot state. + * + * Produces a string of the form + * `"3/8 out, 10/21 in, 2 connecting, 0 closing"` used in log + * messages throughout `Logic`. + * + * @return Diagnostic string; no side effects. + */ [[nodiscard]] std::string stateString() const { @@ -210,7 +349,19 @@ public: //-------------------------------------------------------------------------- private: - /** Increments or decrements a counter based on the adjustment direction. */ + /** Increment or decrement a counter by exactly one step. + * + * All `std::size_t` counters MUST be updated through this helper rather + * than via `+= static_cast(dir)`. Adding `-1` to a `std::size_t` + * implicitly converts to `SIZE_MAX` (unsigned-integer overflow), which + * masks underflow bugs and is flagged by UBSan. Plain `int` counters + * (`acceptCount_`, `attempts_`, `closingCount_`) are safe with `+= n` + * and bypass this helper. + * + * @tparam T Counter type (integral). + * @param counter Reference to the counter to adjust. + * @param dir Whether to increment or decrement. + */ template static void adjustCounter(T& counter, CountAdjustment dir) @@ -226,14 +377,25 @@ private: } } - // Adjusts counts based on the specified slot, in the direction indicated. - // - // IMPORTANT: All std::size_t counters MUST be adjusted via adjustCounter() - // and NEVER via `+= n` where n = static_cast(dir). When dir is - // Decrement, n == -1; adding -1 to a std::size_t implicitly converts -1 to - // SIZE_MAX, which UBSan flags as unsigned-integer-overflow and masks real - // underflow bugs (decrementing a counter already at zero). Plain int - // counters (acceptCount_, attempts_, closingCount_) are safe with += n. + /** Update all counters that track the given slot's state and properties. + * + * Single entry-point for all count mutations: `add()` and `remove()` are + * thin wrappers that call this with `Increment` or `Decrement`. Keeping + * all counter logic here ensures add/remove can never diverge. + * + * State mapping: + * - `Accept` → `acceptCount_` (asserts inbound). + * - `Connect` / `Connected` → `attempts_` (asserts outbound). + * - `Active` → `active_`; additionally `fixed_active_` for fixed slots, + * or `in_active_`/`out_active_` for ordinary slots. + * - `Closing` → `closingCount_`. + * + * Fixed and reserved slots are always tracked in `fixed_`/`reserved_` + * regardless of state. + * + * @param s The slot whose current state drives the counter selection. + * @param dir `Increment` when adding a slot; `Decrement` when removing. + */ void adjust(Slot const& s, CountAdjustment const dir) { @@ -317,11 +479,10 @@ private: /** Reserved connections. */ std::size_t reserved_{0}; - // Number of inbound connections that are - // not active or gracefully closing. + /** Inbound connections in `Accept` state (TCP established, pre-handshake). */ int acceptCount_{0}; - // Number of connections that are gracefully closing. + /** Connections in `Closing` state (graceful teardown in progress). */ int closingCount_{0}; }; diff --git a/src/xrpld/peerfinder/detail/Endpoint.cpp b/src/xrpld/peerfinder/detail/Endpoint.cpp index ab9cd66809..751dce93bf 100644 --- a/src/xrpld/peerfinder/detail/Endpoint.cpp +++ b/src/xrpld/peerfinder/detail/Endpoint.cpp @@ -1,3 +1,16 @@ +/** @file + * Implements the parameterized constructor for `PeerFinder::Endpoint`. + * + * The file is intentionally minimal: the struct is declared in + * `PeerfinderManager.h` and the default constructor is defaulted there. + * Only this constructor requires a separate translation unit because it + * references `Tuning::kMAX_HOPS`, which is defined in `Tuning.h`. + * + * The sole logic — clamping `hops` to `kMAX_HOPS + 1` — enforces the + * gossip-propagation horizon at the data layer so that adversarial or + * buggy peers cannot inject unbounded hop counts into the address caches. + */ + #include #include @@ -10,6 +23,13 @@ namespace xrpl::PeerFinder { Endpoint::Endpoint(beast::IP::Endpoint ep, std::uint32_t hops) + // Cap at kMAX_HOPS+1 (7), not kMAX_HOPS (6). The +1 is a sentinel + // meaning "beyond the horizon": Logic::preprocess drops any entry with + // hops > kMAX_HOPS, so a clamped-to-7 value is immediately discarded + // there. Clamping to exactly 6 instead would let that entry survive + // preprocess and then be incremented to 7 in the livecache, producing + // the same outcome but with an ambiguous boundary. The +1 makes the + // contract unambiguous and ensures no malformed network value exceeds it. : hops(std::min(hops, Tuning::kMAX_HOPS + 1)), address(std::move(ep)) { } diff --git a/src/xrpld/peerfinder/detail/Fixed.h b/src/xrpld/peerfinder/detail/Fixed.h index c334b9ac99..8b1d314cc3 100644 --- a/src/xrpld/peerfinder/detail/Fixed.h +++ b/src/xrpld/peerfinder/detail/Fixed.h @@ -4,24 +4,63 @@ namespace xrpl::PeerFinder { -/** Metadata for a Fixed slot. */ +/** Reconnection backoff state for a statically-configured (fixed) peer slot. + * + * Tracks when the next connection attempt is permitted for a single fixed + * peer endpoint. On failure, `when_` is advanced along a Fibonacci backoff + * sequence (`Tuning::kCONNECTION_BACKOFF`: 1,1,2,3,5,8,13,21,34,55 minutes) + * so unreachable peers are retried progressively less frequently, capping at + * 55 minutes. On success, the peer is immediately re-eligible. + * + * Instances live as values in `Logic::fixed_` (`std::map`) + * and are accessed only while `Logic`'s internal mutex is held — no + * per-object synchronization is needed. + * + * @see Logic::getFixed + */ class Fixed { public: + /** Construct a `Fixed` record that is immediately eligible for connection. + * + * Sets the earliest permitted attempt time to `clock.now()`, so the + * peer qualifies for its first connection attempt without any delay. + * + * @param clock The PeerFinder clock used to obtain the current time. + */ explicit Fixed(clock_type& clock) : when_(clock.now()) { } Fixed(Fixed const&) = default; - /** Returns the time after which we should allow a connection attempt. */ + /** Return the earliest time at which a new connection attempt is allowed. + * + * `Logic::getFixed` compares this value against the current time to + * decide whether the peer is on cooldown. A time point at or before + * `now` means the peer is eligible. + * + * @return The `clock_type::time_point` after which a connection attempt + * may be made. + */ [[nodiscard]] clock_type::time_point const& when() const { return when_; } - /** Updates metadata to reflect a failed connection. */ + /** Advance the backoff state after a failed connection attempt. + * + * Increments the consecutive-failure counter (capped at the last index + * of `Tuning::kCONNECTION_BACKOFF` to prevent out-of-bounds access), + * then sets `when_` to `now + kCONNECTION_BACKOFF[failures_]` minutes. + * The Fibonacci-shaped sequence (1,1,2,3,5,8,13,21,34,55) grows quickly + * enough to avoid hammering an unreachable peer, yet caps at 55 minutes + * so a fixed peer is never abandoned entirely. + * + * @param now The current clock time, used to compute the next eligible + * attempt time. + */ void failure(clock_type::time_point const& now) { @@ -29,7 +68,15 @@ public: when_ = now + std::chrono::minutes(Tuning::kCONNECTION_BACKOFF[failures_]); } - /** Updates metadata to reflect a successful connection. */ + /** Reset the backoff state after a successful connection. + * + * Clears the consecutive-failure counter and sets `when_` to `now`, + * making the peer immediately eligible for a new attempt should the + * connection drop and need to be re-established. + * + * @param now The current clock time, assigned directly as the new + * earliest eligible attempt time. + */ void success(clock_type::time_point const& now) { @@ -38,7 +85,14 @@ public: } private: + /** Earliest time at which the next connection attempt is permitted. */ clock_type::time_point when_; + + /** Number of consecutive failures since the last successful connection. + * + * Used as an index into `Tuning::kCONNECTION_BACKOFF`; clamped to + * `kCONNECTION_BACKOFF.size() - 1` so it is always a valid index. + */ std::size_t failures_{0}; }; diff --git a/src/xrpld/peerfinder/detail/Handouts.h b/src/xrpld/peerfinder/detail/Handouts.h index b26a44015d..d6ddea6b3b 100644 --- a/src/xrpld/peerfinder/detail/Handouts.h +++ b/src/xrpld/peerfinder/detail/Handouts.h @@ -1,3 +1,12 @@ +/** @file + * Endpoint distribution logic for PeerFinder peer exchange. + * + * Provides `handout()` and three target types — `RedirectHandouts`, + * `SlotHandouts`, and `ConnectHandouts` — used by `Logic` to distribute + * peer addresses fairly across recipients while enforcing hop limits, + * per-slot deduplication, and reconnect-rate squelching. + */ + #pragma once #include @@ -12,10 +21,24 @@ namespace xrpl::PeerFinder { namespace detail { -/** Try to insert one object in the target. - When an item is handed out it is moved to the end of the container. - @return The number of objects inserted -*/ +/** Attempt to give one endpoint from a hop container to a single target. + * + * Walks `h` from front to back until `t.tryInsert()` accepts an entry. + * On acceptance the entry is rotated to the tail of `h` via `moveBack()`, + * providing a weak round-robin so the same endpoint is not handed to + * every target in a single pass before others get a turn. + * + * @tparam Target Duck-typed target with `full() -> bool` and + * `tryInsert(Endpoint const&) -> bool`. + * @tparam HopContainer Sequence container whose iterators support + * `begin()`, `end()`, and `moveBack(it)`. + * @param t The target to receive the endpoint. + * @param h The hop-level container to draw from. + * @return 1 if an endpoint was inserted, 0 if no suitable endpoint was found. + * @pre `!t.full()` — the caller must check before invoking. + * @note A future optimization could use `splice` for `std::list` containers + * instead of erase + push_back. + */ // VFALCO TODO specialization that handles std::list for SequenceContainer // using splice for optimization over erase/push_back // @@ -38,10 +61,29 @@ handoutOne(Target& t, HopContainer& h) } // namespace detail -/** Distributes objects to targets according to business rules. - A best effort is made to evenly distribute items in the sequence - container list into the target sequence list. -*/ +/** Distribute endpoints from a sequence of hop containers to a set of targets. + * + * Drives the multi-target distribution loop: for each pass it iterates every + * hop container in `[seqFirst, seqLast)` and offers one endpoint to each + * non-full target via `handoutOne`. The outer loop repeats until all targets + * are full or a complete pass yields no insertions, whichever comes first. + * An `allFull` short-circuit exits immediately when no target has remaining + * capacity, avoiding a full scan of remaining hop containers. + * + * This structure ensures endpoints are distributed as evenly as possible + * across all recipients and hop levels before any recipient receives a + * second item from the same source. + * + * @tparam TargetFwdIter Forward iterator whose value type satisfies the + * `full() -> bool` / `tryInsert(Endpoint const&) -> bool` interface. + * @tparam SeqFwdIter Forward iterator over hop containers whose value + * type satisfies the `handoutOne` `HopContainer` concept. + * @param first Beginning of the target range. + * @param last End of the target range. + * @param seqFirst Beginning of the hop-container sequence (one entry per + * hop level in the Livecache). + * @param seqLast End of the hop-container sequence. + */ template void handout(TargetFwdIter first, TargetFwdIter last, SeqFwdIter seqFirst, SeqFwdIter seqLast) @@ -72,37 +114,65 @@ handout(TargetFwdIter first, TargetFwdIter last, SeqFwdIter seqFirst, SeqFwdIter //------------------------------------------------------------------------------ -/** Receives handouts for redirecting a connection. - An incoming connection request is redirected when we are full on slots. -*/ +/** Collects redirect endpoints to return to a peer that cannot be accepted. + * + * Used when a new inbound connection must be turned away because the node + * has no free slots. Gathers up to `Tuning::kREDIRECT_ENDPOINT_COUNT` (10) + * alternative addresses to include in the TMEndpoints redirect response so + * the rejected peer can try elsewhere. + * + * `tryInsert()` enforces: hop limit ≤ `kMAX_HOPS`, rejects hops-0 (our own + * address), rejects the connecting peer's own IP, and deduplicates by address + * ignoring port (port dedup prevents an adversary from flooding the list with + * the same host on different ports). + */ class RedirectHandouts { public: + /** Construct with the slot of the connecting peer being redirected. + * + * @param slot The inbound slot for the peer that will receive the redirect. + * Its remote address is used to filter out the peer's own address. + */ template explicit RedirectHandouts(SlotImp::ptr slot); + /** Attempt to add an endpoint to the redirect list. + * + * Rejects the endpoint if the list is full, the hop count exceeds + * `Tuning::kMAX_HOPS`, the hop count is 0 (our own address), the + * address belongs to the connecting peer, or the address (ignoring + * port) is already in the list. + * + * @param ep The candidate endpoint. + * @return `true` if the endpoint was accepted and added, `false` otherwise. + */ template bool tryInsert(Endpoint const& ep); + /** Return `true` when the redirect list has reached `kREDIRECT_ENDPOINT_COUNT`. */ [[nodiscard]] bool full() const { return list_.size() >= Tuning::kREDIRECT_ENDPOINT_COUNT; } + /** Return the slot of the peer being redirected. */ [[nodiscard]] SlotImp::ptr const& slot() const { return slot_; } + /** Return a mutable reference to the collected redirect endpoints. */ std::vector& list() { return list_; } + /** Return a read-only reference to the collected redirect endpoints. */ [[nodiscard]] std::vector const& list() const { @@ -158,35 +228,66 @@ RedirectHandouts::tryInsert(Endpoint const& ep) //------------------------------------------------------------------------------ -/** Receives endpoints for a slot during periodic handouts. */ +/** Collects endpoints for the periodic TMEndpoints gossip broadcast to a peer. + * + * Accumulates up to `Tuning::kNUMBER_OF_ENDPOINTS` (12) entries for one + * outbound TMEndpoints message. Beyond the standard filters (hop limit, + * own-address exclusion, port-agnostic address dedup), `tryInsert()` consults + * `slot_->recent.filter()` to avoid re-sending anything recently exchanged + * with this peer. On acceptance it also calls `slot_->recent.insert()` — + * a pessimistic update that prevents the same endpoint from being re-sent + * on the next broadcast cycle before the far end's cache has expired. + * + * @note `Logic::buildEndpointsForPeers` creates one `SlotHandouts` per active + * peer, then calls `handout()` across the full set — this is the primary + * multi-target use of `handout()` where its fairness properties matter + * most, preventing early slots from monopolising the best endpoints. + */ class SlotHandouts { public: + /** Construct a handout collector for the given connected peer slot. + * + * @param slot The active peer slot that will receive the endpoint list. + */ template explicit SlotHandouts(SlotImp::ptr slot); + /** Attempt to add an endpoint to the broadcast list. + * + * Applies hop-limit, recent-cache, own-address, and port-agnostic + * address-dedup filters. On success, records the endpoint in + * `slot_->recent` to suppress duplicate sends for the cache's lifetime. + * + * @param ep The candidate endpoint. + * @return `true` if the endpoint was accepted and added, `false` otherwise. + */ template bool tryInsert(Endpoint const& ep); + /** Return `true` when the list has reached `kNUMBER_OF_ENDPOINTS`. */ [[nodiscard]] bool full() const { return list_.size() >= Tuning::kNUMBER_OF_ENDPOINTS; } + /** Append an endpoint unconditionally (used to inject the self-advertisement entry). */ void insert(Endpoint const& ep) { list_.push_back(ep); } + /** Return the slot that will receive this broadcast. */ [[nodiscard]] SlotImp::ptr const& slot() const { return slot_; } + /** Return the collected endpoint list for transmission. */ [[nodiscard]] std::vector const& list() const { @@ -242,14 +343,33 @@ SlotHandouts::tryInsert(Endpoint const& ep) //------------------------------------------------------------------------------ -/** Receives handouts for making automatic connections. */ +/** Collects candidate addresses for the node to actively dial. + * + * Combines filtering and squelch-set management in a single object. + * `tryInsert()` atomically checks and writes the shared `Squelches` set: + * addresses already present are silently skipped; new addresses are added + * to both the squelch set and the output list. Because `squelches_` is + * taken by reference, squelches from prior connection rounds persist across + * calls — recently-attempted addresses age out after + * `Tuning::kRECENT_ATTEMPT_DURATION` (60 s) with no extra bookkeeping in + * the caller. + * + * `Logic::autoconnect` feeds both the Livecache (reverse hop order, to + * prefer topologically distant nodes) and the Bootcache as a fallback. + */ class ConnectHandouts { public: - // Keeps track of addresses we have made outgoing connections - // to, for the purposes of not connecting to them too frequently. + /** Time-bounded set of addresses recently attempted, shared across calls. + * + * An `aged_set` keyed by `beast::IP::Address`; entries expire after + * `Tuning::kRECENT_ATTEMPT_DURATION` (60 s). Held by reference so that + * retry suppression persists across multiple `ConnectHandouts` instances + * within the same `Logic` lifetime. + */ using Squelches = beast::aged_set; + /** The output container type for candidate outbound addresses. */ using list_type = std::vector; private: @@ -258,37 +378,58 @@ private: list_type list_; public: + /** Construct with the number of connections needed and the shared squelch set. + * + * @param needed Number of outbound addresses to collect. + * @param squelches Reference to the `Logic`-owned aged set used to + * suppress rapid reconnects to the same address. + */ template ConnectHandouts(std::size_t needed, Squelches& squelches); + /** Attempt to add a raw endpoint to the candidate list. + * + * Rejects the endpoint if the list is full, the address (ignoring port) + * is already in the list, or the address is present in `squelches_`. + * On acceptance the address is inserted into `squelches_` so subsequent + * calls (in this or future rounds) will skip it until it ages out. + * + * @param endpoint The candidate outbound address. + * @return `true` if the endpoint was accepted, `false` otherwise. + */ template bool tryInsert(beast::IP::Endpoint const& endpoint); + /** Return `true` when no candidate addresses have been collected yet. */ [[nodiscard]] bool empty() const { return list_.empty(); } + /** Return `true` when the required number of addresses has been collected. */ [[nodiscard]] bool full() const { return list_.size() >= needed_; } + /** Overload accepting a PeerFinder `Endpoint`; delegates to the `beast::IP::Endpoint` overload. */ bool tryInsert(Endpoint const& endpoint) { return tryInsert(endpoint.address); } + /** Return a mutable reference to the collected candidate addresses. */ list_type& list() { return list_; } + /** Return a read-only reference to the collected candidate addresses. */ [[nodiscard]] list_type const& list() const { diff --git a/src/xrpld/peerfinder/detail/Livecache.h b/src/xrpld/peerfinder/detail/Livecache.h index 3222a13d60..44a2770761 100644 --- a/src/xrpld/peerfinder/detail/Livecache.h +++ b/src/xrpld/peerfinder/detail/Livecache.h @@ -1,3 +1,16 @@ +/** @file + * Short-lived peer endpoint cache partitioned by hop count. + * + * `Livecache` stores endpoint advertisements received via `mtENDPOINTS` + * gossip messages. Entries expire after `Tuning::kLIVE_CACHE_SECONDS_TO_LIVE` + * (30 seconds) — peers only advertise when they have open slots, so a stale + * advertisement is actively misleading. + * + * Contrast with `Bootcache`, which persists verified addresses across + * restarts. `Livecache` is the hot, real-time view of who has open slots + * right now; `Bootcache` is the cold historical record of reachable peers. + */ + #pragma once #include @@ -22,12 +35,25 @@ class Livecache; namespace detail { +/** Non-template base for `Livecache` holding type definitions shared across + * all allocator specializations. + * + * Separating these types from the template avoids redundant instantiation + * and allows `Hop` to be used without knowing the allocator. + */ class LivecacheBase { public: explicit LivecacheBase() = default; protected: + /** Combined storage node for the aged map and a hop-count intrusive list. + * + * Inheriting from `list_base_hook<>` embeds the doubly-linked list + * pointers directly in the struct, so each `Element` can simultaneously + * be a value in the `aged_map` and a node in one of the per-hop + * `list_type` containers with no additional heap allocation. + */ struct Element : boost::intrusive::list_base_hook<> { Element(Endpoint endpoint) : endpoint(std::move(endpoint)) @@ -37,19 +63,39 @@ protected: Endpoint endpoint; }; + /** Intrusive doubly-linked list of `Element` objects. + * + * `constant_time_size` is chosen because `size()` is never + * called on individual hop lists; omitting the counter saves one + * word per list. + */ using list_type = boost::intrusive::make_list>::type; public: - /** A list of Endpoint at the same hops - This is a lightweight wrapper around a reference to the underlying - container. - */ + /** Read-only (or mutable, depending on `IsConst`) view of all endpoints + * at a single hop distance. + * + * `Hop` is a lightweight reference wrapper around a `list_type`. It + * exposes `Endpoint const&` through `boost::transform_iterator` so + * callers never see the intrusive-list machinery. The `IsConst` parameter + * is threaded through `beast::MaybeConst` to produce either a `const` + * or mutable reference to the underlying list from a single template. + * + * @note `moveBack()` requires a `const_cast` internally because the + * iterator type is always const even though the storage is mutable. + * This is safe: the `Element` objects are owned by the `aged_map` + * and are never truly const. + */ template class Hop { public: - // Iterator transformation to extract the endpoint from Element + /** Functor that projects an `Element` to its contained `Endpoint`. + * + * Used as the transformation in `boost::transform_iterator` so that + * iteration over the intrusive list yields `Endpoint const&` values. + */ struct Transform { using first_argument = Element; @@ -122,7 +168,14 @@ public: return reverse_iterator(list_.get().crend(), Transform()); } - // move the element to the end of the container + /** Move the element at `pos` to the tail of this hop bucket. + * + * Called by `Handouts` after an endpoint is handed out to a peer, + * ensuring that recently-distributed addresses recede to the back + * and other entries get priority on the next handout round. + * + * @param pos Iterator to the element to move. + */ void moveBack(const_iterator pos) { @@ -155,18 +208,26 @@ protected: //------------------------------------------------------------------------------ -/** The Livecache holds the short-lived relayed Endpoint messages. - - Since peers only advertise themselves when they have open slots, - we want these messages to expire rather quickly after the peer becomes - full. - - Addresses added to the cache are not connection-tested to see if - they are connectable (with one small exception regarding neighbors). - Therefore, these addresses are not suitable for persisting across - launches or for bootstrapping, because they do not have verifiable - and locally observed uptime and connectability information. -*/ +/** Short-lived cache of relayed peer endpoint advertisements. + * + * Populated from `mtENDPOINTS` gossip messages. Entries expire after + * `Tuning::kLIVE_CACHE_SECONDS_TO_LIVE` (30 s) because a peer only + * advertises itself when it has open connection slots; stale entries + * represent peers that are now full and should not be contacted. + * + * Addresses are stored without connectivity verification, so this cache + * is unsuitable for bootstrapping across restarts — use `Bootcache` for + * that. `Livecache` is the real-time view of peers with open slots. + * + * Entries are partitioned into `hops` buckets (one per hop distance 0–7). + * `Logic` calls `hops.shuffle()` before every handout to prevent a + * malicious peer from biasing topology by spamming its own address to the + * front of the list. + * + * @tparam Allocator Standard allocator forwarded to the internal + * `beast::aged_map`. Defaults to `std::allocator`. Tests inject + * a custom allocator for deterministic memory tracking. + */ template > class Livecache : protected detail::LivecacheBase { @@ -184,27 +245,44 @@ private: public: using allocator_type = Allocator; - /** Create the cache. */ + /** Construct the cache. + * + * @param clock Steady clock used by the internal `aged_map` for TTL + * tracking. In production this is the process-wide `steady_clock`; + * tests inject a `TestStopwatch` for deterministic expiration. + * @param journal Logging sink for insert/expire/refresh trace messages. + * @param alloc Allocator forwarded to the `aged_map`. Defaults to + * `std::allocator`. + */ Livecache(clock_type& clock, beast::Journal journal, Allocator alloc = Allocator()); - // - // Iteration by hops - // - // The range [begin, end) provides a sequence of list_type - // where each list contains endpoints at a given hops. - // - + /** Hop-partitioned index over the entries in `cache_`. + * + * Iterating over `hops` yields a sequence of `Hop` views, one + * per hop distance. Array index 0 is the local node; indices 1 through + * `kMAX_HOPS` are normal relayable endpoints; index `kMAX_HOPS + 1` is + * an overflow bucket for endpoints that arrived at `kMAX_HOPS` and had + * their hop count incremented before insertion. Overflow endpoints are + * used for outbound connection attempts and redirect responses but are + * never propagated further — doing so would push them past the hop limit. + * + * @note Always call `shuffle()` before iterating for handout. New + * entries are always pushed to the front (`push_front`), so without + * a shuffle a malicious peer can bias which addresses other nodes + * connect to by repeatedly advertising its own address. + */ class HopsT { private: - // An endpoint at hops=0 represents the local node. - // Endpoints coming in at maxHops are stored at maxHops +1, - // but not given out (since they would exceed maxHops). They - // are used for automatic connection attempts. - // using Histogram = std::array; using lists_type = std::array; + /** Functor that wraps a `list_type` reference in a `Hop` view. + * + * Used as the transformation in the outer `boost::transform_iterator` + * so that iterating over `HopsT` yields `Hop` objects rather than + * raw `list_type` references. + */ template struct Transform { @@ -305,23 +383,49 @@ public: return const_reverse_iterator(lists_.crend(), Transform()); } - /** Shuffle each hop list. */ + /** Randomize the order of every hop bucket. + * + * Copies each bucket's elements into a temporary vector, shuffles + * with `defaultPrng()`, and rebuilds the list. Must be called before + * any handout operation (`redirect`, `autoconnect`, + * `buildEndpointsForPeers`) to prevent topology bias from a peer + * that repeatedly advertises itself to the front of the list. + */ void shuffle(); + /** Return a comma-separated count of entries per hop bucket. + * + * The string has one integer per bucket (indices 0 through + * `kMAX_HOPS + 1`), e.g. `"0, 3, 5, 2, 0, 0, 0, 0"`. Used by + * `onWrite` for diagnostics and by unit tests to verify distribution. + * + * @return Comma-separated histogram string. + */ [[nodiscard]] std::string histogram() const; private: explicit HopsT(Allocator const& alloc); + /** Add `e` to the front of its hop bucket and update the histogram. */ void insert(Element& e); - // Reinsert e at a new hops + /** Move `e` from its current hop bucket to the bucket for `hops`, + * updating the histogram accordingly. + * + * Called when a duplicate address is re-advertised at a lower hop + * count than the stored value. + */ void reinsert(Element& e, std::uint32_t hops); + /** Unlink `e` from its hop bucket and update the histogram. + * + * Called by `Livecache::expire()` before erasing the element from + * the aged map. + */ void remove(Element& e); @@ -330,29 +434,60 @@ public: Histogram hist_{}; } hops; - /** Returns `true` if the cache is empty. */ + /** Return `true` if the cache contains no entries. */ [[nodiscard]] bool empty() const { return cache_.empty(); } - /** Returns the number of entries in the cache. */ + /** Return the number of unique addresses currently in the cache. */ typename cache_type::size_type size() const { return cache_.size(); } - /** Erase entries whose time has expired. */ + /** Remove all entries older than `Tuning::kLIVE_CACHE_SECONDS_TO_LIVE`. + * + * Scans the `aged_map` in chronological order (oldest first) and stops + * as soon as an entry within the TTL window is found. For each expired + * entry, removes it from its hop bucket before erasing it from the map. + * Called by `Logic::once_per_second()`. + */ void expire(); - /** Creates or updates an existing Element based on a new message. */ + /** Insert or refresh an endpoint advertisement. + * + * Three cases: + * - **New address**: added to the map and placed in the hop bucket for + * `ep.hops`. + * - **Duplicate at a higher hop count**: silently dropped. A closer + * path already exists; the more-distant advertisement carries no new + * information. + * - **Duplicate at the same or lower hop count**: the entry's TTL is + * refreshed via `touch()`. If the new hop count is strictly lower, the + * element is moved to the correct bucket via `reinsert()`. + * + * @param ep The endpoint to insert. `ep.hops` must be ≤ + * `Tuning::kMAX_HOPS + 1`; the caller (Logic::onEndpoints) is + * responsible for incrementing hop count before calling this method, + * so an address received at `kMAX_HOPS` arrives here at + * `kMAX_HOPS + 1` and lands in the overflow bucket. + * @pre `ep.hops <= Tuning::kMAX_HOPS + 1` (asserted). + */ void insert(Endpoint const& ep); - /** Output statistics. */ + /** Write cache statistics and entry details to a `PropertyStream`. + * + * Emits `size`, a `hist` histogram string, and an `entries` array + * where each element contains the address, hop count, and remaining + * TTL in clock ticks. + * + * @param map Destination property map (e.g. for the `/crawl` endpoint). + */ void onWrite(beast::PropertyStream::Map& map); }; @@ -391,12 +526,6 @@ template void Livecache::insert(Endpoint const& ep) { - // The caller already incremented hop, so if we got a - // message at maxHops we will store it at maxHops + 1. - // This means we won't give out the address to other peers - // but we will use it to make connections and hand it out - // when redirecting. - // XRPL_ASSERT( ep.hops <= (Tuning::kMAX_HOPS + 1), "xrpl::PeerFinder::Livecache::insert : maximum input hops"); @@ -411,7 +540,6 @@ Livecache::insert(Endpoint const& ep) } if (!result.second && (ep.hops > e.endpoint.hops)) { - // Drop duplicates at higher hops std::size_t const excess(ep.hops - e.endpoint.hops); JLOG(journal_.trace()) << beast::Leftw(18) << "Livecache drop " << ep.address << " at hops +" << excess; @@ -420,7 +548,6 @@ Livecache::insert(Endpoint const& ep) cache_.touch(result.first); - // Address already in the cache so update metadata if (ep.hops < e.endpoint.hops) { hops.reinsert(e, ep.hops); diff --git a/src/xrpld/peerfinder/detail/Logic.h b/src/xrpld/peerfinder/detail/Logic.h index fce5bb4afa..1d78a8a8f0 100644 --- a/src/xrpld/peerfinder/detail/Logic.h +++ b/src/xrpld/peerfinder/detail/Logic.h @@ -1,3 +1,15 @@ +/** @file + * Central connection-strategy engine for the XRPL PeerFinder subsystem. + * + * Declares `Logic`, the policy core that answers every + * resource-management question the overlay layer cannot resolve on its own: + * which addresses to connect to, which inbound connections to accept, what + * endpoint gossip to broadcast, and how to record success or failure. The + * `Checker` template parameter is the async TCP reachability prober; the + * production implementation is `PeerFinder::Checker` while unit tests inject + * a synchronous mock. + */ + #pragma once #include @@ -25,70 +37,124 @@ namespace xrpl::PeerFinder { -/** The Logic for maintaining the list of Slot addresses. - We keep this in a separate class so it can be instantiated - for unit tests. -*/ +/** Central decision-making engine for the XRPL PeerFinder subsystem. + * + * `Logic` answers every policy question the network layer cannot resolve on + * its own: which addresses to attempt, which incoming connections to accept, + * what endpoint gossip to broadcast, and how to record success or failure. + * + * Six data structures capture the complete topology state: + * - `slots` — master table keyed by remote endpoint; every live connection + * regardless of state has an entry here. + * - `connectedAddresses` — port-stripped multiset used to enforce the per-IP + * connection limit (`Config::ipLimit`). + * - `keys` — deduplication set of public keys; prevents two connections to the + * same cryptographic identity. + * - `fixed_` — private map of always-on peer endpoints to their `Fixed` + * Fibonacci-backoff records. + * - `livecache` — short-lived (30 s TTL) gossip cache populated from + * received `mtENDPOINTS` messages. + * - `bootcache` — persistent address store backed by the injected `Store` + * (SQLite in production). + * + * @par Thread safety + * All public methods acquire `lock` (a `std::recursive_mutex`) before + * accessing shared state. The recursive variant is required because + * `onClosed()` calls `remove()`, which is also independently lockable. + * + * @tparam Checker Async reachability prober. In production this is + * `PeerFinder::Checker`; unit tests may inject a synchronous mock. + * + * @see Counts, Bootcache, Livecache, Fixed, SlotImp + */ template class Logic { public: - // Maps remote endpoints to slots. Since a slot has a - // remote endpoint upon construction, this holds all counts_. - // + /** Map type from remote endpoint to slot. Every live connection, inbound + * or outbound and regardless of handshake state, has an entry here. + * This is the single source of truth for "are we connected to this + * address?". + */ using Slots = std::map>; + /** Journal used for all diagnostic and trace logging within Logic. */ beast::Journal journal; + + /** Monotonic clock shared with the Livecache, Bootcache, and Fixed records. */ clock_type& clock; + + /** Persistent address store (SQLite in production) used by `bootcache`. */ Store& store; + + /** Async reachability prober; called to verify a peer's listening port. */ Checker& checker; + /** Guards all mutable state. Recursive because `onClosed` calls `remove`. */ std::recursive_mutex lock; - // True if we are stopping. + /** Set to `true` by `stop()`; prevents new fetches and terminates running ones. */ bool stopping = false; - // The source we are currently fetching. - // This is used to cancel I/O during program exit. + /** The address source currently being fetched; set so `stop()` can cancel it. */ std::shared_ptr fetchSource; private: - // Configuration settings Config config_; - // Slot counts and other aggregate statistics. Counts counts_; - // A list of slots that should always be connected + /** Always-on peers mapped to their Fibonacci-backoff reconnect state. */ std::map fixed_; public: - // Live livecache from mtENDPOINTS messages + /** Short-lived (30 s TTL) gossip cache populated from received `mtENDPOINTS` + * messages. Organised internally by hop count; `buildEndpointsForPeers` + * reads from this when assembling endpoint broadcasts. + */ Livecache<> livecache; - // LiveCache of addresses suitable for gaining initial connections + /** Persistent address store consulted when the livecache is empty and no + * outbound attempts are in flight. + */ Bootcache bootcache; - // Holds all counts + /** Master slot table keyed by remote endpoint. See the `Slots` typedef. */ Slots slots; - // The addresses (but not port) we are connected to. This includes - // outgoing connection attempts. Note that this set can contain - // duplicates (since the port is not set) + /** Port-stripped multiset of all connected (or attempting) IP addresses, + * used to enforce `Config::ipLimit`. May contain duplicates when multiple + * connections share the same host but use different ports. + */ std::multiset connectedAddresses; - // Set of public keys belonging to active peers + /** Deduplication set of public keys for all active peers. + * Prevents two simultaneous connections to the same cryptographic identity. + */ std::set keys; - // A list of dynamic sources to consult as a fallback + /** Dynamic address sources consulted when the bootcache needs refilling. */ std::vector> sources; + /** Next time at which `buildEndpointsForPeers` will broadcast endpoint lists. + * Advanced by `Tuning::kSECONDS_PER_MESSAGE` after each broadcast cycle. + */ clock_type::time_point whenBroadcast; + /** Aged set (60 s TTL) of recently attempted remote addresses, shared across + * `autoconnect()` calls to prevent rapid reconnection to the same address. + */ ConnectHandouts::Squelches squelches; //-------------------------------------------------------------------------- public: + /** Construct a `Logic` instance and apply a default-constructed `Config`. + * + * @param clock Shared monotonic clock (typically `UptimeClock`). + * @param store Persistent address store used to back the bootcache. + * @param checker Async TCP reachability prober for connectivity tests. + * @param journal Diagnostic journal for all log output from this object. + */ Logic(clock_type& clock, Store& store, Checker& checker, beast::Journal journal) : journal(journal) , clock(clock) @@ -102,8 +168,11 @@ public: config({}); } - // Load persistent state information from the Store - // + /** Load persistent bootcache state from the backing store. + * + * Must be called once during startup, before any connections are made. + * Delegates to `Bootcache::load()` under `lock`. + */ void load() { @@ -111,12 +180,12 @@ public: bootcache.load(); } - /** Stop the logic. - This will cancel the current fetch and set the stopping flag - to `true` to prevent further fetches. - Thread safety: - Safe to call from any thread. - */ + /** Signal shutdown: cancel any in-progress source fetch and block new ones. + * + * Sets `stopping = true` and calls `cancel()` on `fetchSource` if a fetch + * is currently running. After this returns, `fetch()` will no-op on every + * subsequent call. Safe to call from any thread. + */ void stop() { @@ -132,6 +201,13 @@ public: // //-------------------------------------------------------------------------- + /** Apply a new configuration and propagate slot-count limits to `Counts`. + * + * Replaces the stored `Config` and calls `Counts::onConfig` to recompute + * inbound/outbound maximums. Safe to call after startup to adjust limits. + * + * @param c New configuration to apply. + */ void config(Config const& c) { @@ -140,6 +216,10 @@ public: counts_.onConfig(config_); } + /** Return a snapshot of the current configuration. + * + * @return A copy of the active `Config` at the time of the call. + */ Config config() { @@ -147,12 +227,31 @@ public: return config_; } + /** Add a single fixed-peer endpoint to the always-on list. + * + * Convenience overload that wraps `ep` in a one-element vector and + * delegates to the multi-address overload. + * + * @param name Human-readable label used in log messages. + * @param ep The single remote endpoint to add as a fixed peer. + */ void addFixedPeer(std::string const& name, beast::IP::Endpoint const& ep) { addFixedPeer(name, std::vector{ep}); } + /** Add one or more fixed-peer endpoints to the always-on list. + * + * For each address that is not already in `fixed_`, a new `Fixed` record + * is created with a zero backoff (immediately eligible). Addresses with + * port 0 are rejected with an exception. If `addresses` is empty, a + * warning is logged and the call is a no-op. + * + * @param name Human-readable label (e.g. hostname) for log messages. + * @param addresses Resolved endpoints to register as fixed peers. + * @throws std::runtime_error if any address has port 0. + */ void addFixedPeer(std::string const& name, std::vector const& addresses) { @@ -188,7 +287,23 @@ public: //-------------------------------------------------------------------------- - // Called when the Checker completes a connectivity test + /** Handle completion of an async connectivity test initiated by `onEndpoints`. + * + * If `ec == boost::asio::error::operation_aborted` the check was cancelled + * during shutdown and the callback returns immediately without acquiring + * `lock`. Otherwise the slot identified by `remoteAddress` is looked up; + * if the connection has already closed, the result is silently discarded. + * + * On success (`!ec`): marks `slot.canAccept = true` and records the + * peer's listening port so subsequent `onEndpoints` calls can add it to + * the livecache. On failure: marks `slot.canAccept = false` and calls + * `bootcache.onFailure(checkedAddress)`. + * + * @param remoteAddress Remote endpoint of the slot that triggered the + * check; used as the `slots` map key. + * @param checkedAddress The address/port that was probed by `Checker`. + * @param ec Result of the async TCP connect attempt. + */ void checkComplete( beast::IP::Endpoint const& remoteAddress, @@ -232,6 +347,23 @@ public: //-------------------------------------------------------------------------- + /** Register a new inbound connection and allocate its slot. + * + * Applies two gatekeeping checks before creating the slot: + * 1. Per-IP connection limit (`Config::ipLimit`) — enforced only for + * public remote addresses; RFC-private addresses are exempt. + * 2. Duplicate detection — if `slots` already contains `remoteEndpoint`, + * the connection is a duplicate and is rejected. + * + * On success the slot is created in `Slot::State::Accept`, inserted into + * `slots` and `connectedAddresses`, and `Counts` is updated. + * + * @param localEndpoint The local socket endpoint (TLS listener address). + * @param remoteEndpoint The remote peer's TCP endpoint. + * @return A pair of `{slot, Result::Success}` on success, or + * `{nullptr, Result::IpLimitExceeded}` / `{nullptr, Result::DuplicatePeer}` + * on rejection. + */ std::pair newInboundSlot( beast::IP::Endpoint const& localEndpoint, @@ -282,7 +414,19 @@ public: return {result.first->second, Result::Success}; } - // Can't check for self-connect because we don't know the local endpoint + /** Register a new outbound connection attempt and allocate its slot. + * + * Creates a slot in `Slot::State::Connect` for the given remote endpoint. + * Rejects duplicate attempts (same `remoteEndpoint` already in `slots`). + * Self-connect detection is deferred to `onConnected()` because the local + * endpoint is not yet known at this stage. + * + * On success the slot is inserted into `slots` and `connectedAddresses`, + * and `Counts` is updated to reflect the in-flight attempt. + * + * @param remoteEndpoint The remote peer's TCP endpoint to connect to. + * @return `{slot, Result::Success}` or `{nullptr, Result::DuplicatePeer}`. + */ std::pair newOutboundSlot(beast::IP::Endpoint const& remoteEndpoint) { @@ -319,6 +463,19 @@ public: return {result.first->second, Result::Success}; } + /** Notify Logic that a TCP connection has been established for an outbound slot. + * + * Records the now-known `localEndpoint` on the slot and performs + * self-connect detection: if `localEndpoint` already appears as a remote + * endpoint in `slots`, this connection loops back to ourselves and must be + * torn down. Advances the slot state from `Connect` to `Connected` and + * updates `Counts`. + * + * @param slot The outbound slot that just completed TCP connect. + * @param localEndpoint The local socket address assigned by the OS. + * @return `true` if the connection is valid and should proceed to TLS + * handshake; `false` if it was detected as a self-connect. + */ bool onConnected(SlotImp::ptr const& slot, beast::IP::Endpoint const& localEndpoint) { @@ -357,6 +514,28 @@ public: return true; } + /** Promote a handshaked slot to active status after XRPL protocol handshake. + * + * This is the final gate before a peer is considered a full, active + * connection. Three checks must pass: + * 1. `key` must not already be in `keys` (prevents duplicate identity). + * 2. `counts_.canActivate(*slot)` must return `true` (capacity check), + * unless the slot is fixed or reserved — those bypass slot limits. + * 3. The slot must currently be in `Accept` or `Connected` state. + * + * On success: registers `key` in `keys`, transitions the slot to + * `Slot::State::Active`, updates `Counts`, and records a bootcache success + * for outbound slots. For outbound fixed slots, advances the `Fixed` + * success state (resets backoff). + * + * @param slot The slot that completed the XRPL handshake. + * @param key The peer's node public key as exchanged in the handshake. + * @param reserved `true` if the peer is in the reservation table or cluster. + * @return `Result::Success` if the slot was activated; + * `Result::DuplicatePeer` if `key` is already connected; + * `Result::Full` if all ordinary slots of the appropriate direction + * are occupied; `Result::InboundDisabled` if inbound is configured off. + */ Result activate(SlotImp::ptr const& slot, PublicKey const& key, bool reserved) { @@ -432,10 +611,17 @@ public: return Result::Success; } - /** Return a list of addresses suitable for redirection. - This is a legacy function, redirects should be returned in - the HTTP handshake and not via TMEndpoints. - */ + /** Return a list of livecache addresses to send as a redirect response. + * + * Legacy function: redirects are now returned in the HTTP 503 handshake + * body rather than via `TMEndpoints`. Shuffles the livecache hop list + * and feeds it through the `handout()` algorithm into a + * `RedirectHandouts` receiver for the given slot. + * + * @param slot The slot being redirected; used to filter already-known + * addresses via its `recent` cache. + * @return A vector of `Endpoint` objects suitable for a redirect message. + */ std::vector redirect(SlotImp::ptr const& slot) { @@ -446,9 +632,32 @@ public: return std::move(h.list()); } - /** Create new outbound connection attempts as needed. - This implements PeerFinder's "Outbound Connection Strategy" - */ + /** Compute the next batch of outbound connection addresses. + * + * Implements the four-tier outbound connection strategy. Returns early at + * the first tier that produces candidates, preventing redundant attempts: + * + * 1. **Fixed peers** — if fewer fixed connections are active than + * configured, `getFixed()` scans `fixed_` for eligible (backoff + * elapsed, not already connected or squelched) entries. If none are + * ready but outbound attempts are in flight, returns empty and waits. + * 2. **Livecache** — shuffled and iterated in reverse-hop order (highest + * hops first for topological diversity) via `ConnectHandouts`. + * 3. **Bootcache refill** — DNS-based placeholder (not yet implemented). + * 4. **Bootcache fallback** — iterates bootcache entries until the + * `ConnectHandouts` receiver is full. + * + * Between tiers, if in-flight attempts already exist but a tier produced + * no new candidates, the call returns an empty list to avoid a + * thundering-herd reconnect storm. The `squelches` aged set (60 s TTL) + * prevents rapid reconnection to the same address across calls. + * + * @return Endpoints to attempt; may be empty if no candidates are + * available or if existing attempts are still pending. + * + * @note Must be called periodically (e.g. once per `kSECONDS_PER_CONNECT`) + * by the owning `ManagerImp` timer callback. + */ // VFALCO TODO This should add the returned addresses to the // squelch list in one go once the list is built, // rather than having each module add to the squelch list. @@ -557,6 +766,24 @@ public: return none; } + /** Assemble the endpoint lists to broadcast to each active peer. + * + * Called periodically (rate-limited to one pass per + * `Tuning::kSECONDS_PER_MESSAGE`). On each cycle: + * 1. Collects all `Slot::State::Active` slots, shuffles them to vary + * broadcast order across cycles. + * 2. If `config_.wantIncoming` and inbound slots are available, + * injects a self-advertisement entry with `hops == 0` and the + * all-zeros IPv6 address (recipients substitute the socket's remote + * address, sidestepping the "what is my public IP" problem). + * 3. Distributes livecache entries fairly across all target slots via + * the `handout()` round-robin algorithm. + * 4. Advances `whenBroadcast` by `Tuning::kSECONDS_PER_MESSAGE`. + * + * @return A vector of `{slot, endpoints}` pairs. The caller (typically + * `ManagerImp`) is responsible for serialising each list into a + * `mtENDPOINTS` message and sending it on the corresponding slot. + */ std::vector, std::vector>> buildEndpointsForPeers() { @@ -639,6 +866,15 @@ public: return result; } + /** Perform periodic maintenance tasks, called once per second. + * + * Under `lock`, in order: + * - Expires stale livecache entries. + * - Expires the `recent` address cache in each slot. + * - Expires stale entries from the `squelches` aged set. + * - Calls `Bootcache::periodicActivity` for cooldown-throttled SQLite + * writes and cache pruning. + */ void oncePerSecond() { @@ -659,7 +895,26 @@ public: //-------------------------------------------------------------------------- - // Validate and clean up the list that we received from the slot. + /** Validate and normalise an endpoint list received from a peer. + * + * Mutates `list` in place, removing entries that fail any check and + * incrementing the hop count of surviving entries by one (so that when + * the entries are later retransmitted, the hop reflects our own distance + * to the origin): + * + * - Entries with `hops > Tuning::kMAX_HOPS` (6) are dropped. + * - The first `hops == 0` entry is treated as the sender's self- + * advertisement: its IP is replaced with the sender's actual socket + * address (the sender does not know its own public IP). Any subsequent + * `hops == 0` entries are dropped as duplicates. + * - Non-public or unspecified addresses are dropped. + * - Addresses duplicated within the list are dropped (O(n²) scan, + * acceptable for small lists bounded by `kNUMBER_OF_ENDPOINTS_MAX`). + * + * @param slot The slot from which the endpoint list was received; used to + * substitute the sender's socket address for zero-hop entries. + * @param list The endpoint list to validate and normalise; modified in place. + */ void preprocess(SlotImp::ptr const& slot, Endpoints& list) { @@ -724,6 +979,28 @@ public: } } + /** Process an `mtENDPOINTS` gossip message received from an active peer. + * + * Oversized lists are randomly sampled down to + * `Tuning::kNUMBER_OF_ENDPOINTS_MAX` before any other processing. + * A per-slot rate limit (`Tuning::kSECONDS_PER_MESSAGE`, 151 s) prevents + * flooding — messages arriving faster than this are silently dropped. + * + * After the rate check, `preprocess()` cleans the list. For each + * surviving entry: + * - The address is recorded in the slot's `recent` cache. + * - For first-hop entries (`hops == 1` after increment), if the slot has + * not yet been connectivity-tested, `checker.asyncConnect()` is called + * and `connectivityCheckInProgress` is set; the first such entry is + * discarded pending the result. If the test already failed + * (`!slot->canAccept`), the entry is dropped entirely. + * - Entries that survive are added to `livecache` and `bootcache`. + * + * @param slot The active slot from which the message was received. + * @param list The deserialized endpoint list (taken by value for mutation). + * + * @note The slot must be in `Slot::State::Active`; the assert fires otherwise. + */ void onEndpoints(SlotImp::ptr const& slot, Endpoints list) { @@ -821,6 +1098,18 @@ public: //-------------------------------------------------------------------------- + /** Remove a slot from all internal data structures and update `Counts`. + * + * Erases the slot from `slots`, removes its public key from `keys` (if + * the key was set), and removes one entry from `connectedAddresses`. All + * three must exist at the time of the call; their absence triggers a + * `logicError` (fatal assertion). + * + * Called by `onClosed()`. Also callable independently; the recursive + * `lock` allows both paths to hold it simultaneously. + * + * @param slot The slot to remove; must be present in `slots`. + */ void remove(SlotImp::ptr const& slot) { @@ -869,6 +1158,18 @@ public: counts_.remove(*slot); } + /** Handle peer disconnection: clean up state and record outcomes. + * + * Calls `remove(slot)` to purge the slot from all tables, then performs + * state-specific bookkeeping: + * - **Fixed outbound slot not yet active**: calls `Fixed::failure()` to + * advance the Fibonacci backoff so the next reconnect attempt is delayed. + * - **`Connect` / `Connected` state**: calls `bootcache.onFailure()` to + * penalise the address's valence streak. + * - **`Active` / `Closing`**: informational log only. + * + * @param slot The slot whose connection just closed. + */ void onClosed(SlotImp::ptr const& slot) { @@ -929,6 +1230,14 @@ public: } } + /** Record a connection failure for bootcache valence accounting. + * + * Called when an outbound connection attempt fails at the TCP or TLS layer + * (distinct from `onClosed` which handles clean disconnects). Delegates + * to `Bootcache::onFailure` to decrement the address's valence streak. + * + * @param slot The slot whose outbound attempt failed. + */ void onFailure(SlotImp::ptr const& slot) { @@ -937,15 +1246,31 @@ public: bootcache.onFailure(slot->remoteEndpoint()); } - // Insert a set of redirect IP addresses into the Bootcache + /** Insert redirect IP addresses from an HTTP 503 response into the bootcache. + * + * Accepts up to `Tuning::kMAX_REDIRECTS` addresses from the iterator + * range `[first, last)`, converts each from the Asio TCP endpoint type to + * a `beast::IP::Endpoint`, and calls `bootcache.insert()`. Addresses + * beyond the limit are silently ignored. + * + * @tparam FwdIter Forward iterator over `boost::asio::ip::tcp::endpoint`. + * @param first Beginning of the redirect address range. + * @param last End of the redirect address range. + * @param remoteAddress The peer that sent the redirect (for logging only). + */ template void onRedirects(FwdIter first, FwdIter last, boost::asio::ip::tcp::endpoint const& remoteAddress); //-------------------------------------------------------------------------- - // Returns `true` if the address matches a fixed slot address - // Must have the lock held + /** Return `true` if `endpoint` exactly matches a configured fixed peer. + * + * Port-sensitive: `192.0.2.1:51235` and `192.0.2.1:51236` are distinct. + * Must be called with `lock` held. + * + * @param endpoint The remote endpoint to test. + */ bool fixed(beast::IP::Endpoint const& endpoint) const { @@ -957,9 +1282,14 @@ public: return false; } - // Returns `true` if the address matches a fixed slot address - // Note that this does not use the port information in the IP::Endpoint - // Must have the lock held + /** Return `true` if `address` (without port) matches any fixed peer. + * + * Port-insensitive: any fixed peer on the given IP qualifies, regardless + * of port. Used by `newInboundSlot` to tag inbound connections from a + * configured fixed-peer host. Must be called with `lock` held. + * + * @param address The remote IP address to test (port ignored). + */ bool fixed(beast::IP::Address const& address) const { @@ -977,7 +1307,24 @@ public: // //-------------------------------------------------------------------------- - /** Adds eligible Fixed addresses for outbound attempts. */ + /** Populate `c` with fixed-peer endpoints that are eligible for a new connection attempt. + * + * Iterates `fixed_` and appends up to `needed` endpoints that satisfy all + * three conditions: + * 1. The `Fixed::when()` backoff time has elapsed. + * 2. The address is not already in `squelches`. + * 3. The address is not already present in `slots` (not connected or + * attempting). + * + * Each selected address is also inserted into `squelches` so that the + * caller's handout loop does not try to add it again from another tier. + * + * @tparam Container A sequence container supporting `push_back` + * (typically `std::vector`). + * @param needed Maximum number of endpoints to add to `c`. + * @param c Output container receiving eligible endpoints. + * @param squelches Aged set of recently attempted addresses (in/out). + */ template void getFixed(std::size_t needed, Container& c, typename ConnectHandouts::Squelches& squelches) @@ -1000,12 +1347,27 @@ public: //-------------------------------------------------------------------------- + /** Fetch addresses from `source` immediately and insert them into the bootcache. + * + * Calls `fetch()` synchronously before returning. Intended for static + * sources (e.g. `[ips]` config entries) that must be loaded at startup + * before any connections are attempted. + * + * @param source The address source to fetch from. + */ void addStaticSource(std::shared_ptr const& source) { fetch(source); } + /** Register a dynamic address source for deferred resolution. + * + * Appends `source` to `sources` for future DNS-based bootcache refill. + * The source is not fetched immediately. + * + * @param source The dynamic address source to register. + */ void addSource(std::shared_ptr const& source) { @@ -1018,9 +1380,17 @@ public: // //-------------------------------------------------------------------------- - // Add a set of addresses. - // Returns the number of addresses added. - // + /** Insert a set of static addresses into the bootcache with elevated valence. + * + * Delegates to `Bootcache::insertStatic` for each address, which assigns + * `staticValence = 32` so configured peers outrank dynamically discovered + * ones during sorted iteration. Addresses already present are not + * re-inserted. + * + * @param list Addresses to insert (typically resolved from `[ips]` config). + * @return The number of addresses that were newly inserted (not already + * present in the bootcache). + */ int addBootcacheAddresses(IPAddresses const& list) { @@ -1034,7 +1404,21 @@ public: return count; } - // Fetch bootcache addresses from the specified source. + /** Synchronously fetch addresses from `source` and add them to the bootcache. + * + * Guards against shutdown races using a double-checked `stopping` flag: + * checked once before starting the synchronous fetch (sets `fetchSource` + * so `stop()` can cancel it) and again after the fetch returns (to avoid + * processing results after teardown has begun). + * + * On success, calls `addBootcacheAddresses` and logs the count. On error, + * logs at `error` level but does not throw. + * + * @note The fetch is currently synchronous (see inline VFALCO note). This + * blocks the calling thread for the duration of I/O. + * + * @param source The address source to fetch from. + */ void fetch(std::shared_ptr const& source) { @@ -1082,7 +1466,13 @@ public: // //-------------------------------------------------------------------------- - // Returns true if the IP::Endpoint contains no invalid data. + /** Return `true` if `address` is valid for inclusion in gossip or the bootcache. + * + * Rejects unspecified (0.0.0.0 / ::), non-public (RFC-private, loopback, + * multicast), and zero-port addresses. All three checks must pass. + * + * @param address The endpoint to validate. + */ bool isValidAddress(beast::IP::Endpoint const& address) { @@ -1101,6 +1491,16 @@ public: // //-------------------------------------------------------------------------- + /** Serialise all slots into a `PropertyStream::Set` for diagnostics. + * + * Each slot produces one map entry in `set` with keys `local_address`, + * `remote_address`, `state`, and optional flags `inbound`, `fixed`, + * `reserved`. Used by `onWrite` to populate the `peers` sub-tree of the + * administrative property stream. + * + * @param set Output set to append slot maps into. + * @param slots The slot table to serialise. + */ void writeSlots(beast::PropertyStream::Set& set, Slots const& slots) { @@ -1122,6 +1522,19 @@ public: } } + /** Serialise the complete internal state into a `PropertyStream::Map`. + * + * Produces the following sub-trees (consumed by the `peers` RPC endpoint): + * - `bootcache` — total entry count (uint32). + * - `fixed` — number of configured fixed peers. + * - `peers` — per-slot details via `writeSlots`. + * - `counts` — aggregated slot counts from `Counts::onWrite`. + * - `config` — active configuration from `Config::onWrite`. + * - `livecache` — livecache statistics. + * - `bootcache` — bootcache statistics (detailed sub-map). + * + * @param map The top-level property stream map to write into. + */ void onWrite(beast::PropertyStream::Map& map) { @@ -1165,12 +1578,27 @@ public: // //-------------------------------------------------------------------------- + /** Return the current slot-count snapshot for diagnostic inspection. + * + * Returns a `const` reference valid only while `lock` is held by the + * caller. Used by unit tests to verify count invariants after state + * transitions. + */ Counts const& counts() const { return counts_; } + /** Convert a `Slot::State` enumerator to a human-readable string. + * + * Returns one of: `"accept"`, `"connect"`, `"connected"`, `"active"`, + * `"closing"`, or `"?"` for unrecognised values. Used in `writeSlots` + * and log messages. + * + * @param state The slot state to convert. + * @return A string literal naming the state. + */ static std::string stateString(Slot::State state) { diff --git a/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp b/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp index 83b78883db..746c145151 100644 --- a/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp +++ b/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements `PeerFinder::Config` method bodies. + * + * This file bridges the server-level `xrpl::Config` (parsed from + * `rippled.cfg`) and the `PeerFinder::Manager` that enforces the + * connection policy at runtime. It provides the outbound-peer + * calculation (`calcOutPeers`), the per-IP admission-limit enforcement + * (`applyTuning`), and the factory that produces a fully validated + * `Config` from operator settings (`makeConfig`). + */ + #include #include #include @@ -15,6 +26,16 @@ Config::Config() : outPeers(calcOutPeers()) { } +/** Return `true` if every field of two `Config` objects is identical. + * + * Compares all policy fields: `autoConnect`, `peerPrivate`, + * `wantIncoming`, `inPeers`, `maxPeers`, `outPeers`, `features`, + * `ipLimit`, and `listeningPort`. + * + * @param lhs Left-hand `Config` operand. + * @param rhs Right-hand `Config` operand. + * @return `true` if all fields compare equal. + */ bool operator==(Config const& lhs, Config const& rhs) { @@ -37,17 +58,14 @@ Config::applyTuning() { if (ipLimit == 0) { - // Unless a limit is explicitly set, we allow between - // 2 and 5 connections from non RFC-1918 "private" - // IP addresses. ipLimit = 2; if (inPeers > Tuning::kDEFAULT_MAX_PEERS) ipLimit += std::min(5, static_cast(inPeers / Tuning::kDEFAULT_MAX_PEERS)); } - // We don't allow a single IP to consume all incoming slots, - // unless we only have one incoming slot available. + // Clamp so no single IP can monopolise inbound slots; minimum of 1 + // ensures the node remains connectable even with a single inbound slot. ipLimit = std::max(1, std::min(ipLimit, static_cast(inPeers / 2))); } @@ -74,7 +92,6 @@ Config::makeConfig( config.peerPrivate = cfg.PEER_PRIVATE; - // Servers with peer privacy don't want to allow incoming connections config.wantIncoming = (!config.peerPrivate) && (port != 0); if ((cfg.PEERS_OUT_MAX == 0u) && (cfg.PEERS_IN_MAX == 0u)) @@ -85,13 +102,9 @@ Config::makeConfig( config.maxPeers = std::max(config.maxPeers, Tuning::kMIN_OUT_COUNT); config.outPeers = config.calcOutPeers(); - // Calculate the number of outbound peers we want. If we dont want - // or can't accept incoming, this will simply be equal to maxPeers. if (!config.wantIncoming) config.outPeers = config.maxPeers; - // Calculate the largest number of inbound connections we could - // take. if (config.maxPeers >= config.outPeers) { config.inPeers = config.maxPeers - config.outPeers; @@ -108,21 +121,18 @@ Config::makeConfig( config.maxPeers = 0; } - // This will cause servers configured as validators to request that - // peers they connect to never report their IP address. We set this - // after we set the 'wantIncoming' because we want a "soft" version - // of peer privacy unless the operator explicitly asks for it. + // Force peerPrivate on validators *after* wantIncoming is set, so a + // validator without an explicit [peer_private] stanza still advertises + // inbound willingness ("soft" privacy: accepts connections but asks + // peers not to gossip its address). if (validationPublicKey) config.peerPrivate = true; - // if it's a private peer or we are running as standalone - // automatic connections would defeat the purpose. config.autoConnect = !cfg.standalone() && !cfg.PEER_PRIVATE; config.listeningPort = port; config.features = ""; config.ipLimit = ipLimit; - // Enforce business rules config.applyTuning(); return config; diff --git a/src/xrpld/peerfinder/detail/PeerfinderManager.cpp b/src/xrpld/peerfinder/detail/PeerfinderManager.cpp index 873c18aad9..de8b5e8d5a 100644 --- a/src/xrpld/peerfinder/detail/PeerfinderManager.cpp +++ b/src/xrpld/peerfinder/detail/PeerfinderManager.cpp @@ -1,3 +1,13 @@ +/** @file + * Concrete implementation of the PeerFinder `Manager` interface. + * + * Defines `ManagerImp`, which wires together the SQLite bootcache store, + * the async TCP reachability checker, and the `Logic` policy engine into a + * single cohesive subsystem. The class is intentionally hidden from all + * callers; the only externally visible symbols are the `Manager` base class + * (declared in `PeerfinderManager.h`) and the `makeManager()` factory below. + */ + #include #include @@ -30,22 +40,83 @@ namespace xrpl::PeerFinder { +/** Concrete implementation of the PeerFinder `Manager` interface. + * + * `ManagerImp` is the assembly point for the XRPL peer-discovery subsystem. + * It composes a SQLite-backed address store (`StoreSqdb`), an async TCP + * reachability prober (`Checker`), and the central policy engine (`Logic`), + * then delegates every `Manager` virtual call to `Logic` via a one-liner + * forwarding pattern. + * + * Slot events accept `std::shared_ptr` (the opaque public type) and + * downcast to `SlotImp` before forwarding, enforcing the layering boundary + * between callers and the internal machinery. The casts are safe because + * all `Slot` instances in circulation are created by `Logic` as `SlotImp`. + * + * @note This class is not exposed in any header. All consumer code must + * interact through the `Manager` interface returned by `makeManager()`. + */ class ManagerImp : public Manager { public: // NOLINTBEGIN(readability-identifier-naming) + /** The `io_context` shared with the rest of the node. */ boost::asio::io_context& io_context_; + + /** Keeps `io_context_` running while the manager is active. + * + * Constructed `std::in_place` during initialization and destroyed + * (via `work_.reset()`) in `stop()`. This is the canonical Asio + * pattern for preventing a context from exiting when its queue is + * transiently empty. + */ std::optional> work_; + + /** Abstract clock injected for testability. */ clock_type& clock_; + + /** Diagnostic journal for all PeerFinder log output. */ beast::Journal journal_; + + /** SQLite-backed persistence for the bootstrap address cache. + * + * Not opened until `start()` is called; construction is therefore + * cheap and performs no disk I/O. + */ StoreSqdb store_; + + /** Async TCP prober that verifies candidate peers' listening ports. + * + * Shares `io_context_` so its async operations participate in the same + * event loop as the rest of the node. + */ Checker checker_; + + /** Central policy engine for peer discovery and slot management. + * + * Nearly every `Manager` virtual method is a one-liner that forwards + * to `logic_`. + */ Logic logic_; + + /** Server-level configuration used to open the SQLite store in `start()`. */ BasicConfig const& config_; // NOLINTEND(readability-identifier-naming) //-------------------------------------------------------------------------- + /** Construct and wire all subsystem components. + * + * The work guard is armed immediately so the `io_context` stays alive + * from construction onward. The SQLite store is NOT opened here; + * call `start()` to perform disk I/O and populate the boot cache. + * + * @param ioContext The shared Asio I/O context. + * @param clock Abstract clock for TTL and backoff calculations. + * @param journal Diagnostic sink for PeerFinder log messages. + * @param config Server-level configuration (used in `start()`). + * @param collector Metrics collector for registering peer-count gauges. + */ ManagerImp( boost::asio::io_context& ioContext, clock_type& clock, @@ -64,11 +135,27 @@ public: { } + /** Delegate to `stop()`, making explicit shutdown optional for callers. */ ~ManagerImp() override { stop(); } + /** Shut down all subsystem components in dependency order. + * + * Idempotent: guarded by `if (work_)` so calling twice — including + * from the destructor after an explicit `stop()` — is harmless. + * + * Shutdown sequence: + * 1. Reset the work guard to unblock `io_context_` after queued + * handlers drain. + * 2. `checker_.stop()` — cancel all pending async TCP probe operations. + * 3. `logic_.stop()` — cancel any in-flight source fetch. + * + * The ordering is deliberate: `Logic` may hold shared references to + * `Checker` operations that must be cancelled before the checker itself + * can be destroyed. + */ void stop() override { @@ -111,6 +198,12 @@ public: logic_.addStaticSource(SourceStrings::make(name, strings)); } + /** @note Not yet implemented; body is intentionally empty. + * + * The corresponding pure-virtual declaration is also commented out of + * the `Manager` base class header. Fallback sources are currently + * supplied only as static string lists via `addFallbackStrings()`. + */ void addFallbackURL(std::string const& name, std::string const& url) { @@ -204,6 +297,12 @@ public: return logic_.buildEndpointsForPeers(); } + /** Open the SQLite store (running schema migration if needed) and + * populate the in-memory boot cache from persisted entries. + * + * Must be called before any other method. No disk I/O occurs before + * `start()`, making construction cheap and safe. + */ void start() override { @@ -224,8 +323,21 @@ public: } private: + /** Metrics registration and live peer-count gauges. + * + * The `hook` member is driven by the collector framework: the collector + * calls the registered handler whenever it wants fresh data rather than + * the application pushing on every change. `statsMutex_` guards against + * concurrent calls from the collector, not from peer connection events. + */ struct Stats { + /** Register the collection hook and create the two peer-count gauges. + * + * @param handler Callable invoked by the collector to refresh data; + * bound to `ManagerImp::collectMetrics`. + * @param collector The metrics backend that owns the hook and gauges. + */ template Stats(Handler const& handler, beast::insight::Collector::ptr const& collector) : hook(collector->makeHook(handler)) @@ -234,14 +346,26 @@ private: { } + /** Collector hook; its destructor deregisters the callback. */ beast::insight::Hook hook; + + /** Gauge tracking the number of active inbound peer connections. */ beast::insight::Gauge activeInboundPeers; + + /** Gauge tracking the number of active outbound peer connections. */ beast::insight::Gauge activeOutboundPeers; }; + /** Guards `stats_` against concurrent metric-collection calls. */ std::mutex statsMutex_; + Stats stats_; + /** Refresh peer-count gauges from live `Logic` counts. + * + * Invoked by the collector framework via `stats_.hook`; protected by + * `statsMutex_` to guard against concurrent collector calls. + */ void collectMetrics() { @@ -253,10 +377,24 @@ private: //------------------------------------------------------------------------------ +/** Register the `Manager` as a `beast::PropertyStream::Source` named "peerfinder". */ Manager::Manager() noexcept : beast::PropertyStream::Source("peerfinder") { } +/** Create the concrete `Manager` implementation. + * + * Instantiates `ManagerImp` and returns it as a `std::unique_ptr`, + * keeping the implementation type entirely hidden from callers. Call + * `start()` on the returned object before issuing any other methods. + * + * @param ioContext The Asio I/O context shared with the rest of the node. + * @param clock Abstract clock for TTL and backoff calculations. + * @param journal Diagnostic sink for PeerFinder log messages. + * @param config Server-level configuration; passed through to `start()`. + * @param collector Metrics backend for registering peer-count gauges. + * @return A fully constructed (but not yet started) `Manager`. + */ std::unique_ptr makeManager( boost::asio::io_context& ioContext, diff --git a/src/xrpld/peerfinder/detail/SlotImp.cpp b/src/xrpld/peerfinder/detail/SlotImp.cpp index b941f54b48..7c75de7d3d 100644 --- a/src/xrpld/peerfinder/detail/SlotImp.cpp +++ b/src/xrpld/peerfinder/detail/SlotImp.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements the concrete PeerFinder slot — per-connection state machine and + * the recent-endpoint deduplication cache. + * + * `SlotImp` is the mutable counterpart of the read-only `Slot` interface. + * All lifecycle state transitions pass through this file; the header provides + * only trivial accessor inlines. + */ + #include #include @@ -13,6 +22,22 @@ namespace xrpl::PeerFinder { +/** Construct an inbound slot for an already-accepted TCP connection. + * + * Both endpoints are known immediately from the accept call, so both are + * recorded at construction. Because the remote peer has not yet been + * interrogated, `checked` and `canAccept` are initialised to `false` — + * the PeerFinder does not yet know whether the remote address is publicly + * reachable. A connectivity probe may be launched later to determine this. + * + * Initial state is `State::Accept`. + * + * @param localEndpoint The local socket endpoint of the accepted connection. + * @param remoteEndpoint The remote peer's socket endpoint. + * @param fixed Whether the remote address is in the operator's + * fixed-peers list; exempts the slot from normal slot-budget enforcement. + * @param clock Shared clock used to timestamp the `recent` cache. + */ SlotImp::SlotImp( beast::IP::Endpoint const& localEndpoint, beast::IP::Endpoint remoteEndpoint, @@ -32,6 +57,21 @@ SlotImp::SlotImp( { } +/** Construct an outbound slot for a connection being initiated. + * + * The local endpoint is unknown until the OS assigns a port after + * `async_connect` completes; `local_endpoint_` is left as `nullopt` and + * filled in by `Logic::onConnected`. Crucially, `checked` and `canAccept` + * are initialised to `true`: successfully connecting to a remote address is + * itself proof of reachability, so no separate connectivity probe is needed. + * + * Initial state is `State::Connect`. + * + * @param remoteEndpoint The remote peer's address being dialled. + * @param fixed Whether the remote address is in the operator's + * fixed-peers list; exempts the slot from normal slot-budget enforcement. + * @param clock Shared clock used to timestamp the `recent` cache. + */ SlotImp::SlotImp(beast::IP::Endpoint remoteEndpoint, bool fixed, clock_type& clock) : recent(clock) , inbound_(false) @@ -46,31 +86,45 @@ SlotImp::SlotImp(beast::IP::Endpoint remoteEndpoint, bool fixed, clock_type& clo { } +/** Advance the slot to a new lifecycle state, enforcing the state machine topology. + * + * This is the general-purpose state setter for all transitions **except** + * the move to `State::Active`, which must go through `activate()` so that + * `whenAcceptEndpoints` is stamped atomically. The following invariants are + * enforced via `XRPL_ASSERT` and will abort in debug builds on violation: + * + * - `State::Active` is rejected here — use `activate()`. + * - The target state must differ from the current state (no no-op transitions). + * - `State::Accept` and `State::Connect` are entry-point-only states; no + * code path may re-enter them after construction. + * - `State::Connected` is only reachable from outbound slots currently in + * `State::Connect`. Inbound slots skip this state entirely, going directly + * from `State::Accept` to `State::Active` via `activate()`. + * - `State::Closing` is forbidden while the slot is still in `State::Connect` + * (an in-progress outbound attempt should be aborted, not gracefully closed). + * + * @param state The desired target state. + */ void SlotImp::state(State state) { - // Must go through activate() to set active state XRPL_ASSERT( state != State::Active, "xrpl::PeerFinder::SlotImp::state : input state is not active"); - // The state must be different XRPL_ASSERT( state_ != state, "xrpl::PeerFinder::SlotImp::state : input state is different from " "current"); - // You can't transition into the initial states XRPL_ASSERT( state != State::Accept && state != State::Connect, "xrpl::PeerFinder::SlotImp::state : input state is not an initial"); - // Can only become connected from outbound connect state XRPL_ASSERT( state != State::Connected || (!inbound_ && state_ == State::Connect), "xrpl::PeerFinder::SlotImp::state : input state is not connected an " "invalid state"); - // Can't gracefully close on an outbound connection attempt XRPL_ASSERT( state != State::Closing || state_ != State::Connect, "xrpl::PeerFinder::SlotImp::state : input state is not closing an " @@ -79,10 +133,24 @@ SlotImp::state(State state) state_ = state; } +/** Transition the slot to `State::Active` and arm the endpoint-flood throttle. + * + * This is the **sole** path to `State::Active`. Splitting activation into + * its own method — rather than allowing `state(State::Active)` — guarantees + * that `whenAcceptEndpoints` is always set when a slot goes live. Skipping + * this stamp would leave the flood-control timestamp uninitialised, allowing + * a peer to immediately flood `mtENDPOINTS` messages. + * + * Valid prior states are `State::Accept` (inbound, post-handshake) and + * `State::Connected` (outbound, post-handshake). Any other prior state + * triggers an `XRPL_ASSERT` abort in debug builds. + * + * @param now Current clock time, recorded as `whenAcceptEndpoints` to + * establish the start of the per-slot endpoint rate-limiting window. + */ void SlotImp::activate(clock_type::time_point const& now) { - // Can only become active from the accept or connected state XRPL_ASSERT( state_ == State::Accept || state_ == State::Connected, "xrpl::PeerFinder::SlotImp::activate : valid state"); @@ -93,21 +161,50 @@ SlotImp::activate(clock_type::time_point const& now) //------------------------------------------------------------------------------ +/** Out-of-line definition required for the pure-virtual destructor. + * + * C++ requires a definition for pure-virtual destructors because the + * base-class destructor is called during the destruction of any derived + * object. The `= default` body satisfies this without adding code. + */ Slot::~Slot() = default; //------------------------------------------------------------------------------ +/** Construct the recent-endpoint cache backed by the given clock. + * + * @param clock Shared clock for the underlying `beast::aged_unordered_map`, + * used to track insertion time for TTL-based expiry. + */ SlotImp::RecentT::RecentT(clock_type& clock) : cache_(clock) { } +/** Record an endpoint and its hop distance in the per-slot deduplication cache. + * + * Called for every endpoint we receive **from** this peer and every endpoint + * we **send to** this peer, so that `filter()` can suppress redundant gossip + * in both directions. + * + * If the endpoint is already in the cache and the new hop count is less than + * or equal to the stored value, the stored hop count is updated and the + * entry's age is reset via `touch()`. If the new hop count is strictly + * greater than the stored value, the entry is left unchanged — a closer + * known path takes precedence. + * + * @note The `<=` update boundary is load-bearing: `filter()` mirrors it, and + * the `Handouts` algorithm depends on this pair of semantics to correctly + * decide which endpoints are redundant for a given peer. + * + * @param ep The endpoint address to record. + * @param hops The hop distance at which `ep` was observed or announced. + */ void SlotImp::RecentT::insert(beast::IP::Endpoint const& ep, std::uint32_t hops) { auto const result(cache_.emplace(ep, hops)); if (!result.second) { - // NOTE Other logic depends on this <= inequality. if (hops <= result.first->second) { result.first->second = hops; @@ -116,18 +213,43 @@ SlotImp::RecentT::insert(beast::IP::Endpoint const& ep, std::uint32_t hops) } } +/** Returns `true` if sending this endpoint to the peer would be redundant. + * + * Suppresses an outbound announcement when the cache already holds the same + * endpoint at a hop count **less than or equal to** the hop count we are + * about to send. This reflects the principle that a peer with closer + * knowledge of an address does not benefit from hearing about it again at + * the same or greater distance. + * + * Mirroring `insert()`'s `<=` update boundary is intentional and load-bearing + * for the `Handouts` round-robin algorithm — changing one without the other + * would break the gossip deduplication contract. + * + * @param ep The endpoint address to test. + * @param hops The hop distance at which we would announce `ep`. + * @return `true` if the send should be suppressed; `false` if the endpoint + * is unknown or the cached hop count is strictly greater than `hops` + * (meaning we have a closer path worth sharing). + */ bool SlotImp::RecentT::filter(beast::IP::Endpoint const& ep, std::uint32_t hops) { auto const iter(cache_.find(ep)); if (iter == cache_.end()) return false; - // We avoid sending an endpoint if we heard it - // from them recently at the same or lower hop count. - // NOTE Other logic depends on this <= inequality. return iter->second <= hops; } +/** Remove stale entries from the deduplication cache. + * + * Prunes all entries older than `Tuning::kLIVE_CACHE_SECONDS_TO_LIVE` (30 s). + * Called by `Logic::oncePerSecond()` so that a peer which drops and reconnects + * will receive fresh endpoint announcements rather than having them + * indefinitely suppressed by stale cache state. + * + * The TTL deliberately matches the Livecache TTL so that the two caches + * remain consistent in their view of "recently known" endpoints. + */ void SlotImp::RecentT::expire() { diff --git a/src/xrpld/peerfinder/detail/SlotImp.h b/src/xrpld/peerfinder/detail/SlotImp.h index b198c93e5d..fa7d0623bd 100644 --- a/src/xrpld/peerfinder/detail/SlotImp.h +++ b/src/xrpld/peerfinder/detail/SlotImp.h @@ -1,3 +1,9 @@ +/** @file + * Declares `SlotImp`, the mutable concrete implementation of the `Slot` + * interface used internally by PeerFinder's `Logic` class to track per-peer + * connection state, lifecycle transitions, and endpoint gossip deduplication. + */ + #pragma once #include @@ -10,19 +16,64 @@ namespace xrpl::PeerFinder { +/** Concrete, mutable peer connection slot owned by PeerFinder's `Logic`. + * + * `SlotImp` extends the read-only `Slot` interface with the writable state + * and state-machine enforcement that `Logic` needs to manage the overlay + * topology. `Logic` keeps slots in a `std::map` keyed by remote endpoint and + * passes them around as `SlotImp::ptr`; the broader codebase receives only the + * weaker `shared_ptr` handle. + * + * @note `m_inbound` and `m_fixed` are `const` after construction — the + * connection direction and fixed-peer status never change for the lifetime + * of the slot. All other state evolves through the methods below. + * @see Slot, Logic + */ class SlotImp : public Slot { public: + /** Shared-ownership handle used within the `detail` namespace. + * + * `Logic` stores and passes slots as `SlotImp::ptr` rather than the + * weaker `Slot::ptr` so it can reach mutable members without casting. + */ using ptr = std::shared_ptr; - // inbound + /** Construct an inbound slot for an already-accepted TCP connection. + * + * Both endpoints are known from the accept call. `checked` and + * `canAccept` start `false` — reachability of the remote address has not + * been verified yet; a connectivity probe may be launched later. Initial + * state is `State::Accept`. + * + * @param localEndpoint Local socket endpoint of the accepted connection. + * @param remoteEndpoint Remote peer's socket endpoint. + * @param fixed `true` if the remote address is in the + * operator's fixed-peers list, exempting this slot from the normal + * slot-budget enforced by `Counts::can_activate`. + * @param clock Shared clock for timestamping the `recent` cache. + */ SlotImp( beast::IP::Endpoint const& localEndpoint, beast::IP::Endpoint remoteEndpoint, bool fixed, clock_type& clock); - // outbound + /** Construct an outbound slot for a connection being initiated. + * + * The local endpoint is unknown until the OS assigns a port after + * `async_connect` completes; `local_endpoint_` is left `nullopt` and + * filled in by `Logic::onConnected`. `checked` and `canAccept` are + * initialised `true` — a successful outbound TCP connect is itself proof + * of reachability, so no separate connectivity probe is needed. Initial + * state is `State::Connect`. + * + * @param remoteEndpoint Remote peer's address being dialled. + * @param fixed `true` if the remote address is in the + * operator's fixed-peers list, exempting this slot from the normal + * slot-budget enforced by `Counts::can_activate`. + * @param clock Shared clock for timestamping the `recent` cache. + */ SlotImp(beast::IP::Endpoint remoteEndpoint, bool fixed, clock_type& clock); bool @@ -67,6 +118,14 @@ public: return public_key_; } + /** Returns a human-readable log prefix identifying this slot. + * + * Wraps the remote endpoint's fingerprint (IP:port + optional public key + * abbreviation) in brackets for use at the start of log lines. Delegates + * to `getFingerprint()`. + * + * @return A string of the form `"[] "`. + */ std::string prefix() const { @@ -82,30 +141,61 @@ public: return value; } + /** Record the port on which the remote peer accepts inbound connections. + * + * Written by `Logic` after the connectivity probe confirms reachability. + * The underlying `atomic` makes this safe to call from a thread + * different from the reader of `listeningPort()`. + * + * @param port The verified listening port of the remote peer. + */ void setListeningPort(std::uint16_t port) { listening_port_ = port; } + /** Set the local socket endpoint, filled in after outbound connect completes. + * + * @param endpoint The OS-assigned local endpoint for this connection. + */ void localEndpoint(beast::IP::Endpoint const& endpoint) { local_endpoint_ = endpoint; } + /** Update the remote endpoint, e.g. after address resolution refines the port. + * + * @param endpoint The updated remote endpoint. + */ void remoteEndpoint(beast::IP::Endpoint const& endpoint) { remote_endpoint_ = endpoint; } + /** Record the peer's public key once the overlay handshake completes. + * + * `Logic::activate` uses the key as a deduplication guard — a second slot + * presenting the same key is rejected as a duplicate peer. + * + * @param key The peer's node public key. + */ void publicKey(PublicKey const& key) { public_key_ = key; } + /** Mark this slot as reserved (cluster member or explicit reservation). + * + * Reserved slots bypass the public slot cap in `Counts::can_activate`. + * Must only be set during or after `activate()`, once the peer's identity + * is known from the handshake. + * + * @param reserved `true` to grant reserved status. + */ void reserved(bool reserved) { @@ -114,30 +204,90 @@ public: //-------------------------------------------------------------------------- + /** Transition the slot to a new lifecycle state, enforcing state-machine rules. + * + * This setter handles all transitions **except** `State::Active`, which + * must go through `activate()` so that `whenAcceptEndpoints` is always + * stamped. The following invariants are asserted (debug builds abort on + * violation): + * - `State::Active` is rejected — use `activate()`. + * - The target state must differ from the current state. + * - `State::Accept` and `State::Connect` are entry-only; cannot be + * re-entered after construction. + * - `State::Connected` is only reachable from an outbound slot currently + * in `State::Connect`. + * - `State::Closing` is forbidden while still in `State::Connect`. + * + * @param state The desired target state. + */ void state(State state); + /** Promote the slot to `State::Active` and arm the endpoint-flood throttle. + * + * This is the **sole** legal path to `State::Active`. It stamps + * `whenAcceptEndpoints = now`, initialising the per-slot rate-limit window + * that gates acceptance of `mtENDPOINTS` messages. Bypassing this method + * (e.g. via the generic `state()` setter) would leave the flood-control + * timestamp uninitialised. + * + * Valid prior states are `State::Accept` (inbound) and + * `State::Connected` (outbound). Any other prior state aborts in debug. + * + * @param now Current clock time, recorded as `whenAcceptEndpoints` to + * start the per-slot endpoint rate-limiting window. + */ void activate(clock_type::time_point const& now); - // "Memberspace" - // - // The set of all recent addresses that we have seen from this peer. - // We try to avoid sending a peer the same addresses they gave us. - // + /** Per-slot deduplication cache for endpoint gossip. + * + * Tracks every `beast::IP::Endpoint` we have either received from this + * peer or sent to this peer, keyed by the lowest hop count seen. Used by + * `Logic`'s `Handouts` algorithm to suppress redundant re-announcement of + * addresses the peer already knows at an equal or shorter hop distance. + * Entries expire after `Tuning::kLIVE_CACHE_SECONDS_TO_LIVE` (30 s), + * matching the Livecache TTL. + * + * @note The `<=` boundary in both `insert()` and `filter()` is + * load-bearing. Changing one without the other breaks the gossip + * deduplication contract relied on by `Handouts`. + */ class RecentT { public: + /** Construct the cache backed by the given clock. + * + * @param clock Shared clock for TTL-based entry expiry. + */ explicit RecentT(clock_type& clock); - /** Called for each valid endpoint received for a slot. - We also insert messages that we send to the slot to prevent - sending a slot the same address too frequently. - */ + /** Record an endpoint and its hop distance in the deduplication cache. + * + * Called both for endpoints received **from** this peer and for + * endpoints we **send to** this peer. If the endpoint is already + * cached and the new hop count is less than or equal to the stored + * value, the entry is updated and its age is reset. If the new hop + * count is strictly greater, the existing (closer) entry is preserved. + * + * @param ep The endpoint to record. + * @param hops The hop distance at which `ep` was observed or sent. + */ void insert(beast::IP::Endpoint const& ep, std::uint32_t hops); - /** Returns `true` if we should not send endpoint to the slot. */ + /** Returns `true` if sending this endpoint to the peer would be redundant. + * + * Suppresses the send when the cache holds the endpoint at a hop count + * **less than or equal to** `hops`. A peer with closer knowledge of an + * address gains nothing from hearing about it again at the same or + * greater distance. + * + * @param ep The endpoint to test. + * @param hops The hop distance at which we would announce `ep`. + * @return `true` to suppress the send; `false` if unknown or the + * cached distance is strictly greater than `hops`. + */ bool filter(beast::IP::Endpoint const& ep, std::uint32_t hops); @@ -149,6 +299,12 @@ public: beast::aged_unordered_map cache_; } recent; + /** Expire stale entries from the `recent` deduplication cache. + * + * Delegates to `RecentT::expire()`, which prunes entries older than + * `Tuning::kLIVE_CACHE_SECONDS_TO_LIVE`. Called by `Logic::oncePerSecond` + * so that reconnecting peers receive fresh endpoint announcements. + */ void expire() { @@ -168,24 +324,52 @@ private: std::atomic listening_port_; public: - // DEPRECATED public data members + // DEPRECATED: raw public data members pending a planned refactor of the + // connectivity-check workflow. Still read and written directly by `Logic`. - // Tells us if we checked the connection. Outbound connections - // are always considered checked since we successfully connected. + /** Whether the remote address has been reachability-checked. + * + * `true` for outbound slots from construction (TCP connect proves + * reachability). For inbound slots, set to `true` by `Logic` once the + * async `Checker` probe completes (successfully or not). + * + * @deprecated Will move to a private accessor when the checker workflow + * is refactored. + */ bool checked; - // Set to indicate if the connection can receive incoming at the - // address advertised in mtENDPOINTS. Only valid if checked is true. + /** Whether the remote peer can accept inbound connections at its advertised address. + * + * Only meaningful when `checked == true`. `true` means the connectivity + * probe succeeded and the address is publicly reachable; `false` means the + * probe failed. Outbound slots initialise this to `true` unconditionally. + * + * @deprecated Will move to a private accessor when the checker workflow + * is refactored. + */ bool canAccept; - // Set to indicate that a connection check for this peer is in - // progress. Valid always. + /** Whether an async connectivity probe for this peer is currently in flight. + * + * Set to `true` by `Logic` when it launches a `Checker::async_connect` + * probe; cleared to `false` in the probe completion callback regardless + * of outcome. Guards against launching duplicate probes for the same slot. + * + * @deprecated Will move to a private accessor when the checker workflow + * is refactored. + */ bool connectivityCheckInProgress; - // The time after which we will accept mtENDPOINTS from the peer - // This is to prevent flooding or spamming. Receipt of mtENDPOINTS - // sooner than the allotted time should impose a load charge. - // + /** Earliest time at which the next `mtENDPOINTS` message from this peer will be accepted. + * + * Stamped to `now` by `activate()` and then advanced by + * `Tuning::kSECONDS_PER_MESSAGE` after each accepted batch. `Logic` + * compares `whenAcceptEndpoints > now` to enforce the per-peer endpoint + * rate limit; early delivery should trigger a load charge. + * + * @deprecated Will move to a private accessor when the checker workflow + * is refactored. + */ clock_type::time_point whenAcceptEndpoints; }; diff --git a/src/xrpld/peerfinder/detail/Source.h b/src/xrpld/peerfinder/detail/Source.h index c86176911e..b6166b03ee 100644 --- a/src/xrpld/peerfinder/detail/Source.h +++ b/src/xrpld/peerfinder/detail/Source.h @@ -6,36 +6,70 @@ namespace xrpl::PeerFinder { -/** A static or dynamic source of peer addresses. - These are used as fallbacks when we are bootstrapping and don't have - a local cache, or when none of our addresses are functioning. Typically - sources will represent things like static text in the config file, a - separate local file with addresses, or a remote HTTPS URL that can - be updated automatically. Another solution is to use a custom DNS server - that hands out peer IP addresses when name lookups are performed. -*/ +/** Abstract provider of peer IP addresses for bootstrapping. + * + * Used as a fallback when the node has no local peer cache or when all + * known addresses are unreachable. Concrete implementations may source + * addresses from the config file (`[ips]`/`[ips_fixed]` stanzas via + * `SourceStrings`), a local file, a remote HTTPS endpoint, or a custom + * DNS resolver. + * + * `Logic` calls `fetch()` synchronously while holding no lock, then + * pipes successful results into `Bootcache::insertStatic()`. The current + * in-flight source is tracked in `Logic::fetchSource_` so that `cancel()` + * can be dispatched during shutdown. + * + * @note `fetch()` is synchronous; a slow or unresponsive source will stall + * the bootstrap thread. `cancel()` exists as an extension point for + * future async implementations — all current subclasses treat it as a + * no-op. + */ class Source { public: - /** The results of a fetch. */ + /** Carries the outcome of a single `fetch()` call. */ struct Results { explicit Results() = default; - // error_code on a failure + /** Set on failure; default-constructed (no error) on success. */ boost::system::error_code error; - // list of fetched endpoints + /** Peer endpoints collected by the fetch; empty on failure. */ IPAddresses addresses; }; virtual ~Source() = default; + + /** Returns a human-readable label used in log messages by `Logic::fetch()`. + * + * The value is diagnostic only and carries no operational meaning. + */ virtual std::string const& name() = 0; + + /** Requests early termination of an in-progress fetch. + * + * Called by `Logic` during shutdown when a fetch is in flight. + * The default implementation is a no-op; async subclasses should + * override to abort any pending I/O. + */ virtual void cancel() { } + + /** Synchronously fetches peer addresses into @p results. + * + * Implementations must populate `results.addresses` on success or + * set `results.error` on failure. The call is synchronous and blocks + * the caller until completion or until `cancel()` is invoked from + * another thread (for async-capable subclasses). + * + * @param results Output struct; `addresses` is populated on success, + * `error` is set on failure. + * @param journal Journal for diagnostic logging during the fetch. + */ virtual void fetch(Results& results, beast::Journal journal) = 0; }; diff --git a/src/xrpld/peerfinder/detail/SourceStrings.cpp b/src/xrpld/peerfinder/detail/SourceStrings.cpp index 61a3e0c021..727b749478 100644 --- a/src/xrpld/peerfinder/detail/SourceStrings.cpp +++ b/src/xrpld/peerfinder/detail/SourceStrings.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements the static-string peer address source for PeerFinder. + * + * Bridges the gap between raw configuration strings (from `[ips]` / + * `[ips_fixed]` stanzas) and the typed `beast::IP::Endpoint` objects that + * the rest of PeerFinder requires. All parsing and validation is centralised + * here rather than scattered across callers. + */ + #include #include @@ -11,9 +20,24 @@ namespace xrpl::PeerFinder { +/** Hidden concrete implementation of `SourceStrings`. + * + * Defined entirely within this translation unit so that callers only ever + * see the abstract `Source` interface. This pimpl-adjacent pattern means + * implementation changes — additional fields, altered parsing logic — never + * force recompilation of files that include `SourceStrings.h`. + * + * Constructed exclusively via `SourceStrings::make()`. + */ class SourceStringsImp : public SourceStrings { public: + /** Constructs the source, taking ownership of @p name and @p strings. + * + * @param name Human-readable label returned by `name()`, used by + * `Logic` in diagnostic log messages. + * @param strings Raw address strings to be parsed on each `fetch()`. + */ SourceStringsImp(std::string name, Strings strings) : name_(std::move(name)), strings_(std::move(strings)) { @@ -21,12 +45,37 @@ public: ~SourceStringsImp() override = default; + /** Returns the diagnostic label supplied at construction. */ std::string const& name() override { return name_; } + /** Parses the stored strings into validated endpoints. + * + * Iterates over `strings_`, attempts to parse each entry via + * `beast::IP::Endpoint::fromString()`, and appends successfully parsed + * endpoints to `results.addresses`. Entries that fail to parse — empty + * strings, malformed addresses — are silently dropped; no error is set + * and no warning is logged. This is intentional: a bad config entry is + * a configuration problem, not a runtime fault, and the node should + * still connect to whichever addresses parse correctly. + * + * The `journal` parameter is accepted to satisfy the `Source` interface + * but is unused here; a static string list has nothing asynchronous + * to report. + * + * @param results Output struct; `addresses` is populated with every + * valid endpoint found. `results.error` is never set by this + * implementation. + * @param journal Unused; present only to satisfy the `Source` contract. + * + * @note The loop contains a redundant second call to `fromString()` on + * the same string when the first parse fails. This produces an + * identical (unspecified) result and is vestigial — the effective + * behavior is simply: parse once, keep if valid. + */ void fetch(Results& results, beast::Journal journal) override { @@ -43,12 +92,27 @@ public: } private: + /** Human-readable label for log messages. */ std::string name_; + + /** Raw address strings to parse on each `fetch()` call. */ Strings strings_; }; //------------------------------------------------------------------------------ +/** Creates a `Source` backed by a static list of address strings. + * + * The sole factory for `SourceStringsImp`. Called from + * `PeerfinderManagerImp::addFallbackStrings()`, which passes the result + * directly to `Logic::addStaticSource()` for use as a bootstrap fallback. + * + * @param name Diagnostic label used in log output by `Logic::fetch()`. + * @param strings Address strings from the node configuration (`[ips]` / + * `[ips_fixed]`); malformed entries are silently dropped by `fetch()`. + * @return A `shared_ptr` owning a newly constructed + * `SourceStringsImp`; callers never see the concrete type. + */ std::shared_ptr SourceStrings::make(std::string const& name, Strings const& strings) { diff --git a/src/xrpld/peerfinder/detail/SourceStrings.h b/src/xrpld/peerfinder/detail/SourceStrings.h index 156db0ce85..dcc975c1f8 100644 --- a/src/xrpld/peerfinder/detail/SourceStrings.h +++ b/src/xrpld/peerfinder/detail/SourceStrings.h @@ -6,14 +6,44 @@ namespace xrpl::PeerFinder { -/** Provides addresses from a static set of strings. */ +/** Bootstrap peer address source backed by a static list of config strings. + * + * Wraps the operator-supplied `[ips]` / `[ips_fixed]` address strings from + * the node configuration into the polymorphic `Source` interface consumed by + * `PeerFinder::Logic`. It is registered as a static (non-refreshable) source + * via `Logic::addStaticSource()`, meaning `fetch()` is called once at startup + * and the results are fed directly into `Bootcache`. + * + * The concrete implementation (`SourceStringsImp`) is hidden in the `.cpp` + * file. The `make()` factory returns a `shared_ptr`, so callers are + * never exposed to the concrete type and coupling is minimised. + * + * @note `fetch()` silently drops malformed address strings — no error is set + * and no warning is logged. A bad config entry is a configuration problem, + * not a runtime fault. `cancel()` is a no-op: there is no async I/O to + * interrupt. + */ class SourceStrings : public Source { public: explicit SourceStrings() = default; + /** Ordered list of raw address strings from the node configuration. */ using Strings = std::vector; + /** Creates a `Source` backed by a static list of address strings. + * + * The sole factory for the hidden `SourceStringsImp` subclass. Invoked + * from `PeerfinderManagerImp::addFallbackStrings()`, which passes the + * result directly to `Logic::addStaticSource()`. + * + * @param name Diagnostic label used in log output by `Logic::fetch()`. + * @param strings Raw address strings (e.g. from `[ips]` / `[ips_fixed]`); + * entries that fail to parse as `beast::IP::Endpoint` are silently + * dropped during `fetch()`. + * @return A `shared_ptr` owning the newly constructed source; + * callers never see the concrete `SourceStringsImp` type. + */ static std::shared_ptr make(std::string const& name, Strings const& strings); }; diff --git a/src/xrpld/peerfinder/detail/Store.h b/src/xrpld/peerfinder/detail/Store.h index 347fc09b15..9b9118bc61 100644 --- a/src/xrpld/peerfinder/detail/Store.h +++ b/src/xrpld/peerfinder/detail/Store.h @@ -2,25 +2,77 @@ namespace xrpl::PeerFinder { -/** Abstract persistence for PeerFinder data. */ +/** Abstract persistence boundary for the PeerFinder bootstrap cache. + * + * Defines the load/save contract between `Bootcache` (the in-memory ranked + * endpoint map) and durable storage. The only concrete production + * implementation is `StoreSqdb`, which delegates to SQLite via SOCI. + * + * The interface is intentionally minimal: two pure virtual methods cover the + * two moments the in-memory cache crosses the storage boundary — initial load + * at startup and periodic full-replace saves triggered by + * `Bootcache::periodicActivity()`. All ranking, bimap management, and + * connection logic remain in `Bootcache` and `Logic`; this class never sees + * any of that. + * + * @see Bootcache.h, StoreSqdb.h, Logic.h + */ class Store { public: virtual ~Store() = default; - // load the bootstrap cache + /** Callback invoked once per valid entry during `load`. + * + * @param endpoint The peer's IP endpoint read from storage. + * @param valence The persisted streak counter for that endpoint + * (positive = consecutive successes, negative = consecutive failures). + */ using load_callback = std::function; + + /** Stream all persisted bootstrap entries into the caller via callback. + * + * Iterates over the durable store, invoking @p cb once for each valid + * entry. Using a callback rather than returning a container lets + * `Bootcache::load()` insert records directly into its bimap as they + * arrive, avoiding an intermediate allocation. + * + * Implementations should silently skip malformed entries (e.g., bad + * address strings) and not invoke the callback for them; only valid, + * fully-parsed entries count toward the return value. + * + * @param cb Invoked for each valid entry with its endpoint and valence. + * @return The number of valid entries passed to @p cb. + */ virtual std::size_t load(load_callback const& cb) = 0; - // save the bootstrap cache + /** A single bootstrap cache record as serialized to/from storage. */ struct Entry { explicit Entry() = default; + /** The peer's IP address and port. */ beast::IP::Endpoint endpoint; + + /** Connection quality history for this endpoint. + * + * Positive values count consecutive successful handshakes; negative + * values count consecutive failures. Higher valence entries are + * tried first by `Bootcache` when seeding outbound connections. + */ int valence{}; }; + + /** Atomically overwrite the entire persisted bootstrap cache. + * + * Replaces whatever is currently in durable storage with the snapshot + * in @p v. This is a full replace, not an incremental update — the + * implementation is expected to clear the existing data before writing. + * + * @param v Flat snapshot of all current cache entries, typically + * produced by `Bootcache` draining its bimap. + */ virtual void save(std::vector const& v) = 0; }; diff --git a/src/xrpld/peerfinder/detail/StoreSqdb.h b/src/xrpld/peerfinder/detail/StoreSqdb.h index 1239818ba3..8a82dc67a4 100644 --- a/src/xrpld/peerfinder/detail/StoreSqdb.h +++ b/src/xrpld/peerfinder/detail/StoreSqdb.h @@ -7,7 +7,26 @@ namespace xrpl::PeerFinder { -/** Database persistence for PeerFinder using SQLite */ +/** SQLite-backed persistence adapter for the PeerFinder bootstrap cache. + * + * Implements the `Store` interface by delegating all SQL to the free + * functions in `xrpld/app/rdb/PeerFinder.h` (`initPeerFinderDB`, + * `updatePeerFinderDB`, `readPeerFinderDB`, `savePeerFinderDB`). + * `StoreSqdb` itself contains no SQL literals; it is a thin adapter that + * wires the `Store` virtual interface to the `rdb` layer, passing + * `sqlDb_` as the shared connection handle. + * + * The `soci::session` is a value member — the SQLite connection is open + * for the lifetime of the object and requires no external synchronization + * beyond what PeerFinder's `Logic` mutex already provides. + * + * Typical call sequence after construction: + * 1. `open(config)` — opens the database file, creates tables, migrates schema. + * 2. `load(cb)` — streams persisted entries into `Bootcache` at startup. + * 3. `save(v)` — called periodically by `Bootcache::periodicActivity()`. + * + * @see Store, Bootcache, Logic, initPeerFinderDB, updatePeerFinderDB + */ class StoreSqdb : public Store { private: @@ -15,8 +34,26 @@ private: soci::session sqlDb_; public: - static constexpr auto kCURRENT_SCHEMA_VERSION = 4; // on-database format version + /** On-disk schema version this binary understands. + * + * `updatePeerFinderDB` applies migrations from any stored version up to + * this value. If the stored version exceeds this constant, `update()` + * throws `std::runtime_error` to prevent a stale binary from silently + * corrupting a database written by a newer one. + * + * Migration history: + * - v1–v2: removed legacy endpoint tables (`LegacyEndpoints` family). + * - v3→v4: dropped the `uptime` column from `PeerFinder_BootstrapCache` + * via create-copy-drop-rename because SQLite does not support + * `DROP COLUMN`. + */ + static constexpr auto kCURRENT_SCHEMA_VERSION = 4; + /** Construct with an optional journal for diagnostic logging. + * + * @param journal Structured logger; defaults to the null sink so + * callers that do not need log output can omit the argument. + */ explicit StoreSqdb(beast::Journal journal = beast::Journal{beast::Journal::getNullSink()}) : journal_(journal) { @@ -24,6 +61,17 @@ public: ~StoreSqdb() override = default; + /** Open the database and bring the schema up to date. + * + * Calls `initPeerFinderDB` to open the SQLite file specified in + * @p config, creating the `PeerFinder_BootstrapCache` and + * `SchemaVersion` tables if they do not exist, then calls `update()` + * to apply any pending schema migrations. Must be called once before + * `load()` or `save()`. + * + * @param config Application config containing the database path and + * other connection parameters. + */ void open(BasicConfig const& config) { @@ -31,8 +79,17 @@ public: update(); } - // Loads the bootstrap cache, calling the callback for each entry - // + /** Stream all persisted bootstrap entries to the caller via callback. + * + * Reads every row from `PeerFinder_BootstrapCache` and invokes @p cb + * for each one whose address string parses to a valid, specified + * `beast::IP::Endpoint`. Rows with malformed or unspecified addresses + * are silently skipped with a `journal_.error()` log entry — they are + * never forwarded to the callback. + * + * @param cb Invoked once per valid entry with its endpoint and valence. + * @return The number of valid entries passed to @p cb. + */ std::size_t load(load_callback const& cb) override { @@ -55,16 +112,29 @@ public: return n; } - // Overwrites the stored bootstrap cache with the specified array. - // + /** Atomically replace the entire persisted bootstrap cache. + * + * Issues `DELETE FROM PeerFinder_BootstrapCache` followed by a bulk + * `INSERT` of all entries in @p v, inside a single SOCI transaction. + * This is a full snapshot replace — not an upsert or diff — so the + * table's contents after the call exactly mirror @p v. + * + * @param v Complete snapshot of the current in-memory bootstrap cache, + * typically produced by `Bootcache` draining its bimap. + */ void save(std::vector const& v) override { savePeerFinderDB(sqlDb_, v); } - // Convert any existing entries from an older schema to the - // current one, if appropriate. + /** Migrate the on-disk schema to `kCURRENT_SCHEMA_VERSION`. + * + * Reads the stored version number from `SchemaVersion` and applies + * any outstanding migrations in sequence. Throws `std::runtime_error` + * if the stored version is higher than `kCURRENT_SCHEMA_VERSION`. + * Called automatically by `open()`; exposed publicly for testing. + */ void update() { diff --git a/src/xrpld/peerfinder/detail/Tuning.h b/src/xrpld/peerfinder/detail/Tuning.h index 92d71ff8d8..3c1385d481 100644 --- a/src/xrpld/peerfinder/detail/Tuning.h +++ b/src/xrpld/peerfinder/detail/Tuning.h @@ -1,100 +1,182 @@ +/** @file + * Single source of truth for every magic number in the PeerFinder subsystem. + * + * All heuristically chosen values — connection policy, backoff schedules, + * cache sizes, and gossip rate limits — live here under + * `xrpl::PeerFinder::Tuning` so they can be reviewed and adjusted together + * rather than being scattered across a dozen implementation files. + */ + #pragma once #include -/** Heuristically tuned constants. */ +/** Heuristically tuned constants for the PeerFinder subsystem. */ /** @{ */ namespace xrpl::PeerFinder::Tuning { -//--------------------------------------------------------- -// -// Automatic Connection Policy -// -//--------------------------------------------------------- +// --- Automatic Connection Policy --- -/** Time to wait between making batches of connection attempts */ +/** Interval between batches of outbound connection attempts, in seconds. + * + * `Logic::once_per_second` accumulates elapsed time and fires a new + * `autoconnect()` batch whenever this threshold is crossed. + */ static constexpr auto kSECONDS_PER_CONNECT = 10; -/** Maximum number of simultaneous connection attempts. */ +/** Maximum number of simultaneous outbound connection attempts. + * + * `Counts::attempts_needed()` consults this directly; once this many + * attempts are in-flight no new ones are dispatched, preventing a + * connection storm against the rest of the network. + */ static constexpr auto kMAX_CONNECT_ATTEMPTS = 20; -/** The percentage of total peer slots that are outbound. - The number of outbound peers will be the larger of the - minOutCount and outPercent * Config::maxPeers specially - rounded. -*/ +/** Target percentage of total peer slots that are outbound. + * + * The actual outbound count is `max(kMIN_OUT_COUNT, round(maxPeers * + * kOUT_PERCENT / 100))`, keeping inbound capacity dominant so the node + * remains openly reachable while still guaranteeing a minimum number of + * self-initiated connections. + * + * @see kMIN_OUT_COUNT + */ static constexpr auto kOUT_PERCENT = 15; -/** A hard minimum on the number of outgoing connections. - This is enforced outside the Logic, so that the unit test - can use any settings it wants. -*/ +/** Hard floor on the number of outbound connections. + * + * Applied after the percentage calculation so that even a minimal + * deployment maintains at least this many self-initiated connections. + * Enforced in `PeerfinderConfig.cpp` outside `Logic` so unit tests can + * override slot counts freely. + * + * @see kOUT_PERCENT + */ static constexpr auto kMIN_OUT_COUNT = 10; -/** The default value of Config::maxPeers. */ +/** Default value for `Config::maxPeers`. + * + * 21 is odd by design: applying the 15% rule yields 3 outbound peers, + * a reasonable default for a light node. + */ static constexpr auto kDEFAULT_MAX_PEERS = 21; -/** Max redirects we will accept from one connection. - Redirects are limited for security purposes, to prevent - the address caches from getting flooded. -*/ +/** Maximum number of redirect addresses accepted from a single peer. + * + * When a connecting peer's slots are full it may respond with a list of + * alternative addresses. This cap prevents a malicious peer from flooding + * the address caches by supplying an arbitrarily large redirect list. + */ static constexpr auto kMAX_REDIRECTS = 30; -//------------------------------------------------------------------------------ -// -// Fixed -// -//------------------------------------------------------------------------------ +// --- Fixed Connection Backoff --- +/** Fibonacci retry-backoff schedule (in minutes) for fixed peers. + * + * `Fixed::failure()` advances an index clamped to the last position, + * so backoff saturates at 55 minutes rather than growing unboundedly. + * A successful reconnect resets the index to zero (immediate + * re-consideration). The Fibonacci progression grows fast enough to + * avoid hammering an unreachable host while keeping the worst-case + * delay bounded. + */ static std::array const kCONNECTION_BACKOFF{{1, 1, 2, 3, 5, 8, 13, 21, 34, 55}}; -//------------------------------------------------------------------------------ -// -// Bootcache -// -//------------------------------------------------------------------------------ +// --- Bootcache --- -// Threshold of cache entries above which we trim. +/** Entry count threshold above which `Bootcache::trim()` fires. + * + * When the cache exceeds this size the lowest-valence entries are pruned + * by `kBOOTCACHE_PRUNE_PERCENT` percent. + * + * @see kBOOTCACHE_PRUNE_PERCENT + */ static constexpr auto kBOOTCACHE_SIZE = 1000; -// The percentage of addresses we prune when we trim the cache. +/** Percentage of entries removed per trim pass. + * + * Applied to the current cache size: 1000 entries trims to 900 rather + * than slashing the cache aggressively. + * + * @see kBOOTCACHE_SIZE + */ static constexpr auto kBOOTCACHE_PRUNE_PERCENT = 10; -// The cool down wait between database updates -// Ideally this should be larger than the time it takes a full -// peer to send us a set of addresses and then disconnect. -// +/** Write-coalescing guard for SQLite bootcache updates. + * + * `flagForUpdate()` is called on every valence change, but the actual + * SQLite write only occurs if this much time has elapsed since the last + * flush. The window is intentionally larger than the typical lifetime of + * a transient peer that connects, advertises addresses, and disconnects, + * ensuring useful addresses are captured in a single write rather than + * triggering multiple redundant flushes. + */ static std::chrono::seconds const kBOOTCACHE_COOLDOWN_TIME(60); -//------------------------------------------------------------------------------ -// -// Livecache -// -//------------------------------------------------------------------------------ +// --- Livecache --- -// Drop incoming messages with hops greater than this number +/** Gossip horizon: endpoints arriving with hops greater than this are dropped. + * + * The `Endpoint` constructor clamps to `kMAX_HOPS + 1` (7) as a sentinel; + * `Logic::on_endpoints()` and the `Handouts` filters then reject anything + * above the limit. Six hops provides a network diameter large enough to + * reach far corners of a large P2P graph while preventing stale long-chain + * entries from polluting the cache. + */ std::uint32_t constexpr kMAX_HOPS = 6; -// How many Endpoint to send in each mtENDPOINTS +/** Number of endpoints to include in each outbound `mtENDPOINTS` message. + * + * Derived as `2 * kMAX_HOPS` (12). `SlotHandouts` stops accepting entries + * once this count is reached. + */ std::uint32_t constexpr kNUMBER_OF_ENDPOINTS = 2 * kMAX_HOPS; -// The most Endpoint we will accept in mtENDPOINTS +/** Maximum number of endpoints accepted from an inbound `mtENDPOINTS` message. + * + * `Logic::on_endpoints()` randomly trims oversized lists to this bound + * before insertion. Clamped to at least 64 to give headroom above the + * nominal 12-endpoint send size. + */ std::uint32_t constexpr kNUMBER_OF_ENDPOINTS_MAX = std::max(kNUMBER_OF_ENDPOINTS * 2, 64); -// Number of addresses we provide when redirecting. +/** Number of alternative addresses handed to a redirected peer. + * + * When a new connection is rejected because slots are full, this many + * livecache addresses are included in the redirect response. Kept small + * to avoid bandwidth waste while still providing enough alternatives for + * the client to find an open slot. + */ std::uint32_t constexpr kREDIRECT_ENDPOINT_COUNT = 10; -// How often we send or accept mtENDPOINTS messages per peer -// (we use a prime number of purpose) +/** Minimum interval between `mtENDPOINTS` sends or accepts per peer. + * + * A prime value is intentional: it de-synchronizes gossip timers across + * nodes, preventing coordinated broadcast floods where many peers all + * gossip at the same wall-clock moment. + * + * @note Much longer than `kLIVE_CACHE_SECONDS_TO_LIVE` (30 s) by design — + * the asymmetry keeps control-plane traffic low while still allowing + * fast cache expiry for peers that drop offline. + */ std::chrono::seconds constexpr kSECONDS_PER_MESSAGE(151); -// How long an Endpoint will stay in the cache -// This should be a small multiple of the broadcast frequency +/** Time-to-live for a livecache entry. + * + * `Livecache::expire()` and `SlotImp::expire()` both use this value. + * Intentionally much shorter than `kSECONDS_PER_MESSAGE` so that a peer + * which goes offline does not linger in the cache; a node must receive a + * fresh `mtENDPOINTS` message to keep a remote address visible. + */ std::chrono::seconds constexpr kLIVE_CACHE_SECONDS_TO_LIVE(30); -// How much time to wait before trying an outgoing address again. -// Note that we ignore the port for purposes of comparison. +/** Suppression window for recently attempted outbound addresses. + * + * `Logic::once_per_second()` expires the squelch map using this duration, + * preventing the same address from being hammered repeatedly within a + * single connection cycle. Port is ignored for comparison purposes. + */ std::chrono::seconds constexpr kRECENT_ATTEMPT_DURATION(60); } // namespace xrpl::PeerFinder::Tuning diff --git a/src/xrpld/peerfinder/detail/iosformat.h b/src/xrpld/peerfinder/detail/iosformat.h index 632ac10c16..32b99facc9 100644 --- a/src/xrpld/peerfinder/detail/iosformat.h +++ b/src/xrpld/peerfinder/detail/iosformat.h @@ -1,3 +1,16 @@ +/** + * @file + * @brief Stream formatting utilities for PeerFinder diagnostic log output. + * + * Provides a small set of stream manipulators and formatting helpers used + * throughout the PeerFinder subsystem (`Logic`, `Livecache`, `Bootcache`) to + * produce consistently column-aligned log lines without scattering + * `std::setw`/`std::left` boilerplate at every log site. + * + * The file has no XRPL-specific dependencies and lives in the `beast` + * namespace, reflecting its origin as a reusable library primitive. + */ + #pragma once #include @@ -6,10 +19,27 @@ namespace beast { -// A collection of handy stream manipulators and -// functions to produce nice looking log output. - -/** Left justifies a field at the specified width. */ +/** + * @brief Stream manipulator that sets left-justification and a fixed field + * width on a `std::basic_ios` in a single expression. + * + * Unlike manipulators that write characters, `Leftw` modifies the stream's + * sticky format state via `setf`/`width`. The width effect is consumed by the + * very next field write (standard `std::ios` semantics), so it is idiomatic + * to chain it directly before the value: + * + * @code + * JLOG(journal_.debug()) << beast::Leftw(18) << "Livecache insert " << ep.address; + * @endcode + * + * Every PeerFinder log prefix uses `Leftw(18)`, establishing a fixed 18-char + * column (e.g., `"Livecache insert "`, `"Logic connect "`) before appending + * variable-length address or count data, making log files scannable without a + * full structured-logging framework. + * + * @note Targets `std::basic_ios`, not `std::ostream` — it fits between a + * JLOG expression and its first datum without emitting characters. + */ struct Leftw { explicit Leftw(int width) : width(width) @@ -26,7 +56,29 @@ struct Leftw } }; -/** Produce a section heading and fill the rest of the line with dashes. */ +/** + * @brief Pad a title string out to a fixed column width with a fill character. + * + * Appends a space separator after @p title, then extends the string to + * @p width characters using @p fill (default: dash). Useful for producing + * section-break lines in multi-line diagnostic dumps: + * + * @code + * os << beast::heading("Endpoints") << '\n'; + * // "Endpoints ------------------------------------------------------..." + * @endcode + * + * `reserve()` is called upfront so the subsequent `push_back`/`resize` + * sequence incurs at most one allocation. + * + * @param title Section label; taken by value so the caller's string is not + * modified. + * @param width Total output length in characters (default: 80). Mirrors the + * default of `Divider` so the two can be mixed in box-formatted output. + * @param fill Padding character appended after the space separator + * (default: `'-'`). + * @return The padded heading string, ready to stream. + */ template std::basic_string heading(std::basic_string title, int width = 80, CharT fill = CharT('-')) @@ -37,7 +89,22 @@ heading(std::basic_string title, int width = 80, CharT return title; } -/** Produce a dashed line separator, with a specified or default size. */ +/** + * @brief Streamable solid-line separator for diagnostic output sections. + * + * Emits a string of @p width repeated @p fill characters directly to an + * `std::ostream`. Unlike `heading()`, which returns a `std::string`, + * `Divider` defers rendering until it is streamed, fitting naturally into + * chained `operator<<` expressions: + * + * @code + * os << beast::Divider() << '\n'; // 80 dashes + * os << beast::Divider(40, '=') << '\n'; + * @endcode + * + * The default column width of 80 mirrors `heading()` so the two can be used + * together to produce box-formatted diagnostic sections. + */ struct Divider { using CharT = char; @@ -55,7 +122,22 @@ struct Divider } }; -/** Creates a padded field with an optional fill character. */ +/** + * @brief Streamable whitespace block for column spacing in tabular output. + * + * Emits a fixed block of @p fill characters totalling `width + pad` + * characters. The constructor merges @p width and @p pad into a single + * `width_` member so the stream operator emits exactly one string. + * + * Useful for visually indenting or spacing columns in tabular diagnostic + * output when neither left/right justification nor text content is needed — + * only a fixed-width gap. + * + * @param width Base number of fill characters. + * @param pad Additional fill characters merged into `width` at construction + * (default: 0). + * @param fill Character used to fill the block (default: space). + */ struct Fpad { explicit Fpad(int width, int pad = 0, char fill = ' ') : width(width + pad), fill(fill) @@ -76,6 +158,18 @@ struct Fpad namespace detail { +/** + * @brief Convert any streamable value to `std::string` via `std::stringstream`. + * + * Used internally by the generic `field()` and `rField()` overloads to accept + * arbitrary value types (integers, addresses, etc.) without requiring callers + * to pre-convert. Works for any type that defines `operator<<` for + * `std::ostream`, at the cost of one heap allocation per call. + * + * @tparam T Any type with a streaming `operator<<`. + * @param t Value to convert. + * @return String representation produced by streaming @p t. + */ template std::string to_string(T const& t) @@ -87,7 +181,29 @@ to_string(T const& t) } // namespace detail -/** Justifies a field at the specified width. */ +/** + * @brief Streamable fixed-width text column with optional trailing pad and + * configurable justification. + * + * Holds the text content and layout parameters; actual characters are written + * by `operator<<`. Text shorter than @p width is padded with spaces on the + * left (right-justified) or the right (left-justified). An additional @p pad + * space block is appended after the justified content, useful for column + * gutters in tabular output. + * + * Unlike `Leftw`, which modifies stream state and is consumed by the next + * field write, `FieldT` manages its own padding and is independent of stream + * format state — both can coexist in the same expression. + * + * In practice all usages are narrow-`char` and are constructed via the + * `field()` / `rField()` factory functions rather than directly. + * + * @tparam CharT Character type. + * @tparam Traits Character traits (default: `std::char_traits`). + * @tparam Allocator Allocator (default: `std::allocator`). + * + * @see field(), rField() + */ /** @{ */ template < class CharT, @@ -128,6 +244,17 @@ public: } }; +/** + * @brief Construct a left-justified `FieldT` from a `std::basic_string`. + * + * @param text Text to display. + * @param width Minimum column width; text shorter than this is padded on the + * right (default: 8). + * @param pad Extra trailing spaces appended after the justified field + * (default: 0). + * @param right Set `true` for right-justification (default: `false`). + * @return A `FieldT` ready to stream. + */ template FieldT field( @@ -139,6 +266,15 @@ field( return FieldT(text, width, pad, right); } +/** + * @brief Construct a left-justified `FieldT` from a null-terminated string. + * + * @param text Null-terminated character array. + * @param width Minimum column width (default: 8). + * @param pad Extra trailing spaces (default: 0). + * @param right Set `true` for right-justification (default: `false`). + * @return A `FieldT` ready to stream. + */ template FieldT field(CharT const* text, int width = 8, int pad = 0, bool right = false) @@ -150,6 +286,20 @@ field(CharT const* text, int width = 8, int pad = 0, bool right = false) right); } +/** + * @brief Construct a left-justified `FieldT` from any streamable value. + * + * Converts @p t to a string via `detail::to_string()` (streams through + * `std::stringstream`), then delegates to the string overload. Accepts + * integers, addresses, or any type with `operator<<`. + * + * @tparam T Any type with `operator<<` for `std::ostream`. + * @param t Value to display. + * @param width Minimum column width (default: 8). + * @param pad Extra trailing spaces (default: 0). + * @param right Set `true` for right-justification (default: `false`). + * @return A `FieldT` ready to stream. + */ template FieldT field(T const& t, int width = 8, int pad = 0, bool right = false) @@ -158,6 +308,19 @@ field(T const& t, int width = 8, int pad = 0, bool right = false) return field(text, width, pad, right); } +/** + * @brief Construct a right-justified `FieldT` from a `std::basic_string`. + * + * Named alias for `field(..., right=true)` that makes call sites more + * readable than passing a boolean flag. + * + * @param text Text to display. + * @param width Minimum column width; text shorter than this is padded on the + * left (default: 8). + * @param pad Extra trailing spaces appended after the justified field + * (default: 0). + * @return A right-justified `FieldT` ready to stream. + */ template FieldT rField(std::basic_string const& text, int width = 8, int pad = 0) @@ -165,6 +328,14 @@ rField(std::basic_string const& text, int width = 8, i return FieldT(text, width, pad, true); } +/** + * @brief Construct a right-justified `FieldT` from a null-terminated string. + * + * @param text Null-terminated character array. + * @param width Minimum column width (default: 8). + * @param pad Extra trailing spaces (default: 0). + * @return A right-justified `FieldT` ready to stream. + */ template FieldT rField(CharT const* text, int width = 8, int pad = 0) @@ -176,6 +347,18 @@ rField(CharT const* text, int width = 8, int pad = 0) true); } +/** + * @brief Construct a right-justified `FieldT` from any streamable value. + * + * Converts @p t to a string via `detail::to_string()`, then delegates to the + * string overload. Equivalent to `field(t, width, pad, true)`. + * + * @tparam T Any type with `operator<<` for `std::ostream`. + * @param t Value to display. + * @param width Minimum column width (default: 8). + * @param pad Extra trailing spaces (default: 0). + * @return A right-justified `FieldT` ready to stream. + */ template FieldT rField(T const& t, int width = 8, int pad = 0) diff --git a/src/xrpld/peerfinder/make_Manager.h b/src/xrpld/peerfinder/make_Manager.h index 846330988b..f4035233a6 100644 --- a/src/xrpld/peerfinder/make_Manager.h +++ b/src/xrpld/peerfinder/make_Manager.h @@ -1,3 +1,12 @@ +/** @file + * Factory declaration for the PeerFinder subsystem. + * + * This is the sole mechanism by which a `Manager` is constructed. The + * concrete implementation type (`ManagerImp`) is never exposed in any public + * header, enforcing a hard compile-time boundary between consumers and the + * implementation. + */ + #pragma once #include @@ -8,7 +17,32 @@ namespace xrpl::PeerFinder { -/** Create a new Manager. */ +/** Construct and return an owned PeerFinder Manager instance. + * + * Hides the concrete `ManagerImp` type entirely; callers hold only a + * `unique_ptr` and interact exclusively through the `Manager` + * abstract interface. The returned object must be started via + * `Manager::start()` before use and stopped via `Manager::stop()` before + * destruction. + * + * @param ioContext Asio executor used for all asynchronous timer and I/O + * operations inside the manager (endpoint fetching, cache flushes, + * periodic logic runs). + * @param clock Abstract steady clock injected for all time-based + * decisions. Accepting an abstract clock rather than calling + * `std::chrono::steady_clock::now()` directly makes the manager + * unit-testable with a controlled fake clock. + * @param journal Diagnostic output sink; labelled `"PeerFinder"` by the + * caller in production. + * @param config Raw server configuration used during construction; the + * manager converts it into its own `PeerFinder::Config` via + * `Config::makeConfig()`. + * @param collector Metrics collector against which the manager registers + * internal stats counters (active inbound/outbound peer counts, etc.). + * @return A `unique_ptr` holding exclusive ownership of the newly + * constructed manager. + * @see Manager, PeerfinderManager.h + */ std::unique_ptr makeManager( boost::asio::io_context& ioContext, diff --git a/src/xrpld/rpc/BookChanges.h b/src/xrpld/rpc/BookChanges.h index 04c7a2c449..1fd43ab8f0 100644 --- a/src/xrpld/rpc/BookChanges.h +++ b/src/xrpld/rpc/BookChanges.h @@ -1,3 +1,13 @@ +/** @file + * Header-only template that aggregates per-ledger order book activity into + * OHLCV-style market data. + * + * `computeBookChanges` is the sole entry point. It is consumed by two + * independent paths: the `book_changes` WebSocket subscription stream + * (pushed from `NetworkOPs` on each validated ledger) and the + * `book_changes` RPC command (on-demand via `handlers/orderbook/BookChanges.cpp`). + */ + #pragma once #include @@ -22,6 +32,52 @@ class STTx; namespace RPC { +/** Scan a closed ledger and produce OHLCV market-data for every crossed order book. + * + * Iterates `sfAffectedNodes` in each transaction's metadata, identifies + * modified or deleted `ltOFFER` nodes, and accumulates per-pair Open, High, + * Low, Close, and Volume data into a tally map. The tally is then serialised + * to JSON and returned. + * + * Filtering rules applied before any accumulation: + * - Only `ltOFFER` nodes are considered; all other object types are ignored. + * - `sfCreatedNode` entries are skipped — a freshly created, uncrossed offer + * has no volume yet. + * - Nodes missing either `sfFinalFields` or `sfPreviousFields` are skipped; + * this is typical of cancelled offers where no crossing occurred. + * - `sfDeletedNode` entries whose `sfSequence` matches the transaction's + * cancel target (`sfOfferSequence` in `OfferCancel` / `OfferCreate`) are + * excluded so that explicit cancellations are not counted as volume. + * + * Canonical pair key ordering: XRP always occupies the first position; for + * two non-XRP assets, the lexicographically smaller asset string comes first. + * This ensures one tally entry per book regardless of offer direction. + * + * The exchange rate is `divide(first, second, noIssue())`. `noIssue()` is a + * static sentinel (`noCurrency()` / `noAccount()`) used because the rate is a + * dimensionless ratio and is not attributable to any specific IOU issuer. + * + * @tparam L Ledger type. Must expose `.txs` (iterable of `(STTx, TxMeta)` + * pairs) and `.header()` (providing `seq`, `hash`, `validated`, + * `closeTime`). No virtual interface is required; this template works + * with both production `ReadView`-derived types and lightweight test + * fixtures. + * @param lpAccepted The closed ledger to scan. + * @return A `Json::Value` object containing `type`, `validated`, + * `ledger_index`, `ledger_hash`, `ledger_time`, and a `changes` array. + * Each element of `changes` carries `currency_a` / `currency_b` (IOU or + * XRP pairs) or `mpt_issuance_id_a` / `mpt_issuance_id_b` (MPT pairs), + * plus `volume_a`, `volume_b`, `high`, `low`, `open`, `close`, and an + * optional `domain` field for permissioned-DEX tagged books. + * @note Open and close reflect transaction ordering within the ledger, not + * wall-clock timestamps. The `domain` field of the *last* processed trade + * for a given pair wins; this is consistent because all offers within one + * permissioned book share the same domain. + * @note A zero-value `second` delta causes the pair to be skipped entirely + * (defensive guard against malformed metadata — should never occur in + * practice). Negative volume deltas are normalised with `abs()` before + * accumulation. + */ template json::Value computeBookChanges(std::shared_ptr const& lpAccepted) @@ -29,13 +85,13 @@ computeBookChanges(std::shared_ptr const& lpAccepted) std::map< std::string, std::tuple< - STAmount, // side A volume - STAmount, // side B volume - STAmount, // high rate - STAmount, // low rate - STAmount, // open rate - STAmount, // close rate - std::optional>> // optional: domain id + STAmount, // vol A + STAmount, // vol B + STAmount, // high + STAmount, // low + STAmount, // open (first trade, never updated) + STAmount, // close (most recent trade) + std::optional>> // domain id (last trade wins) tally; for (auto& tx : lpAccepted->txs) @@ -64,14 +120,9 @@ computeBookChanges(std::shared_ptr const& lpAccepted) SField const& metaType = node.getFName(); uint16_t const nodeType = node.getFieldU16(sfLedgerEntryType); - // we only care about ltOFFER objects being modified or - // deleted if (nodeType != ltOFFER || metaType == sfCreatedNode) continue; - // if either FF or PF are missing we can't compute - // but generally these are cancelled rather than crossed - // so skipping them is consistent if (!node.isFieldPresent(sfFinalFields) || !node.isFieldPresent(sfPreviousFields)) continue; @@ -80,7 +131,6 @@ computeBookChanges(std::shared_ptr const& lpAccepted) auto const& pfBase = node.peekAtField(sfPreviousFields); auto const& previousFields = pfBase.template downcast(); - // defensive case that should never be hit if (!finalFields.isFieldPresent(sfTakerGets) || !finalFields.isFieldPresent(sfTakerPays) || !previousFields.isFieldPresent(sfTakerGets) || @@ -92,8 +142,6 @@ computeBookChanges(std::shared_ptr const& lpAccepted) finalFields.getFieldU32(sfSequence) == *offerCancel) continue; - // compute the difference in gets and pays actually - // affected onto the offer STAmount const deltaGets = finalFields.getFieldAmount(sfTakerGets) - previousFields.getFieldAmount(sfTakerGets); STAmount const deltaPays = finalFields.getFieldAmount(sfTakerPays) - @@ -107,7 +155,6 @@ computeBookChanges(std::shared_ptr const& lpAccepted) STAmount first = noswap ? deltaGets : deltaPays; STAmount second = noswap ? deltaPays : deltaGets; - // defensively programmed, should (probably) never happen if (second == beast::kZERO) continue; @@ -135,31 +182,23 @@ computeBookChanges(std::shared_ptr const& lpAccepted) if (!tally.contains(key)) { - tally[key] = { - first, // side A vol - second, // side B vol - rate, // high - rate, // low - rate, // open - rate, // close - domain}; + tally[key] = {first, second, rate, rate, rate, rate, domain}; } else { - // increment volume auto& entry = tally[key]; - std::get<0>(entry) += first; // side A vol - std::get<1>(entry) += second; // side B vol + std::get<0>(entry) += first; + std::get<1>(entry) += second; - if (std::get<2>(entry) < rate) // high + if (std::get<2>(entry) < rate) std::get<2>(entry) = rate; - if (std::get<3>(entry) > rate) // low + if (std::get<3>(entry) > rate) std::get<3>(entry) = rate; - std::get<5>(entry) = rate; // close - std::get<6>(entry) = domain; // domain + std::get<5>(entry) = rate; + std::get<6>(entry) = domain; } } } @@ -167,7 +206,6 @@ computeBookChanges(std::shared_ptr const& lpAccepted) json::Value jvObj(json::ValueType::Object); jvObj[jss::type] = "bookChanges"; - // retrieve validated information from LedgerHeader class jvObj[jss::validated] = lpAccepted->header().validated; jvObj[jss::ledger_index] = lpAccepted->header().seq; jvObj[jss::ledger_hash] = to_string(lpAccepted->header().hash); diff --git a/src/xrpld/rpc/CTID.h b/src/xrpld/rpc/CTID.h index 6c7e95a246..76cfce05ca 100644 --- a/src/xrpld/rpc/CTID.h +++ b/src/xrpld/rpc/CTID.h @@ -1,3 +1,28 @@ +/** + * @file CTID.h + * @brief Concise Transaction ID (CTID) encoding and decoding (XLS-15d). + * + * A CTID uniquely identifies a transaction by its position within a ledger on + * a specific network. Unlike a 64-character transaction hash, a CTID encodes + * three coordinates — ledger sequence, transaction index, and network ID — in + * a single 16-character uppercase hex string, making it compact and + * unambiguous across networks. + * + * ## Binary layout (64-bit value, 16 hex digits) + * ``` + * [4 bits: 0xC] [28 bits: ledgerSeq] [16 bits: txnIndex] [16 bits: networkID] + * ``` + * The top nibble is always `C` (0b1100), acting as a magic prefix that + * distinguishes a CTID from an arbitrary hex string. Valid CTIDs therefore + * satisfy `(value & 0xF000'0000'0000'0000) == 0xC000'0000'0000'0000`. + * + * Practical capacity: ~268 million ledgers, 65,536 transactions per ledger, + * 65,536 distinct networks. + * + * @see https://github.com/XRPLF/XRPL-Standards/discussions/34 + * @see encodeCTID, decodeCTID + */ + #pragma once #include @@ -8,24 +33,24 @@ namespace xrpl::RPC { -// CTID stands for Concise Transaction ID. -// -// The CTID comes from XLS-15d: Concise Transaction Identifier #34 -// -// https://github.com/XRPLF/XRPL-Standards/discussions/34 -// -// The Concise Transaction ID provides a way to identify a transaction -// that includes which network the transaction was submitted to. - /** - * @brief Encodes ledger sequence, transaction index, and network ID into a CTID - * string. + * Encodes a transaction's position in a ledger as a Concise Transaction ID + * (CTID) string. * - * @param ledgerSeq Ledger sequence number (max 0x0FFF'FFFF). - * @param txnIndex Transaction index within the ledger (max 0xFFFF). - * @param networkID Network identifier (max 0xFFFF). - * @return Optional CTID string in uppercase hexadecimal, or std::nullopt if - * inputs are out of range. + * The resulting 16-character uppercase hex string encodes the three inputs + * using the layout `[4-bit 0xC magic][28-bit ledgerSeq][16-bit txnIndex] + * [16-bit networkID]`. The output is zero-padded to exactly 16 characters, + * which the decoder relies on as its first validation step. + * + * Out-of-range inputs return `std::nullopt` immediately rather than truncating + * silently — silent truncation would produce a valid-looking CTID pointing to + * the wrong transaction. + * + * @param ledgerSeq Ledger sequence number. Must be ≤ 0x0FFF'FFFF (28 bits). + * @param txnIndex Transaction index within the ledger. Must be ≤ 0xFFFF. + * @param networkID Network identifier. Must be ≤ 0xFFFF. + * @return The CTID as a 16-character uppercase hex string, or `std::nullopt` + * if any argument exceeds its allowed range. */ inline std::optional encodeCTID(uint32_t ledgerSeq, uint32_t txnIndex, uint32_t networkID) noexcept @@ -46,12 +71,45 @@ encodeCTID(uint32_t ledgerSeq, uint32_t txnIndex, uint32_t networkID) noexcept } /** - * @brief Decodes a CTID string or integer into its component parts. + * Decodes a Concise Transaction ID (CTID) into its component ledger sequence, + * transaction index, and network ID. * - * @tparam T Type of the CTID input (string, string_view, char*, integral). - * @param ctid CTID value to decode. - * @return Optional tuple of (ledgerSeq, txnIndex, networkID), or std::nullopt - * if invalid. + * The template accepts string-like types (`std::string`, `std::string_view`, + * `char*`, `const char*`) and integral types via `if constexpr` dispatch. + * Passing an unsupported type safely returns `std::nullopt` rather than + * producing a compile error. + * + * **String path:** two guards fire before numeric conversion — an exact + * 16-character length check, then a `boost::regex` match against + * `^[0-9A-Fa-f]{16}$` (case-insensitive hex). `boost::regex` is used + * consistently with the broader XRPL codebase to avoid the poor performance + * of some `std::regex` implementations. The `std::stoull` call that follows + * is wrapped in a `try/catch` that is excluded from coverage + * (`LCOV_EXCL_START/STOP`) because the prior validation makes it unreachable + * in practice; the guard exists for defensive correctness. + * + * **Integral path:** the value is cast directly to `uint64_t`, then subjected + * to the same prefix mask check as the string path. + * + * After type-specific parsing the prefix mask + * `(value & 0xF000'0000'0000'0000) == 0xC000'0000'0000'0000` is verified, + * rejecting any value whose top nibble is not `C`. + * + * The return type uses `uint32_t` for `ledgerSeq` (28 usable bits fit) and + * `uint16_t` for `txnIndex` and `networkID`, enabling compile-time range + * reasoning by callers. + * + * @tparam T Input type: `std::string`, `std::string_view`, `char*`, + * `const char*`, or any integral type. Other types return `std::nullopt`. + * @param ctid The CTID value to decode. + * @return A tuple of `(ledgerSeq, txnIndex, networkID)` on success, or + * `std::nullopt` if the input is the wrong length, contains non-hex + * characters, has an incorrect `C` prefix nibble, or is an unsupported + * type. + * @note Callers receiving a decoded `networkID` should compare it against the + * local node's network ID and reject mismatches — a CTID from another + * network will decode successfully but refer to a different ledger + * history. */ template inline std::optional> diff --git a/src/xrpld/rpc/Context.h b/src/xrpld/rpc/Context.h index 3724ce1783..c216dc6d08 100644 --- a/src/xrpld/rpc/Context.h +++ b/src/xrpld/rpc/Context.h @@ -1,3 +1,12 @@ +/** @file + * Defines the RPC context types that bundle execution state for handler dispatch. + * + * Every RPC handler receives a context object — either `JsonContext` or a + * specialization of `GRPCContext` — as its primary input for all node + * infrastructure concerns. The base `Context` struct holds protocol-neutral + * state; the subtypes carry only the transport-specific request payload. + */ + #pragma once #include @@ -14,40 +23,123 @@ class LedgerMaster; namespace RPC { -/** The context of information needed to call an RPC. */ +/** Protocol-neutral execution state passed to every RPC handler. + * + * Bundles all node-level references an RPC handler may need into a single + * aggregate, avoiding the need to thread individual subsystem pointers + * through every function signature. All members are non-owning references or + * lightweight handles; `Context` owns nothing and imposes no lifecycle + * obligations on its holders. + * + * @note Using references for `app`, `netOps`, and `ledgerMaster` is a + * deliberate invariant: a context cannot be constructed with null + * subsystems. All referenced objects are guaranteed live for the + * duration of any in-flight RPC call. + * + * @see JsonContext, GRPCContext + */ struct Context { + /** Structured logger for this call; stored by value (journals are cheap handles). */ beast::Journal const j; + + /** Root of the node's service graph: wallet, config, database, etc. */ Application& app; + + /** Resource cost the handler should charge for this call. + * + * This is an in-out field: the transport layer initialises it to + * `feeReferenceRPC` before dispatch; handlers update it to + * `feeMediumBurdenRPC` or `feeHeavyBurdenRPC` to reflect actual work + * performed. An uncaught exception automatically escalates the charge + * to `feeExceptionRPC`. + */ Resource::Charge& loadType; + + /** Network and consensus state; used to check operating mode and submit transactions. */ NetworkOPs& netOps; + + /** Ledger history, current open ledger, and validated ledger state. */ LedgerMaster& ledgerMaster; + + /** Per-client resource tracking record used to enforce rate limits. */ Resource::Consumer& consumer; + + /** Authorization level resolved from port config and request credentials. + * + * Handlers gate admin-only operations on this value. Possible values are + * `GUEST`, `USER`, `IDENTIFIED`, `ADMIN`, `PROXY`, and `FORBID`. + */ Role role; + + /** Optional coroutine handle for long-running handlers to yield cooperatively. + * + * When set, the handler may call `coro->yield()` to return the thread to + * the job queue and resume later, preventing thread starvation. Most + * handlers leave this null. + */ std::shared_ptr coro; + + /** Open WebSocket session for subscription-oriented handlers. + * + * Set only for WebSocket connections. Handlers such as `subscribe` and + * `path_find` use this to register or deregister the session for event + * feeds. Null for plain HTTP requests. + */ InfoSub::pointer infoSub; + + /** Client's requested API version. + * + * Handlers use this to shape response formats and error codes. + * For example, `conditionMet()` returns `rpcNO_NETWORK` on v1 but + * `rpcNOT_SYNCED` on v2 and later. + */ unsigned int apiVersion; }; +/** Extends `Context` for JSON-RPC handlers dispatched over HTTP or WebSocket. + * + * Adds the parsed request parameters and any proxy-supplied identity headers. + * Every old-style handler function has signature `Status(JsonContext&, Json::Value&)`. + */ struct JsonContext : public Context { - /** - * Data passed in from HTTP headers. + /** Identity fields sourced from upstream proxy HTTP headers. + * + * Views into the HTTP request buffer that lives for the duration of the + * call; no copy is made. Only trusted when the remote IP belongs to a + * configured `secure_gateway` network — otherwise these fields are + * stripped by `ServerHandler` before the context is constructed. */ struct Headers { + /** Value of the `X-User` header, identifying the authenticated client. */ std::string_view user; + + /** Value of the `X-Forwarded-For` header, carrying the original client IP. */ std::string_view forwardedFor; }; + /** Parsed JSON request parameters for this call. */ json::Value params; + /** Proxy-supplied identity headers; default-initialised to empty views. */ Headers headers{}; }; +/** Extends `Context` for strongly-typed gRPC handlers. + * + * The template parameter `RequestType` is the generated protobuf message + * type for the specific gRPC method (e.g., + * `org::xrpl::rpc::v1::GetLedgerRequest`). All node infrastructure is + * inherited from the base `Context`. + * + * @tparam RequestType The protobuf request message type for this gRPC method. + */ template struct GRPCContext : public Context { + /** Decoded protobuf request for this gRPC call. */ RequestType params; }; diff --git a/src/xrpld/rpc/DeliveredAmount.h b/src/xrpld/rpc/DeliveredAmount.h index b603fa4acd..04917de2c1 100644 --- a/src/xrpld/rpc/DeliveredAmount.h +++ b/src/xrpld/rpc/DeliveredAmount.h @@ -1,3 +1,16 @@ +/** @file + * Utilities for computing and injecting the `delivered_amount` field into + * transaction metadata JSON responses. + * + * The `Amount` field on a Payment (or CheckCash) transaction records the + * *intended* delivery, not the actual delivery. When `tfPartialPayment` is + * set, the ledger may settle for less. The `DeliveredAmount` field in + * `TxMeta` was introduced at ledger sequence 4594095 (January 24, 2014) to + * record the actual delivery. This module bridges the historical gap between + * old and new ledgers so RPC consumers always receive a reliable + * `delivered_amount` value. + */ + #pragma once #include @@ -23,42 +36,123 @@ struct JsonContext; struct Context; -/** - Add a `delivered_amount` field to the `meta` input/output parameter. - The field is only added to successful payment and check cash transactions. - If a delivered amount field is available in the TxMeta parameter, that value - is used. Otherwise, the transaction's `Amount` field is used. If neither is - available, then the delivered amount is set to "unavailable". - - @{ +/** Inject a `delivered_amount` field into a transaction metadata JSON object. + * + * The field is added only for `ttPAYMENT`, `ttCHECK_CASH`, and + * `ttACCOUNT_DELETE` transactions that completed with `tesSUCCESS`. The + * resolution follows a three-tier fallback: + * 1. Use `TxMeta::getDeliveredAmount()` if present — authoritative for all + * ledgers after sequence 4594095. + * 2. If the field is absent but the ledger index is ≥ 4594095, or the ledger + * closed after NetClock timestamp 446000000s (February 2014), return the + * transaction's `Amount` field — absence of `DeliveredAmount` after + * deployment means full delivery. + * 3. Otherwise write the string `"unavailable"` — a sentinel that cannot be + * parsed as a valid `STAmount`, preventing misinterpretation by consumers. + * + * The ledger index and close time are read directly from `ledger.header()`, + * making this overload appropriate for full-ledger serialization in + * `LedgerToJson`. + * + * @param meta The `meta` JSON object to mutate; `delivered_amount` is + * written into this object. + * @param ledger The closed ledger whose header supplies the sequence number + * and close time used for the historical threshold check. + * @param serializedTx The serialized transaction being described. + * @param transactionMeta The transaction metadata associated with + * `serializedTx` in `ledger`. + * + * @{ */ void insertDeliveredAmount( json::Value& meta, - ReadView const&, + ReadView const& ledger, std::shared_ptr const& serializedTx, - TxMeta const&); + TxMeta const& transactionMeta); +/** Inject a `delivered_amount` field into a transaction metadata JSON object. + * + * Overload for use in `tx` and `account_tx` RPC handlers where the ledger + * is not directly available. The ledger sequence is derived from + * `TxMeta::getLgrSeq()`; the close time is fetched lazily via + * `context.ledgerMaster.getCloseTimeBySeq()` — the lookup is skipped + * entirely when `TxMeta` already carries `DeliveredAmount`, which is the + * common case for modern ledgers. + * + * This overload unwraps the application-level `Transaction` object and + * delegates to the `STTx const` overload below. + * + * @param meta The `meta` JSON object to mutate. + * @param context The RPC dispatch context; `context.ledgerMaster` is used + * for the lazy close-time lookup. + * @param transaction The application-level transaction wrapper whose inner + * `STTx` is used for type and amount inspection. + * @param transactionMeta The transaction metadata for `transaction`. + */ void insertDeliveredAmount( json::Value& meta, - RPC::JsonContext const&, - std::shared_ptr const&, - TxMeta const&); + RPC::JsonContext const& context, + std::shared_ptr const& transaction, + TxMeta const& transactionMeta); + +/** Inject a `delivered_amount` field into a transaction metadata JSON object. + * + * Overload for use in `tx` and `account_tx` RPC handlers operating directly + * on a serialized transaction. See the `Transaction` overload above for + * full behavioral description. Both overloads use `TxMeta::getLgrSeq()` as + * the ledger index source and lazily resolve the close time through + * `context.ledgerMaster`. + * + * @param meta The `meta` JSON object to mutate. + * @param context The RPC dispatch context; `context.ledgerMaster` is used + * for the lazy close-time lookup. + * @param transaction The serialized transaction. + * @param transactionMeta The transaction metadata for `transaction`. + */ void insertDeliveredAmount( json::Value& meta, - RPC::JsonContext const&, - std::shared_ptr const&, - TxMeta const&); + RPC::JsonContext const& context, + std::shared_ptr const& transaction, + TxMeta const& transactionMeta); +/** @} */ + +/** Resolve the delivered amount for a transaction without mutating JSON. + * + * Applies the same three-tier source resolution as `insertDeliveredAmount` + * but returns the value rather than writing it into a JSON object. Returns + * `std::nullopt` when the transaction type or result is not eligible, or + * when the ledger predates the `DeliveredAmount` deployment and no + * authoritative value can be determined (the `"unavailable"` sentinel is + * not expressible as an `STAmount`). + * + * Uses the base `RPC::Context` rather than `RPC::JsonContext` because it + * does not need request parameters — only `context.ledgerMaster` for the + * lazy close-time lookup. + * + * @param context The RPC context; `context.ledgerMaster` is queried for + * the close time of `ledgerIndex` when needed. + * @param serializedTx The serialized transaction to inspect. + * @param transactionMeta The transaction metadata; checked for eligibility + * (type must be `ttPAYMENT`, `ttCHECK_CASH`, or `ttACCOUNT_DELETE`; + * result must be `tesSUCCESS`) and for the `sfDeliveredAmount` field. + * @param ledgerIndex The sequence number of the ledger that applied + * `serializedTx`; used as the primary historical threshold check. + * @return The resolved delivered amount, or `std::nullopt` if the + * transaction is ineligible or the amount cannot be determined. + * @note This is intended for callers such as `Simulate` that need to + * reason about the delivered amount without constructing the full JSON + * response inline. + */ std::optional getDeliveredAmount( RPC::Context const& context, std::shared_ptr const& serializedTx, TxMeta const& transactionMeta, LedgerIndex const& ledgerIndex); -/** @} */ } // namespace RPC } // namespace xrpl diff --git a/src/xrpld/rpc/GRPCHandlers.h b/src/xrpld/rpc/GRPCHandlers.h index ac419c18ee..55af8d4e1a 100644 --- a/src/xrpld/rpc/GRPCHandlers.h +++ b/src/xrpld/rpc/GRPCHandlers.h @@ -1,3 +1,22 @@ +/** @file + * Declares the four gRPC ledger handler functions that form XRPL's + * Protocol Buffers API surface. + * + * Every handler follows the same contract: it accepts a + * `RPC::GRPCContext` (which bundles the protobuf request with + * the shared `Context` infrastructure for role checking, load accounting, + * and coroutine dispatch) and returns a `(ResponseType, grpc::Status)` pair. + * When the status is not `grpc::Status::OK`, the transport layer discards + * the response object and forwards only the error status to the client. + * Implementations can therefore return a default-constructed response on + * any error path without risk of sending partial data. + * + * Role checks, load-shed decisions, and node-condition preconditions are + * enforced by `GRPCServer::CallData::process()` before any handler is + * invoked, so the handlers themselves may assume the request is already + * authorized and the node is in a valid state to serve it. + */ + #pragma once #include @@ -8,23 +27,77 @@ namespace xrpl { -/* - * These handlers are for gRPC. They each take in a protobuf message that is - * nested inside RPC::GRPCContext, where T is the request type - * The return value is the response type, as well as a status - * If the status is not Status::OK (meaning an error occurred), then only - * the status will be sent to the client, and the response will be omitted +/** Return full header information for the requested ledger. + * + * Resolves the ledger identified by the request's specifier (sequence number + * or hash), serializes its header fields into the response, and optionally + * includes the transaction list and/or state-object diff relative to the + * previous ledger. This is the gRPC equivalent of the `ledger` JSON-RPC + * command. + * + * @param context gRPC dispatch envelope carrying the `GetLedgerRequest` + * protobuf and the shared application context. + * @return A pair of `(GetLedgerResponse, grpc::Status)`. On error the + * status is non-OK and the response is discarded by the caller. */ - std::pair doLedgerGrpc(RPC::GRPCContext& context); +/** Fetch a single ledger state object by key. + * + * Looks up the serialized ledger entry (SLE) identified by the key in the + * request and returns its raw binary blob. Clients use this to retrieve + * individual account roots, trust lines, offers, or any other SLE type + * without pulling the entire state map. + * + * @param context gRPC dispatch envelope carrying the `GetLedgerEntryRequest` + * protobuf and the shared application context. + * @return A pair of `(GetLedgerEntryResponse, grpc::Status)`. Returns + * `NOT_FOUND` status when the requested key does not exist in the ledger. + */ std::pair doLedgerEntryGrpc(RPC::GRPCContext& context); +/** Return a paginated slice of the ledger's raw state map. + * + * Iterates the SHAMap state trie starting at the opaque `marker` from the + * previous page (or from the beginning if absent), returning up to the + * requested number of serialized leaf objects. A non-empty `marker` in the + * response signals that more data is available. The fixed page size of 2048 + * objects applies regardless of role. Supports an `end_marker` for + * range-bounded parallel replication. + * + * This is the primary mechanism for external services to replicate a full + * ledger state one page at a time. + * + * @param context gRPC dispatch envelope carrying the `GetLedgerDataRequest` + * protobuf and the shared application context. + * @return A pair of `(GetLedgerDataResponse, grpc::Status)`. On error the + * status is non-OK and the response is discarded by the caller. + */ std::pair doLedgerDataGrpc(RPC::GRPCContext& context); +/** Compute the incremental difference between two ledgers' state maps. + * + * Resolves both `base_ledger` and `desired_ledger` from their specifiers, + * downcasts each `ReadView` to a fully-validated `Ledger` (returning + * `NOT_FOUND` if either is not validated), and calls + * `baseLedger->stateMap().compare(...)` to enumerate added, modified, and + * deleted state objects. Each changed key is included in the response; + * raw object blobs are included only when `include_blobs` is set in the + * request. + * + * Purpose-built for light clients and indexers (e.g., Clio) that track + * incremental ledger mutations without replaying the full transaction set. + * + * @param context gRPC dispatch envelope carrying the `GetLedgerDiffRequest` + * protobuf and the shared application context. + * @return A pair of `(GetLedgerDiffResponse, grpc::Status)`. Returns + * `NOT_FOUND` if either ledger cannot be resolved or is not a validated + * ledger; returns `RESOURCE_EXHAUSTED` if the difference set overflows + * the internal comparison limit. + */ std::pair doLedgerDiffGrpc(RPC::GRPCContext& context); diff --git a/src/xrpld/rpc/MPTokenIssuanceID.h b/src/xrpld/rpc/MPTokenIssuanceID.h index 8a8f6f3d51..9ff356bb92 100644 --- a/src/xrpld/rpc/MPTokenIssuanceID.h +++ b/src/xrpld/rpc/MPTokenIssuanceID.h @@ -10,27 +10,84 @@ namespace xrpl::RPC { -/** - Add a `mpt_issuance_id` field to the `meta` input/output parameter. - The field is only added to successful MPTokenIssuanceCreate transactions. - The mpt_issuance_id is parsed from the sequence and the issuer in the - MPTokenIssuance object. +/** @file + * Declares the three-function enrichment pipeline that injects + * `mpt_issuance_id` into RPC JSON responses for successful + * `MPTokenIssuanceCreate` transactions. + * + * `MPTID` is a 192-bit identifier derived at runtime from the `Sequence` + * and `Issuer` fields inside the `MPTokenIssuance` ledger entry created by + * the transaction; it is not stored redundantly in the transaction itself. + * These functions bridge that gap by extracting the ID from the transaction's + * `CreatedNode` metadata and injecting it into the JSON response. + * + * @see insertDeliveredAmount(), RPC::insertNFTSyntheticInJson() — sibling + * enrichment functions that follow the same pattern and are always + * applied at the same call sites. + */ - @{ +/** Determine whether a transaction can carry an `mpt_issuance_id` field. + * + * Returns `true` only when both of the following hold: + * - `serializedTx` is non-null and its type is `ttMPTOKEN_ISSUANCE_CREATE`. + * - `transactionMeta` reports `tesSUCCESS`. + * + * A failed `MPTokenIssuanceCreate` does not create a ledger entry, so there + * is no `CreatedNode` to scan and no identifier to inject. This predicate + * is exposed separately from `insertMPTokenIssuanceID` to allow unit tests + * to verify eligibility without constructing a full JSON response object. + * + * @param serializedTx The signed transaction to inspect; may be null. + * @param transactionMeta The execution metadata for the transaction. + * @return `true` if the transaction is a successful `MPTokenIssuanceCreate`, + * `false` otherwise. */ bool canHaveMPTokenIssuanceID( std::shared_ptr const& serializedTx, TxMeta const& transactionMeta); +/** Extract the `MPTID` of the issuance created by a transaction. + * + * Walks the affected-nodes list in `transactionMeta` looking for a + * `sfCreatedNode` whose `LedgerEntryType` is `ltMPTOKEN_ISSUANCE`. When + * found, reads `sfSequence` and `sfIssuer` from `sfNewFields` and returns + * `makeMptID(sequence, issuer)`. + * + * Returns `std::nullopt` when no matching `CreatedNode` is present. Callers + * should guard with `canHaveMPTokenIssuanceID()` before invoking this + * function if they want to avoid scanning metadata for ineligible + * transactions. + * + * @param transactionMeta The execution metadata to scan. + * @return The 192-bit `MPTID` of the newly created issuance, or + * `std::nullopt` if no `ltMPTOKEN_ISSUANCE` `CreatedNode` is found. + */ std::optional getIDFromCreatedIssuance(TxMeta const& transactionMeta); +/** Inject `mpt_issuance_id` into a JSON response for eligible transactions. + * + * Calls `canHaveMPTokenIssuanceID()` and returns early if the transaction + * is not a successful `MPTokenIssuanceCreate`. Otherwise, delegates to + * `getIDFromCreatedIssuance()` and, if an ID is found, writes + * `response[jss::mpt_issuance_id]` as a stringified `MPTID`. + * + * Must be called at every site where transaction metadata is serialized to + * JSON — alongside `insertDeliveredAmount()` and `insertNFTSyntheticInJson()` + * — so that API consumers always receive the derived identifier without + * having to recompute it. + * + * @param response The JSON object to enrich in place; unchanged if the + * transaction is ineligible or no `CreatedNode` is found. + * @param transaction The signed transaction; may be null (treated as + * ineligible). + * @param transactionMeta The execution metadata for the transaction. + */ void insertMPTokenIssuanceID( json::Value& response, std::shared_ptr const& transaction, TxMeta const& transactionMeta); -/** @} */ } // namespace xrpl::RPC diff --git a/src/xrpld/rpc/Output.h b/src/xrpld/rpc/Output.h index 1c74562842..5d518f5a77 100644 --- a/src/xrpld/rpc/Output.h +++ b/src/xrpld/rpc/Output.h @@ -1,3 +1,14 @@ +/** + * @file + * @brief Streaming write-sink abstraction for the RPC layer. + * + * Mirrors the pattern established in `include/xrpl/json/Output.h` for the + * JSON serialization subsystem, but uses `boost::utility/string_ref` rather + * than `boost::beast::string_view`. The divergence reflects the older lineage + * of this header; no files in the repository currently include it, making it + * a vestigial parallel to the canonical `json::Output` infrastructure. + */ + #pragma once #include @@ -5,8 +16,33 @@ namespace xrpl { namespace RPC { +/** + * Streaming write-sink callable for RPC response data. + * + * Models a write sink that accepts successive non-owning string fragments + * without any knowledge of the underlying destination (network socket, + * in-memory buffer, test harness, etc.). Decouples response generation + * from delivery: any callable matching this signature satisfies the interface. + * + * @note This type uses `boost::string_ref` (from `boost/utility/string_ref`). + * The canonical JSON-layer counterpart `json::Output` uses + * `boost::beast::string_view` instead — the difference reflects the older + * lineage of this header. + */ using Output = std::function; +/** + * Create an `Output` sink that appends all fragments to a string. + * + * Returns a lambda that captures `s` by reference and calls + * `s.append(b.data(), b.size())` for each fragment written to the sink. + * Use this when a fully-materialized RPC response is needed — for example, + * in tests or when a caller must hold the complete result as a single string. + * + * @param s The string to append response fragments to; must outlive the + * returned `Output` object. + * @return An `Output` callable that appends each fragment to `s`. + */ inline Output stringOutput(std::string& s) { diff --git a/src/xrpld/rpc/RPCCall.h b/src/xrpld/rpc/RPCCall.h index 7a09115e42..e5849e072c 100644 --- a/src/xrpld/rpc/RPCCall.h +++ b/src/xrpld/rpc/RPCCall.h @@ -1,3 +1,17 @@ +/** @file + * Client-side RPC origination: CLI entry point and async network dispatch. + * + * This header is the counterpart to RPCHandler.h. Where RPCHandler.h handles + * calls arriving at a running node, this file provides the machinery for + * initiating those calls — from the `xrpld` binary or a test harness. + * + * @note This is a trusted interface. The caller (typically a node + * administrator) is expected to supply valid input. Error catching and + * rich diagnostics are intentionally minimal here; validation belongs + * in the server-side handler pipeline, which operates in an adversarial + * environment. + */ + #pragma once #include @@ -14,18 +28,56 @@ namespace xrpl { -// This a trusted interface, the user is expected to provide valid input to -// perform valid requests. Error catching and reporting is not a requirement of -// the command line interface. -// -// Improvements to be more strict and to provide better diagnostics are welcome. - -/** Processes XRPL RPC calls. */ +/** Client-side RPC dispatch: CLI entry point and async network submission. */ namespace RPCCall { +/** Parse and dispatch a CLI command to a running node, printing the result. + * + * Translates raw CLI tokens into a JSON-RPC request via `rpcCmdToJson()`, + * connects to the node described in @p config, sends the request + * synchronously (runs a local `io_context` to completion), and writes the + * formatted response to `stdout`. + * + * @param config Node configuration providing server connection parameters + * (host, port, TLS, admin credentials). If no config is available, + * connection setup failures are silently swallowed so the CLI still + * works without a config file. + * @param vCmd Raw argument vector; `vCmd[0]` is the method name and + * subsequent elements are positional parameters. + * @param logs Logging sink used throughout the dispatch pipeline. + * @return Shell exit code: 0 (`rpcSUCCESS`) on success, 1 (`rpcBAD_SYNTAX`) + * for parse errors, or the numeric `error_code` from the server response + * on application-level errors. + */ int fromCommandLine(Config const& config, std::vector const& vCmd, Logs& logs); +/** Register an async JSON-RPC request against an already-running io_context. + * + * Encodes HTTP Basic Auth from @p strUsername / @p strPassword into the + * `Authorization` header, then delegates to `HTTPClient::request()` with a + * 256 MB response cap and a 30-second timeout. The function returns + * immediately; actual I/O is driven by the caller's @p ioContext event loop. + * + * @param ioContext Caller-owned Boost.Asio event loop; the async operation + * is registered on it but not driven — the caller must call + * `ioContext.run()` (or equivalent) to complete the request. + * @param strIp Target server IP address or hostname. + * @param iPort Target server port. + * @param strUsername HTTP Basic Auth username (may be empty). + * @param strPassword HTTP Basic Auth password (may be empty). + * @param strPath HTTP request path (empty string sends to "/"). + * @param strMethod JSON-RPC method name. + * @param jvParams JSON-RPC parameters array to include in the request body. + * @param bSSL Whether to use TLS for the connection. + * @param quiet Suppresses connection log messages when true. + * @param logs Logging sink. + * @param callbackFuncP Optional callback invoked with the parsed + * `Json::Value` response. When omitted (default-constructed + * `std::function`), the response is discarded. + * @param headers Additional HTTP headers merged with the auto-generated + * `Authorization` header before the request is sent. + */ void fromNetwork( boost::asio::io_context& ioContext, @@ -44,6 +96,29 @@ fromNetwork( std::unordered_map headers = {}); } // namespace RPCCall +/** Translate a raw CLI argument vector into a structured JSON-RPC request. + * + * Constructs an `RPCParser` for the given @p apiVersion and dispatches to + * the per-method parse function found in the internal static command table. + * The `api_version` field is injected into the returned object unless the + * parse produced an error or the request already carries a version. + * + * The dual-output design separates the translated request body (return + * value) from the raw invocation record (@p retParams), which is attached + * as `"rpc"` in error responses so callers can see exactly what was sent. + * + * @param args Argument vector; `args[0]` is the method name, remaining + * elements are positional parameters. + * @param retParams Out-parameter filled with the raw invocation record: + * `{ "method": args[0], "params": [ args[1..] ] }`. + * @param apiVersion API version to embed in the request and use for + * version-sensitive parsing (e.g., `account_tx` ledger-range errors + * differ between v1 and v2+). + * @param j Journal for trace/debug logging inside the parser. + * @return Translated `Json::Value` request body ready for network + * submission, or an error object if the method was not recognised or + * parameter validation failed. + */ json::Value rpcCmdToJson( std::vector const& args, @@ -51,8 +126,29 @@ rpcCmdToJson( unsigned int apiVersion, beast::Journal j); -/** Internal invocation of RPC client. - * Used by both xrpld command line as well as xrpld unit tests +/** Shared RPC client path used by the CLI binary and unit tests. + * + * Calls `rpcCmdToJson()` to translate @p args, reads server connection + * parameters from @p config (falling back gracefully if no config is + * available), then spins a local `io_context`, calls + * `RPCCall::fromNetwork()`, and blocks until the async operation completes. + * On error, the returned `Json::Value` includes `"rpc"` (the raw invocation + * record) and `"request_sent"` (the translated request) for diagnostics. + * + * @note A `static_assert` in the implementation guards that `rpcBAD_SYNTAX + * == 1` and `rpcSUCCESS == 0`, preserving the shell exit-code contract. + * + * @param args Raw argument vector; `args[0]` is the method name. + * @param config Node configuration used to locate the target server. Config + * setup failures are silently ignored so the call works without a file. + * @param logs Logging sink. + * @param apiVersion API version injected into the request and passed to the + * CLI uses `RPC::kAPI_COMMAND_LINE_VERSION`; tests may supply others. + * @param headers Additional HTTP headers forwarded to `fromNetwork()`. + * @return A pair of `{ exit_code, response_payload }`. The exit code is 0 + * on success, 1 for syntax errors, or the numeric `error_code` from the + * server response. The JSON value is the unwrapped result object, or an + * `rpcJSON_RPC` error object on transport failure. */ std::pair rpcClient( diff --git a/src/xrpld/rpc/RPCHandler.h b/src/xrpld/rpc/RPCHandler.h index 7e57b456fc..d653721f11 100644 --- a/src/xrpld/rpc/RPCHandler.h +++ b/src/xrpld/rpc/RPCHandler.h @@ -1,3 +1,17 @@ +/** @file + * Public entry points for the XRPL RPC command dispatch layer. + * + * This header is the only interface callers outside the RPC subsystem need + * in order to drive the full RPC pipeline: `doCommand` executes a request + * end-to-end, and `roleRequired` probes the handler table for the minimum + * permission level before any context is allocated. + * + * All handler-table internals (`Handler.h`, `Tuning.h`, `Condition`) are + * confined to the `detail/` subdirectory and are not exposed here. + * + * @see RPC::JsonContext, RPC::Role + */ + #pragma once #include @@ -7,10 +21,66 @@ namespace xrpl::RPC { struct JsonContext; -/** Execute an RPC command and store the results in a json::Value. */ +/** Execute an RPC command and write the result into @p result. + * + * Applies every gate in sequence before invoking the matched handler: + * + * 1. **Load shedding** — counts `jtCLIENT`-priority jobs; returns + * `rpcTOO_BUSY` if the queue exceeds `Tuning::kMAX_JOB_QUEUE_CLIENTS`. + * Callers holding `ADMIN` or `IDENTIFIED` roles bypass this check. + * 2. **Command resolution** — extracts the name from the `command` or + * `method` JSON field (both are accepted for compatibility). If both are + * present with differing values the request is rejected as + * `rpcUNKNOWN_COMMAND`. + * 3. **Role enforcement** — rejects non-admin callers attempting + * `Role::ADMIN`-gated commands with `rpcNO_PERMISSION`. This check + * occurs before any ledger-state inspection. + * 4. **Network condition gates** — verifies amendment-blocked status, + * UNL-blocked status, `OperatingMode`, validated-ledger age, and + * current/validated ledger gap. Error codes are version-sensitive: API + * v1 returns `rpcNO_NETWORK` / `rpcNO_CURRENT` / `rpcNO_CLOSED`; + * API v2+ collapses these to `rpcNOT_SYNCED`. + * 5. **Instrumented dispatch** — wraps the handler call with `PerfLog` + * start/finish/error events and a `JobQueue` load event for timing. + * Unhandled exceptions are caught and translated to `rpcINTERNAL`; if + * the load charge was still at `feeReferenceRPC` it is escalated to + * `feeExceptionRPC`. + * + * @note `doCommand` is transport-agnostic: it fills only the inner + * `result` object. The surrounding envelope (`status` placement, + * `id`, `type` fields) is assembled by the HTTP/WebSocket server + * layers above this call. + * + * @param context Fully-populated execution context including parsed params, + * resolved role, API version, and node subsystem references. + * @param result Output JSON object into which the handler writes its + * response fields. On error, an error object is injected before + * returning. + * @return A `Status` carrying the RPC error code, or `RpcSuccess` (0) on + * success. + */ Status -doCommand(RPC::JsonContext&, json::Value&); +doCommand(RPC::JsonContext& context, json::Value& result); +/** Look up the minimum `Role` required to invoke an RPC method. + * + * Queries the static handler table without constructing a context or + * touching the job queue, allowing the transport layer to reject + * unauthorized requests early — before any work is queued. `doCommand` + * re-validates the role internally, so this function acts as a fast + * pre-authorization probe only. + * + * @param version Client's requested API version, used to select the + * correct version-ranged handler entry. + * @param betaEnabled Whether beta API versions are enabled on this node + * (`[beta_rpc_api]` config flag). + * @param method The RPC method name to look up (e.g., `"ledger"`, + * `"account_info"`). + * @return The `Role` the caller must hold to execute `method`, or + * `Role::FORBID` if the method does not exist in the handler table + * for the given version. The `FORBID` sentinel signals the server + * layer to close the connection rather than queue the request. + */ Role roleRequired(unsigned int version, bool betaEnabled, std::string const& method); diff --git a/src/xrpld/rpc/RPCSub.h b/src/xrpld/rpc/RPCSub.h index 4c0903ed3e..95a08b6f1d 100644 --- a/src/xrpld/rpc/RPCSub.h +++ b/src/xrpld/rpc/RPCSub.h @@ -8,20 +8,80 @@ namespace xrpl { -/** Subscription object for JSON RPC. */ +/** Outbound push-subscription handle for delivering ledger events to a remote + * HTTP/HTTPS endpoint. + * + * When a client subscribes with a `url` field, the server creates an + * `RPCSub` and uses it to POST JSON events to that URL as they occur — a + * "webhook" delivery model. `RPCSub` is the `InfoSub` subclass that + * implements this push path; WebSocket clients use a different subclass. + * + * The concrete implementation (`RPCSubImp`) is hidden in the `.cpp` file. + * Callers interact only through this interface and obtain instances via + * `makeRPCSub()`. Events are queued internally and drained by a single + * `jtCLIENT_SUBSCRIBE` job at a time; failed deliveries are logged and + * silently dropped without retry. + * + * @note This feature exists for one specific integration. New subscribers + * should use WebSocket instead. The `InfoSub::Source` registry methods + * (`findRpcSub`, `addRpcSub`, `tryRemoveRpcSub`) deduplicate + * subscriptions by URL so only one `RPCSub` per URL is active. + * @see makeRPCSub() + */ class RPCSub : public InfoSub { public: + /** Update the HTTP Basic Auth username sent with each event POST. + * + * May be called at any time after construction; the change takes effect + * on the next outbound delivery. Thread-safe. + * + * @param strUsername New username for the remote endpoint. + */ virtual void setUsername(std::string const& strUsername) = 0; + + /** Update the HTTP Basic Auth password sent with each event POST. + * + * May be called at any time after construction; the change takes effect + * on the next outbound delivery. Thread-safe. + * + * @param strPassword New password for the remote endpoint. + */ virtual void setPassword(std::string const& strPassword) = 0; protected: + /** Construct the base, associating this subscription with its source. + * + * @param source The `InfoSub::Source` (typically `NetworkOPs`) that + * owns the URL registry for outbound subscriptions. + */ explicit RPCSub(InfoSub::Source& source); }; -// VFALCO Why is the ioContext needed? +/** Construct an outbound push-subscription and return it as a shared handle. + * + * Parses `strUrl`, resolves the target host/port/path, and creates an + * `RPCSubImp` ready to accept `send()` calls. The returned object should + * be registered with `InfoSub::Source::addRpcSub()` so duplicate + * subscriptions to the same URL can be detected. + * + * @param source The subscription source (typically `NetworkOPs`) used + * to register and look up outbound subscriptions by URL. + * @param ioContext The ASIO I/O context used by `RPCCall::fromNetwork()` to + * schedule the async HTTP POST for each event delivery. + * @param jobQueue The job queue on which `jtCLIENT_SUBSCRIBE` drain jobs + * are enqueued when new events arrive. + * @param strUrl Destination URL; must use `http` or `https` scheme. + * @param strUsername HTTP Basic Auth username for the remote endpoint. + * @param strPassword HTTP Basic Auth password for the remote endpoint. + * @param registry Service registry used to obtain a `beast::Journal` and + * the global `Logs&` reference required by `RPCCall::fromNetwork()`. + * @return A `shared_ptr` backed by the concrete `RPCSubImp`. + * @throws std::runtime_error if `strUrl` cannot be parsed or uses an + * unsupported scheme (anything other than `http` or `https`). + */ std::shared_ptr makeRPCSub( InfoSub::Source& source, diff --git a/src/xrpld/rpc/Role.h b/src/xrpld/rpc/Role.h index d1b641f067..49afe880c9 100644 --- a/src/xrpld/rpc/Role.h +++ b/src/xrpld/rpc/Role.h @@ -15,22 +15,55 @@ namespace xrpl { -/** Indicates the level of administrative permission to grant. - * IDENTIFIED role has unlimited resources but cannot perform some - * RPC commands. - * ADMIN role has unlimited resources and is able to perform all RPC - * commands. +/** Privilege level assigned to an inbound RPC or WebSocket connection. + * + * Every request is classified into one of these roles by `requestRole()`. + * Downstream code gates commands and resource consumption on the result. + * + * The separation of `IDENTIFIED` from `ADMIN` is intentional: a trusted + * reverse proxy (secure_gateway) can carry high-throughput user traffic + * with per-user rate exemption without exposing admin-only commands such + * as `stop` or `ledger_request` to those end users. */ -enum class Role { GUEST, USER, IDENTIFIED, ADMIN, PROXY, FORBID }; +enum class Role { + GUEST, /**< Unauthenticated external caller; subject to rate limiting. */ + USER, /**< Reserved; not currently assigned by `requestRole()`. */ + IDENTIFIED, /**< Caller identified via `X-User` header through a trusted + secure_gateway proxy; unlimited resources but cannot run + all admin-only commands. */ + ADMIN, /**< Caller from an admin-listed IP with correct credentials; + full command access and unlimited resources. */ + PROXY, /**< Trusted secure_gateway connection without a forwarded user + identity; unlimited for forwarding, but individual end + users are rate-limited by their forwarded IP. */ + FORBID, /**< Access denied; returned when `ADMIN` was required but the + IP/credential check failed. */ +}; -/** Return the allowed privilege role. - params must meet the requirements of the JSON-RPC - specification. It must be of type Object, containing the key params - which is an array with at least one object. Inside this object - are the optional keys 'admin_user' and 'admin_password' used to - validate the credentials. If user is non-blank, it's username - passed in the HTTP header by a secureGateway proxy. -*/ +/** Determine the privilege role for an inbound request. + * + * Evaluates three checks in order: + * 1. Admin: remote IP in `port.admin_nets_*` AND credentials correct → + * returns `Role::ADMIN` immediately. + * 2. Required-but-denied: if `required == Role::ADMIN` and step 1 failed → + * returns `Role::FORBID`. + * 3. Secure gateway: remote IP in `port.secure_gateway_nets_*` → returns + * `Role::IDENTIFIED` when `user` is non-empty, `Role::PROXY` otherwise. + * 4. Fallthrough → `Role::GUEST`. + * + * @param required Minimum role the caller must have; pass `Role::ADMIN` + * to reject non-admin callers with `Role::FORBID` rather than + * `Role::GUEST`. + * @param port Port configuration carrying `admin_nets_*`, + * `secure_gateway_nets_*`, `admin_user`, and `admin_password`. + * @param params JSON-RPC request parameters; the optional fields + * `admin_user` and `admin_password` are read for credential validation. + * @param remoteIp The physical remote endpoint of the connection. + * @param user Value of the `X-User` HTTP header forwarded by a + * secure_gateway proxy; empty string if not present. Untrusted callers + * cannot self-elevate by sending this header — the IP gate runs first. + * @return The assigned `Role`. + */ Role requestRole( Role const& required, @@ -39,6 +72,26 @@ requestRole( beast::IP::Endpoint const& remoteIp, std::string_view user); +/** Obtain a `Resource::Consumer` appropriate for the assigned role. + * + * Translates a role into the correct resource-manager endpoint type: + * - `ADMIN` or `IDENTIFIED` (`isUnlimited`) → unlimited endpoint; bypasses + * all throttling. + * - `PROXY` → proxied inbound endpoint keyed on `forwardedFor`, so each + * end-user behind the proxy gets an independent rate-limit bucket. + * - `GUEST` → standard rate-limited endpoint keyed on `remoteAddress`. + * + * @param manager Resource manager that owns the consumer lifetime. + * @param remoteAddress Physical remote endpoint used as the consumer key + * for non-proxied connections and for unlimited endpoints. + * @param role Role previously assigned by `requestRole()`. + * @param user Forwarded user identity (used by the resource + * manager for logging; may be empty). + * @param forwardedFor Originating client IP extracted from + * `X-Forwarded-For` or `Forwarded`; used as the consumer key when + * `role == Role::PROXY`. + * @return A `Resource::Consumer` tracking this connection's resource usage. + */ Resource::Consumer requestInboundEndpoint( Resource::Manager& manager, @@ -47,18 +100,30 @@ requestInboundEndpoint( std::string_view user, std::string_view forwardedFor); -/** - * Check if the role entitles the user to unlimited resources. +/** Returns true if the role is entitled to unlimited resources. + * + * Only `ADMIN` and `IDENTIFIED` are unlimited. All other roles, including + * `PROXY`, are subject to normal rate limiting. + * + * @param role The role to test. + * @return `true` if `role` is `ADMIN` or `IDENTIFIED`. */ bool isUnlimited(Role const& role); -/** - * True if remoteIp is in any of adminIp +/** Returns true if `remoteIp` falls within any of the given CIDR networks. * - * @param remoteIp Remote address for which to search. - * @param adminIp List of IP's in which to search. - * @return Whether remoteIp is in adminIp. + * The remote address is promoted to a host network (`/32` for IPv4, + * `/128` for IPv6) and tested for subnet-of or equality against each + * entry in the corresponding address-family list. IPv4 and IPv6 are + * kept in separate vectors to prevent cross-family confusion. + * + * Used for both `admin_nets` and `secure_gateway_nets` checks. + * + * @param remoteIp The address of the connecting client. + * @param nets4 Configured IPv4 CIDR blocks to match against. + * @param nets6 Configured IPv6 CIDR blocks to match against. + * @return `true` if `remoteIp` is within any of the supplied networks. */ bool ipAllowed( @@ -66,6 +131,28 @@ ipAllowed( std::vector const& nets4, std::vector const& nets6); +/** Extract the originating client IP from proxy-forwarding HTTP headers. + * + * Tries two header standards in priority order: + * 1. RFC 7239 `Forwarded` — locates the first (case-insensitive) `for=` + * token and parses the IP out of its value. + * 2. De-facto `X-Forwarded-For` — takes the first comma-delimited entry. + * + * The extracted field is then cleaned: leading/trailing whitespace is + * stripped, optional surrounding double-quotes are removed, IPv6 literals + * wrapped in `[...]` are unwrapped, and any appended port suffix is + * stripped from IPv4 addresses. + * + * @param request The inbound HTTP request. + * @return A `string_view` into the request buffer containing the bare IP + * address string, or an empty `string_view` if no valid forwarded + * address could be found. + * @note This value is used for per-user rate-limit accounting when the + * connection comes through a `PROXY`-role secure_gateway. Both false + * negatives (losing the real IP) and false positives (accepting a + * spoofed value) have resource-limit consequences; the IP gate in + * `requestRole()` must pass before this value is trusted. + */ std::string_view forwardedFor(http_request_type const& request); diff --git a/src/xrpld/rpc/ServerHandler.h b/src/xrpld/rpc/ServerHandler.h index 6b4cc34cc5..d2ddbe6818 100644 --- a/src/xrpld/rpc/ServerHandler.h +++ b/src/xrpld/rpc/ServerHandler.h @@ -1,3 +1,13 @@ +/** @file + * Declares ServerHandler, the central gateway between the TCP/TLS acceptor + * layer and the RPC / WebSocket processing pipeline. + * + * ServerHandler owns the Beast-backed Server, enforces per-port connection + * limits, routes accepted connections to either the overlay peer protocol or + * the JSON-RPC subsystem, and dispatches all request work to the JobQueue as + * coroutines. + */ + #pragma once #include @@ -21,41 +31,90 @@ namespace xrpl { +/** Order Port objects by name, enabling use of Port references as map keys. + * + * This ordering is required so that `std::reference_wrapper` + * can serve as the key type in `ServerHandler::count_`. + * + * @param lhs Left-hand Port. + * @param rhs Right-hand Port. + * @return True if `lhs.name` is lexicographically less than `rhs.name`. + */ inline bool operator<(Port const& lhs, Port const& rhs) { return lhs.name < rhs.name; } +/** Central gateway between the low-level TCP/TLS acceptor and the RPC pipeline. + * + * ServerHandler owns the Beast-backed Server object, wires it to the + * JobQueue, and fulfils the duck-typed handler concept required by + * `make_Server()`. It handles: + * + * - Per-port connection-count limiting (onAccept / onClose). + * - Routing: WebSocket upgrades, overlay peer connections, and HTTP RPC + * (onHandoff / onRequest / onWSMessage). + * - Dispatching work as `jtCLIENT_RPC` / `jtCLIENT_WEBSOCKET` coroutines. + * - Blocking two-phase shutdown via stop() / onStopped(). + * - Metrics: request count, payload size, and processing duration. + * + * @note Instances must be created through makeServerHandler(); the constructor + * is public only so `std::make_unique` can call it, but the + * `ServerHandlerCreator` token argument cannot be satisfied by any code + * outside the factory. + */ class ServerHandler { public: + /** Configuration loaded from `rippled.cfg` for the server and its clients. + * + * Populated by setupServerHandler() and passed to setup(). Call + * makeContexts() after parsing to instantiate SSL contexts for any port + * advertising `https`, `wss`, or similar protocols. + */ struct Setup { explicit Setup() = default; + /** Ordered list of listening port descriptors parsed from config. */ std::vector ports; // Memberspace + /** Outgoing-RPC client credentials used when the process calls itself + * or another node. + */ struct ClientT { explicit ClientT() = default; + /** True if the outgoing connection should use TLS. */ bool secure = false; + /** Target IP address for outgoing RPC calls. */ std::string ip; + /** Target port for outgoing RPC calls; 0 = auto-resolved. */ std::uint16_t port = 0; + /** HTTP Basic-Auth username for outgoing calls. */ std::string user; + /** HTTP Basic-Auth password for outgoing calls. */ std::string password; + /** Admin username forwarded in outgoing calls. */ std::string admin_user; + /** Admin password forwarded in outgoing calls. */ std::string admin_password; }; - // Configuration when acting in client role + /** Configuration when acting in client role */ ClientT client; - // Configuration for the Overlay + /** Bind endpoint for the overlay peer listener. */ boost::asio::ip::tcp::endpoint overlay; + /** Instantiate SSL contexts for all ports that advertise a TLS protocol. + * + * Must be called once after the Setup is fully populated and before + * it is passed to ServerHandler::setup(). + */ void makeContexts(); }; @@ -98,7 +157,25 @@ private: CollectorManager& cm); public: - // Must be public so make_unique can call it. + /** Construct a ServerHandler via the capability-key idiom. + * + * The `ServerHandlerCreator` parameter is an unpublished type whose only + * constructor is private; it can only be created inside makeServerHandler(), + * which is declared as a friend. This pattern keeps the constructor `public` + * (required for `std::make_unique`) while preventing arbitrary callers from + * instantiating the class directly. + * + * Three metrics are registered with the `"rpc"` group of `cm`: + * `requests` (counter), `size` (event), and `time` (event). + * + * @param creator Capability token — only obtainable inside makeServerHandler(). + * @param app The running Application instance. + * @param ioContext Boost.Asio I/O context for the underlying Beast Server. + * @param jobQueue Job queue for dispatching RPC coroutines. + * @param networkOPs Network operations interface. + * @param resourceManager Resource charge and disconnect tracking. + * @param cm CollectorManager used to register RPC metrics. + */ ServerHandler( ServerHandlerCreator const&, Application& app, @@ -112,21 +189,45 @@ public: using Output = json::Output; + /** Apply port configuration and start listening. + * + * Stores the setup, calls `server_->ports()` to obtain actual bound + * endpoints, and back-fills any auto-assigned port numbers (port == 0) + * into `setup_.ports`, `setup_.client.port`, and `setup_.overlay`. + * + * @param setup Port descriptors and client/overlay config. + * @param journal Journal used for subsequent server-level logging. + */ void setup(Setup const& setup, beast::Journal journal); + /** Return the active port configuration. + * + * @return Reference to the Setup stored by the last call to setup(). + */ [[nodiscard]] Setup const& setup() const { return setup_; } + /** Return the resolved listen endpoints keyed by port name. + * + * @return Reference to the Endpoints map populated by setup(). + */ [[nodiscard]] Endpoints const& endpoints() const { return endpoints_; } + /** Begin shutdown and block until all sessions have closed. + * + * Calls `server_->close()` to initiate asynchronous teardown, then waits + * on `condition_` until onStopped() sets `stopped_ = true`. Callers are + * guaranteed that no in-flight I/O references ServerHandler members after + * this function returns. + */ void stop(); @@ -134,9 +235,38 @@ public: // Handler // + /** Accept or reject a new inbound TCP connection. + * + * Atomically increments the per-port live-connection counter and rejects + * the connection (returns false) if the port's configured limit has been + * reached. The mutex is held during the increment because accepts can fire + * concurrently on multiple I/O threads. + * + * @param session The newly accepted session. + * @param endpoint Remote TCP endpoint of the connecting client. + * @return True to accept the connection; false to drop it immediately. + */ bool onAccept(Session& session, boost::asio::ip::tcp::endpoint endpoint); + /** Route an HTTP connection to WebSocket, overlay peer, or HTTP RPC. + * + * Decision logic: + * - WebSocket upgrade request → promotes the session via + * `session.websocketUpgrade()`, attaches a WSInfoSub to + * `WSSession::appDefined`, and returns a moved Handoff. + * - SSL bundle present and port carries `peer` protocol → forwarded to + * the Overlay via `app_.getOverlay().onHandoff()`. + * - Bare GET `/` on a WebSocket-only port → answered immediately with + * statusResponse() (HTTP 200 with build info). + * - Otherwise → empty Handoff, signalling the server to call onRequest(). + * + * @param session The incoming session. + * @param bundle SSL stream bundle (non-null for TLS connections). + * @param request The parsed HTTP request. + * @param remoteAddress Remote TCP endpoint. + * @return A Handoff describing how the connection was routed. + */ Handoff onHandoff( Session& session, @@ -144,6 +274,13 @@ public: http_request_type&& request, boost::asio::ip::tcp::endpoint const& remoteAddress); + /** Overload for plain (non-TLS) connections; forwards to the TLS overload. + * + * @param session The incoming session. + * @param request The parsed HTTP request. + * @param remoteAddress Remote TCP endpoint. + * @return A Handoff describing how the connection was routed. + */ Handoff onHandoff( Session& session, @@ -153,30 +290,116 @@ public: return onHandoff(session, {}, std::forward(request), remoteAddress); } + /** Handle an HTTP RPC request. + * + * Verifies that the port has `http`/`https` in its protocol set and that + * the Basic-Auth header matches the port's credentials (403 otherwise). + * The session is then detached and a `jtCLIENT_RPC` coroutine is posted + * to the JobQueue. If the queue is closed (node shutting down), a 503 is + * sent and the session is closed immediately. + * + * @param session The HTTP session carrying the RPC request body. + */ void onRequest(Session& session); + /** Handle an incoming WebSocket text frame. + * + * JSON parsing happens inline on the I/O thread. Frames that exceed + * `RPC::Tuning::kMAX_REQUEST_SIZE`, fail to parse, or are not JSON objects + * receive an immediate `jsonInvalid` error without touching the JobQueue. + * Valid messages are dispatched as `jtCLIENT_WEBSOCKET` coroutines. If the + * queue is full, the session is closed with a `going_away` frame. + * + * @param session The WebSocket session that received the frame. + * @param buffers The raw frame data as a const-buffer sequence. + */ void onWSMessage( std::shared_ptr session, std::vector const& buffers); + /** Decrement the per-port connection counter when a session closes. + * + * Mirrors onAccept(); both operations are performed under `mutex_`. + * + * @param session The session that has closed. + */ void onClose(Session& session, boost::system::error_code const&); + /** Signal that all sessions have closed after a server shutdown. + * + * Sets `stopped_ = true` under `mutex_` and notifies `condition_` so + * that the thread blocked in stop() can return. + * + * @param server The Server that has finished stopping (unused; required + * by the handler concept). + */ void onStopped(Server&); private: + /** Process a single WebSocket JSON-RPC request and return the response. + * + * Checks the resource consumer's disconnect threshold, validates the + * request structure (command/method presence and API version), resolves + * the caller's role, and calls RPC::doCommand(). The response always + * carries `type: response` plus any `id`, `jsonrpc`, `ripplerpc`, and + * `api_version` fields echoed from the request. Sensitive fields + * (`passphrase`, `secret`, `seed`, `seed_hex`) are masked as `` + * in error response echoes. + * + * @param session The WebSocket session making the request. + * @param coro The coroutine handle for cooperative yielding. + * @param jv Parsed JSON request object. + * @return JSON response object ready to serialise and send. + */ json::Value processSession( std::shared_ptr const& session, std::shared_ptr const& coro, json::Value const& jv); + /** Process a single HTTP session request by delegating to processRequest(). + * + * Extracts the raw body, forwarded-for header, and X-User header, then + * calls processRequest(). Keeps the connection alive if the request + * includes a keep-alive header, otherwise closes it. + * + * @param session Detached HTTP session whose body contains the JSON request. + * @param coro The coroutine handle for cooperative yielding. + */ void processSession(std::shared_ptr const&, std::shared_ptr coro); + /** HTTP JSON-RPC request engine — handles both single and batch requests. + * + * A top-level object with `method == "batch"` is treated as a batch; + * `params` is iterated as an array of individual sub-requests and the + * results are accumulated into a JSON array. For each sub-request: + * + * 1. Resolve API version (fallback: `kAPI_VERSION_IF_UNSPECIFIED`). + * 2. Determine required Role via RPC::roleRequired() and actual Role via + * requestRole() (IP ranges, admin credentials, secure-gateway headers). + * 3. Allocate a Resource::Consumer (unlimited for admin, metered otherwise). + * Return 503 / per-item error if the consumer is already disconnected. + * 4. Construct an RPC::JsonContext and call RPC::doCommand(). + * 5. Format response per `ripplerpc` version: v2+ separates success/error + * at the envelope level; v1 always nests under `result`. + * 6. Mask `passphrase`, `secret`, `seed`, `seed_hex` in error echoes. + * + * HTTP status: `ripplerpc >= "3.0"` maps ledger error codes to appropriate + * HTTP statuses; earlier versions always return 200. + * + * @param port The listening port the request arrived on. + * @param request Raw request body string. + * @param remoteIPAddress Caller's IP endpoint (port stripped to 0). + * @param output Sink function for writing the HTTP response body. + * @param coro Coroutine handle for cooperative yielding. + * @param forwardedFor Value of the X-Forwarded-For header, if present. + * @param user Value of the X-User header, if present. + */ void processRequest( Port const& port, @@ -187,13 +410,41 @@ private: std::string_view forwardedFor, std::string_view user); + /** Build an HTTP 200 status response for bare GET / on a WebSocket port. + * + * @param request The original HTTP request (version is preserved). + * @return A Handoff carrying the pre-built response writer. + */ [[nodiscard]] Handoff statusResponse(http_request_type const& request) const; }; +/** Parse `rippled.cfg` server and client sections into a ServerHandler::Setup. + * + * Reads `[server]`, `[port_*]`, `[rpc_startup]`, and related sections from + * `c`. Parse warnings and errors are written to `log`. + * + * @param c The loaded configuration. + * @param log Stream for diagnostic messages during parsing. + * @return A fully populated Setup ready to pass to ServerHandler::setup(). + */ ServerHandler::Setup setupServerHandler(Config const& c, std::ostream& log); +/** Factory that constructs and returns a ServerHandler. + * + * This is the only public way to create a ServerHandler. It synthesises the + * private `ServerHandlerCreator` token and forwards all arguments to the + * constructor. + * + * @param app The running Application instance. + * @param ioContext Boost.Asio I/O context for the underlying Server. + * @param jobQueue Job queue for dispatching RPC coroutines. + * @param networkOPs Network operations interface. + * @param resourceManager Resource charge and disconnect tracking. + * @param cm CollectorManager for registering RPC metrics. + * @return Owning pointer to the newly created ServerHandler. + */ std::unique_ptr makeServerHandler( Application& app, diff --git a/src/xrpld/rpc/Status.h b/src/xrpld/rpc/Status.h index 8f7c620baa..a9443394f9 100644 --- a/src/xrpld/rpc/Status.h +++ b/src/xrpld/rpc/Status.h @@ -1,3 +1,12 @@ +/** @file + * Unified error result type for the XRPL RPC layer. + * + * Provides `Status`, an adapter over the two legacy error spaces — `TER` + * (transaction engine results) and `error_code_i` (RPC protocol errors) — + * plus a raw integer fallback, so handler code can propagate and report + * errors through a single type regardless of origin. + */ + #pragma once #include @@ -6,67 +15,127 @@ namespace xrpl::RPC { -/** Status represents the results of an operation that might fail. - - It wraps the legacy codes TER and error_code_i, providing both a uniform - interface and a way to attach additional information to existing status - returns. - - A Status can also be used to fill a json::Value with a JSON-RPC 2.0 - error response: see http://www.jsonrpc.org/specification#error_object +/** Unified error result type for RPC handlers. + * + * Wraps either a `TER` (transaction engine result), an `error_code_i` (RPC + * protocol error), or a raw integer, together with an optional list of + * diagnostic message strings. The discriminant `type_` tag ensures each code + * space is converted back correctly; calling `toTER()` or `toErrorCode()` on + * the wrong type triggers an assertion. + * + * The success sentinel is `kOK == 0`, which is coherent because both + * `tesSUCCESS` and `rpcSUCCESS` are also zero. `operator bool()` therefore + * reads as "true if something went wrong" and works uniformly across all + * three code spaces. + * + * Inherits `std::exception` to support throw/catch flows; the dominant usage + * pattern in practice is value-based propagation (e.g., as a return value or + * inside `std::variant`). + * + * @see ErrorCodes.h for `error_code_i` and `inject_error()` + * @see TER.h for the transaction engine result type */ struct Status : public std::exception { public: - enum class Type { None, TER, ErrorCodeI }; + /** Discriminant tag identifying which error code space this Status holds. */ + enum class Type { + None, /**< Raw integer or default-constructed (no semantic type). */ + TER, /**< Transaction engine result (`TER`). */ + ErrorCodeI /**< RPC protocol error (`error_code_i`). */ + }; + + /** Underlying integer storage type for the error code. */ using Code = int; + + /** List of freeform diagnostic message strings attached to the status. */ using Strings = std::vector; + /** Success sentinel; equals zero, consistent with `tesSUCCESS` and `rpcSUCCESS`. */ static constexpr Code kOK = 0; + /** Constructs an OK (success) status with `Type::None` and `code_ == kOK`. */ Status() = default; - // The enable_if allows only integers (not enums). Prevents enum narrowing. + /** Constructs a raw-integer status. + * + * The `enable_if` guard restricts this constructor to genuine integral + * types and excludes enums. This prevents `TER` or `error_code_i` enum + * values from silently binding here and leaving `type_` as `Type::None`, + * which would cause `inject()` and `toErrorCode()` to misbehave. + * + * @tparam T An integral (non-enum) type. + * @param code Raw integer status code. + * @param d Optional diagnostic messages attached to this status. + */ template >> Status(T code, Strings d = {}) : code_(code), messages_(std::move(d)) { } + /** Constructs a status from a `TER` transaction engine result. + * + * @param ter The transaction engine result; stored as `TERtoInt(ter)`. + * @param d Optional diagnostic messages attached to this status. + */ Status(TER ter, Strings d = {}) : type_(Type::TER), code_(TERtoInt(ter)), messages_(std::move(d)) { } + /** Constructs a status from an `error_code_i` RPC error code. + * + * @param e The RPC error code. + * @param d Optional diagnostic messages attached to this status. + */ Status(ErrorCodeI e, Strings d = {}) : type_(Type::ErrorCodeI), code_(e), messages_(std::move(d)) { } + /** Constructs a status from an `error_code_i` with a single diagnostic message. + * + * Convenience overload for the common single-message case, e.g. + * `RPC::Status{rpcINVALID_PARAMS, "ledgerHashMalformed"}`. + * + * @param e The RPC error code. + * @param s A single diagnostic message string. + */ Status(ErrorCodeI e, std::string const& s) : type_(Type::ErrorCodeI), code_(e), messages_({s}) { } - /* Returns a representation of the integer status Code as a string. - If the Status is OK, the result is an empty string. - */ + /** Returns a human-readable representation of the error code. + * + * For `Type::TER`, returns the TER token and description (e.g. + * `"temBAD_AMOUNT: Malformed: Bad amount."`). For `Type::ErrorCodeI`, + * returns the error token and message from the `ErrorInfo` table (e.g. + * `"badSyntax: Syntax error."`). For `Type::None`, returns the raw + * integer as a decimal string. Returns an empty string when OK. + * + * @return Human-readable code string, or empty string if `code_ == kOK`. + */ [[nodiscard]] std::string codeString() const; - /** Returns true if the Status is *not* OK. */ + /** Returns `true` if the status represents an error (i.e. `code_ != kOK`). */ operator bool() const { return code_ != kOK; } - /** Returns true if the Status is OK. */ + /** Returns `true` if the status is OK (i.e. `code_ == kOK`). */ bool operator!() const { return !bool(*this); } - /** Returns the Status as a TER. - This may only be called if type() == Type::TER. */ + /** Recovers the stored code as a `TER`. + * + * @pre `type() == Type::TER`; asserts otherwise. + * @return The original `TER` value reconstructed via `TER::fromInt`. + */ [[nodiscard]] TER toTER() const { @@ -74,8 +143,11 @@ public: return TER::fromInt(code_); } - /** Returns the Status as an error_code_i. - This may only be called if type() == Type::ErrorCodeI. */ + /** Recovers the stored code as an `error_code_i`. + * + * @pre `type() == Type::ErrorCodeI`; asserts otherwise. + * @return The original `error_code_i` value. + */ [[nodiscard]] ErrorCodeI toErrorCode() const { @@ -83,7 +155,19 @@ public: return ErrorCodeI(code_); } - /** Apply the Status to a JsonObject + /** Writes the error into a JSON response object. + * + * Delegates to `injectError()` from `ErrorCodes.h`, which populates + * `object` with `jss::error` (token), an error code integer, and a + * message from the `ErrorInfo` table. If this `Status` carries attached + * messages, the first one is forwarded as a supplemental message string, + * overriding the default `ErrorInfo` message. Has no effect when OK. + * + * @param object The JSON object to populate with error fields. + * @note Only `Type::ErrorCodeI` statuses carry through to `injectError()`. + * A `TER`-typed or raw-integer status will silently produce no output + * because `toErrorCode()` is called unconditionally; callers that + * hold a `TER` status should convert or check `type()` before calling. */ void inject(json::Value& object) const @@ -101,28 +185,44 @@ public: } } + /** Returns all diagnostic messages attached to this status. */ [[nodiscard]] Strings const& messages() const { return messages_; } - /** Return the first message, if any. */ + /** Returns the attached diagnostic messages concatenated with `'/'` separators. + * + * Returns an empty string if no messages are attached. + * + * @return All messages joined by `'/'`, or empty string if none. + */ [[nodiscard]] std::string message() const; + /** Returns the discriminant tag identifying the error code space. */ [[nodiscard]] Type type() const { return type_; } + /** Returns a combined string of `codeString()` and `message()`, or empty if OK. */ [[nodiscard]] std::string toString() const; - /** Fill a json::Value with an RPC 2.0 response. - If the Status is OK, fillJson has no effect. - Not currently used. */ + /** Writes a JSON-RPC 2.0 error object into `value`. + * + * Populates `value["error"]` with `code`, `message`, and (if present) + * `data` fields per the JSON-RPC 2.0 spec at + * http://www.jsonrpc.org/specification#error_object. Has no effect when OK. + * + * @note Not currently used by the production RPC dispatch pipeline; the + * active bridge to JSON responses is `inject()`. Present to document + * intent for future JSON-RPC 2.0 compliance. + * @param value The JSON object to populate. + */ void fillJson(json::Value&); diff --git a/src/xrpld/rpc/detail/AccountAssets.cpp b/src/xrpld/rpc/detail/AccountAssets.cpp index 5bc4360a0e..cf37483704 100644 --- a/src/xrpld/rpc/detail/AccountAssets.cpp +++ b/src/xrpld/rpc/detail/AccountAssets.cpp @@ -1,3 +1,15 @@ +/** @file + * @brief Asset eligibility helpers for the XRPL path-finding subsystem. + * + * Implements `accountSourceAssets` and `accountDestAssets`, which classify + * which assets an account can send or receive. Both functions read from a + * shared `AssetCache` (a ledger-snapshot cache) to avoid redundant SLE + * lookups during a path-finding session and return a `hash_set` + * that seeds the `Pathfinder` before the expensive graph traversal begins. + * + * See `AccountAssets.h` for full behavioral contracts. + */ + #include #include @@ -31,18 +43,18 @@ accountSourceAssets( { auto& saBalance = rspEntry.getBalance(); - // Filter out non - if (saBalance > beast::kZERO - // Have IOUs to send. - || (rspEntry.getLimitPeer() - // Peer extends credit. - && ((-saBalance) < rspEntry.getLimitPeer()))) // Credit left. + // Include if the account holds a positive balance (IOUs to push) + // or is a borrower with room left under the peer's credit limit. + if (saBalance > beast::kZERO || + (rspEntry.getLimitPeer() && + ((-saBalance) < rspEntry.getLimitPeer()))) { assets.insert(saBalance.get().currency); } } } + // Remove the reserved sentinel to guard against malformed trust-line entries. assets.erase(badCurrency()); if (auto const mpts = lrCache->getMPTs(account)) @@ -65,9 +77,11 @@ accountDestAssets( { hash_set assets; + // XRP is always a valid destination — even if the account does not yet + // exist — because account creation itself requires receiving XRP above + // the base reserve. if (includeXRP) assets.insert(xrpCurrency()); - // Even if account doesn't exist if (auto const lines = lrCache->getRippleLines(account, LineDirection::Outgoing)) { @@ -75,17 +89,21 @@ accountDestAssets( { auto& saBalance = rspEntry.getBalance(); - if (saBalance < rspEntry.getLimit()) // Can take more + // Include if the account's own trust limit has not been reached. + if (saBalance < rspEntry.getLimit()) assets.insert(saBalance.get().currency); } } + // Remove the reserved sentinel to guard against malformed trust-line entries. assets.erase(badCurrency()); if (auto const mpts = lrCache->getMPTs(account)) { for (auto const& rspEntry : *mpts) { + // Zero balance means fresh capacity; !isMaxedOut() confirms the + // issuer's supply ceiling permits new transfers. if (rspEntry.isZeroBalance() && !rspEntry.isMaxedOut()) assets.insert(rspEntry.getMptID()); } diff --git a/src/xrpld/rpc/detail/AccountAssets.h b/src/xrpld/rpc/detail/AccountAssets.h index 55fce62fa6..7e8a4d6b8e 100644 --- a/src/xrpld/rpc/detail/AccountAssets.h +++ b/src/xrpld/rpc/detail/AccountAssets.h @@ -1,3 +1,13 @@ +/** @file + * Asset eligibility interface for the XRPL payment path-finding subsystem. + * + * Declares `accountSourceAssets` and `accountDestAssets`, which answer the + * question "what can this account send / receive?" without exposing `AssetCache` + * internals or the raw trust-line / MPT SLE representations to callers. + * Both functions are consumed exclusively by `PathRequest` to scope the + * `Pathfinder` graph traversal to assets that are actually usable. + */ + #pragma once #include @@ -6,12 +16,83 @@ namespace xrpl { +/** Enumerate every asset that @p account is capable of receiving. + * + * Scans the account's outgoing trust lines and MPT holdings from @p cache + * and returns the subset of assets for which there is remaining receive + * capacity. Called by `PathRequest` to populate the + * `destination_currencies` field in both the streaming `path_find` and the + * legacy `ripple_path_find` responses. + * + * An IOU currency is included when `balance < ownLimit`, i.e. the account's + * self-imposed trust limit has not been reached. An MPT issuance is included + * when the account holds a *zero* balance (fresh, not yet funded) and the + * issuer's supply ceiling has not been reached (`!isMaxedOut()`). + * + * XRP is always a valid destination even if the account does not yet exist, + * because account creation requires an XRP payment above the base reserve. + * + * The sentinel `badCurrency()` is removed from the result as a defensive + * measure against malformed trust-line entries. + * + * @note The MPT eligibility condition (zero balance) is the *inverse* of the + * source check in `accountSourceAssets` (non-zero balance). A freshly + * authorized or recently emptied MPT holder has zero balance and is the + * correct receive target; an MPT holder with a positive balance is the + * correct send source. + * + * @note The caller is responsible for honoring `lsfDisallowXRP`: pass + * `!disallowXRP` as @p includeXRP when the flag is set on the destination + * account so that XRP is suppressed at the RPC layer rather than here. + * + * @param account The destination account to evaluate. + * @param cache Ledger-snapshot cache shared within a path-finding + * session; amortises repeated SLE reads across multiple calls. + * @param includeXRP When true, XRP is unconditionally added to the result. + * @return A deduplicated set of `PathAsset` values (each a `Currency` or + * `MPTID`) that the account can accept. + */ hash_set accountDestAssets( AccountID const& account, std::shared_ptr const& cache, bool includeXRP); +/** Enumerate every asset that @p account is capable of sending. + * + * Scans the account's outgoing trust lines and MPT holdings from @p cache + * and returns the subset of assets that can be pushed outward. Called by + * `PathRequest` when no explicit `SendMax` asset has been provided, to seed + * the `Pathfinder` with all possible source currencies before graph traversal. + * + * An IOU currency is included when either: + * - the account's balance is positive (it holds issued currency to push), or + * - the account is a net borrower with room left under the peer's credit + * limit (`(-balance) < peerLimit`). + * + * An MPT issuance is included when the holder's balance is non-zero + * (`!isZeroBalance()`) and the issuer's supply ceiling has not been reached + * (`!isMaxedOut()`). + * + * The sentinel `badCurrency()` is removed from the result as a defensive + * measure against malformed trust-line entries. + * + * @note No reserve check is performed before inserting XRP; the caller is + * responsible for gating on account balance if that distinction matters. + * + * @note This function returns *all* spendable assets without an upper-bound. + * The caller (`PathRequest`) caps auto-detected source currencies at + * `RPC::Tuning::max_auto_src_cur` after this function returns. + * + * @param account The source account to evaluate. + * @param lrLedger Ledger-snapshot `AssetCache` shared within a path-finding + * session; amortises repeated SLE reads across multiple calls. + * (The parameter name is a historical artifact; the type is `AssetCache`, + * not a raw ledger view.) + * @param includeXRP When true, XRP is unconditionally added to the result. + * @return A deduplicated set of `PathAsset` values (each a `Currency` or + * `MPTID`) that the account can send. + */ hash_set accountSourceAssets( AccountID const& account, diff --git a/src/xrpld/rpc/detail/AssetCache.cpp b/src/xrpld/rpc/detail/AssetCache.cpp index a0743a2303..81b291f71a 100644 --- a/src/xrpld/rpc/detail/AssetCache.cpp +++ b/src/xrpld/rpc/detail/AssetCache.cpp @@ -1,3 +1,23 @@ +/** @file + * Implementation of `AssetCache`: a per-ledger, thread-safe cache of trust + * lines and MPT holdings used by the pathfinding engine. + * + * The cache sits between `Pathfinder` and the ledger SLE store. Because the + * pathfinder may query the same account's trust lines many times during a + * single search pass, the cache amortises the cost of repeated SLE reads + * across all traversal steps. It is scoped to a single immutable `ReadView`, + * so its contents are always consistent with one ledger version and can never + * become stale during the lifetime of a path request. + * + * Key design points documented here: + * - Direction-superset optimisation in `getRippleLines()` keeps at most one + * trust-line vector per account in memory (always the outgoing superset). + * - `nullptr` sentinels in both maps avoid allocating empty vectors for the + * estimated >90 % of accounts that have no usable lines for a given search. + * - Both `getRippleLines()` and `getMPTs()` hold `lock_` for their entire + * operation; concurrency is handled by coarse mutual exclusion. + */ + #include #include @@ -22,12 +42,27 @@ namespace xrpl { +/** Construct the cache for a specific ledger snapshot. + * + * Logs a `debug`-level message recording the ledger sequence so that + * cache lifetimes can be correlated with path-search activity. + * + * @param ledger The immutable ledger view this cache is bound to. Kept + * alive by the `shared_ptr` for the lifetime of the cache. + * @param j Journal used for diagnostic logging. + */ AssetCache::AssetCache(std::shared_ptr const& ledger, beast::Journal j) : ledger_(ledger), journal_(j) { JLOG(journal_.debug()) << "created for ledger " << ledger_->header().seq; } +/** Log cache statistics at destruction. + * + * Emits a `debug`-level message with the final account count and total + * distinct trust-line count, giving operators insight into path-search + * memory workload for the associated ledger sequence. + */ AssetCache::~AssetCache() { JLOG(journal_.debug()) << "destroyed for ledger " << ledger_->header().seq << " with " @@ -35,6 +70,39 @@ AssetCache::~AssetCache() << " distinct trust lines."; } +/** Return the cached trust lines for `accountID` under the direction-superset + * optimisation. + * + * The outgoing set (all trust lines) is always a superset of the incoming set + * (only rippling-enabled lines). To avoid storing the same data twice the + * cache converges to at most one entry per account: + * + * - If the **outgoing** set is requested and an **incoming** subset is already + * cached: the smaller subset is erased and the full outgoing set is built + * and stored in its place. `totalLineCount_` is decremented by the erased + * count before the new count is added, and an `XRPL_ASSERT` guards against + * underflow. + * - If the **incoming** set is requested and an **outgoing** superset is + * already cached: the superset is returned directly (the key is redirected + * to the existing outgoing entry). Non-rippling lines that it contains are + * harmlessly ignored by the pathfinder. + * - On the first request for either direction: a `nullptr` sentinel is + * emplaced and then replaced by the result of + * `PathFindTrustLine::getItems()`. A second `XRPL_ASSERT` ensures the + * emplace only fires when the slot was genuinely unoccupied. + * + * A `nullptr` return value means the account has no usable trust lines for + * the given direction; the pathfinder treats this identically to an empty + * vector but at lower memory cost. + * + * @param accountID The account whose trust lines are requested. + * @param direction `Outgoing` to obtain all lines; `Incoming` to obtain only + * rippling-enabled lines (though the superset may be returned if already + * cached). + * @return A shared pointer to the trust-line vector, or `nullptr` if the + * account has no usable lines. The pointer may safely be shared across + * threads; the underlying vector is never mutated after insertion. + */ std::shared_ptr> AssetCache::getRippleLines(AccountID const& accountID, LineDirection direction) { @@ -114,6 +182,31 @@ AssetCache::getRippleLines(AccountID const& accountID, LineDirection direction) return it->second; } +/** Return the cached MPT holdings for `account`, populating on first access. + * + * On the first call for a given account the function scans the account's + * owner directory via `forEachItem()` and collects two SLE categories: + * + * - `ltMPTOKEN_ISSUANCE` — the account is the issuer. `zeroBalance` is + * always `false` for issuers; `maxedOut` is `true` when + * `sfOutstandingAmount` equals `maxMPTAmount()`. + * - `ltMPTOKEN` — the account is a holder. `zeroBalance` is `true` when + * `sfMPTAmount == 0`. `maxedOut` is derived from the corresponding + * issuance SLE; if the issuance SLE cannot be found (e.g. it was deleted), + * `maxedOut` defaults conservatively to `true`. + * + * Like `getRippleLines()`, accounts with no MPTs are stored as `nullptr` + * rather than an empty vector to reduce allocations for the common case. + * + * @param account The account whose MPT holdings and issuances are requested. + * @return A const reference to the internal `shared_ptr` holding the MPT + * vector, or a reference to a `nullptr` if the account has no MPTs. + * Returning by `const&` avoids an unnecessary reference-count increment + * on the hot path. + * @note The returned reference is valid only while `lock_` is not re-acquired + * by another thread that could rehash `mpts_`; callers must copy the + * `shared_ptr` if they need to hold it past the current call frame. + */ std::shared_ptr> const& AssetCache::getMPTs(xrpl::AccountID const& account) { @@ -123,7 +216,6 @@ AssetCache::getMPTs(xrpl::AccountID const& account) return it->second; std::vector mpts; - // Get issued/authorized tokens forEachItem(*ledger_, account, [&](std::shared_ptr const& sle) { if (sle->getType() == ltMPTOKEN_ISSUANCE) { @@ -140,6 +232,8 @@ AssetCache::getMPTs(xrpl::AccountID const& account) { return sleIssuance->at(sfOutstandingAmount) == maxMPTAmount(*sleIssuance); } + // Conservative default: treat missing issuance as maxed out so + // the pathfinder does not attempt to route through a defunct MPT. return true; }(); diff --git a/src/xrpld/rpc/detail/AssetCache.h b/src/xrpld/rpc/detail/AssetCache.h index 9112d78715..7c752a0db2 100644 --- a/src/xrpld/rpc/detail/AssetCache.h +++ b/src/xrpld/rpc/detail/AssetCache.h @@ -1,3 +1,11 @@ +/** @file + * Per-ledger, thread-safe trust-line and MPT cache for the `Pathfinder`. + * + * `AssetCache` amortises repeated SLE reads during a single pathfinding + * session by loading each account's asset data once from an immutable + * `ReadView` and reusing it across all traversal steps. + */ + #pragma once #include @@ -13,34 +21,92 @@ namespace xrpl { -// Used by Pathfinder +/** Per-ledger trust-line and MPT cache for the pathfinding engine. + * + * The cache is bound to a single immutable `ReadView` snapshot, so all + * queries are answered against one consistent ledger version. It is shared + * across pathfinder invocations within a single client request via + * `std::shared_ptr`, then discarded when the request ends. + * + * Both `getRippleLines()` and `getMPTs()` are thread-safe; each acquires + * `lock_` for its entire duration. Null `shared_ptr` sentinels are stored + * instead of empty vectors for accounts with no assets — over 90% of + * accounts are estimated to fall into this category, making the sentinel + * approach a meaningful memory saving at pathfinding scale. + * + * @see Pathfinder + * @see PathRequest + */ class AssetCache final : public CountedObject { public: + /** Construct the cache bound to a specific ledger snapshot. + * + * Logs a debug message with the ledger sequence so that cache + * lifetimes can be correlated with path-search activity. + * + * @param l The immutable ledger view all queries are answered against. + * Kept alive for the lifetime of the cache. + * @param j Journal used for diagnostic logging. + */ explicit AssetCache(std::shared_ptr const& l, beast::Journal j); + + /** Destroy the cache, logging the final account and trust-line counts. */ ~AssetCache(); + /** Return the immutable ledger view this cache is bound to. */ [[nodiscard]] std::shared_ptr const& getLedger() const { return ledger_; } - /** Find the trust lines associated with an account. - - @param accountID The account - @param direction Whether the account is an "outgoing" link on the path. - "Outgoing" is defined as the source account, or an account found via a - trustline that has rippling enabled on the @accountID's side. If an - account is "outgoing", all trust lines will be returned. If an account is - not "outgoing", then any trust lines that don't have rippling enabled are - not usable, so only return trust lines that have rippling enabled on - @accountID's side. - @return Returns a vector of the usable trust lines. - */ + /** Return the cached trust lines for an account, applying the + * direction-superset optimisation. + * + * The outgoing set (all trust lines) is always a superset of the + * incoming set (only rippling-enabled lines). To avoid storing the + * same data twice the cache converges to at most one entry per account: + * + * - If an **outgoing** set is requested and an **incoming** subset is + * already cached, the subset is erased and the full outgoing set is + * fetched and stored in its place. + * - If an **incoming** set is requested and an **outgoing** superset is + * already cached, the superset is returned directly. The pathfinder + * silently ignores any non-rippling lines it contains. + * - On the first request for either direction a fresh set is loaded + * from the ledger via `PathFindTrustLine::getItems()`. + * + * @param accountID The account whose trust lines are requested. + * @param direction `Outgoing` to obtain all trust lines; `Incoming` to + * obtain only rippling-enabled lines (though the full superset may be + * returned if it was already cached for that account). + * @return A shared pointer to the trust-line vector, or `nullptr` if the + * account has no usable trust lines. The vector is never mutated after + * insertion so the pointer may safely be shared across threads. + */ std::shared_ptr> getRippleLines(AccountID const& accountID, LineDirection direction); + /** Return the cached MPT holdings and issuances for an account, + * populating on first access. + * + * On the first call for a given account the function walks the account's + * owner directory and collects `ltMPTOKEN_ISSUANCE` entries (tokens the + * account has issued) and `ltMPTOKEN` entries (tokens the account holds). + * Each `PathFindMPT` records whether the holder's balance is zero and + * whether the issuance has reached its maximum outstanding amount — flags + * the pathfinder uses to prune unusable MPT paths without additional + * ledger reads. If the issuance SLE for a held token cannot be found, + * `maxedOut` defaults conservatively to `true`. + * + * @param account The account whose MPT holdings and issuances are + * requested. + * @return A `const` reference to the internal `shared_ptr` holding the + * MPT vector, or a reference to `nullptr` if the account has no MPTs. + * Callers must copy the `shared_ptr` if they need to retain it past + * the current call frame, as the reference is into the internal map. + */ std::shared_ptr> const& getMPTs(AccountID const& account); @@ -52,6 +118,13 @@ private: beast::Journal journal_; + /** Composite map key encoding an account and its path direction. + * + * The hash is computed once from the `AccountID` alone and shared + * between the outgoing and incoming keys for the same account, so + * the direction-superset check in `getRippleLines()` never needs to + * re-hash when looking up the opposite-direction entry. + */ struct AccountKey final : public CountedObject { AccountID account; @@ -81,6 +154,9 @@ private: return hash_value; } + /** Passthrough hasher that returns the pre-computed `hash_value`, + * avoiding redundant hashing during map lookups. + */ struct Hash { Hash() = default; @@ -93,13 +169,19 @@ private: }; }; - // Use a shared_ptr so entries can be removed from the map safely. - // Even though a shared_ptr to a vector will take more memory just a vector, - // most accounts are not going to have any entries (estimated over 90%), so - // vectors will not need to be created for them. This should lead to far - // less memory usage overall. + // Null shared_ptr sentinels are stored for accounts with no trust lines + // (estimated >90% of accounts), avoiding vector allocations for the + // common empty case while still allowing safe removal and sharing of + // entries without invalidating outstanding shared_ptr copies. hash_map>, AccountKey::Hash> lines_; + + /** Running total of trust-line objects across all cached accounts. + * Adjusted on every insert or erase so the destructor can log it + * without iterating the map. + */ std::size_t totalLineCount_ = 0; + + /** MPT holdings/issuances keyed by account; null pointer for empty accounts. */ hash_map>> mpts_; }; diff --git a/src/xrpld/rpc/detail/DeliveredAmount.cpp b/src/xrpld/rpc/detail/DeliveredAmount.cpp index 8d8aac33bf..a4b843353d 100644 --- a/src/xrpld/rpc/detail/DeliveredAmount.cpp +++ b/src/xrpld/rpc/detail/DeliveredAmount.cpp @@ -1,3 +1,15 @@ +/** @file + * Implements `delivered_amount` resolution and injection for RPC transaction + * metadata responses. + * + * The `GetLedgerIndex` and `GetCloseTime` callables used throughout this file + * are intentionally lazy: ledger index and close time are only evaluated when + * the historical fallback branch is reached. In the common case — a modern + * ledger whose `TxMeta` already contains `sfDeliveredAmount` — neither value + * is ever computed, avoiding a potentially expensive `LedgerMaster` lookup by + * sequence number. + */ + #include #include @@ -18,15 +30,35 @@ namespace xrpl::RPC { -/* - GetLedgerIndex and GetCloseTime are lambdas that allow the close time and - ledger index to be lazily calculated. Without these lambdas, these values - would be calculated even when not needed, and in some circumstances they are - not trivial to compute. - - GetLedgerIndex is a callable that returns a LedgerIndex - GetCloseTime is a callable that returns a - std::optional +/** Resolve the actual delivered amount for a transaction using lazy accessors. + * + * Implements the three-tier resolution strategy: + * 1. Return `TxMeta::getDeliveredAmount()` directly if present — authoritative + * for all ledgers from sequence 4594095 onward. + * 2. If the metadata field is absent but the ledger postdates the + * `DeliveredAmount` deployment (index >= 4594095 **or** close time > + * 446000000s ≈ February 2014), return the transaction's `sfAmount` — a + * missing field in a post-deployment ledger means full delivery. + * 3. Return `std::nullopt` for pre-deployment ledgers where the delivered + * amount cannot be determined; callers emit the `"unavailable"` sentinel. + * + * @tparam GetLedgerIndex Callable with signature `LedgerIndex ()`. + * @tparam GetCloseTime Callable with signature + * `std::optional ()`. + * @param getLedgerIndex Lazy accessor for the ledger sequence number; only + * called when `transactionMeta` lacks `sfDeliveredAmount`. + * @param getCloseTime Lazy accessor for the ledger close time; only called + * when `transactionMeta` lacks `sfDeliveredAmount` and the ledger index + * alone does not cross the historical threshold. + * @param serializedTx The transaction to inspect; returns `std::nullopt` + * immediately if null. + * @param transactionMeta Metadata for `serializedTx`; the primary source for + * `sfDeliveredAmount`. + * @return The resolved delivered amount, or `std::nullopt` if the ledger + * predates the `DeliveredAmount` feature and no authoritative value exists. + * @note Both threshold conditions (`getLedgerIndex() >= 4594095` and + * `getCloseTime() > 446000000s`) identify the same era; the close-time + * check is a fallback for ledgers whose sequence alone is ambiguous. */ template std::optional @@ -49,14 +81,6 @@ getDeliveredAmount( { using namespace std::chrono_literals; - // Ledger 4594095 is the first ledger in which the DeliveredAmount field - // was present when a partial payment was made and its absence indicates - // that the amount delivered is listed in the Amount field. - // - // If the ledger closed long after the DeliveredAmount code was deployed - // then its absence indicates that the amount delivered is listed in the - // Amount field. DeliveredAmount went live January 24, 2014. - // 446000000 is in Feb 2014, well after DeliveredAmount went live if (getLedgerIndex() >= 4594095 || getCloseTime() > NetClock::time_point{446000000s}) { return serializedTx->getFieldAmount(sfAmount); @@ -66,8 +90,18 @@ getDeliveredAmount( return {}; } -// Returns true if transaction meta could contain a delivered amount field, -// based on transaction type and transaction result +/** Determine whether a transaction can carry a `delivered_amount` field. + * + * Acts as a cheap type-and-result gate before the more expensive amount + * resolution logic runs. Only `ttPAYMENT`, `ttCHECK_CASH`, and + * `ttACCOUNT_DELETE` transactions that completed with `tesSUCCESS` are + * eligible; failed transactions deliver nothing and must not have the field. + * + * @param serializedTx The transaction to test; returns `false` if null. + * @param transactionMeta Metadata carrying the transaction result code. + * @return `true` if the transaction type and result allow a + * `delivered_amount` field to be present in the metadata. + */ bool canHaveDeliveredAmount( std::shared_ptr const& serializedTx, @@ -77,8 +111,6 @@ canHaveDeliveredAmount( return false; TxType const tt{serializedTx->getTxnType()}; - // Transaction type should be ttPAYMENT, ttACCOUNT_DELETE or ttCHECK_CASH - // and if the transaction failed nothing could have been delivered. return (tt == ttPAYMENT || tt == ttCHECK_CASH || tt == ttACCOUNT_DELETE) && transactionMeta.getResultTER() == tesSUCCESS; } @@ -111,6 +143,23 @@ insertDeliveredAmount( } } +/** Internal bridge: gates on eligibility, then calls the lazy-accessor overload. + * + * Constructs a `getCloseTime` lambda that delegates to + * `context.ledgerMaster.getCloseTimeBySeq()`, keeping the potentially + * expensive DB lookup deferred until the historical-fallback branch is + * actually reached. Returns `std::nullopt` immediately for ineligible + * transactions without touching `LedgerMaster` at all. + * + * @tparam GetLedgerIndex Callable with signature `LedgerIndex ()`. + * @param context RPC context; `context.ledgerMaster` is used only + * when the close-time lazy lambda is invoked. + * @param serializedTx The transaction to resolve. + * @param transactionMeta Metadata for `serializedTx`. + * @param getLedgerIndex Lazy accessor for the ledger sequence number. + * @return The resolved delivered amount, or `std::nullopt` if ineligible or + * the ledger predates the `DeliveredAmount` feature. + */ template static std::optional getDeliveredAmount( diff --git a/src/xrpld/rpc/detail/Handler.cpp b/src/xrpld/rpc/detail/Handler.cpp index 938e2e645d..16f140e8ae 100644 --- a/src/xrpld/rpc/detail/Handler.cpp +++ b/src/xrpld/rpc/detail/Handler.cpp @@ -1,3 +1,12 @@ +/** @file + * Central dispatch registry mapping RPC method names to handler functions. + * + * Defines the static handler table (`kHANDLER_ARRAY`), the `HandlerTable` + * singleton that owns the live multimap, and the two public lookup functions + * `getHandler()` and `getHandlerNames()`. Version overlap between same-named + * handlers is a fatal `LogicError` detected at startup. + */ + #include #include @@ -21,7 +30,19 @@ namespace xrpl::RPC { namespace { -/** Adjust an old-style handler to be call-by-reference. */ +/** Adapt an old-style free-function handler to the canonical by-reference signature. + * + * Old-style handlers return `Json::Value` by value. The dispatch layer requires + * `Status(JsonContext&, Json::Value&)`. This shim calls `f`, assigns the result + * to `result`, and verifies that the return value is a JSON object. If it is not + * — which is a programming error, not a user error — `makeObjectValue()` wraps it + * defensively. That branch is excluded from coverage because correct handlers + * never reach it. + * + * @tparam Function An old-style handler callable: `Json::Value(JsonContext&)`. + * @param f The old-style handler to wrap. + * @return A `Handler::Method` suitable for storage in `Handler::valueMethod`. + */ template Handler::Method byRef(Function const& f) @@ -40,6 +61,20 @@ byRef(Function const& f) }; } +/** Drive a new-style class-based handler through its two-phase dispatch. + * + * Asserts that `context.apiVersion` is within `[HandlerImpl::minApiVer, + * HandlerImpl::maxApiVer]`, then constructs `HandlerImpl`, runs `check()`, + * and either injects the error status into `object` or calls `writeResult()`. + * + * @tparam Object The JSON output type (typically `json::Value`). + * @tparam HandlerImpl A class with static `minApiVer`/`maxApiVer`, a + * `check()` method returning `Status`, and a `writeResult(Object&)` method. + * @param context The dispatched RPC context. + * @param object Output parameter populated by `writeResult()` on success, + * or by `Status::inject()` on failure. + * @return The `Status` returned by `check()`, or `Status()` on success. + */ template Status handle(JsonContext& context, Object& object) @@ -62,6 +97,16 @@ handle(JsonContext& context, Object& object) return status; } +/** Construct a `Handler` value-struct from a new-style class-based handler. + * + * Reads the static metadata fields (`name`, `role`, `condition`, + * `minApiVer`, `maxApiVer`) from `HandlerImpl` and binds `handle<>` as + * the callable, producing a `Handler` ready for insertion into the table. + * + * @tparam HandlerImpl A new-style handler class (e.g. `LedgerHandler`, + * `VersionHandler`) with the required static metadata fields. + * @return A fully populated `Handler` struct. + */ template Handler handlerFrom() @@ -75,6 +120,22 @@ handlerFrom() HandlerImpl::maxApiVer}; } +/** Static registry of all old-style RPC handlers. + * + * Each entry specifies the method name, the wrapped handler callable, + * the required role (`USER` or `ADMIN`), the network/ledger condition + * that must be satisfied before dispatch, and an optional API version + * range (defaults to `[kAPI_MINIMUM_SUPPORTED_VERSION, kAPI_MAXIMUM_VALID_VERSION]`). + * + * Entries with explicit `minApiVer`/`maxApiVer` (e.g. `ledger_header`, + * `tx_history`) are hidden from clients using API versions outside that + * range. `LedgerHandler` and `VersionHandler` are new-style handlers + * registered separately via `HandlerTable::addHandler()` and are not + * listed here. + * + * @note Adding a handler here without also ensuring no version overlap + * with an existing same-named entry causes `logicError()` at startup. + */ Handler const kHANDLER_ARRAY[]{ // Some handlers not specified here are added to the table via addHandler() // Request-response methods @@ -367,13 +428,34 @@ Handler const kHANDLER_ARRAY[]{ .condition = Condition::NoCondition}, }; +/** Immutable dispatch table mapping RPC method names to `Handler` entries. + * + * Built once at startup from `kHANDLER_ARRAY` plus the two new-style + * handlers (`LedgerHandler`, `VersionHandler`). The backing store is a + * `std::multimap` so that a single method name may have multiple entries + * covering non-overlapping API version ranges. Any version overlap detected + * during construction causes `logicError()`, crashing the process before it + * accepts any requests. + * + * Access the singleton via `HandlerTable::instance()`. The object is + * const after construction and requires no locking. + */ class HandlerTable { private: using handler_table_t = std::multimap; - // Use with equal_range to enforce that API range of a newly added handler - // does not overlap with API range of an existing handler with same name + /** Check whether a candidate version range overlaps any existing entry for the same name. + * + * Uses the standard interval-overlap test: two ranges `[a,b]` and `[c,d]` overlap + * iff `a <= d && b >= c`. Called during construction to enforce the invariant that + * each `(name, version)` pair maps to at most one handler. + * + * @param range The `equal_range` result for the method name being inserted. + * @param minVer Lower bound of the candidate version range (inclusive). + * @param maxVer Upper bound of the candidate version range (inclusive). + * @return `true` if any existing entry for the name overlaps `[minVer, maxVer]`. + */ [[nodiscard]] static bool overlappingApiVersion( std::pair range, @@ -393,6 +475,16 @@ private: }); } + /** Construct the table from a compile-time array of `Handler` entries. + * + * Inserts every entry from `entries` into `table_`, calling `logicError()` + * on any version overlap. After processing the array, registers the two + * new-style handlers (`LedgerHandler`, `VersionHandler`) via `addHandler()`. + * + * @tparam N Size of the handler array (deduced). + * @param entries Array of old-style `Handler` descriptors (typically `kHANDLER_ARRAY`). + * @throws LogicError if any two entries for the same name have overlapping version ranges. + */ template explicit HandlerTable(Handler const (&entries)[N]) { @@ -415,6 +507,13 @@ private: } public: + /** Return the process-wide singleton instance, initializing it on first call. + * + * Thread-safe by C++11 static-local initialization guarantees. + * The returned reference is valid for the lifetime of the process. + * + * @return A const reference to the single `HandlerTable`. + */ static HandlerTable const& instance() { @@ -422,6 +521,22 @@ public: return kHANDLER_TABLE; } + /** Look up the handler for a given API version and method name. + * + * Returns `nullptr` immediately if `version` falls outside the supported + * range: below `kAPI_MINIMUM_SUPPORTED_VERSION`, above + * `kAPI_MAXIMUM_SUPPORTED_VERSION` (or `kAPI_BETA_VERSION` when + * `betaEnabled` is true). This hides beta-only handlers from non-beta + * nodes at the lookup level rather than inside each handler. + * + * @param version The API version of the incoming request. + * @param betaEnabled Whether to extend the upper version bound to + * `kAPI_BETA_VERSION` for this node. + * @param name The RPC method name (e.g. `"account_info"`). + * @return Pointer to the matching `Handler` in the immutable table, or + * `nullptr` if the version is unsupported or no handler is registered + * for `name` at `version`. + */ [[nodiscard]] Handler const* getHandler(unsigned version, bool betaEnabled, std::string const& name) const { @@ -437,6 +552,14 @@ public: return i == range.second ? nullptr : &i->second; } + /** Return the names of all registered RPC methods. + * + * Returns raw string pointers into the table entries. Callers must treat + * these as interned constants — they are valid for the lifetime of the + * process and must not be freed or compared by address. + * + * @return A `std::set` of `char const*` pointing into the handler name fields. + */ [[nodiscard]] std::set getHandlerNames() const { @@ -450,6 +573,16 @@ public: private: handler_table_t table_; + /** Register a new-style class-based handler in the table. + * + * Performs compile-time bounds checks on the handler's version range and + * a runtime overlap check against existing entries. Calls `logicError()` + * if any overlap is detected. + * + * @tparam HandlerImpl A new-style handler class with static metadata fields + * (`name`, `minApiVer`, `maxApiVer`, `role`, `condition`). + * @throws LogicError if `HandlerImpl`'s version range overlaps an existing entry. + */ template void addHandler() @@ -474,12 +607,34 @@ private: } // namespace +/** Look up the handler for an incoming RPC request. + * + * Delegates to `HandlerTable::instance().getHandler()`. Returns `nullptr` + * when `version` is outside the supported range or no handler is registered + * for `name` at that version. The returned pointer is into the immutable + * singleton table and remains valid for the lifetime of the process. + * + * @param version The API version of the incoming request. + * @param betaEnabled Whether beta API versions are enabled on this node. + * @param name The RPC method name string. + * @return Pointer to the matching `Handler`, or `nullptr` if not found. + * @see HandlerTable::getHandler + */ Handler const* getHandler(unsigned version, bool betaEnabled, std::string const& name) { return HandlerTable::instance().getHandler(version, betaEnabled, name); } +/** Return the names of every registered RPC method. + * + * Used by introspection paths (e.g. `server_info`, tests) to enumerate the + * full method surface. The returned pointers are interned into the singleton + * table and are valid for the process lifetime; do not free them. + * + * @return A `std::set` of method name strings. + * @see HandlerTable::getHandlerNames + */ std::set getHandlerNames() { diff --git a/src/xrpld/rpc/detail/Handler.h b/src/xrpld/rpc/detail/Handler.h index 5140f5d6be..15859c3fd4 100644 --- a/src/xrpld/rpc/detail/Handler.h +++ b/src/xrpld/rpc/detail/Handler.h @@ -1,3 +1,13 @@ +/** @file + * Declares the RPC handler registry and dispatch primitives. + * + * Defines `Handler` (a single dispatchable RPC endpoint), the `Condition` + * bitmask encoding node-state requirements, the `conditionMet()` gating + * predicate, and the `getHandler()` / `getHandlerNames()` lookup interface. + * The backing `HandlerTable` singleton lives in the companion `Handler.cpp` + * translation unit, keeping handler-table compile-time dependencies out of + * every translation unit that only needs to call `getHandler()`. + */ #pragma once #include @@ -14,32 +24,100 @@ class Object; namespace xrpl::RPC { -// Under what condition can we call this RPC? +/** Bitmask of node-state prerequisites that must be satisfied before an RPC + * method may be dispatched. + * + * Each enumerator represents an independent requirement. Values are powers + * of two so they can be OR-combined, though the current handler table only + * uses one condition per handler. `NoCondition` methods (e.g. `sign`, + * `random`, `channel_authorize`) execute even on a fully isolated node. + */ enum class Condition { + /** No network or ledger state is required; always executable. */ NoCondition = 0, + /** The node must be at least `SYNCING` (connected to the network). */ NeedsNetworkConnection = 1, + /** A current open ledger must be available; implies network connection. */ NeedsCurrentLedger = 1 << 1, + /** A recently closed ledger must be available via `LedgerMaster`. */ NeedsClosedLedger = 1 << 2, }; +/** Descriptor for a single dispatchable RPC endpoint. + * + * The `HandlerTable` singleton in `Handler.cpp` stores one `Handler` per + * registered method (keyed by `name`). Multiple `Handler` entries may share + * the same `name` as long as their `[minApiVer, maxApiVer]` ranges do not + * overlap — version-range overlap is a fatal `LogicError` detected at + * startup. + * + * `valueMethod` writes its output into a `json::Value&` reference rather + * than returning by value, avoiding an extra copy of potentially large JSON + * results while keeping `Status` independent of the output. + */ struct Handler { + /** Callable type for a single RPC endpoint. + * + * @tparam JsonValue The JSON output type (typically `json::Value`). + * + * Receives the dispatch context and an output reference to populate. + * Returns `Status()` on success or a non-zero `Status` on failure. + */ template using Method = std::function; + /** JSON-RPC method name (e.g. `"account_info"`). Interned string literal. */ char const* name; + + /** Callable that executes the handler and writes the JSON result. */ Method valueMethod; + + /** Minimum role (`USER` or `ADMIN`) the caller must hold. */ Role role; + + /** Node-state prerequisites checked by `conditionMet()` before dispatch. */ RPC::Condition condition; + /** Lowest API version that routes to this handler entry (inclusive). */ unsigned minApiVer = kAPI_MINIMUM_SUPPORTED_VERSION; + + /** Highest API version that routes to this handler entry (inclusive). */ unsigned maxApiVer = kAPI_MAXIMUM_VALID_VERSION; }; +/** Look up the handler for an incoming RPC request. + * + * Delegates to the process-wide `HandlerTable` singleton. The returned + * pointer is into the immutable singleton table and is valid for the + * lifetime of the process; do not delete or cache it across restarts. + * + * Returns `nullptr` when `version` is below `kAPI_MINIMUM_SUPPORTED_VERSION`, + * above `kAPI_MAXIMUM_SUPPORTED_VERSION` (or `kAPI_BETA_VERSION` when + * `betaEnabled` is true), or when no handler is registered for `name` at + * that version. + * + * @param version API version of the incoming request. + * @param betaEnabled Whether to extend the upper version bound to + * `kAPI_BETA_VERSION` for this node. + * @param name The RPC method name string (e.g. `"account_info"`). + * @return Pointer to the matching `Handler`, or `nullptr` if not found. + */ Handler const* getHandler(unsigned int version, bool betaEnabled, std::string const&); -/** Return a json::ValueType::Object with a single entry. */ +/** Construct a `json::Value` object with a single named field. + * + * Creates a `json::ValueType::Object` and assigns `value` to `field`. + * Used as a defensive fallback in the old-style handler adapter (`byRef()`) + * when a legacy handler returns a non-object JSON value — a programming + * error that should never occur in a correct handler. + * + * @tparam Value Any type assignable to a `json::Value` field. + * @param value The value to store. + * @param field The key to store it under; defaults to `jss::message`. + * @return A `json::Value` object with one entry: `{ field: value }`. + */ template json::Value makeObjectValue(Value const& value, json::StaticString const& field = jss::message) @@ -49,10 +127,54 @@ makeObjectValue(Value const& value, json::StaticString const& field = jss::messa return result; } -/** Return names of all methods. */ +/** Return the names of all registered RPC methods. + * + * Used by introspection paths and tests to enumerate the full method surface. + * The returned `char const*` pointers are interned into the immutable + * singleton table; they are valid for the process lifetime and must not be + * freed or compared by address. + * + * @return A `std::set` containing one entry per unique method name. + */ std::set getHandlerNames(); +/** Check whether the required node-state condition is currently satisfied. + * + * This is the single enforcement point for the `Condition` contract. Checks + * are performed in order; the first failure terminates the check and returns + * the appropriate error code. The checks are: + * + * 1. **Amendment block** — if the node is amendment-blocked and + * `conditionRequired != NoCondition`, returns `RpcAmendmentBlocked`. Reads + * from an amendment-blocked node would surface ledger state that diverges + * from the rest of the network. + * 2. **UNL block** — if the validator list has expired and + * `conditionRequired != NoCondition`, returns `RpcExpiredValidatorList`. + * 3. **Operating mode** — the node must be at least `SYNCING`. On failure, + * returns `RpcNoNetwork` (API v1) or `RpcNotSynced` (API v2+). + * 4. **Ledger freshness** (networked nodes only) — the last validated ledger + * must be younger than `Tuning::kMAX_VALIDATED_LEDGER_AGE` (2 minutes), + * and the current ledger index must be within 10 of the validated index. + * On failure, returns `RpcNoCurrent` (v1) or `RpcNotSynced` (v2+). + * 5. **Closed ledger** — if `conditionRequired != NoCondition` and + * `LedgerMaster::getClosedLedger()` returns null, returns `RpcNoClosed` + * (v1) or `RpcNotSynced` (v2+). + * + * The API v1 / v2+ error split is intentional: v2 collapses `RpcNoNetwork`, + * `RpcNoCurrent`, and `RpcNoClosed` into the single `RpcNotSynced` for a + * cleaner client experience. + * + * @tparam T A context type that exposes `app`, `netOps`, `ledgerMaster`, + * `j`, and `apiVersion` — typically `JsonContext` or a gRPC context. + * @param conditionRequired The bitmask condition declared by the handler. + * @param context The RPC dispatch context for the current request. + * @return `RpcSuccess` if all required conditions are met; otherwise the + * appropriate `ErrorCodeI` for the first failing check. + * + * @note Standalone mode (`app.config().standalone()`) bypasses the ledger + * freshness check (step 4) entirely. + */ template ErrorCodeI conditionMet(Condition conditionRequired, T& context) diff --git a/src/xrpld/rpc/detail/LegacyPathFind.cpp b/src/xrpld/rpc/detail/LegacyPathFind.cpp index 83c7c30549..d6c806479b 100644 --- a/src/xrpld/rpc/detail/LegacyPathFind.cpp +++ b/src/xrpld/rpc/detail/LegacyPathFind.cpp @@ -1,3 +1,23 @@ +/** @file + * Implements `LegacyPathFind`, the RAII admission-control guard for the + * synchronous `ripple_path_find` RPC operation. + * + * The constructor applies three sequential checks for non-admin callers: + * + * 1. **Job-queue pressure** — rejected if `getJobCountGE(JtClient)` exceeds + * `Tuning::kMAX_PATHFIND_JOB_COUNT` (50) or local fee load is elevated. + * These two conditions are ORed so either is sufficient to refuse. + * 2. **Concurrent-pathfind ceiling** — enforced via a lock-free CAS loop on + * the static `inProgress` counter. The loop retries on CAS failure + * (caused by a racing increment) rather than over-counting. On success, + * `std::memory_order_release` makes the increment visible to other threads + * that subsequently load `inProgress` with acquire semantics; relaxed + * ordering is used on failure because nothing was changed. + * + * The ceiling (`kMAX_PATHFINDS_IN_PROGRESS = 2`) is deliberately low because + * a single path-find can be orders of magnitude more expensive than a typical + * RPC call. + */ #include #include diff --git a/src/xrpld/rpc/detail/LegacyPathFind.h b/src/xrpld/rpc/detail/LegacyPathFind.h index 226191848f..380834db8a 100644 --- a/src/xrpld/rpc/detail/LegacyPathFind.h +++ b/src/xrpld/rpc/detail/LegacyPathFind.h @@ -8,12 +8,63 @@ class Application; namespace RPC { +/** RAII admission-control guard for the synchronous `ripple_path_find` RPC. + * + * Path-finding is orders of magnitude more expensive than a typical RPC call. + * This guard enforces a tiered throttle so that at most + * `Tuning::kMAX_PATHFINDS_IN_PROGRESS` (2) non-admin path-find operations run + * concurrently. Construction either acquires a slot (setting `isOk()` true) or + * fails admission (leaving `isOk()` false) with no side effects. The acquired + * slot is released automatically on destruction. + * + * The guard is used in two call sites: the `ripple_path_find` handler + * (`RipplePathFind.cpp`, when the caller names a specific ledger) and the + * auto-bridging path inside `TransactionSign.cpp`. Both sites follow the same + * pattern — construct on the stack, call `isOk()`, return `rpcTOO_BUSY` on + * failure, then proceed with path-finding for the lifetime of the guard. + * + * @note Admin callers (derived from `isUnlimited(context.role)`) bypass all + * throttle checks and always acquire a slot. Non-admin requests are also + * rejected early when the job queue exceeds + * `Tuning::kMAX_PATHFIND_JOB_COUNT` (50) or local fee load is elevated. + * The concurrent-pathfind ceiling is enforced via a process-wide + * `static std::atomic` shared across both call sites, so the cap of + * 2 simultaneous legacy path-finds is a global invariant, not per-handler. + * + * @see `Tuning::kMAX_PATHFINDS_IN_PROGRESS`, `Tuning::kMAX_PATHFIND_JOB_COUNT` + */ class LegacyPathFind { public: + /** Attempt to acquire a path-find slot. + * + * For non-admin callers, admission is refused if the job queue is + * saturated, local fee load is elevated, or the concurrent path-find + * ceiling has been reached. The ceiling is enforced via a lock-free CAS + * loop on `inProgress`; a failed CAS due to a racing increment causes a + * retry rather than an over-count. Call `isOk()` after construction to + * determine whether the slot was granted. + * + * @param isAdmin True if the caller holds an unlimited/admin role; + * bypasses all throttle checks when set. + * @param app The running Application, used to query job-queue depth + * and local fee load. + */ LegacyPathFind(bool isAdmin, Application& app); + + /** Release the acquired slot, if any. + * + * Decrements the global `inProgress` counter only when `isOk()` is true, + * i.e. only when construction actually acquired a slot. Safe to call on a + * failed-admission instance. + */ ~LegacyPathFind(); + /** Returns true if a path-find slot was successfully acquired. + * + * Must be checked immediately after construction. If false, the handler + * should return `rpcError(RpcTooBusy)` without performing any path-finding. + */ [[nodiscard]] bool isOk() const { @@ -21,8 +72,10 @@ public: } private: + /** Global count of path-find operations currently in progress. */ static std::atomic inProgress; + /** True only when this instance incremented `inProgress` in the constructor. */ bool isOk_{false}; }; diff --git a/src/xrpld/rpc/detail/MPT.h b/src/xrpld/rpc/detail/MPT.h index 5cf2d5490f..64b43fa975 100644 --- a/src/xrpld/rpc/detail/MPT.h +++ b/src/xrpld/rpc/detail/MPT.h @@ -1,40 +1,129 @@ +/** @file + * Defines `PathFindMPT`, a compact snapshot of MPT ledger state used + * during path construction to avoid redundant ledger reads. + */ + #pragma once #include namespace xrpl { +/** Immutable ledger-state snapshot for a single MPT used during pathfinding. + * + * Carries an `MPTID` together with two boolean flags that encode + * economically relevant state: whether the account's balance is zero, and + * whether the issuance has reached its supply ceiling. Both flags are + * read once by `AssetCache::getMPTs()` and cached so that the pathfinder's + * combinatorial search can evaluate candidate paths without re-reading the + * ledger on every iteration. + * + * This type is the MPT analogue of `PathFindTrustLine`. It is `final`, + * all members are `const`, and no mutation methods are provided — it is a + * read-once snapshot, not a live view. + * + * The implicit conversion to `MPTID const&` lets templated pathfinder + * code pass a `PathFindMPT` wherever an `MPTID` is expected (e.g. + * `getMPTIssuer(asset)`) without knowing the concrete type. + * + * @see AssetCache::getMPTs() + * @see PathFindTrustLine + */ class PathFindMPT final { private: MPTID const mptID_; - // If true then holder's balance is 0, always false for issuer + /** True when the holder's `sfMPTAmount` is zero; always `false` for the + * issuer. A holder with a zero balance can receive but not send, which + * the pathfinder uses to prune outbound paths through that account. + */ bool const zeroBalance_; - // OutstandingAmount is equal to MaximumAmount + /** True when `sfOutstandingAmount` equals the issuance's maximum. + * Computed for the issuer directly from `ltMPTOKEN_ISSUANCE`; for + * holders, the associated issuance is looked up and the flag defaults + * to `true` if that ledger entry is missing (conservative: treat + * orphaned issuances as non-viable routes). + */ bool const maxedOut_; public: + /** Construct with only an MPTID, setting both flags to `false`. + * + * Convenience constructor used when the caller knows a priori that the + * balance is non-zero and the issuance is not maxed out (e.g. when + * synthesising a `PathFindMPT` from an `MPTIssue` for uniform + * interface with `PathFindTrustLine`). + * + * @param mptID The 192-bit Multi-Party Token identifier. + */ PathFindMPT(MPTID const& mptID) : mptID_(mptID), zeroBalance_(false), maxedOut_(false) { } + + /** Construct with explicit state flags captured from the ledger. + * + * @param mptID The 192-bit Multi-Party Token identifier. + * @param zeroBalance `true` if the account's `sfMPTAmount` is zero. + * Always pass `false` for the issuer. + * @param maxedOut `true` if `sfOutstandingAmount` equals the + * issuance maximum, or if the issuance ledger entry is missing. + */ PathFindMPT(MPTID const& mptID, bool zeroBalance, bool maxedOut) : mptID_(mptID), zeroBalance_(zeroBalance), maxedOut_(maxedOut) { } + + /** Implicit conversion to `MPTID const&`. + * + * Allows a `PathFindMPT` to be passed directly to functions that + * accept an `MPTID`, such as `getMPTIssuer()`, so that templated + * pathfinder loops need not specialise on the asset-vector type. + */ operator MPTID const&() const { return mptID_; } + + /** Return the underlying `MPTID`. + * + * Named accessor that mirrors `MPTIssue::getMptID()`, providing an + * unambiguous alternative to the implicit conversion in contexts where + * overload resolution or `constexpr` evaluation requires it. + * + * @return A const reference to the stored `MPTID`. + */ [[nodiscard]] MPTID const& getMptID() const { return mptID_; } + + /** Return whether the account's MPToken balance is zero. + * + * When `true`, the pathfinder skips this asset for outbound path + * segments — a holder with no balance cannot fund a payment. Always + * `false` for the issuer, which has no `sfMPTAmount`. + * + * @return `true` if `sfMPTAmount == 0` for a holder; always `false` + * for the issuer. + */ [[nodiscard]] bool isZeroBalance() const { return zeroBalance_; } + + /** Return whether the MPT issuance has reached its supply ceiling. + * + * When `true`, no further tokens can be minted, so paths that would + * require the issuer to create new supply are not viable. Defaults to + * `true` when the associated `ltMPTOKEN_ISSUANCE` ledger entry cannot + * be found for a holder (conservative: orphaned issuances are treated + * as non-viable). + * + * @return `true` if `sfOutstandingAmount == maxMPTAmount`, or if the + * issuance ledger entry is absent. + */ [[nodiscard]] bool isMaxedOut() const { diff --git a/src/xrpld/rpc/detail/MPTokenIssuanceID.cpp b/src/xrpld/rpc/detail/MPTokenIssuanceID.cpp index e34980aee2..f405838eab 100644 --- a/src/xrpld/rpc/detail/MPTokenIssuanceID.cpp +++ b/src/xrpld/rpc/detail/MPTokenIssuanceID.cpp @@ -1,3 +1,21 @@ +/** @file + * Implements the three-function enrichment pipeline that injects + * `mpt_issuance_id` into RPC JSON responses for successful + * `MPTokenIssuanceCreate` transactions. + * + * The pipeline mirrors the structure of `DeliveredAmount.cpp`: + * `canHaveMPTokenIssuanceID` gates eligibility, `getIDFromCreatedIssuance` + * extracts the identifier from transaction metadata, and + * `insertMPTokenIssuanceID` composes both and writes the response field. + * + * @note `getIDFromCreatedIssuance` performs a dual check on each metadata + * node: `sfLedgerEntryType == ltMPTOKEN_ISSUANCE` confirms the object + * type, and `getFName() == sfCreatedNode` confirms the operation. Both + * checks are required because modified (`sfModifiedNode`) or deleted + * (`sfDeletedNode`) entries of the same type must be skipped — only a + * `CreatedNode` carries `sfNewFields` with the authoritative `sfSequence` + * and `sfIssuer` values needed to reconstruct the `MPTID`. + */ #include #include @@ -30,7 +48,6 @@ canHaveMPTokenIssuanceID( if (tt != ttMPTOKEN_ISSUANCE_CREATE) return false; - // if the transaction failed nothing could have been delivered. if (!isTesSuccess(transactionMeta.getResultTER())) return false; diff --git a/src/xrpld/rpc/detail/PathRequest.cpp b/src/xrpld/rpc/detail/PathRequest.cpp index 0b2d3b96c3..0fddb1097b 100644 --- a/src/xrpld/rpc/detail/PathRequest.cpp +++ b/src/xrpld/rpc/detail/PathRequest.cpp @@ -1,3 +1,12 @@ +/** @file + * Per-request pathfinding state machine for the XRP Ledger. + * + * Each `PathRequest` represents one outstanding client request — either a + * persistent `path_find` subscription or a one-shot `ripple_path_find` call. + * `PathRequestManager` owns and schedules these objects; this file contains + * the full lifecycle: JSON parsing, ledger-state validation, adaptive-depth + * pathfinding via `Pathfinder` + `RippleCalc`, and timing instrumentation. + */ #include #include @@ -48,6 +57,18 @@ namespace xrpl { +/** Constructs a subscription-mode path request (`path_find` semantics). + * + * Results are pushed to `subscriber` on every ledger close. The subscriber + * is held as a `weak_ptr` so that network-layer destruction does not stall + * background pathfinding threads. + * + * @param app The running Application instance. + * @param subscriber The WebSocket subscriber that issued the request. + * @param id Numeric identifier for log correlation. + * @param owner The managing `PathRequestManager`. + * @param journal Logging sink. + */ PathRequest::PathRequest( Application& app, std::shared_ptr const& subscriber, @@ -70,6 +91,19 @@ PathRequest::PathRequest( JLOG(journal_.debug()) << iIdentifier_ << " created"; } +/** Constructs a one-shot path request (`ripple_path_find` semantics). + * + * `completion` is invoked exactly once when `updateComplete()` is called, + * then cleared so subsequent calls are no-ops. The caller supplies + * `consumer` directly because there is no persistent subscriber object. + * + * @param app The running Application instance. + * @param completion Callback fired when the single pathfinding pass finishes. + * @param consumer Resource consumer used to charge for path complexity. + * @param id Numeric identifier for log correlation. + * @param owner The managing `PathRequestManager`. + * @param journal Logging sink. + */ PathRequest::PathRequest( Application& app, std::function const& completion, @@ -93,6 +127,11 @@ PathRequest::PathRequest( JLOG(journal_.debug()) << iIdentifier_ << " created"; } +/** Logs fast-reply, full-reply, and total lifetime latencies at `info` level. + * + * Only emits if the `info` stream is active. Times are reported in + * milliseconds relative to `created_`. + */ PathRequest::~PathRequest() { using namespace std::chrono; @@ -118,47 +157,71 @@ PathRequest::~PathRequest() << "ms"; } +/** Returns true if this request has never completed a full pathfinding pass. + * + * Used by `PathRequestManager::updateAll` to prioritise new requests. + * Thread-safe; acquires `indexLock_`. + * + * @return `true` until the first successful `doUpdate` records a ledger index. + */ bool PathRequest::isNew() { std::scoped_lock const sl(indexLock_); - - // does this path request still need its first full path return lastIndex_ == 0; } +/** Atomically claims this request for processing by the calling thread. + * + * Returns `true` and sets `inProgress_` if all of the following hold: + * no other thread is already processing this request, the `newOnly` filter + * does not exclude it, and `index` is strictly newer than `lastIndex_`. + * Callers must call `updateComplete()` when finished to release the claim. + * + * @param newOnly If `true`, only requests that have never been processed + * (i.e. `isNew()`) are eligible. + * @param index The ledger sequence number being processed; skipped if + * `lastIndex_` is already >= this value. + * @return `true` if the caller now owns the update slot; `false` otherwise. + */ bool PathRequest::needsUpdate(bool newOnly, LedgerIndex index) { std::scoped_lock const sl(indexLock_); if (inProgress_) - { - // Another thread is handling this return false; - } if (newOnly && (lastIndex_ != 0)) - { - // Only handling new requests, this isn't new return false; - } if (lastIndex_ >= index) - { return false; - } inProgress_ = true; return true; } +/** Returns true if this is a one-shot `ripple_path_find` request. + * + * Used throughout the file to branch between subscription push behaviour + * and the legacy one-shot callback path. After `updateComplete()` fires + * `fCompletion_` and clears it, subsequent calls return `false`. + * + * @return `true` while a completion callback is still pending. + */ bool PathRequest::hasCompletion() { return bool(fCompletion_); } +/** Releases the in-progress claim and fires the one-shot completion callback. + * + * Must be called by whichever thread received `true` from `needsUpdate()`, + * even if `doUpdate` failed. For one-shot requests, fires `fCompletion_` + * then clears it so the callback is invoked at most once. + */ void PathRequest::updateComplete() { @@ -174,6 +237,22 @@ PathRequest::updateComplete() } } +/** Validates request parameters against live ledger state in `crCache`. + * + * Verifies that the source account exists, and that the destination account + * can receive the requested asset (non-existent destinations may only receive + * XRP at or above reserve). Populates `jvStatus_[destination_currencies]` + * from the destination account's current trust lines, respecting `lsfDisallowXRP`. + * This side-effect allows the client to build a currency picker without a + * separate RPC call. + * + * Called both from `doCreate` and at the start of every `doUpdate`, because + * ledger state can change between updates. Must be called with `lock_` held. + * + * @param crCache Snapshot of the current ledger state. + * @return `true` if the request is consistent with current ledger state; + * `false` with `jvStatus_` set to the appropriate RPC error otherwise. + */ bool PathRequest::isValid(std::shared_ptr const& crCache) { @@ -182,7 +261,6 @@ PathRequest::isValid(std::shared_ptr const& crCache) if (!convert_all_ && (saSendMax_ || saDstAmount_ <= beast::kZERO)) { - // If send max specified, dst amt must be -1. jvStatus_ = rpcError(RpcDstAmtMalformed); return false; } @@ -191,7 +269,6 @@ PathRequest::isValid(std::shared_ptr const& crCache) if (!lrLedger->exists(keylet::account(*raSrcAccount_))) { - // Source account does not exist. jvStatus_ = rpcError(RpcSrcActNotFound); return false; } @@ -205,14 +282,14 @@ PathRequest::isValid(std::shared_ptr const& crCache) jvDestCur.append(json::Value(systemCurrencyCode())); if (!saDstAmount_.native()) { - // Only XRP can be send to a non-existent account. + // Only XRP can be sent to a non-existent account. jvStatus_ = rpcError(RpcActNotFound); return false; } if (!convert_all_ && saDstAmount_ < STAmount(lrLedger->fees().reserve)) { - // Payment must meet reserve. + // Payment must meet the account reserve. jvStatus_ = rpcError(RpcDstAmtMalformed); return false; } @@ -234,15 +311,22 @@ PathRequest::isValid(std::shared_ptr const& crCache) return true; } -/* If this is a normal path request, we want to run it once "fast" now - to give preliminary results. - - If this is a legacy path request, we are only going to run it once, - and we can't run it in full now, so we don't want to run it at all. - - If there's an error, we need to be sure to return it to the caller - in all cases. -*/ +/** Initialises the request and, for subscription mode, runs a fast first pass. + * + * Chains `parseJson` → `isValid`. For subscription-mode requests (`path_find`) + * a fast preliminary `doUpdate` (depth = `PATH_SEARCH_FAST`) is run immediately + * so the client receives an initial result in the same request/response cycle. + * Legacy one-shot requests (`ripple_path_find`) skip this fast pass because + * they will be run once in full by `PathRequestManager`. + * + * Errors from parsing or validation are embedded in `jvStatus_` and returned + * to the caller; the request is dead and should not be queued. + * + * @param cache Ledger snapshot to validate against and run the fast pass on. + * @param value Raw JSON from the client's `path_find create` command. + * @return A pair of {validity flag, current `jvStatus_`}. The caller should + * only enqueue the request when the flag is `true`. + */ std::pair PathRequest::doCreate(std::shared_ptr const& cache, json::Value const& value) { @@ -271,6 +355,25 @@ PathRequest::doCreate(std::shared_ptr const& cache, json::Value cons return {valid, jvStatus_}; } +/** Parses and stores the client-supplied JSON request parameters. + * + * Validates the presence and format of `source_account`, `destination_account`, + * and `destination_amount`. Optional fields handled: + * - `send_max`: only legal when `destination_amount` is the "convert all" + * sentinel (value `-1` / max-flag); enforces source-asset filtering and + * issuer reconciliation against `send_max`. + * - `source_currencies`: array of currency/issuer pairs or `mpt_issuance_id` + * hex strings, capped at `RPC::Tuning::kMAX_SRC_CUR`. + * - `domain`: 256-bit hex identifier restricting pathfinding to a permissioned + * domain; stored as `std::optional`. + * - `id`: opaque echo value forwarded unchanged to every update response. + * + * @param jvParams Raw JSON from the client. + * @return `PFR_PJ_NOCHANGE` (0) on success, `PFR_PJ_INVALID` (-1) on any + * parse error (with `jvStatus_` set to the relevant RPC error code). + * @note `send_max` requires `destination_amount == -1`. Providing `send_max` + * with a fixed destination amount is rejected with `RpcDstAmtMalformed`. + */ int PathRequest::parseJson(json::Value const& jvParams) { @@ -474,6 +577,14 @@ PathRequest::parseJson(json::Value const& jvParams) return PFR_PJ_NOCHANGE; } +/** Marks this request as closed and returns the final status snapshot. + * + * Called by `PathRequestManager` when the subscriber disconnects or the + * subscription is explicitly cancelled. Sets `jvStatus_["closed"] = true` + * so the client can detect the closure in buffered responses. + * + * @return The final `jvStatus_` with `"closed": true` injected. + */ json::Value PathRequest::doClose() { @@ -483,6 +594,13 @@ PathRequest::doClose() return jvStatus_; } +/** Returns the current status snapshot with `"status": "success"` injected. + * + * Used by `path_find status` sub-command to let a client poll the last + * computed result without triggering a new search. + * + * @return A copy of `jvStatus_` with the success flag set. + */ json::Value PathRequest::doStatus(json::Value const&) { @@ -491,12 +609,39 @@ PathRequest::doStatus(json::Value const&) return jvStatus_; } +/** Logs an early-abort event at `info` level. + * + * Invoked by `PathRequestManager` when the subscriber has already gone away + * mid-update and the pathfinding work is being discarded before completion. + */ void PathRequest::doAborting() const { JLOG(journal_.info()) << iIdentifier_ << " aborting early"; } +/** Returns (or constructs and ranks) a `Pathfinder` for a given source asset. + * + * Looks up `currency` in `currencyMap`; on a miss, constructs a fresh + * `Pathfinder`, runs `findPaths` at `level`, then `computePathRanks` limited + * to `kMAX_PATHS` (4). If `findPaths` fails the entry is stored as `nullptr` + * so callers can distinguish "not yet tried" from "tried and found nothing". + * + * `currencyMap` is local to a single `findPaths` call, so `Pathfinder` + * objects are never reused across ledger updates. + * + * @param cache Ledger snapshot for this update cycle. + * @param currencyMap Per-call cache of already-constructed pathfinders. + * @param currency The source asset to find paths from. + * @param dstAmount Effective destination amount (after `convert_all_` + * conversion). + * @param level Search depth passed to `Pathfinder::findPaths`. + * @param continueCallback Abort predicate; pathfinding stops if it returns + * `false`. + * @return Reference to the (possibly null) `unique_ptr` in `currencyMap`. + * @note `raSrcAccount_` and `raDstAccount_` are asserted non-null by + * `isValid()` before this is called. + */ std::unique_ptr const& PathRequest::getPathFinder( std::shared_ptr const& cache, @@ -527,11 +672,49 @@ PathRequest::getPathFinder( } else { - pathfinder.reset(); // It's a bad request - clear it. + pathfinder.reset(); } return currencyMap[currency] = std::move(pathfinder); } +/** Enumerates viable payment paths for each candidate source asset and + * appends `RippleCalc`-estimated results to `jvArray`. + * + * Source asset resolution order: + * 1. Explicit `sciSourceAssets_` from `parseJson`. + * 2. The asset of `saSendMax_` when no explicit set was given. + * 3. Automatic enumeration from the source account's holdings via + * `accountSourceAssets`, capped at `RPC::Tuning::kMAX_AUTO_SRC_CUR` + * (88). When source == destination, the destination asset is excluded. + * + * For each source asset a `Pathfinder` is obtained or constructed via + * `getPathFinder` (per-call cache; never reused across ledger updates). + * `getBestPaths` selects up to `kMAX_PATHS` (4) candidates and may also + * return a `fullLiquidityPath` — a single path unlocking more liquidity + * at the cost of covering a wider graph segment. + * + * `RippleCalc::rippleCalculate` is called on a non-committing + * `PaymentSandbox` to obtain realistic `actualAmountIn`/`actualAmountOut` + * estimates. If the result is `terNO_LINE` or `tecPATH_PARTIAL` and a + * `fullLiquidityPath` exists, a second attempt appends it to the path + * set. This two-shot fallback is skipped in `convert_all_` mode because + * partial payments are already allowed there. + * + * Resource cost formula: `clamp(size² + 34, 50, 400)` where `size` is + * the number of source assets evaluated. The quadratic term reflects + * that path complexity grows super-linearly with source currencies. + * + * @param cache Ledger snapshot for this update cycle. + * @param level Pathfinder search depth. + * @param jvArray Output JSON array; successful path alternatives + * are appended here. + * @param continueCallback Abort predicate; work stops early if it returns + * `false`. + * @return Always `true`; resource charge is applied regardless of whether + * any paths were found. + * @note Legacy `ripple_path_find` responses include an empty + * `"paths_canonical"` array for backwards compatibility. + */ bool PathRequest::findPaths( std::shared_ptr const& cache, @@ -627,36 +810,37 @@ PathRequest::findPaths( auto sandbox = std::make_unique(&*cache->getLedger(), TapNone); auto rc = path::RippleCalc::rippleCalculate( *sandbox, - saMaxAmount, // --> Amount to send is unlimited - // to get an estimate. - dstAmount, // --> Amount to deliver. + saMaxAmount, + dstAmount, // NOLINTBEGIN(bugprone-unchecked-optional-access) isValid() ensures both are set - *raDstAccount_, // --> Account to deliver to. - *raSrcAccount_, // --> Account sending from. + *raDstAccount_, + *raSrcAccount_, // NOLINTEND(bugprone-unchecked-optional-access) - ps, // --> Path set. - domain_, // --> Domain. + ps, + domain_, app_, &rcInput); if (!convert_all_ && !fullLiquidityPath.empty() && (rc.result() == terNO_LINE || rc.result() == tecPATH_PARTIAL)) { + // Two-shot fallback: append the full-liquidity covering path and + // retry. Often rescues paths that were partial due to limited + // liquidity in the ranked set. JLOG(journal_.debug()) << iIdentifier_ << " Trying with an extra path element"; ps.pushBack(fullLiquidityPath); sandbox = std::make_unique(&*cache->getLedger(), TapNone); rc = path::RippleCalc::rippleCalculate( *sandbox, - saMaxAmount, // --> Amount to send is unlimited - // to get an estimate. - dstAmount, // --> Amount to deliver. + saMaxAmount, + dstAmount, // NOLINTBEGIN(bugprone-unchecked-optional-access) isValid() ensures both are set - *raDstAccount_, // --> Account to deliver to. - *raSrcAccount_, // --> Account sending from. + *raDstAccount_, + *raSrcAccount_, // NOLINTEND(bugprone-unchecked-optional-access) - ps, // --> Path set. - domain_, // --> Domain. + ps, + domain_, app_); if (!isTesSuccess(rc.result())) @@ -687,7 +871,7 @@ PathRequest::findPaths( if (hasCompletion()) { - // Old ripple_path_find API requires this + // Old ripple_path_find API requires this field (may be empty). jvEntry[jss::paths_canonical] = json::ValueType::Array; } @@ -700,15 +884,39 @@ PathRequest::findPaths( } } - /* The resource fee is based on the number of source currencies used. - The minimum cost is 50 and the maximum is 400. The cost increases - after four source currencies, 50 - (4 * 4) = 34. - */ + // Resource cost: clamp(size² + 34, 50, 400). The constant 34 = 50 - 4² + // sets the inflection point at four source currencies. int const size = sourceAssets.size(); consumer_.charge({std::clamp((size * size) + 34, 50, 400), "path update"}); return true; } +/** Runs one pathfinding pass, updates `jvStatus_`, and reports latency. + * + * Calls `isValid` first; returns the current error status immediately if + * the request is no longer valid against `cache`. Then adapts `iLevel_` + * (search depth) based on three signals: + * - **Server load** (`isLoadedLocal`): depth is capped at `PATH_SEARCH_FAST`. + * - **Fast pass**: first-pass depth is `PATH_SEARCH_FAST` regardless of load. + * - **Last-pass success** (`bLastSuccess_`): depth decrements toward + * `PATH_SEARCH` when paths were found; increments toward `PATH_SEARCH_MAX` + * when they were not (unless load prevents it). + * + * Delegates to `findPaths` for the actual computation. On the first fast + * pass, records `quick_reply_` and calls `owner_.reportFast`; on the first + * full pass, records `full_reply_` and calls `owner_.reportFull`. + * + * For legacy `ripple_path_find` requests, `destination_currencies` is + * included in the result so clients can build a currency picker. + * + * @param cache Ledger snapshot for this update cycle. + * @param fast `true` for a preliminary shallow search + * (`PATH_SEARCH_FAST` depth); `false` for the normal adaptive search. + * @param continueCallback Abort predicate forwarded to `findPaths`; work + * stops early if it returns `false`. + * @return The newly computed status JSON object (also stored in + * `jvStatus_` under `lock_`). + */ json::Value PathRequest::doUpdate( std::shared_ptr const& cache, @@ -729,7 +937,8 @@ PathRequest::doUpdate( if (hasCompletion()) { - // Old ripple_path_find API gives destination_currencies + // Legacy ripple_path_find API includes destination_currencies so the + // client can build a currency picker without a separate RPC call. auto& destAssets = (newStatus[jss::destination_currencies] = json::ValueType::Array); // NOLINTNEXTLINE(bugprone-unchecked-optional-access) isValid() ensures both are set auto const assets = accountDestAssets(*raDstAccount_, cache, true); @@ -749,35 +958,25 @@ PathRequest::doUpdate( bool const loaded = app_.getFeeTrack().isLoadedLocal(); + // Adaptive depth: see doUpdate docstring for the full state-machine logic. if (iLevel_ == 0) { - // first pass - if (loaded || fast) - { - iLevel_ = app_.config().PATH_SEARCH_FAST; - } - else - { - iLevel_ = app_.config().PATH_SEARCH; - } + iLevel_ = (loaded || fast) ? app_.config().PATH_SEARCH_FAST : app_.config().PATH_SEARCH; } else if ((iLevel_ == app_.config().PATH_SEARCH_FAST) && !fast) { - // leaving fast pathfinding iLevel_ = app_.config().PATH_SEARCH; if (loaded && (iLevel_ > app_.config().PATH_SEARCH_FAST)) --iLevel_; } else if (bLastSuccess_) { - // decrement, if possible if (iLevel_ > app_.config().PATH_SEARCH || (loaded && (iLevel_ > app_.config().PATH_SEARCH_FAST))) --iLevel_; } else { - // adjust as needed if (!loaded && (iLevel_ < app_.config().PATH_SEARCH_MAX)) ++iLevel_; if (loaded && (iLevel_ > app_.config().PATH_SEARCH_FAST)) @@ -818,6 +1017,14 @@ PathRequest::doUpdate( return newStatus; } +/** Attempts to lock the subscriber weak pointer. + * + * Returns a null `shared_ptr` if the subscriber has been destroyed by the + * network layer. Callers use this as the signal to abort background work + * and remove the request from `PathRequestManager`. + * + * @return The locked subscriber, or `nullptr` if the subscription is gone. + */ InfoSub::pointer PathRequest::getSubscriber() const { diff --git a/src/xrpld/rpc/detail/PathRequest.h b/src/xrpld/rpc/detail/PathRequest.h index b74d4c6ade..2c98eb2a83 100644 --- a/src/xrpld/rpc/detail/PathRequest.h +++ b/src/xrpld/rpc/detail/PathRequest.h @@ -1,3 +1,12 @@ +/** @file + * Per-request pathfinding state machine for the XRP Ledger RPC layer. + * + * Declares `PathRequest`, which backs a single in-flight payment-path query. + * Each instance operates in one of two modes: a persistent `path_find` + * WebSocket subscription or a one-shot `ripple_path_find` call. + * `PathRequestManager` schedules and owns (weakly) these objects; the calling + * context is responsible for holding the strong reference. + */ #pragma once #include @@ -17,16 +26,40 @@ namespace xrpl { -// A pathfinding request submitted by a client -// The request issuer must maintain a strong pointer - class AssetCache; class PathRequestManager; -// Return values from parseJson <0 = invalid, >0 = valid +/** Return value from `parseJson` indicating a parse or validation error. */ #define PFR_PJ_INVALID (-1) + +/** Return value from `parseJson` indicating success (no changed state to + * signal; the zero value is a relic of the original C-style API). */ #define PFR_PJ_NOCHANGE 0 +/** Per-request pathfinding state machine bridging the RPC layer and + * `Pathfinder`/`RippleCalc`. + * + * Each instance backs one client query — either a persistent `path_find` + * WebSocket subscription (results pushed on every ledger close) or a + * one-shot `ripple_path_find` call (runs once, fires a completion callback). + * The two modes are distinguished at construction time by which constructor + * is called and by `hasCompletion()` throughout the implementation. + * + * **Ownership invariant**: `PathRequestManager` stores only `weak_ptr` + * references. The calling context — the WebSocket subscriber for + * `path_find`, or the coroutine for `ripple_path_find` — must hold the + * strong pointer for the lifetime of the request. When the strong pointer + * is released the request is destroyed and automatically pruned from the + * manager's weak-reference vector. + * + * **Thread safety**: `lock_` serialises access to `jvStatus_`. + * `indexLock_` serialises the scheduler fields `lastIndex_` and + * `inProgress_`. Both are `recursive_mutex` because completion callbacks + * may re-enter through the call chain. `doUpdate()` must not hold `lock_` + * while executing the potentially expensive `findPaths()` — it takes a + * fresh snapshot of valid state, releases the lock, does the work, then + * re-acquires to store the result. + */ class PathRequest final : public InfoSubRequest, public std::enable_shared_from_this, public CountedObject @@ -38,8 +71,19 @@ public: using wref = wptr const&; public: - // path_find semantics - // Subscriber is updated + /** Constructs a subscription-mode (`path_find`) path request. + * + * Results are pushed to `subscriber` after every ledger close via + * `doUpdate()`. The subscriber is held as a `weak_ptr` so that + * network-layer destruction does not stall background pathfinding + * threads. `getSubscriber()` returning null is the signal to abort. + * + * @param app The running Application instance. + * @param subscriber The WebSocket subscriber that issued the request. + * @param id Numeric identifier used for log correlation. + * @param owner The `PathRequestManager` that schedules updates. + * @param journal Logging sink. + */ PathRequest( Application& app, std::shared_ptr const& subscriber, @@ -47,8 +91,21 @@ public: PathRequestManager&, beast::Journal journal); - // ripple_path_find semantics - // Completion function is called after path update is complete + /** Constructs a one-shot (`ripple_path_find`) path request. + * + * `completion` is invoked exactly once when `updateComplete()` is + * called, then cleared to prevent double-firing. There is no + * subscriber; `consumer` is supplied directly by the caller. + * + * @param app The running Application instance. + * @param completion Callback fired when the single pathfinding pass + * finishes; cleared by `updateComplete()` after invocation. + * @param consumer Resource consumer used to charge for path + * complexity. + * @param id Numeric identifier used for log correlation. + * @param owner The `PathRequestManager` that schedules updates. + * @param journal Logging sink. + */ PathRequest( Application& app, std::function const& completion, @@ -59,40 +116,190 @@ public: ~PathRequest() override; + /** Returns true if this request has never completed a pathfinding pass. + * + * Used by `PathRequestManager::updateAll` to prioritise new requests + * over ones that already have a cached result. Thread-safe; acquires + * `indexLock_`. + * + * @return `true` until the first successful `doUpdate` records a ledger + * index in `lastIndex_`. + */ bool isNew(); + + /** Atomically claims this request for processing by the calling thread. + * + * Returns `true` and sets `inProgress_` if all of the following hold: + * no other thread is already processing this request; the `newOnly` + * filter does not exclude it; and `index` is strictly newer than + * `lastIndex_`. The caller must invoke `updateComplete()` when + * finished to release the claim, even if `doUpdate` fails. + * + * @param newOnly If `true`, only requests that have never been + * processed (i.e. `isNew()`) are eligible. + * @param index Ledger sequence number being processed; the request + * is skipped if `lastIndex_` is already >= this value. + * @return `true` if the caller now owns the update slot; `false` if + * another thread is already processing or the ledger index is + * not new enough. + */ bool needsUpdate(bool newOnly, LedgerIndex index); - // Called when the PathRequest update is complete. + /** Releases the in-progress claim and fires the one-shot completion + * callback. + * + * Must be called by whichever thread received `true` from + * `needsUpdate()`. For one-shot (`ripple_path_find`) requests, fires + * `fCompletion_` then clears it so the callback is invoked at most once. + */ void updateComplete(); + /** Initialises the request and, for subscription mode, runs a fast first + * pass. + * + * Chains `parseJson` → `isValid`. For subscription-mode requests a + * fast preliminary `doUpdate` is run immediately at `PATH_SEARCH_FAST` + * depth so the client receives an initial result in the same + * request/response cycle. One-shot requests skip this fast pass and + * will be run once in full by `PathRequestManager`. + * + * Errors from parsing or validation are embedded in `jvStatus_`. + * Callers should only enqueue the request when the returned flag is + * `true`. + * + * @param cache Ledger snapshot to validate against and, for + * subscription mode, to run the fast pass on. + * @param value Raw JSON from the client's `path_find create` or + * `ripple_path_find` command. + * @return A pair of `{validity flag, current jvStatus_}`. + */ std::pair doCreate(std::shared_ptr const&, json::Value const&); + /** Marks this request as closed and returns the final status snapshot. + * + * Called by `PathRequestManager` when the subscriber disconnects or + * the subscription is explicitly cancelled. Injects `"closed": true` + * into `jvStatus_` so the client can detect closure in buffered + * responses. + * + * @return The final `jvStatus_` with `"closed": true` injected. + */ json::Value doClose() override; + + /** Returns the current cached status with `"status": "success"` injected. + * + * Used by the `path_find status` sub-command to let a client poll the + * last computed result without triggering a new search. Thread-safe; + * acquires `lock_`. + * + * @param Ignored; present for interface compatibility. + * @return A copy of `jvStatus_` with the success flag set. + */ json::Value doStatus(json::Value const&) override; + + /** Logs an early-abort event at `info` level. + * + * Invoked by `PathRequestManager` when the subscriber has already gone + * away mid-update and the pathfinding work is being discarded before + * completion. + */ void doAborting() const; - // update jvStatus + /** Runs one pathfinding pass, updates `jvStatus_`, and reports latency. + * + * Validates the request against `cache` first; returns the current error + * status immediately if the request is no longer valid. Adapts `iLevel_` + * (search depth) based on server load and whether the previous pass + * succeeded, then delegates to `findPaths`. + * + * On the first fast pass, records `quick_reply_` and calls + * `owner_.reportFast`. On the first full pass, records `full_reply_` + * and calls `owner_.reportFull` for metrics collection. + * + * @param cache Ledger snapshot for this update cycle. + * @param fast `true` for a preliminary shallow search at + * `PATH_SEARCH_FAST` depth; `false` for the normal adaptive search. + * @param continueCallback Optional abort predicate forwarded to + * `findPaths`; pathfinding stops early if it returns `false` (e.g. + * when a new ledger closes and the current search is already stale). + * @return The newly computed status JSON object, also stored in + * `jvStatus_` under `lock_`. + */ json::Value doUpdate( std::shared_ptr const&, bool fast, std::function const& continueCallback = {}); + + /** Attempts to lock the subscriber weak pointer. + * + * Returns a null `shared_ptr` when the subscriber has been destroyed + * by the network layer. Callers use a null return as the signal to + * abort background work and remove the request from + * `PathRequestManager`. + * + * @return The locked subscriber, or `nullptr` if the subscription is + * gone. + */ InfoSub::pointer getSubscriber() const; + + /** Returns true while a one-shot completion callback is still pending. + * + * Used throughout the implementation to branch between subscription + * push behaviour and the legacy one-shot callback path. Returns + * `false` after `updateComplete()` has fired and cleared `fCompletion_`. + * + * @return `true` if `fCompletion_` is non-empty. + */ bool hasCompletion(); private: + /** Validates request parameters against live ledger state in `crCache`. + * + * Verifies the source account exists and that the destination can + * receive the requested asset. Populates + * `jvStatus_[destination_currencies]` from the destination account's + * current trust lines as a convenience for the caller. Must be called + * with `lock_` held; re-invoked at the start of each `doUpdate` because + * ledger state can change between updates. + * + * @param crCache Snapshot of the current ledger state. + * @return `true` if consistent with current ledger state; `false` with + * `jvStatus_` set to the appropriate RPC error otherwise. + */ bool isValid(std::shared_ptr const& crCache); + /** Returns (or constructs and ranks) a `Pathfinder` for a source asset. + * + * Looks up `currency` in `currencyMap`; on a miss, constructs a fresh + * `Pathfinder`, runs `findPaths` at `level`, then `computePathRanks` + * limited to `kMAX_PATHS` (4). If `findPaths` fails the entry is stored + * as `nullptr` so callers distinguish "not yet tried" from "tried and + * found nothing". `currencyMap` is local to a single `findPaths` call + * so `Pathfinder` objects are never reused across ledger updates. + * + * @param cache Ledger snapshot for this update cycle. + * @param currencyMap Per-call cache of already-constructed + * pathfinders; modified in place. + * @param currency Source asset to find paths from. + * @param dstAmount Effective destination amount (after + * `convert_all_` conversion). + * @param level Search depth passed to `Pathfinder::findPaths`. + * @param continueCallback Abort predicate; pathfinding stops if it + * returns `false`. + * @return Reference to the (possibly null) `unique_ptr` stored in + * `currencyMap` for `currency`. + */ std::unique_ptr const& getPathFinder( std::shared_ptr const&, @@ -103,8 +310,32 @@ private: std::function const&); /** Finds and sets a PathSet in the JSON argument. - Returns false if the source currencies are invalid. - */ + * + * Enumerates viable payment paths for each candidate source asset and + * appends `RippleCalc`-estimated results to `jvArray`. Source assets + * come from: explicit `sciSourceAssets_` (from `parseJson`); the asset + * of `saSendMax_` when no explicit set was provided; or automatic + * enumeration of the source account's holdings capped at + * `RPC::Tuning::kMAX_AUTO_SRC_CUR` (88). + * + * If `RippleCalc` returns `terNO_LINE` or `tecPATH_PARTIAL` and + * `Pathfinder` produced a `fullLiquidityPath`, that path is appended + * and `rippleCalculate` is retried. This two-shot fallback is skipped + * in `convert_all_` mode where partial payments are already allowed. + * + * Resource cost: `clamp(size² + 34, 50, 400)` where `size` is the + * number of source assets evaluated. The quadratic term reflects that + * path complexity grows super-linearly with source currencies. + * + * @param cache Ledger snapshot for this update cycle. + * @param level Pathfinder search depth. + * @param jvArray Output JSON array; successful path alternatives + * are appended. + * @param continueCallback Abort predicate; work stops early if it + * returns `false`. + * @return `false` if the source currencies are invalid; `true` otherwise + * (resource charge is applied regardless of whether paths were found). + */ bool findPaths( std::shared_ptr const&, @@ -112,49 +343,113 @@ private: json::Value&, std::function const&); + /** Parses and stores the client-supplied JSON request parameters. + * + * Validates the presence and format of `source_account`, + * `destination_account`, and `destination_amount`. Handles optional + * fields: `send_max` (requires `destination_amount == -1`), + * `source_currencies` (array of currency/issuer pairs or + * `mpt_issuance_id` hex strings, capped at `kMAX_SRC_CUR`), `domain` + * (256-bit hex restricting pathfinding to a permissioned domain), and + * `id` (opaque echo value forwarded unchanged to every update response). + * + * @param jvParams Raw JSON from the client. + * @return `PFR_PJ_NOCHANGE` (0) on success; `PFR_PJ_INVALID` (-1) on + * any parse error with `jvStatus_` set to the relevant RPC error + * code. + */ int parseJson(json::Value const&); Application& app_; beast::Journal journal_; + /** Serialises access to `jvStatus_`. Separate from `indexLock_` so + * `doUpdate` does not hold this lock during the expensive `findPaths` + * computation. */ std::recursive_mutex lock_; PathRequestManager& owner_; - std::weak_ptr wpSubscriber_; // Who this request came from + /** Weak reference to the WebSocket subscriber; null for one-shot + * requests. Held weakly so subscriber destruction does not stall + * background pathfinding threads. */ + std::weak_ptr wpSubscriber_; + + /** One-shot completion callback for `ripple_path_find` mode; empty for + * subscription-mode requests and after `updateComplete()` fires it. */ std::function fCompletion_; - Resource::Consumer& consumer_; // Charge according to source currencies + /** Resource consumer charged per update cycle; quadratic in source + * currency count. */ + Resource::Consumer& consumer_; + + /** Opaque client-supplied `id` echoed back in every update response. */ json::Value jvId_; - json::Value jvStatus_; // Last result - // Client request parameters + /** Most recently computed result; protected by `lock_`. */ + json::Value jvStatus_; + + // --- Client request parameters (set once by parseJson) --- + std::optional raSrcAccount_; std::optional raDstAccount_; STAmount saDstAmount_; std::optional saSendMax_; + /** Explicit source assets from the client's `source_currencies` field; + * empty when auto-discovery via `accountSourceAssets` is used. */ std::set sciSourceAssets_; + + /** Best `STPathSet` per source asset from the previous update; used as + * seed paths by `getBestPaths` on the next cycle. */ std::map context_; + /** Optional permissioned-domain filter for pathfinding; `nullopt` for + * unrestricted searches. */ std::optional domain_; + /** `true` when `destination_amount` is the "convert all" sentinel, + * enabling partial-payment mode in `RippleCalc`. */ bool convert_all_{}; + /** Serialises `lastIndex_` and `inProgress_`; separate from `lock_` so + * scheduler state checks never block on long pathfinding work. */ std::recursive_mutex indexLock_; + + /** Ledger sequence number of the last completed update; 0 while the + * request is new. */ LedgerIndex lastIndex_; + + /** `true` while a thread owns the update slot for this request. */ bool inProgress_; + /** Adaptive search depth; increments toward `PATH_SEARCH_MAX` when idle + * and the last search failed, decrements under load or after success. */ int iLevel_; + + /** `true` if the last `findPaths` call produced at least one alternative; + * used to drive `iLevel_` adaptation. */ bool bLastSuccess_; + /** Numeric identifier for log-line correlation; set at construction. */ int const iIdentifier_; + /** Wall-clock instant this request was constructed; used to compute + * latency metrics reported to `PathRequestManager`. */ std::chrono::steady_clock::time_point const created_; + + /** Wall-clock instant of the first fast (`PATH_SEARCH_FAST`) reply; + * zero-initialised until the fast pass completes. */ std::chrono::steady_clock::time_point quick_reply_; + + /** Wall-clock instant of the first full reply; zero-initialised until + * the full pass completes. */ std::chrono::steady_clock::time_point full_reply_; + /** Maximum number of alternative paths returned per source currency. + * Balances practical usefulness against response payload size and the + * cost of `computePathRanks`. */ static unsigned int const kMAX_PATHS = 4; }; diff --git a/src/xrpld/rpc/detail/PathRequestManager.cpp b/src/xrpld/rpc/detail/PathRequestManager.cpp index ba42a0122f..22aec5996f 100644 --- a/src/xrpld/rpc/detail/PathRequestManager.cpp +++ b/src/xrpld/rpc/detail/PathRequestManager.cpp @@ -26,9 +26,34 @@ namespace xrpl { -/** Get the current AssetCache, updating it if necessary. - Get the correct ledger to use. -*/ +/** Return the shared AssetCache for the given ledger, rebuilding it when necessary. + * + * The manager holds `assetCache_` as a `weak_ptr` so the cache lives only as + * long as at least one `PathRequest` or in-progress update holds a + * `shared_ptr` to it. The local `assetCache` variable is populated first and + * then stored to `assetCache_`; if the assignment were made in the reverse + * order the cache would be immediately destroyed because no other owner would + * exist yet. + * + * The cache is rebuilt when any of the following conditions hold: + * - No prior cache exists (`lineSeq == 0`). + * - `authoritative` is true and `ledger` is strictly newer — the normal + * ledger-advance case. + * - `authoritative` is true and `ledger` is more than 8 slots *older* than + * the cached ledger — indicates a reorg or sync restart. + * - `ledger` is more than 8 slots *newer* than the cached ledger — a forward + * jump large enough to make the cache meaningfully stale. + * + * The ±8 tolerance avoids unnecessary rebuilds during minor gaps while still + * catching drift that would produce incorrect pathfinding results. + * + * @param ledger The ledger the caller intends to use for pathfinding. + * @param authoritative True when called from the main background sweep + * (`updateAll`); false for setup or one-shot calls. Only authoritative + * callers trigger rebuilds on a normal ledger advance. + * @return A `shared_ptr` to the current (possibly freshly built) cache for + * `ledger`. Never null. + */ std::shared_ptr PathRequestManager::getAssetCache(std::shared_ptr const& ledger, bool authoritative) { @@ -56,6 +81,45 @@ PathRequestManager::getAssetCache(std::shared_ptr const& ledger, return assetCache; } +/** Drive one full background pass over all live `path_find` subscriptions. + * + * Called on a `jtPATH_FIND` job-queue thread dispatched by `LedgerMaster` + * whenever the validated ledger advances. The method re-snapshots `requests_` + * under lock at the start of each pass so that individual `doUpdate` calls — + * which can be lengthy — are made without holding the lock. + * + * **Re-entrant loop:** new subscriptions arriving mid-pass are detected via + * `LedgerMaster::isNewPathRequest()`. When a new request appears: + * - `mustBreak` is set and the current pass aborts early. + * - The loop restarts with `newRequests = true` so the newcomer is serviced + * promptly rather than waiting for the next ledger close. + * - After a pass that started with `newRequests = true`, one additional pass + * is always performed to catch any further arrivals during the second pass. + * - The loop exits only when a full pass completes with no new requests + * detected at its end. + * + * **Subscriber liveness (the `getSubscriber` lambda):** the subscriber weak + * pointer is locked and its `getRequest()` is compared against the current + * `PathRequest`. If they do not match — indicating the client closed and + * reopened a session, replacing the subscriber's current request — the stale + * request has `doAborting()` called and the lambda returns `nullptr`. + * + * The subscriber `shared_ptr` is deliberately released (`ipSub.reset()`) + * before calling `doUpdate` so that a client disconnecting during the + * (potentially long) computation can free its `InfoSub` immediately. After + * `doUpdate` returns, `getSubscriber` is called again; if the lock fails the + * update result is silently discarded. + * + * **Rate limiting:** if `Consumer::warn()` returns true the update is skipped + * for that iteration; the request stays in the queue and is retried on the + * next ledger pass. + * + * **Removal:** a request is removed from `requests_` when its weak pointer + * is expired, its subscriber has been replaced, or its one-shot callback has + * fired. Dangling weak pointers are reaped in the same `remove_if` pass. + * + * @param inLedger The newly validated ledger to use for this sweep. + */ void PathRequestManager::updateAll(std::shared_ptr const& inLedger) { @@ -79,6 +143,10 @@ PathRequestManager::updateAll(std::shared_ptr const& inLedger) int processed = 0, removed = 0; + // Two-part liveness check: the InfoSub weak pointer must be lockable AND + // its current request must still point to this PathRequest. A mismatch + // means the client opened a new path_find session; the old request is + // aborted and nullptr is returned so the caller skips the update. auto getSubscriber = [](PathRequest::pointer const& request) -> InfoSub::pointer { if (auto ipSub = request->getSubscriber(); ipSub && ipSub->getRequest() == request) { @@ -203,6 +271,11 @@ PathRequestManager::updateAll(std::shared_ptr const& inLedger) << " removed"; } +/** Return true if there is at least one active path request queued. + * + * Used by `LedgerMaster` to decide whether a path-find job needs to be + * dispatched after a ledger advance. + */ bool PathRequestManager::requestsPending() const { @@ -210,6 +283,17 @@ PathRequestManager::requestsPending() const return !requests_.empty(); } +/** Insert a new request into `requests_`, ahead of any already-serviced entries. + * + * Maintains the invariant that unserviced (new) requests appear before + * already-serviced ones. The insertion point is the first entry where + * `!r->isNew()`, so new requests are encountered early during the next + * `updateAll` pass and serviced quickly rather than buried behind a long + * queue of already-updated subscriptions. + * + * @param req The freshly constructed `PathRequest` to enqueue. Must be + * non-null; the caller owns the `shared_ptr` contract. + */ void PathRequestManager::insertPathRequest(PathRequest::pointer const& req) { @@ -227,7 +311,22 @@ PathRequestManager::insertPathRequest(PathRequest::pointer const& req) requests_.emplace(ret, req); } -// Make a new-style path_find request +/** Create a subscription-based `path_find` request and push an initial result. + * + * Implements the `path_find` WebSocket command. The request is registered in + * `requests_` and the subscriber is notified of updates on every subsequent + * ledger advance via `updateAll`. The subscriber holds a weak reference back + * to the `PathRequest`; when the client disconnects the next `updateAll` pass + * will detect the broken weak pointer and silently discard the request. + * + * If `doCreate` reports the request parameters are invalid, the request is not + * registered and the error JSON is returned directly. + * + * @param subscriber The WebSocket subscriber that will receive push updates. + * @param inLedger Current validated ledger to use for the initial result. + * @param requestJson Parsed `path_find` request object from the client. + * @return JSON response for the initial `path_find` reply (success or error). + */ json::Value PathRequestManager::makePathRequest( std::shared_ptr const& subscriber, @@ -247,7 +346,33 @@ PathRequestManager::makePathRequest( return std::move(jvRes); } -// Make an old-style ripple_path_find request +/** Register an asynchronous `ripple_path_find` request for background processing. + * + * Implements the legacy `ripple_path_find` coroutine variant. The request is + * enqueued in `requests_` and `LedgerMaster::newPathRequest()` is called to + * schedule a `jtPATH_FIND` job. The `completion` callback is invoked when + * `updateAll` finishes processing this request. + * + * `req` is assigned before `completion` could possibly fire so that the caller + * always sees a valid pointer when the callback runs. + * + * On failure (invalid parameters or job queue at capacity) `req` is reset to + * `nullptr` and an error JSON is returned. Callers **must** check `req` on + * return: + * - If `req` is null and the return value is `rpcTOO_BUSY`, the job queue was + * full; `LedgerMaster::newPathRequest()` returned false. + * - If `req` is null for any other reason, parameter validation failed. + * + * @param req Out-parameter populated with the new `PathRequest` on + * success; reset to `nullptr` on any failure. + * @param completion Callback invoked by `updateAll` when the result is ready. + * Must be set on `req` before this function returns. + * @param consumer RPC resource consumer for rate-limiting bookkeeping. + * @param inLedger Current validated ledger for the initial path computation. + * @param request Parsed `ripple_path_find` request object from the client. + * @return JSON response — either the initial pathfinding result or an error + * (`rpcTOO_BUSY` / parameter error). + */ json::Value PathRequestManager::makeLegacyPathRequest( PathRequest::pointer& req, @@ -281,6 +406,21 @@ PathRequestManager::makeLegacyPathRequest( return std::move(jvRes); } +/** Execute a synchronous `ripple_path_find` immediately on the caller's ledger. + * + * Fully synchronous fallback for the `ripple_path_find` command. Creates a + * private, ephemeral `AssetCache` bound to `inLedger`, runs `doUpdate` + * inline, and returns the result to the caller. The request is **never** + * added to `requests_` and never interacts with the background thread or + * `LedgerMaster`. + * + * @param consumer RPC resource consumer for rate-limiting bookkeeping. + * @param inLedger Ledger to use for path computation; typically the current + * validated ledger supplied by the handler. + * @param request Parsed `ripple_path_find` request object from the client. + * @return JSON result of the path computation, or an error object if the + * request parameters are invalid. + */ json::Value PathRequestManager::doLegacyPathRequest( Resource::Consumer& consumer, diff --git a/src/xrpld/rpc/detail/PathRequestManager.h b/src/xrpld/rpc/detail/PathRequestManager.h index 5a5cfde402..934b349258 100644 --- a/src/xrpld/rpc/detail/PathRequestManager.h +++ b/src/xrpld/rpc/detail/PathRequestManager.h @@ -1,3 +1,10 @@ +/** @file + * Coordination hub for all active pathfinding requests. + * + * Declares `PathRequestManager`, which owns (weakly) the live set of + * `PathRequest` objects, drives their periodic updates as the ledger + * advances, and manages the shared `AssetCache` used during graph traversal. + */ #pragma once #include @@ -11,10 +18,39 @@ namespace xrpl { +/** Coordination hub for all active pathfinding requests. + * + * Lives in `Application` as a singleton service. RPC handlers call the + * factory methods (`makePathRequest`, `makeLegacyPathRequest`, + * `doLegacyPathRequest`) to create new requests; `LedgerMaster` calls + * `updateAll()` on a dedicated `jtPATH_FIND` job-queue thread whenever + * the validated ledger advances. + * + * **Ownership model:** `requests_` holds `weak_ptr` entries only. The + * strong reference lives with the subscriber (`path_find`) or the calling + * coroutine (`ripple_path_find`). When either releases it, the weak pointer + * expires and the manager silently prunes it on the next `updateAll` sweep. + * + * **Thread safety:** `lock_` is a `recursive_mutex` because `updateAll()` + * calls `getAssetCache()` while holding it, and `getAssetCache()` re-acquires + * it internally. `lastIdentifier_` is `std::atomic` so unique request + * IDs are assigned without entering the main lock. + */ class PathRequestManager { public: - /** A collection of all PathRequest instances. */ + /** Construct the manager and register telemetry event handles. + * + * Wires up `fast_` and `full_` as named events in the metrics collector + * so individual `PathRequest` objects can report their quick-reply and + * full-reply latencies via `reportFast()` and `reportFull()`. + * + * @param app The running application instance. + * @param journal Logging sink used by the manager and forwarded to + * constructed `PathRequest` objects. + * @param collector Metrics collector used to create the + * `pathfind_fast` and `pathfind_full` event handles. + */ PathRequestManager( Application& app, beast::Journal journal, @@ -25,30 +61,113 @@ public: full_ = collector->makeEvent("pathfind_full"); } - /** Update all of the contained PathRequest instances. - - @param ledger Ledger we are pathfinding in. + /** Drive one full background pass over all live `path_find` subscriptions. + * + * Called on a `jtPATH_FIND` job-queue thread by `LedgerMaster` each time + * the validated ledger advances. Snapshots `requests_` under lock, then + * iterates without the lock so individual `doUpdate` calls do not block + * new-request insertion. + * + * The outer loop re-runs if a new subscription arrives mid-pass + * (`LedgerMaster::isNewPathRequest()` transitions to true), so freshly + * created subscriptions are never delayed by the full backlog of existing + * ones. + * + * Expired weak pointers and requests whose subscribers have been replaced + * are removed from `requests_` during the sweep. + * + * @param inLedger The newly validated ledger to use for this sweep. */ void - updateAll(std::shared_ptr const& ledger); + updateAll(std::shared_ptr const& inLedger); + /** Return true if there is at least one active path request queued. + * + * Used by `LedgerMaster` to decide whether a path-find job should be + * dispatched after a ledger advance. + * + * @return `true` when `requests_` is non-empty; `false` otherwise. + */ bool requestsPending() const; + /** Return the shared `AssetCache` for the given ledger, rebuilding when stale. + * + * The cache is held as a `weak_ptr` and lives only as long as a strong + * reference exists elsewhere (e.g. in an in-progress `updateAll` pass or + * a `doLegacyPathRequest` call). The cache is rebuilt when: + * - No prior cache exists. + * - `authoritative` is true and `ledger` is strictly newer than the + * cached sequence. + * - `authoritative` is true and `ledger` is more than 8 sequences + * behind (reorg or sync restart). + * - `ledger` is more than 8 sequences ahead (large forward jump). + * + * @note The local `shared_ptr` is assigned before the member `weak_ptr` + * to ensure at least one strong reference exists before the weak + * pointer is set; assigning only the `weak_ptr` would cause immediate + * expiry. + * + * @param ledger The ledger the caller intends to use for + * pathfinding. + * @param authoritative `true` when called from the main background sweep + * (`updateAll`); `false` for setup or one-shot calls. Only + * authoritative callers trigger rebuilds on a normal ledger advance. + * @return A `shared_ptr` to the current (possibly freshly built) cache. + * Never null. + */ std::shared_ptr getAssetCache(std::shared_ptr const& ledger, bool authoritative); - // Create a new-style path request that pushes - // updates to a subscriber + /** Create a subscription-based `path_find` request and return the initial result. + * + * Implements the `path_find create` WebSocket command. The request is + * registered in `requests_` and the subscriber will receive push updates + * on every subsequent ledger advance via `updateAll`. Ownership of the + * `PathRequest` lives with the subscriber's `InfoSub` slot; the manager + * holds only a weak pointer. + * + * If `doCreate` reports invalid parameters, the request is not registered + * and the error JSON is returned directly. + * + * @param subscriber The WebSocket subscriber that will receive push + * updates. + * @param ledger Current validated ledger used for the initial result. + * @param request Parsed `path_find create` request object from the + * client. + * @return JSON response for the initial `path_find` reply (success or + * error). + */ json::Value makePathRequest( std::shared_ptr const& subscriber, std::shared_ptr const& ledger, json::Value const& request); - // Create an old-style path request that is - // managed by a coroutine and updated by - // the path engine + /** Register an asynchronous `ripple_path_find` request for background processing. + * + * Implements the legacy `ripple_path_find` coroutine variant. The request + * is enqueued in `requests_` and `LedgerMaster::newPathRequest()` is + * called to schedule a `jtPATH_FIND` job. The `completion` callback is + * invoked by `updateAll` when processing finishes. + * + * On failure (invalid parameters or job queue at capacity) `req` is reset + * to `nullptr` and an error JSON is returned. Callers must check `req` + * after return: a null `req` with `rpcTOO_BUSY` means the job queue was + * full; any other null indicates a parameter validation failure. + * + * @param req Out-parameter set to the new `PathRequest` on success; + * reset to `nullptr` on any failure. + * @param completion Callback invoked by `updateAll` when the single + * pathfinding pass finishes; fired at most once. + * @param consumer RPC resource consumer for rate-limiting bookkeeping. + * @param inLedger Current validated ledger for the initial path + * computation. + * @param request Parsed `ripple_path_find` request object from the + * client. + * @return JSON response — either the initial pathfinding result or an + * error (`rpcTOO_BUSY` or parameter error). + */ json::Value makeLegacyPathRequest( PathRequest::pointer& req, @@ -57,20 +176,50 @@ public: std::shared_ptr const& inLedger, json::Value const& request); - // Execute an old-style path request immediately - // with the ledger specified by the caller + /** Execute a synchronous `ripple_path_find` immediately on the caller's ledger. + * + * Fully synchronous, one-shot variant. Creates a private ephemeral + * `AssetCache` bound to `inLedger`, runs `doCreate` then `doUpdate` + * inline, and returns the result. The `PathRequest` is never added to + * `requests_` and never interacts with the background thread or + * `LedgerMaster`. + * + * @param consumer RPC resource consumer for rate-limiting bookkeeping. + * @param inLedger Ledger to use for path computation; typically the + * current validated ledger supplied by the handler. + * @param request Parsed `ripple_path_find` request object from the + * client. + * @return JSON result of the path computation, or an error object if + * request parameters are invalid. + */ json::Value doLegacyPathRequest( Resource::Consumer& consumer, std::shared_ptr const& inLedger, json::Value const& request); + /** Record the latency of a quick-reply (fast) pathfinding pass. + * + * Called by individual `PathRequest` objects after their first + * `PATH_SEARCH_FAST` pass completes. Feeds the `pathfind_fast` telemetry + * event, keeping timing instrumentation in the manager rather than + * requiring `PathRequest` to depend on the collector directly. + * + * @param ms Elapsed time of the fast pass. + */ void reportFast(std::chrono::milliseconds ms) { fast_.notify(ms); } + /** Record the latency of a full pathfinding pass. + * + * Called by individual `PathRequest` objects after their first full + * (non-fast) pass completes. Feeds the `pathfind_full` telemetry event. + * + * @param ms Elapsed time of the full pass. + */ void reportFull(std::chrono::milliseconds ms) { @@ -78,23 +227,54 @@ public: } private: + /** Insert a new request into `requests_`, ahead of any already-serviced entries. + * + * Scans forward for the first entry where `!r->isNew()` and inserts + * before it, so freshly-created requests are processed early in the + * upcoming `updateAll` pass rather than queuing behind older requests + * that already have a cached result. + * + * @param req The freshly constructed `PathRequest` to enqueue. + */ void insertPathRequest(PathRequest::pointer const&); Application& app_; beast::Journal journal_; + /** Telemetry event handle for quick-reply (fast) pass latency. */ beast::insight::Event fast_; + + /** Telemetry event handle for full pass latency. */ beast::insight::Event full_; - // Track all requests + /** Weak-pointer collection of all live requests. + * + * The manager does not own the requests; strong references live with + * subscribers (`path_find`) or calling coroutines (`ripple_path_find`). + * Expired entries are pruned during `updateAll`. + */ std::vector requests_; - // Use a AssetCache + /** Shared trust-line and MPT cache for the current update pass. + * + * Held as a `weak_ptr` so the cache is released when no `PathRequest` + * or in-progress update holds a strong reference. + */ std::weak_ptr assetCache_; + /** Monotonically increasing counter for assigning unique request IDs. + * + * Incremented with `operator++` before each `PathRequest` construction; + * `std::atomic` ensures uniqueness without acquiring `lock_`. + */ std::atomic lastIdentifier_; + /** Guards `requests_` and `assetCache_`. + * + * Recursive because `updateAll` calls `getAssetCache` while holding + * this lock and `getAssetCache` re-acquires it internally. + */ std::recursive_mutex mutable lock_; };